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..30ec8b6 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' }))) @@ -591,6 +592,102 @@ describe('Terminal', () => { expect(document.title).toBe('vim wire.go') }) + describe('the tag strip', () => { + const strip = () => document.querySelector('[data-flue-tags]') + + it('hangs below the control row, right-aligned over the emptiest ground', () => { + const { sock } = mountTerminal((e) => ) + act(() => + sock.emitControl({ type: 'sessions', sessions: [session({ tags: ['api', 'prod'] })] }), + ) + + expect(screen.getByText('api')).toBeTruthy() + expect(screen.getByText('prod')).toBeTruthy() + // Under the chips at the right edge: terminal text is left-justified, + // so this is the quietest place a line of badges can stand. + expect(strip()!.className).toMatch(/\btop-12\b/) + expect(strip()!.className).toMatch(/\bright-3\b/) + expect(strip()!.className).toMatch(/\bjustify-end\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('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('keeps the same berth on a coarse pointer', () => { + coarsePointer() + const { sock } = mountTerminal((e) => ) + act(() => sock.emitControl({ type: 'sessions', sessions: [session({ tags: ['api'] })] })) + + expect(strip()!.className).toMatch(/\btop-12\b/) + expect(strip()!.className).toMatch(/\bright-3\b/) + }) + + 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()).toBeNull() + expect(screen.queryByText('api')).toBeNull() + }) + }) + describe('touch scrolling', () => { /** Attached at 80x24 with a 17px line, ready to be dragged. */ function mountDraggable() { @@ -1594,14 +1691,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( @@ -1754,6 +1843,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 cfb3e48..3fb1a1d 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' @@ -23,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, @@ -264,6 +266,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 +935,16 @@ 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 === tagged.tags.length && prev.every((t, i) => t === tagged.tags[i]) + ? prev + : tagged.tags, + ) tabName = own.name tabOsc = own.title tabCwd = own.cwd @@ -1240,6 +1256,26 @@ export function Terminal({ }} /> )} + {/* + The group's tags, hung below the control row at the right edge. + Terminal text is left-justified, so the right margin under the chips + is the quietest ground on the screen — a floating badge anywhere + left sits on somebody's prompt. Right-aligned and wrapping downward, + so a long set grows into that same margin. Full chrome only, which + is what makes a surface read them once (see chipsPane). The strip + takes no pointer beyond its own footprint. + */} + {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. */}