thomwolf HF Staff Claude Opus 5 (1M context) commited on
Commit
60e40a4
·
1 Parent(s): 7da11a5

Title a session by the group it belongs to

Browse files

Four panes named claude-code-N tell you nothing about which fleet you are
looking at, so the surfaces that show an agent ON ITS OWN now say the group
first: `[Agent-manager] claude-code-7`. The sidebar and the Overview already
draw the group as a frame around its agents and are left alone — saying it
twice is noise.

Changed: the terminal and remote pane headers, the phone's title bar, and the
browser tab (which never named a session before and now does, dropping the
app's own name rather than pushing the agent's out — the favicon already says
which app this is). Left alone: the files pane (titled by the folder it is
showing) and the trace pane (titled by the transcript's own recorded title).

An agent with no group shows a bare name. Ungrouped is an absence here, not a
word — the tree simply has no group listing the session — so there is no
`[None] foo` to guard against.

The prefix is context and the name is what you are looking for, so the group is
what gives way when there isn't room: in a pane it is weighted to shrink
hundreds of times faster than the name (and drops out below 260px, where a
clipped prefix would say nothing anyway), and in the tab `sessionTitle` elides
then drops it, never cutting into the name. The full title stays in the
header's tooltip.

The group name rides the existing tree poll, so renaming a group or moving an
agent between groups retitles its pane and the tab without a reload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

web/package.json CHANGED
@@ -13,7 +13,7 @@
13
  "dev": "vite",
14
  "build": "tsc --noEmit && vite build",
15
  "typecheck": "tsc --noEmit",
16
- "test": "node test/exchanges.test.mjs",
17
  "preview": "vite preview"
18
  },
19
  "dependencies": {
 
13
  "dev": "vite",
14
  "build": "tsc --noEmit && vite build",
15
  "typecheck": "tsc --noEmit",
16
+ "test": "node test/exchanges.test.mjs && node test/sessionTitle.test.mjs",
17
  "preview": "vite preview"
18
  },
