From 5bb7c19f6c0d629de1beac1250ee5d797ea2597a Mon Sep 17 00:00:00 2001 From: Karn Date: Wed, 19 Aug 2026 22:08:20 +0530 Subject: [PATCH 1/5] feat(web): show session tags on the terminal page A floating strip in the terminal's top-left corner shows the session's tags in full chrome, mirroring the control strip opposite: same z-layer, chip-surface badges over whatever theme the pane wears, gone entirely when the session has no tags and in the minimal chrome of split panes and the scratch modal. Tags follow the sessions poll the terminal already subscribes to, so edits land live. The cap-and-fold badge run moves out of the session table into a shared TagBadges component, keeping one TAG_CAP and one +n tooltip for both surfaces. Co-Authored-By: Claude Fable 5 --- web/src/components/session-table.tsx | 24 +--------- web/src/components/tag-badges.tsx | 46 ++++++++++++++++++ web/src/components/terminal.test.tsx | 71 ++++++++++++++++++++++++++++ web/src/components/terminal.tsx | 30 ++++++++++++ 4 files changed, 149 insertions(+), 22 deletions(-) create mode 100644 web/src/components/tag-badges.tsx diff --git a/web/src/components/session-table.tsx b/web/src/components/session-table.tsx index 74fa545..b0b739a 100644 --- a/web/src/components/session-table.tsx +++ b/web/src/components/session-table.tsx @@ -22,6 +22,7 @@ import { import { GripVerticalIcon } from 'lucide-react' import { SessionPreview } from '@/components/session-preview' +import { TagBadges } from '@/components/tag-badges' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' @@ -170,14 +171,6 @@ function StateDot({ session }: { session: FleetSession }) { /** The quiet text the trailing details are set in. */ const META_TEXT = 'text-xs/6 whitespace-nowrap text-zinc-500 dark:text-zinc-400' -/** - * How many tag badges a row shows before folding the rest into a "+n". A row - * is one line that must never push the pane sideways — the old layout's - * overflow-x wrapper died with it, so this cap is what holds the line now — - * and the folded remainder rides in the +n badge's tooltip. - */ -const TAG_CAP = 3 - /** * One session, one row: a single line with the identity on the left, the * details ranged right, and the whole of it one link. @@ -370,20 +363,7 @@ function SessionRow({
{shown.includes('tags') && s.tags.length > 0 && ( - {s.tags.slice(0, TAG_CAP).map((tag) => ( - - {tag} - - ))} - {s.tags.length > TAG_CAP && ( - - +{s.tags.length - TAG_CAP} - - )} + )} {shown.includes('machine') && ( diff --git a/web/src/components/tag-badges.tsx b/web/src/components/tag-badges.tsx new file mode 100644 index 0000000..43866c2 --- /dev/null +++ b/web/src/components/tag-badges.tsx @@ -0,0 +1,46 @@ +import { Badge } from '@/components/ui/badge' +import { cn } from '@/lib/utils' + +/** + * How many tag badges show before the rest fold into a "+n". A session row + * is one line that must never push the pane sideways, and the terminal's + * corner strip owes the same restraint to the output under it — so the cap + * holds the line on both, and the folded remainder rides in the +n badge's + * tooltip. + */ +export const TAG_CAP = 3 + +/** + * The capped run of tag badges the session row and the terminal share. + * `className` dresses every badge; `overflowClassName` lands on the +n badge + * alone, which is how the row lifts only the tooltip-holder above its + * stretched link. + */ +export function TagBadges({ + tags, + className, + overflowClassName, +}: { + tags: string[] + className?: string + overflowClassName?: string +}) { + return ( + <> + {tags.slice(0, TAG_CAP).map((tag) => ( + + {tag} + + ))} + {tags.length > TAG_CAP && ( + + +{tags.length - TAG_CAP} + + )} + + ) +} diff --git a/web/src/components/terminal.test.tsx b/web/src/components/terminal.test.tsx index a8a9b53..97fe9a1 100644 --- a/web/src/components/terminal.test.tsx +++ b/web/src/components/terminal.test.tsx @@ -591,6 +591,77 @@ describe('Terminal', () => { expect(document.title).toBe('vim wire.go') }) + describe('the tag strip', () => { + const strip = () => document.querySelector('[data-flue-tags]') + + it('floats the session tags in the top-left corner, on the controls layer', () => { + const { sock } = mountTerminal((e) => ) + act(() => + sock.emitControl({ type: 'sessions', sessions: [session({ tags: ['api', 'prod'] })] }), + ) + + expect(screen.getByText('api')).toBeTruthy() + expect(screen.getByText('prod')).toBeTruthy() + // Opposite corner from the control strip, same z-10: xterm's own layers + // carry z-indexes, and an unindexed sibling loses to them. + expect(strip()!.className).toMatch(/\btop-3\b/) + expect(strip()!.className).toMatch(/\bleft-3\b/) + expect(strip()!.className).toMatch(/\bz-10\b/) + }) + + it('draws nothing at all for a session without tags', () => { + const { sock } = mountTerminal((e) => ) + expect(strip()).toBeNull() + + act(() => sock.emitControl({ type: 'sessions', sessions: [session({ tags: [] })] })) + expect(strip()).toBeNull() + }) + + it('caps the badges and folds the remainder into a +n', () => { + const { sock } = mountTerminal((e) => ) + act(() => + sock.emitControl({ + type: 'sessions', + sessions: [session({ tags: ['api', 'edge', 'ops', 'prod', 'staging'] })], + }), + ) + + expect(screen.getByText('api')).toBeTruthy() + expect(screen.getByText('edge')).toBeTruthy() + expect(screen.getByText('ops')).toBeTruthy() + expect(screen.queryByText('prod')).toBeNull() + expect(screen.getByText('+2').getAttribute('title')).toBe('prod, staging') + }) + + it('follows the tags as the sessions poll moves them, for its own row only', () => { + const { sock } = mountTerminal((e) => ) + act(() => sock.emitControl({ type: 'sessions', sessions: [session({ tags: ['api'] })] })) + expect(screen.getByText('api')).toBeTruthy() + + act(() => + sock.emitControl({ + type: 'sessions', + sessions: [session({ tags: ['api', 'v2'] }), session({ id: 'other', tags: ['ops'] })], + }), + ) + expect(screen.getByText('v2')).toBeTruthy() + expect(screen.queryByText('ops')).toBeNull() + + act(() => sock.emitControl({ type: 'sessions', sessions: [session({ tags: [] })] })) + expect(strip()).toBeNull() + }) + + it('stays out of the minimal chrome, where a split pane already says whose it is', () => { + const { sock } = mountTerminal((e) => ( + + )) + act(() => sock.emitControl({ type: 'sessions', sessions: [session({ tags: ['api'] })] })) + + expect(strip()).toBeNull() + expect(screen.queryByText('api')).toBeNull() + }) + }) + describe('touch scrolling', () => { /** Attached at 80x24 with a 17px line, ready to be dragged. */ function mountDraggable() { diff --git a/web/src/components/terminal.tsx b/web/src/components/terminal.tsx index cfb3e48..c0dc827 100644 --- a/web/src/components/terminal.tsx +++ b/web/src/components/terminal.tsx @@ -15,6 +15,7 @@ import { KeyBar } from '@/components/key-bar' import { PasteBox } from '@/components/paste-box' import { SelectionMenu, type MenuEnd } from '@/components/selection-menu' import { ShortcutsHelp } from '@/components/shortcuts-help' +import { TagBadges } from '@/components/tag-badges' import { ThemeMenu } from '@/components/theme-menu' import { DARK_SCHEME_QUERY, prefersDark } from '@/emulator/palette' import { controlColors, resolveTheme, THEME_SYSTEM } from '@/emulator/themes' @@ -264,6 +265,10 @@ export function Terminal({ // This session's directory, for Restart and the new-session link. From the // session list, because `attached` does not carry it. const [cwd, setCwd] = useState(null) + // And its tags, for the corner strip, from the same list. Kept only when + // they change: the poll repeats, and an unguarded set of a fresh array + // would re-render the pane on every tick. + const [tags, setTags] = useState([]) // The theme choice — global, every session wears it — read once per mount // and mirrored into a ref so the effect can resolve it without carrying // the state in its dependency array: a theme change must restyle the live @@ -929,6 +934,11 @@ export function Terminal({ const own = list.find((s) => s.id === sessionId) if (!own) return setCwd(own.cwd) + setTags((prev) => + prev.length === own.tags.length && prev.every((t, i) => t === own.tags[i]) + ? prev + : own.tags, + ) tabName = own.name tabOsc = own.title tabCwd = own.cwd @@ -1240,6 +1250,26 @@ export function Terminal({ }} /> )} + {/* + The session's tags, in the corner the control strip leaves free, and + only in full chrome: a split pane's siblings share one URL and the + scratch modal has its own frame, so neither needs a second identity. + Same z-10 as the controls, for the reason theirs carries; the badges + wear the chip surface so they read quietly over whatever palette the + pane is painted in. The strip takes no pointer beyond its own + footprint — nothing in it stretches over the terminal. + */} + {chrome === 'full' && tags.length > 0 && ( +
+ +
+ )} {/* z-10: xterm's own layers carry z-indexes, and an unindexed sibling loses to them — the controls must win the stack or the scrollbar eats their clicks. */} From 46e0aab827ae9cebc034bee856080d776542ea27 Mon Sep 17 00:00:00 2001 From: Karn Date: Wed, 19 Aug 2026 23:40:56 +0530 Subject: [PATCH 2/5] fix(web): keep tag strip visible in minimal chrome A split's sibling panes render minimal chrome, so a tagged session lost its strip the moment it was split. The strip is the only identity a pane shows, so it now draws in every chrome. Co-Authored-By: Claude Fable 5 --- web/src/components/terminal.test.tsx | 6 +++--- web/src/components/terminal.tsx | 16 ++++++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/web/src/components/terminal.test.tsx b/web/src/components/terminal.test.tsx index 97fe9a1..f24458f 100644 --- a/web/src/components/terminal.test.tsx +++ b/web/src/components/terminal.test.tsx @@ -651,14 +651,14 @@ describe('Terminal', () => { expect(strip()).toBeNull() }) - it('stays out of the minimal chrome, where a split pane already says whose it is', () => { + it('survives the minimal chrome, so a split pane still says whose it is', () => { const { sock } = mountTerminal((e) => ( )) act(() => sock.emitControl({ type: 'sessions', sessions: [session({ tags: ['api'] })] })) - expect(strip()).toBeNull() - expect(screen.queryByText('api')).toBeNull() + expect(strip()).not.toBeNull() + expect(screen.getByText('api')).toBeTruthy() }) }) diff --git a/web/src/components/terminal.tsx b/web/src/components/terminal.tsx index c0dc827..9762c22 100644 --- a/web/src/components/terminal.tsx +++ b/web/src/components/terminal.tsx @@ -1251,15 +1251,15 @@ export function Terminal({ /> )} {/* - The session's tags, in the corner the control strip leaves free, and - only in full chrome: a split pane's siblings share one URL and the - scratch modal has its own frame, so neither needs a second identity. - Same z-10 as the controls, for the reason theirs carries; the badges - wear the chip surface so they read quietly over whatever palette the - pane is painted in. The strip takes no pointer beyond its own - footprint — nothing in it stretches over the terminal. + The session's tags, in the corner the control strip leaves free, in + every chrome: a split pane shows no other identity, so its tags are + the one hint of whose pane it is. Same z-10 as the controls, for the + reason theirs carries; the badges wear the chip surface so they read + quietly over whatever palette the pane is painted in. The strip takes + no pointer beyond its own footprint — nothing in it stretches over + the terminal. */} - {chrome === 'full' && tags.length > 0 && ( + {tags.length > 0 && (
Date: Wed, 19 Aug 2026 23:44:22 +0530 Subject: [PATCH 3/5] fix(web): resolve tag strip through the group anchor A new tab or split spawns a member session, and tags only ever land on the anchor, the row a group folds to in the sessions list. A member pane read its own empty tags and showed nothing. Co-Authored-By: Claude Fable 5 --- web/src/components/terminal.test.tsx | 15 +++++++++++++++ web/src/components/terminal.tsx | 10 ++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/web/src/components/terminal.test.tsx b/web/src/components/terminal.test.tsx index f24458f..149b7dd 100644 --- a/web/src/components/terminal.test.tsx +++ b/web/src/components/terminal.test.tsx @@ -651,6 +651,21 @@ describe('Terminal', () => { expect(strip()).toBeNull() }) + it('wears the anchor tags in a member pane, where the group is what got tagged', () => { + const { sock } = mountTerminal((e) => ) + act(() => + sock.emitControl({ + type: 'sessions', + sessions: [ + session({ id: 's1', tags: ['api'] }), + session({ id: 's2', group: 's1', tags: [] }), + ], + }), + ) + + expect(screen.getByText('api')).toBeTruthy() + }) + it('survives the minimal chrome, so a split pane still says whose it is', () => { const { sock } = mountTerminal((e) => ( diff --git a/web/src/components/terminal.tsx b/web/src/components/terminal.tsx index 9762c22..ba30ebd 100644 --- a/web/src/components/terminal.tsx +++ b/web/src/components/terminal.tsx @@ -24,6 +24,7 @@ import { createXtermEmulator, type XtermOptions } from '@/emulator/xterm' import { createPathDetector } from '@/files/detector' import { FileViewer, type FileTarget } from '@/files/viewer' import { loadThemePref, onThemePref, saveThemePref, THEME_PREF_KEY } from '@/lib/theme-pref' +import { anchorIdOf } from '@/sessions/groups' import { cellAt, cellBox, @@ -934,10 +935,15 @@ export function Terminal({ const own = list.find((s) => s.id === sessionId) if (!own) return setCwd(own.cwd) + // The tags belong to the group, and the sessions list edits them on + // the anchor — the row a group folds to. A member pane (a split, a + // tab) wears the anchor's tags for the same reason; its own list + // entry never carries any. + const tagged = list.find((s) => s.id === anchorIdOf(own)) ?? own setTags((prev) => - prev.length === own.tags.length && prev.every((t, i) => t === own.tags[i]) + prev.length === tagged.tags.length && prev.every((t, i) => t === tagged.tags[i]) ? prev - : own.tags, + : tagged.tags, ) tabName = own.name tabOsc = own.title From de2541531732fd44a65da02743ebe4d2d3cb1cad Mon Sep 17 00:00:00 2001 From: Karn Date: Wed, 19 Aug 2026 23:56:40 +0530 Subject: [PATCH 4/5] feat(web): one tag strip per surface, above the key bar on touch Every pane of a split wore the anchor's tags, so a two-way split said the same thing twice. The route now points the strip at the active tree's top-left leaf, mirroring how chipsPane points the chips at the top-right one, so a surface reads its tags once and each tab keeps them. On a coarse pointer the strip moves above the key bar: the top-left corner is the first line of whatever just ran. Co-Authored-By: Claude Fable 5 --- web/src/components/terminal.test.tsx | 37 ++++++++++++++++++++++------ web/src/components/terminal.tsx | 30 +++++++++++++++------- web/src/routes/terminal.tsx | 6 +++++ web/src/sessions/pane-tree.test.ts | 13 ++++++++++ web/src/sessions/pane-tree.ts | 11 +++++++++ 5 files changed, 80 insertions(+), 17 deletions(-) diff --git a/web/src/components/terminal.test.tsx b/web/src/components/terminal.test.tsx index 149b7dd..232a7d5 100644 --- a/web/src/components/terminal.test.tsx +++ b/web/src/components/terminal.test.tsx @@ -348,6 +348,7 @@ describe('Terminal', () => { vi.useRealTimers() }) + it('reports the process exiting, with its code, once', () => { const { sock, em } = mountTerminal((e) => ) act(() => sock.emitControl(attached({ ref: 1, id: 's1' }))) @@ -666,6 +667,25 @@ describe('Terminal', () => { expect(screen.getByText('api')).toBeTruthy() }) + it('yields the corner when the surface hands the tags to another pane', () => { + const { sock } = mountTerminal((e) => ( + + )) + act(() => sock.emitControl({ type: 'sessions', sessions: [session({ tags: ['api'] })] })) + + expect(strip()).toBeNull() + }) + + it('sits above the key bar on a coarse pointer, off the first line of output', () => { + coarsePointer() + const { sock } = mountTerminal((e) => ) + act(() => sock.emitControl({ type: 'sessions', sessions: [session({ tags: ['api'] })] })) + + expect(strip()!.className).toMatch(/\bbottom-17\b/) + expect(strip()!.className).toMatch(/\bleft-3\b/) + expect(strip()!.className).not.toMatch(/\btop-3\b/) + }) + it('survives the minimal chrome, so a split pane still says whose it is', () => { const { sock } = mountTerminal((e) => ( @@ -1680,14 +1700,6 @@ describe('Terminal', () => { }) describe('the key bar', () => { - /** jsdom has no matchMedia; a coarse pointer is claimed explicitly. */ - function coarsePointer() { - vi.stubGlobal('matchMedia', (query: string) => ({ - matches: query.includes('coarse'), - addEventListener: () => {}, - removeEventListener: () => {}, - })) - } const bar = () => document.querySelector('[data-flue-keybar]') const key = (label: string) => Array.from(document.querySelectorAll('[data-flue-keybar] button')).find( @@ -1840,6 +1852,15 @@ describe('Terminal', () => { }) /** A complete SessionInfo, so a caller only names what it cares about. */ +/** jsdom has no matchMedia; a coarse pointer is claimed explicitly. */ +function coarsePointer() { + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: query.includes('coarse'), + addEventListener: () => {}, + removeEventListener: () => {}, + })) +} + function session(over: Partial = {}): SessionInfo { return { id: 's1', diff --git a/web/src/components/terminal.tsx b/web/src/components/terminal.tsx index ba30ebd..d508ab6 100644 --- a/web/src/components/terminal.tsx +++ b/web/src/components/terminal.tsx @@ -99,6 +99,13 @@ export interface TerminalProps { * a place anyone means to go. */ chrome?: 'full' | 'minimal' + /** + * Whether this pane wears the group's tag strip. The tags name the group, + * not a pane, so a multi-pane surface shows them once — the route points + * this at its top-left leaf, the way chipsPane points the chips at the + * top-right one. A lone terminal wears them by default. + */ + showTags?: boolean /** * Whether the pane pins itself to the visual viewport (lib/viewport.ts). * True everywhere the terminal is the page — which is what the pinning @@ -212,6 +219,7 @@ export function Terminal({ onNewSession, onSplit, chrome = 'full', + showTags = true, fitViewport = true, viewportInset = 0, ownsTitle = true, @@ -1257,18 +1265,22 @@ export function Terminal({ /> )} {/* - The session's tags, in the corner the control strip leaves free, in - every chrome: a split pane shows no other identity, so its tags are - the one hint of whose pane it is. Same z-10 as the controls, for the - reason theirs carries; the badges wear the chip surface so they read - quietly over whatever palette the pane is painted in. The strip takes - no pointer beyond its own footprint — nothing in it stretches over - the terminal. + The group's tags, once per surface (see showTags), in the corner the + control strip leaves free — except on a finger, where the top-left + is the first line of whatever just ran; there the strip sits above + the key bar instead. Same z-10 as the controls, for the reason + theirs carries; the badges wear the chip surface so they read + quietly over whatever palette the pane is painted in. The strip + takes no pointer beyond its own footprint — nothing in it stretches + over the terminal. */} - {tags.length > 0 && ( + {showTags && tags.length > 0 && (
{ // Fired by the exit itself — there is no overlay any more. A // session that was already over when this view opened is being diff --git a/web/src/sessions/pane-tree.test.ts b/web/src/sessions/pane-tree.test.ts index 71bc214..9723f2a 100644 --- a/web/src/sessions/pane-tree.test.ts +++ b/web/src/sessions/pane-tree.test.ts @@ -10,6 +10,7 @@ import { splitInTabs, splitLeaf, tabOf, + topLeftLeaf, topRightLeaf, withRatio, type PaneTree, @@ -139,6 +140,18 @@ describe('tabs of trees', () => { }), ).toBe('b') }) + + it('names the top-left pane: the a side of every split, all the way down', () => { + expect(topLeftLeaf(AB)).toBe('a') + expect( + topLeftLeaf({ + split: 'column', + ratio: 0.5, + a: { split: 'row', ratio: 0.5, a: leaf('x'), b: leaf('y') }, + b: leaf('z'), + }), + ).toBe('x') + }) }) describe('parseTree', () => { diff --git a/web/src/sessions/pane-tree.ts b/web/src/sessions/pane-tree.ts index dd4bfae..6cf5aba 100644 --- a/web/src/sessions/pane-tree.ts +++ b/web/src/sessions/pane-tree.ts @@ -105,6 +105,17 @@ export function topRightLeaf(t: PaneTree): string { return topRightLeaf(t.split === 'row' ? t.b : t.a) } +/** + * The leaf whose box touches the surface's top-left corner: the `a` side of + * a split is its left or top either way, so the walk never branches. It is + * where the tag strip lives, for the chips' reason — the tags name the + * group, not a pane, so they sit at the surface's own corner. + */ +export function topLeftLeaf(t: PaneTree): string { + if ('leaf' in t) return t.leaf + return topLeftLeaf(t.a) +} + /** * Bring a tab list in line with the panes that exist: prune every tab's * tree, drop tabs that emptied, and give each unplaced newcomer a tab of its From 9e96a6642c5b5cd8752f5f5ee262d2c5d547ece9 Mon Sep 17 00:00:00 2001 From: Karn Date: Thu, 20 Aug 2026 00:50:59 +0530 Subject: [PATCH 5/5] feat(web): tags lead the control row instead of floating over output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floating strip sat on the first line of whatever just ran — the screenshot that prompted this had it directly above the prompt. The top-right corner is already spent on chrome, so the badges lead the control row there and cover nothing new. Full chrome only, which is also what makes a surface read them once; the coarse-pointer strip stays above the key bar, where the row has no width to spare. Co-Authored-By: Claude Fable 5 --- web/src/components/terminal.test.tsx | 25 +++++---------- web/src/components/terminal.tsx | 46 +++++++++++++++------------- web/src/routes/terminal.tsx | 6 ---- web/src/sessions/pane-tree.test.ts | 13 -------- web/src/sessions/pane-tree.ts | 11 ------- 5 files changed, 31 insertions(+), 70 deletions(-) diff --git a/web/src/components/terminal.test.tsx b/web/src/components/terminal.test.tsx index 232a7d5..f435626 100644 --- a/web/src/components/terminal.test.tsx +++ b/web/src/components/terminal.test.tsx @@ -595,7 +595,7 @@ describe('Terminal', () => { describe('the tag strip', () => { const strip = () => document.querySelector('[data-flue-tags]') - it('floats the session tags in the top-left corner, on the controls layer', () => { + it('leads the control row, where the corner is already spent on chrome', () => { const { sock } = mountTerminal((e) => ) act(() => sock.emitControl({ type: 'sessions', sessions: [session({ tags: ['api', 'prod'] })] }), @@ -603,11 +603,9 @@ describe('Terminal', () => { expect(screen.getByText('api')).toBeTruthy() expect(screen.getByText('prod')).toBeTruthy() - // Opposite corner from the control strip, same z-10: xterm's own layers - // carry z-indexes, and an unindexed sibling loses to them. - expect(strip()!.className).toMatch(/\btop-3\b/) - expect(strip()!.className).toMatch(/\bleft-3\b/) - expect(strip()!.className).toMatch(/\bz-10\b/) + // Inline in the top-right control row, not floating over the output. + expect(strip()!.closest('[data-flue-controls]')).toBeTruthy() + expect(strip()!.className).not.toMatch(/\babsolute\b/) }) it('draws nothing at all for a session without tags', () => { @@ -667,15 +665,6 @@ describe('Terminal', () => { expect(screen.getByText('api')).toBeTruthy() }) - it('yields the corner when the surface hands the tags to another pane', () => { - const { sock } = mountTerminal((e) => ( - - )) - act(() => sock.emitControl({ type: 'sessions', sessions: [session({ tags: ['api'] })] })) - - expect(strip()).toBeNull() - }) - it('sits above the key bar on a coarse pointer, off the first line of output', () => { coarsePointer() const { sock } = mountTerminal((e) => ) @@ -686,14 +675,14 @@ describe('Terminal', () => { expect(strip()!.className).not.toMatch(/\btop-3\b/) }) - it('survives the minimal chrome, so a split pane still says whose it is', () => { + it('stays out of the minimal chrome, whose surface shows them elsewhere', () => { const { sock } = mountTerminal((e) => ( )) act(() => sock.emitControl({ type: 'sessions', sessions: [session({ tags: ['api'] })] })) - expect(strip()).not.toBeNull() - expect(screen.getByText('api')).toBeTruthy() + expect(strip()).toBeNull() + expect(screen.queryByText('api')).toBeNull() }) }) diff --git a/web/src/components/terminal.tsx b/web/src/components/terminal.tsx index d508ab6..9c933da 100644 --- a/web/src/components/terminal.tsx +++ b/web/src/components/terminal.tsx @@ -99,13 +99,6 @@ export interface TerminalProps { * a place anyone means to go. */ chrome?: 'full' | 'minimal' - /** - * Whether this pane wears the group's tag strip. The tags name the group, - * not a pane, so a multi-pane surface shows them once — the route points - * this at its top-left leaf, the way chipsPane points the chips at the - * top-right one. A lone terminal wears them by default. - */ - showTags?: boolean /** * Whether the pane pins itself to the visual viewport (lib/viewport.ts). * True everywhere the terminal is the page — which is what the pinning @@ -219,7 +212,6 @@ export function Terminal({ onNewSession, onSplit, chrome = 'full', - showTags = true, fitViewport = true, viewportInset = 0, ownsTitle = true, @@ -1265,22 +1257,18 @@ export function Terminal({ /> )} {/* - The group's tags, once per surface (see showTags), in the corner the - control strip leaves free — except on a finger, where the top-left - is the first line of whatever just ran; there the strip sits above - the key bar instead. Same z-10 as the controls, for the reason - theirs carries; the badges wear the chip surface so they read - quietly over whatever palette the pane is painted in. The strip - takes no pointer beyond its own footprint — nothing in it stretches - over the terminal. + The group's tags on a finger, above the key bar: both corners are + spoken for there — the top-left is the first line of whatever just + ran, the top-right is the control row, and a thumb's badges deserve + more width than the row spares. Full chrome only, so a surface reads + them once (see chipsPane). A fine pointer gets them in the control + row below instead. The strip takes no pointer beyond its own + footprint. */} - {showTags && tags.length > 0 && ( + {chrome === 'full' && coarse && tags.length > 0 && (
+
+ {/* + The group's tags lead the row: the corner is already spent on + chrome, so badges here cover nothing new. Height of the icon + buttons beside them, and clipped rather than wrapped — a row that + grows downward would be floating over output again. + */} + {chrome === 'full' && !coarse && tags.length > 0 && ( +
+ +
+ )} {chrome === 'full' && } {chrome === 'full' && ( <> diff --git a/web/src/routes/terminal.tsx b/web/src/routes/terminal.tsx index 2d0c0f2..e4300e1 100644 --- a/web/src/routes/terminal.tsx +++ b/web/src/routes/terminal.tsx @@ -23,7 +23,6 @@ import { saveTabs, splitInTabs, tabOf, - topLeftLeaf, topRightLeaf, withRatio, type PaneTree, @@ -349,9 +348,6 @@ export function TerminalRoute() { shownRef.current = shownTab const activeTree = tabTrees[Math.max(0, tabOf(tabTrees, shownTab))] const chipsPane = isMobile || activeTree === undefined ? shownTab : topRightLeaf(activeTree) - // The tag strip's mirror image: one wearer per surface, at the corner the - // strip draws in. Follows the active tab the way the chips do. - const tagsPane = isMobile || activeTree === undefined ? shownTab : topLeftLeaf(activeTree) // A machine the fleet does not hold: never paired on this browser, or its // pinned key gone. Said in a pill, the way the terminal answers a session @@ -386,8 +382,6 @@ export function TerminalRoute() { // splits whichever pane holds the keyboard, so the chips' // placement costs a sibling nothing but the pointer route. chrome={id === chipsPane ? 'full' : 'minimal'} - // And one tag strip, at the surface's top-left — see tagsPane. - showTags={id === tagsPane} onClosed={() => { // Fired by the exit itself — there is no overlay any more. A // session that was already over when this view opened is being diff --git a/web/src/sessions/pane-tree.test.ts b/web/src/sessions/pane-tree.test.ts index 9723f2a..71bc214 100644 --- a/web/src/sessions/pane-tree.test.ts +++ b/web/src/sessions/pane-tree.test.ts @@ -10,7 +10,6 @@ import { splitInTabs, splitLeaf, tabOf, - topLeftLeaf, topRightLeaf, withRatio, type PaneTree, @@ -140,18 +139,6 @@ describe('tabs of trees', () => { }), ).toBe('b') }) - - it('names the top-left pane: the a side of every split, all the way down', () => { - expect(topLeftLeaf(AB)).toBe('a') - expect( - topLeftLeaf({ - split: 'column', - ratio: 0.5, - a: { split: 'row', ratio: 0.5, a: leaf('x'), b: leaf('y') }, - b: leaf('z'), - }), - ).toBe('x') - }) }) describe('parseTree', () => { diff --git a/web/src/sessions/pane-tree.ts b/web/src/sessions/pane-tree.ts index 6cf5aba..dd4bfae 100644 --- a/web/src/sessions/pane-tree.ts +++ b/web/src/sessions/pane-tree.ts @@ -105,17 +105,6 @@ export function topRightLeaf(t: PaneTree): string { return topRightLeaf(t.split === 'row' ? t.b : t.a) } -/** - * The leaf whose box touches the surface's top-left corner: the `a` side of - * a split is its left or top either way, so the walk never branches. It is - * where the tag strip lives, for the chips' reason — the tags name the - * group, not a pane, so they sit at the surface's own corner. - */ -export function topLeftLeaf(t: PaneTree): string { - if ('leaf' in t) return t.leaf - return topLeftLeaf(t.a) -} - /** * Bring a tab list in line with the panes that exist: prune every tab's * tree, drop tabs that emptied, and give each unplaced newcomer a tab of its