Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5JRPBHXDKJV5CM9KV4R35YH
anchor: function Channel
created: 2026-10-22T21:36:22Z
norm: '1'
sig: eaede8e100f39ef4
body_hash: d29a81f75aaca2c5
raw_hash: 24baf292b9594958
lines: 305-439
---

only this host's own local channels offer Terminal; saved Terminal tabs elsewhere show where terminals open instead of an empty pane
18 changes: 16 additions & 2 deletions apps/app/src/channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -334,9 +334,10 @@ export function Channel(
}, [channel.id]);

// Shells live on this host, so only its own channels have them; reconnecting may follow a reload.
const elsewhere = remote || place.kind !== "local";
const [shells, setShells] = useState<TerminalShell[]>();
useEffect(() => {
if (remote || place.kind !== "local") return;
if (elsewhere) return;
let live = true;
const load = () => {
host.request<TerminalShell[]>({ op: "terminals", channel: channel.id }).then(
Expand All @@ -352,7 +353,7 @@ export function Channel(
live = false;
off();
};
}, [channel.id, remote, place.kind]);
}, [channel.id, elsewhere]);

const opener = useRef<() => void>(undefined);
useEffect(() => {
Expand Down Expand Up @@ -384,6 +385,7 @@ export function Channel(
changed={changed}
opener={opener}
shells={shells}
terminals={!elsewhere}
actions={<ChannelAccess channel={channel} user={user} />}
render={(data, uid, active, update) => {
if (uid === CHAT) {
Expand All @@ -406,6 +408,18 @@ export function Channel(
return <Diff channel={channel} chat={data.chat} user={user} active={active} />;
}
if (data.type !== "terminal") return null;
// Tabs saved before Terminal was withheld here still open, so they say why they're empty.
if (elsewhere) {
return (
<section className="grid h-full min-h-0 place-items-center bg-background px-4 text-center text-sm text-muted-foreground">
{place.kind === "cloud"
? "This cloud channel's shell is in its container, so terminals don't open here."
: place.kind === "hosted"
? `This channel runs on ${place.service}, which doesn't open terminals.`
: `Terminals open on the channel's host, ${channel.host}, and only for its owner.`}
</section>
);
}
return (
<Terminal
channel={channel.id}
Expand Down
33 changes: 26 additions & 7 deletions apps/app/src/layout/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,15 @@ type Props = {
opener?: { current?: () => void };
/** Running shells no client shows; each without a tab here opens as a background Terminal tab. */
shells?: { terminal: string; chat?: number }[];
/** Whether this host opens the channel's shells; only then is Terminal offered. */
terminals: boolean;
};

const MIN = 350;
const CHAT_MIN = 450;
// The old app also offered Plan, Browse, and Browser Preview; they return when their backends do.
const CHOICES: Choice[] = ["chat", "diff", "terminal"];
const SHELLESS: Choice[] = ["chat", "diff"];
const ICONS: Record<Kind, Icon> = {
blank: IconSplitView,
chat: IconMessage,
Expand Down Expand Up @@ -193,14 +196,20 @@ function focus(uid: string) {
document.querySelector<HTMLElement>(`[data-layout-tab="${CSS.escape(uid)}"]`)?.focus();
}

function Blank({ item, onChoose }: { item: Item; onChoose: (choice: Choice) => void }) {
function Blank(
{ item, choices, onChoose }: {
item: Item;
choices: Choice[];
onChoose: (choice: Choice) => void;
},
) {
const Icon = item.icon;

if (item.kind === "blank") {
return (
<div className="flex h-full min-h-0 items-center justify-center px-4 contain-content">
<div className="scrollbar-muted flex max-h-full min-w-0 flex-col overflow-y-auto py-2 contain-style">
{CHOICES.map((choice) => {
{choices.map((choice) => {
const Icon = ICONS[choice];
return (
<Button
Expand Down Expand Up @@ -231,7 +240,7 @@ function Blank({ item, onChoose }: { item: Item; onChoose: (choice: Choice) => v
);
}

function Add({ onAdd }: { onAdd: (choice: Choice) => void }) {
function Add({ choices, onAdd }: { choices: Choice[]; onAdd: (choice: Choice) => void }) {
return (
<DropdownMenu>
<DropdownMenuTrigger
Expand All @@ -253,7 +262,7 @@ function Add({ onAdd }: { onAdd: (choice: Choice) => void }) {
className="scrollbar-muted max-h-80 w-56 overflow-y-auto"
>
<DropdownMenuGroup>
{CHOICES.map((choice) => {
{choices.map((choice) => {
const Icon = ICONS[choice];
return (
<DropdownMenuItem key={choice} onClick={() => onAdd(choice)}>
Expand Down Expand Up @@ -573,6 +582,7 @@ type BarProps = {
onFork: (side: "right" | "bottom") => void;
can: (side: "right" | "bottom") => boolean;
onSelect: (id: string) => void;
choices: Choice[];
onAdd: (choice: Choice) => void;
onClose: (id: string) => void;
onRename: (id: string, name: string) => void;
Expand All @@ -595,6 +605,7 @@ function Bar({
onFork,
can,
onSelect,
choices,
onAdd,
onClose,
onRename,
Expand Down Expand Up @@ -677,7 +688,7 @@ function Bar({
</div>
{more && <More pane={pane} items={items} onSelect={onSelect} />}
{actions}
<Add onAdd={onAdd} />
<Add choices={choices} onAdd={onAdd} />
{!flat && <Tools split={onFork} can={can} />}
</div>
);
Expand All @@ -694,7 +705,8 @@ type PaneProps = BarProps & {
};

function Pane(props: PaneProps) {
const { pane, items, meta, focused, drop, header, render, onFocus, onChoose, onUpdate } = props;
const { pane, items, meta, focused, drop, header, render, choices, onFocus, onChoose, onUpdate } =
props;
const hover = drop?.kind === "pane" ? drop : undefined;
const blocked = !!hover?.side && !hover.valid;

Expand Down Expand Up @@ -731,7 +743,13 @@ function Pane(props: PaneProps) {
active ? "" : "pointer-events-none invisible"
}`}
>
{node || <Blank item={item} onChoose={(choice) => onChoose(id, choice)} />}
{node || (
<Blank
item={item}
choices={choices}
onChoose={(choice) => onChoose(id, choice)}
/>
)}
</div>
);
})}
Expand Down Expand Up @@ -1038,6 +1056,7 @@ export function Layout(props: Props) {
onFork: (side: "right" | "bottom") => add(pane, "blank", side),
can: (side: "right" | "bottom") => allow(pane, side),
onSelect: (uid: string) => choose(uid, pane),
choices: props.terminals ? CHOICES : SHELLESS,
onAdd: (choice: Choice) => add(pane, choice),
onClose: close,
onRename: rename,
Expand Down