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. */}