19
  "dependencies": {
web/src/App.tsx CHANGED
@@ -16,6 +16,7 @@ import Welcome from './components/Welcome';
16
  import * as api from './api';
17
  import type { Cli, GridSpec, MoveTarget, OverviewFilter, Session, Tree } from './types';
18
  import { onPaneMode, readPaneMode, writePaneMode } from './lib/paneMode';
 
19
  import { isPassive, isRemote } from './types';
20
  import { GridGlyph, ListGlyph } from './components/icons';
21
 
@@ -407,6 +408,15 @@ export default function App() {
407
  const cliMap = useMemo(() => Object.fromEntries(clis.map((c) => [c.id, c])), [clis]);
408
  const sessById = useMemo(() => Object.fromEntries(tree.sessions.map((s) => [s.id, s])), [tree.sessions]);
409
  const groupById = useMemo(() => Object.fromEntries(tree.groups.map((g) => [g.id, g])), [tree.groups]);
 
 
 
 
 
 
 
 
 
410
 
411
  // Keep a valid selection ('overview' is always valid).
412
  useEffect(() => {
@@ -437,6 +447,21 @@ export default function App() {
437
  const visibleIds = visibleSessions.map((s) => s.id).join(',');
438
  const showZoom = visibleSessions.length > 0;
439
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
440
  // Keep a small working set of terminal panes alive across navigation. The
441
  // backend session already survives a viewer disconnect; retaining xterm and
442
  // its socket as well makes switching back genuinely instant and preserves the
@@ -739,6 +764,7 @@ export default function App() {
739
  theme={theme}
740
  zoom={zoom}
741
  mode={paneMode}
 
742
  focused={shown && sessions.length > 1 && s.id === focusedId}
743
  visible={shown && deckVisible}
744
  active={shown && deckVisible && s.id === focusedId}
@@ -773,6 +799,7 @@ export default function App() {
773
  <RemotePane
774
  session={s}
775
  zoom={zoom}
 
776
  focused={visibleSessions.length > 1 && s.id === focusedId}
777
  dragId={canDrag ? `p:${s.id}` : undefined}
778
  onDragActive={setPaneDrag}
@@ -904,7 +931,19 @@ export default function App() {
904
  ))}
905
  </div>
906
  ) : (
907
- <span className="mtitle mono">{activeRef === 'overview' ? 'Overview' : activeSingle?.name}</span>
 
 
 
 
 
 
 
 
 
 
 
 
908
  )}
909
  </div>
910
  )}
 
16
  import * as api from './api';
17
  import type { Cli, GridSpec, MoveTarget, OverviewFilter, Session, Tree } from './types';
18
  import { onPaneMode, readPaneMode, writePaneMode } from './lib/paneMode';
19
+ import { groupLabel, sessionTitle } from './lib/sessionTitle';
20
  import { isPassive, isRemote } from './types';
21
  import { GridGlyph, ListGlyph } from './components/icons';
22
 
 
408
  const cliMap = useMemo(() => Object.fromEntries(clis.map((c) => [c.id, c])), [clis]);
409
  const sessById = useMemo(() => Object.fromEntries(tree.sessions.map((s) => [s.id, s])), [tree.sessions]);
410
  const groupById = useMemo(() => Object.fromEntries(tree.groups.map((g) => [g.id, g])), [tree.groups]);
411
+ // Which group an agent belongs to, by name — the one fact every place that
412
+ // titles a single agent needs. It rides the tree poll, so renaming a group or
413
+ // dragging an agent into another one retitles its pane and the browser tab on
414
+ // the next refresh, with no reload and nothing to keep in sync by hand.
415
+ const groupNameOf = useMemo(() => {
416
+ const m: Record<string, string> = {};
417
+ for (const g of tree.groups) for (const id of g.sessionIds) m[id] = g.name;
418
+ return m;
419
+ }, [tree.groups]);
420
 
421
  // Keep a valid selection ('overview' is always valid).
422
  useEffect(() => {
 
447
  const visibleIds = visibleSessions.map((s) => s.id).join(',');
448
  const showZoom = visibleSessions.length > 0;
449
 
450
+ // The browser tab names the agent you are actually in — `[Group] name`, the
451
+ // same reading as its pane header. A tab strip (or a phone's app switcher) is
452
+ // exactly where several Spaces and several `claude-code-N` look alike, so the
453
+ // group earns its place there; the app's own name is what gives way, since
454
+ // the favicon already says which app this is. Under a tiled group the title
455
+ // follows the focused pane, because that is the one taking your keystrokes.
456
+ const onStage = !isMobile || mobileStage;
457
+ const titleSession = onStage
458
+ ? visibleSessions.find((s) => s.id === focusedId) ?? visibleSessions[0] ?? null
459
+ : null;
460
+ const tabTitle = titleSession
461
+ ? sessionTitle(titleSession.name, groupNameOf[titleSession.id])
462
+ : 'Agent Manager';
463
+ useEffect(() => { document.title = tabTitle; }, [tabTitle]);
464
+
465
  // Keep a small working set of terminal panes alive across navigation. The
466
  // backend session already survives a viewer disconnect; retaining xterm and
467
  // its socket as well makes switching back genuinely instant and preserves the
 
764
  theme={theme}
765
  zoom={zoom}
766
  mode={paneMode}
767
+ groupName={groupNameOf[s.id]}
768
  focused={shown && sessions.length > 1 && s.id === focusedId}
769
  visible={shown && deckVisible}
770
  active={shown && deckVisible && s.id === focusedId}
 
799
  <RemotePane
800
  session={s}
801
  zoom={zoom}
802
+ groupName={groupNameOf[s.id]}
803
  focused={visibleSessions.length > 1 && s.id === focusedId}
804
  dragId={canDrag ? `p:${s.id}` : undefined}
805
  onDragActive={setPaneDrag}
 
931
  ))}
932
  </div>
