Spaces:
Running
Title a session by the group it belongs to
Browse filesFour 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 +1 -1
- web/src/App.tsx +40 -1
- web/src/components/RemotePane.tsx +9 -3
- web/src/components/TerminalPane.tsx +15 -2
- web/src/lib/sessionTitle.ts +49 -0
- web/src/styles.css +14 -2
- web/test/sessionTitle.test.mjs +70 -0
|
@@ -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": {
|
|
@@ -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"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
)}
|
|
@@ -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' :
|
| 222 |
onDoubleClick={onRename ? () => { setTitleDraft(session.name); setEditing(true); } : undefined}
|
| 223 |
-
>
|
|
|
|
|
|
|
|
|
|
| 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}>
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|
|
@@ -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 |
+
}
|
|
@@ -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;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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); }
|
|
@@ -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);
|