933
  ) : (
934
+ <span className="mtitle mono" title={activeSingle ? sessionTitle(activeSingle.name, groupNameOf[activeSingle.id]) : undefined}>
935
+ {activeRef === 'overview' ? 'Overview' : (
936
+ <>
937
+ {/* Same rule as the pane header: the group is a prefix that
938
+ elides before the agent's name does. A loose agent — the
939
+ usual case for this bar — shows a bare name. */}
940
+ {activeSingle && groupLabel(groupNameOf[activeSingle.id]) && (
941
+ <span className="ph-group">[{groupNameOf[activeSingle.id]}]</span>
942
+ )}
943
+ <span className="ph-name">{activeSingle?.name}</span>
944
+ </>
945
+ )}
946
+ </span>
947
  )}
948
  </div>
949
  )}
web/src/components/RemotePane.tsx CHANGED
@@ -4,6 +4,7 @@ import { REMOTE_STATE_LABEL } from '../types';
4
  import * as api from '../api';
5
  import Logo from './Logo';
6
  import { renderMarkdown } from '../lib/markdown';
 
7
  import { CloseGlyph, StopGlyph, PlayGlyph, ShareGlyph, AckGlyph } from './icons';
8
 
9
  // Looks like the terminal, is not one: no PTY, no xterm.js, no WebSocket. The
@@ -24,9 +25,10 @@ const fmtAgo = (ts?: number | null) => {
24
  };
25
 
26
  export default function RemotePane({
27
- session, focused, zoom = 100, dragId, onDragActive, onFocus, onClose, onRename,
28
  }: {
29
  session: Session;
 
30
  focused?: boolean;
31
  zoom?: number;
32
  dragId?: string;
@@ -36,6 +38,7 @@ export default function RemotePane({
36
  onRename?: (name: string) => void;
37
  }) {
38
  const name = session.remote?.name || '';
 
39
  const [info, setInfo] = useState<RemoteInfo | null>(null);
40
  const [messages, setMessages] = useState<RemoteMessage[]>([]);
41
  const [draft, setDraft] = useState('');
@@ -218,9 +221,12 @@ export default function RemotePane({
218
  ) : (
219
  <span
220
  className="ph-title"
221
- title={onRename ? 'double-click to rename' : undefined}
222
  onDoubleClick={onRename ? () => { setTitleDraft(session.name); setEditing(true); } : undefined}
223
- >{session.name}</span>
 
 
 
224
  )}
225
  <div className="ph-right">
226
  <div className="rp-pop-wrap" ref={popRef}>
 
4
  import * as api from '../api';
5
  import Logo from './Logo';
6
  import { renderMarkdown } from '../lib/markdown';
7
+ import { groupLabel, sessionTitle } from '../lib/sessionTitle';
8
  import { CloseGlyph, StopGlyph, PlayGlyph, ShareGlyph, AckGlyph } from './icons';
9
 
10
  // Looks like the terminal, is not one: no PTY, no xterm.js, no WebSocket. The
 
25
  };
26
 
27
  export default function RemotePane({
28
+ session, focused, zoom = 100, dragId, groupName, onDragActive, onFocus, onClose, onRename,
29
  }: {
30
  session: Session;
31
+ groupName?: string | null; // the group this pane belongs to, if any
32
  focused?: boolean;
33
  zoom?: number;
34
  dragId?: string;
 
38
  onRename?: (name: string) => void;
39
  }) {
40
  const name = session.remote?.name || '';
41
+ const group = groupLabel(groupName);
42
  const [info, setInfo] = useState<RemoteInfo | null>(null);
43
  const [messages, setMessages] = useState<RemoteMessage[]>([]);
44
  const [draft, setDraft] = useState('');
 
221
  ) : (
222
  <span
223
  className="ph-title"
224
+ title={`${sessionTitle(session.name, groupName)}${onRename ? ' · double-click to rename' : ''}`}
225
  onDoubleClick={onRename ? () => { setTitleDraft(session.name); setEditing(true); } : undefined}
226
+ >
227
+ {group && <span className="ph-group">[{group}]</span>}
228
+ <span className="ph-name">{session.name}</span>
229
+ </span>
230
  )}
231
  <div className="ph-right">
232
  <div className="rp-pop-wrap" ref={popRef}>
web/src/components/TerminalPane.tsx CHANGED
@@ -10,6 +10,7 @@ import Logo from './Logo';
10
  import ConversationView from './conversation/ConversationView';
11
  import { isPassive } from '../types';
12
  import type { PaneMode } from '../lib/paneMode';
 
13
  import { CloseGlyph, RefreshGlyph } from './icons';
14
 
15
  const THEMES: Record<'light' | 'dark', ITheme> = {
@@ -180,11 +181,12 @@ if (typeof window !== 'undefined') {
180
  }
181
 
182
  export default function TerminalPane({
183
- session, cli, theme, focused, visible, active, zoom = 100, mode = 'terminal', dragId, isMobile, onDragActive, onFocus, onRename, onClose,
184
  }: {
185
  session: Session;
186
  cli?: Cli;
187
  theme: 'light' | 'dark';
 
188
  focused?: boolean;
189
  visible?: boolean;
190
  active?: boolean;
@@ -810,6 +812,7 @@ export default function TerminalPane({
810
  // Focused panes tint toward THEIR agent's brand color, not the app accent.
811
  const tint = cli?.color;
812
  const pathLabel = workspaceLabel(session.path);
 
813
  return (
814
  <div
815
  className={`slot${focused ? ' focused' : ''}`}
@@ -840,7 +843,17 @@ export default function TerminalPane({
840
  onKeyDown={(e) => { if (e.key === 'Enter') commitName(); if (e.key === 'Escape') setEditing(false); }}
841
  />
842
  ) : (
843
- <span className="ph-title" title={`${pathLabel} · double-click to rename`} onDoubleClick={() => { setDraft(session.name); setEditing(true); }}>{session.name}</span>
 
 
 
 
 
 
 
 
 
 
844
  )}
845
  <div className="ph-right">
846
  <span className="ph-path" title={pathLabel}>{pathLabel}</span>
 
10
  import ConversationView from './conversation/ConversationView';
11
  import { isPassive } from '../types';
12
  import type { PaneMode } from '../lib/paneMode';
13
+ import { groupLabel, sessionTitle } from '../lib/sessionTitle';
14
  import { CloseGlyph, RefreshGlyph } from './icons';
15
 
16
  const THEMES: Record<'light' | 'dark', ITheme> = {
 
181
  }
182
 
183
  export default function TerminalPane({
184
+ session, cli, theme, focused, visible, active, zoom = 100, mode = 'terminal', dragId, isMobile, groupName, onDragActive, onFocus, onRename, onClose,
185
  }: {
186
  session: Session;
187
  cli?: Cli;
188
  theme: 'light' | 'dark';
189
+ groupName?: string | null; // the group this pane belongs to, if any
190
  focused?: boolean;
191
  visible?: boolean;
192
  active?: boolean;
 
812
  // Focused panes tint toward THEIR agent's brand color, not the app accent.
813
  const tint = cli?.color;
814
  const pathLabel = workspaceLabel(session.path);
815
+ const group = groupLabel(groupName);
816
  return (
817
  <div
818
  className={`slot${focused ? ' focused' : ''}`}
 
843
  onKeyDown={(e) => { if (e.key === 'Enter') commitName(); if (e.key === 'Escape') setEditing(false); }}
844
  />
845
  ) : (
846
+ // Two spans, not one string: the group is a prefix that must give way
847
+ // before the agent's own name does (see .ph-group in styles.css), and
848
+ // the rename below still edits the name alone.
849
+ <span
850
+ className="ph-title"
851
+ title={`${sessionTitle(session.name, groupName)} · ${pathLabel} · double-click to rename`}
852
+ onDoubleClick={() => { setDraft(session.name); setEditing(true); }}
853
+ >
854
+ {group && <span className="ph-group">[{group}]</span>}
855
+ <span className="ph-name">{session.name}</span>
856
+ </span>
857
  )}
858
  <div className="ph-right">
859
  <span className="ph-path" title={pathLabel}>{pathLabel}</span>
web/src/lib/sessionTitle.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // What an agent is called when it is shown on its own: `[Group] name`.
2
+ //
3
+ // The sidebar and the Overview already draw the group as a frame around its
4
+ // agents, so they say it once and leave the rows bare. The surfaces that show a
5
+ // single agent with no such frame — a pane header in a tiled group, the phone's
6
+ // title bar, the browser tab — have to spell the group out, or four panes named
7
+ // `claude-code-1` are indistinguishable.
8
+ //
9
+ // One rule everywhere: the group is context and the name is the thing you are
10
+ // looking for, so when there isn't room the group gives way first. A session
11
+ // with no group is titled by its bare name — never `[None] foo` or `[] foo`.
12
+
13
+ /**
14
+ * The group's display name, or null when the session sits loose in the tree.
15
+ *
16
+ * Ungrouped is an absence here, not a word: the tree simply has no group that
17
+ * lists the session, and `/api/agents` serialises that as `group: null`. So the
18
+ * empty cases all collapse to the same bare name — there is no `[None] foo`.
19
+ */
20
+ export function groupLabel(name?: string | null): string | null {
21
+ const trimmed = (name ?? '').trim();
22
+ return trimmed ? trimmed : null;
23
+ }
24
+
25
+ // About what a browser shows of a tab title before it stops being readable.
26
+ // Past this the prefix would be all you can see, which is the failure this
27
+ // whole module exists to avoid.
28
+ export const TAB_BUDGET = 42;
29
+
30
+ // Shorter than "[abc…]" the prefix identifies nothing, so drop it instead.
31
+ const MIN_GROUP = 4;
32
+
33
+ /**
34
+ * `[Group] name` as a single string, for the places that can only take one: the
35
+ * browser tab and `title=` tooltips.
36
+ *
37
+ * Fits `budget` characters by shortening the group, then by dropping it. The
38
+ * name comes back whole even when it exceeds the budget by itself — cutting
39
+ * into the name is the one thing this must never do.
40
+ */
41
+ export function sessionTitle(name: string, group?: string | null, budget = TAB_BUDGET): string {
42
+ const g = groupLabel(group);
43
+ if (!g) return name;
44
+ const full = `[${g}] ${name}`;
45
+ if (full.length <= budget) return full;
46
+ const room = budget - name.length - 3; // the brackets and the separating space
47
+ if (room < MIN_GROUP) return name;
48
+ return `[${g.slice(0, room - 1)}…] ${name}`;
49
+ }
web/src/styles.css CHANGED
@@ -562,7 +562,14 @@ body {
562
 
563
  .pane-head { container-type: inline-size; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, auto) minmax(0, 1fr); align-items: center; gap: 9px; padding: 7px 11px; background: var(--panel); border-bottom: 1px solid var(--border); font-size: 12px; flex: none; }
564
  .pane-head .ph-left { grid-column: 1; justify-self: start; display: inline-flex; align-items: center; gap: 7px; }
565
- .pane-head .ph-title { grid-column: 2; min-width: 0; max-width: 100%; text-align: center; font-family: var(--font-mono); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: text; }
 
 
 
 
 
 
 
566
  .pane-head .ph-right { grid-column: 3; min-width: 0; display: inline-flex; align-items: center; justify-self: end; gap: 6px; }
567
  .pane-head .ph-path { min-width: 0; max-width: min(24vw, 220px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; }
568
  .pane-head .ph-title-input { width: 100%; text-align: center; font: inherit; font-family: var(--font-mono); font-weight: 600; padding: 1px 6px; border: 1px solid var(--accent); border-radius: var(--r-sm); background: var(--panel-2); color: var(--text); min-width: 0; }
@@ -570,6 +577,11 @@ body {
570
  @container (max-width: 520px) {
571
  .pane-head .ph-path { display: none; }
572
  }
 
 
 
 
 
573
  .slot { transition: border-color 0.15s ease-out, box-shadow 0.15s ease-out; }
574
  /* fallback focus tint (terminal panes override with their agent's color inline) */
575
  .slot.focused { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); box-shadow: 0 4px 18px -10px rgba(0, 0, 0, 0.35); }
@@ -1074,7 +1086,7 @@ a.btn-ghost { text-decoration: none; }
1074
  .modebar { flex: none; margin-right: 6px; }
1075
  .modebar button { padding: 2px 8px; font-size: 10.5px; letter-spacing: 0.02em; }
1076
 
1077
- .mtitle { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1078
  .mchips { display: flex; gap: 6px; overflow-x: auto; flex: 1; padding: 2px; }
1079
  .mchip { display: inline-flex; align-items: center; gap: 7px; padding: 6px 10px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-md); cursor: pointer; flex: none; }
1080
  .mchip.on { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); }
 
562
 
563
  .pane-head { container-type: inline-size; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, auto) minmax(0, 1fr); align-items: center; gap: 9px; padding: 7px 11px; background: var(--panel); border-bottom: 1px solid var(--border); font-size: 12px; flex: none; }
564
  .pane-head .ph-left { grid-column: 1; justify-self: start; display: inline-flex; align-items: center; gap: 7px; }
565
+ .pane-head .ph-title { grid-column: 2; min-width: 0; max-width: 100%; display: inline-flex; align-items: baseline; justify-content: center; gap: 5px; text-align: center; font-family: var(--font-mono); font-weight: 600; white-space: nowrap; overflow: hidden; cursor: text; }
566
+ /* `[Group] name` is one title in two parts, and the group is the part that
567
+ gives way: it is weighted to shrink hundreds of times faster than the name,
568
+ so a tile too narrow for both truncates the context rather than the thing you
569
+ are looking for. Below a tile width where a clipped prefix would say nothing
570
+ anyway, it drops out entirely and the name gets the whole header. */
571
+ .ph-group { flex: 0 500 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-weight: 500; }
572
+ .ph-name { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
573
  .pane-head .ph-right { grid-column: 3; min-width: 0; display: inline-flex; align-items: center; justify-self: end; gap: 6px; }
574
  .pane-head .ph-path { min-width: 0; max-width: min(24vw, 220px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; }
575
  .pane-head .ph-title-input { width: 100%; text-align: center; font: inherit; font-family: var(--font-mono); font-weight: 600; padding: 1px 6px; border: 1px solid var(--accent); border-radius: var(--r-sm); background: var(--panel-2); color: var(--text); min-width: 0; }
 
577
  @container (max-width: 520px) {
578
  .pane-head .ph-path { display: none; }
579
  }
580
+ /* Narrower than this a prefix could only ever be a sliver of itself, and a
581
+ header that reads "[A… name" is worse than one that just reads "name". */
582
+ @container (max-width: 260px) {
583
+ .pane-head .ph-group { display: none; }
584
+ }
585
  .slot { transition: border-color 0.15s ease-out, box-shadow 0.15s ease-out; }
586
  /* fallback focus tint (terminal panes override with their agent's color inline) */
587
  .slot.focused { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); box-shadow: 0 4px 18px -10px rgba(0, 0, 0, 0.35); }
 
1086
  .modebar { flex: none; margin-right: 6px; }
1087
  .modebar button { padding: 2px 8px; font-size: 10.5px; letter-spacing: 0.02em; }
1088
 
1089
+ .mtitle { display: inline-flex; align-items: baseline; gap: 5px; min-width: 0; font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1090
  .mchips { display: flex; gap: 6px; overflow-x: auto; flex: 1; padding: 2px; }
1091
  .mchip { display: inline-flex; align-items: center; gap: 7px; padding: 6px 10px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-md); cursor: pointer; flex: none; }
1092
  .mchip.on { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); }
web/test/sessionTitle.test.mjs ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The length budget, which is the part of `[Group] name` that is easy to get
2
+ // wrong: a prefix that pushes the agent's own name out of a browser tab has
3
+ // made the title worse than it was before.
4
+ //
5
+ // Same shape as exchanges.test.mjs — no test runner, esbuild transpiles the
6
+ // module and we import it. Run with: node test/sessionTitle.test.mjs
7
+ import assert from 'node:assert/strict';
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { fileURLToPath, pathToFileURL } from 'node:url';
12
+ import { build } from 'esbuild';
13
+
14
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
15
+ const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'title-')), 'sessionTitle.mjs');
16
+ await build({
17
+ entryPoints: [path.join(HERE, '../src/lib/sessionTitle.ts')],
18
+ outfile: out, format: 'esm', bundle: false, logLevel: 'error',
19
+ });
20
+ const { groupLabel, sessionTitle, TAB_BUDGET } = await import(pathToFileURL(out).href);
21
+
22
+ let failed = 0;
23
+ const check = (what, fn) => {
24
+ try { fn(); console.log(` ok ${what}`); } catch (e) {
25
+ failed++;
26
+ console.log(` FAIL ${what}\n ${e.message.split('\n')[0]}`);
27
+ }
28
+ };
29
+
30
+ console.log('groupLabel');
31
+ check('a real group is itself', () => assert.equal(groupLabel('Agent-manager'), 'Agent-manager'));
32
+ check('no group is null, not a word', () => {
33
+ assert.equal(groupLabel(null), null);
34
+ assert.equal(groupLabel(undefined), null);
35
+ assert.equal(groupLabel(''), null);
36
+ assert.equal(groupLabel(' '), null);
37
+ });
38
+
39
+ console.log('sessionTitle');
40
+ check('the example from the request', () => {
41
+ assert.equal(sessionTitle('claude-code-7', 'Agent-manager'), '[Agent-manager] claude-code-7');
42
+ });
43
+ check('ungrouped degrades to a bare name', () => {
44
+ for (const g of [null, undefined, '', ' ']) assert.equal(sessionTitle('claude-code-7', g), 'claude-code-7');
45
+ });
46
+ check('an over-budget pair keeps the name whole and elides the group', () => {
47
+ const t = sessionTitle('claude-code-7', 'a-very-long-group-name-that-will-not-fit', 30);
48
+ assert.ok(t.endsWith('claude-code-7'), `name survived: ${t}`);
49
+ assert.ok(t.startsWith('[') && t.includes('…]'), `group elided: ${t}`);
50
+ assert.ok(t.length <= 30, `within budget: ${t.length}`);
51
+ });
52
+ check('when the group can only show a stub, it is dropped instead', () => {
53
+ const t = sessionTitle('a-session-name-of-exactly-this-length', 'Agent-manager', 42);
54
+ assert.equal(t, 'a-session-name-of-exactly-this-length');
55
+ });
56
+ check('a name that blows the budget alone is still returned whole', () => {
57
+ const name = 'x'.repeat(80);
58
+ assert.equal(sessionTitle(name, 'Agent-manager', 42), name);
59
+ });
60
+ check('the budget is respected exactly at the boundary', () => {
61
+ // '[g] name' is 8 chars: fits a budget of 8, elides at 7.
62
+ assert.equal(sessionTitle('name', 'g', 8), '[g] name');
63
+ assert.equal(sessionTitle('name', 'group', 8), 'name');
64
+ });
65
+ check('the default budget fits a realistic pair', () => {
66
+ assert.ok(sessionTitle('claude-code-7', 'Agent-manager').length <= TAB_BUDGET);
67
+ });
68
+
69
+ console.log(failed ? `\n${failed} check(s) failed` : '\nall checks passed');
70
+ process.exit(failed ? 1 : 0);