From 0764f2df94d03e2a85c9d87a23e15fa15f33175d Mon Sep 17 00:00:00 2001 From: Hako Date: Thu, 5 Nov 2026 19:08:28 +0900 Subject: [PATCH 1/3] feat(sidebar): title an agent row by its renamed herdr tab Settings > Appearance gains Agent row title. Agent, the default, keeps every row as it was. Tab name titles an agent's row, and its pane's header, with the name someone gave its tab in herdr, as herdr's own agent rows lead with the tab. The row's own title (the pane's name, else the agent's title) then leads the second line, which no longer repeats the tab; a title the same as the tab is not said twice. A tab herdr still names by its place keeps today's title. The choice is shared with the other browsers of the server. Fixes #2014 Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 9 +++++ changelog.d/agent-row-title-tab-name.md | 5 +++ shared/preferences.ts | 2 +- src/App.tsx | 4 +- src/components/AgentSidebar.test.ts | 49 +++++++++++++++++++++++ src/components/AgentSidebar.tsx | 52 +++++++++++++++++-------- src/components/SettingsDialog.tsx | 3 ++ src/lib/i18n.ja.ts | 2 + src/lib/i18n.ko.ts | 2 + src/lib/i18n.zh.ts | 2 + src/lib/i18n/settings.ja.ts | 2 + src/lib/i18n/settings.ko.ts | 2 + src/lib/i18n/settings.zh.ts | 2 + src/lib/i18n/terminal.ja.ts | 1 - src/lib/i18n/terminal.ko.ts | 1 - src/lib/i18n/terminal.zh.ts | 1 - src/lib/i18n/workspace.ja.ts | 1 - src/lib/i18n/workspace.ko.ts | 1 - src/lib/i18n/workspace.zh.ts | 1 - src/lib/settings.legacy-fixture.ts | 2 +- src/lib/settings.test.ts | 10 +++++ src/lib/settings.ts | 10 ++++- src/lib/sidebarAgents.test.ts | 43 +++++++++++++++++++- src/lib/sidebarAgents.ts | 38 ++++++++++++++---- 24 files changed, 210 insertions(+), 35 deletions(-) create mode 100644 changelog.d/agent-row-title-tab-name.md diff --git a/DESIGN.md b/DESIGN.md index 8ce3b5774e..52351052aa 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1284,6 +1284,15 @@ Alert rows of the table above from the `--droplet-*` roles in `src/styles.css`. It changes only the Agents list; herdr's order and the workspace rows stay as they are. A raw counter rollback clears synthetic recency even on older bridges without an epoch. This ordering correction never changes finish identities or read records. +- Appearance's **Agent row title** is **Agent** by default: an agent row is titled by its pane's + name, else its agent's title, and a renamed tab is part of its place. **Tab name** titles the row + by the name someone gave its tab, as herdr's own agent rows lead with the tab (its default + `[ui.sidebar.agents]` rows put `tab` on the first line), so sessions of one agent read apart by + the names their tabs were given. The row's own title (the pane's name, else the agent's) then + leads its second line before the place, which no longer repeats the tab; a title the same as the + tab is not said twice. The header of an agent's pane takes the tab's name too, as its row does. + A tab herdr still names by its place keeps the row and the header as they are. The choice is + shared with the other browsers of the server (#2014). - Appearance's **Group agents by state** (off by default, kept on this device, as the sidebar filter is) moves the Agents list above the workspaces and splits its unpinned rows into groups: **Needs you** (blocked), **Working** (BG included), **Done** (a finish not read yet) diff --git a/changelog.d/agent-row-title-tab-name.md b/changelog.d/agent-row-title-tab-name.md new file mode 100644 index 0000000000..6eab8b9af0 --- /dev/null +++ b/changelog.d/agent-row-title-tab-name.md @@ -0,0 +1,5 @@ +### Added +- Settings > Appearance > Agent row title: Tab name titles each agent row, and its pane's header, with + the name you gave its tab in herdr, as herdr's own agent rows do. The agent's title (or the pane's + name) then leads the line under it. A tab herdr still names by its number keeps today's title, and + Agent, the default, leaves every row as it was. diff --git a/shared/preferences.ts b/shared/preferences.ts index ad76d09a07..597c5d1236 100644 --- a/shared/preferences.ts +++ b/shared/preferences.ts @@ -8,7 +8,7 @@ * which the server lays over what it holds, so a setting nobody changed stays each device's own. */ export const SHARED_SETTING_KEYS = [ - "theme", "palette", "darkTheme", "lightTheme", "language", "sidebarRows", "agentOrder", "agentMarks", "autoSendQueued", "autoTabNames", + "theme", "palette", "darkTheme", "lightTheme", "language", "sidebarRows", "agentOrder", "agentRowTitle", "agentMarks", "autoSendQueued", "autoTabNames", "showThinking", "quickReplies", "showQuickReplies", "showUsage", "usageCount", "usageGlance", "usageOrder", "usageHidden", "claudeLaunchMode", "codexLaunchMode", ] as const; diff --git a/src/App.tsx b/src/App.tsx index bed2404f93..d365c016e4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -51,6 +51,7 @@ import { lastViewedPane } from "./lib/lastViewedPane.ts"; import { currentSidebarPins, pinnedWorkspaceIds } from "./lib/sidebarPins.ts"; import { ReorderContext } from "./lib/machineContext.tsx"; import { useSettings } from "./lib/settings.ts"; +import { paneTabRowTitle } from "./lib/sidebarAgents.ts"; import { useShownMachines } from "./lib/hiddenMachines.ts"; import { formatKeys, shortcutDisplayKeys, useShortcuts } from "./lib/shortcuts.ts"; import { useMediaQuery } from "./lib/useMediaQuery.ts"; @@ -356,7 +357,8 @@ export function App() { // the herdr the header names as current: this PC's last answer only while it is still shown (through one // failed read, not once unavailable), though its capabilities stay in targetHerdr const shownHerdr = selectedMachineId === "local" ? shownHealth?.herdr : targetHerdr; - const selectedTitle = selectedPane ? displayPaneTitle(selectedPane) : null; + // Settings → Agent row title → Tab name: an agent's renamed tab titles the header as it titles its row + const selectedTitle = selectedPane ? paneTabRowTitle(settings.agentRowTitle, snapshot, selectedPane) ?? displayPaneTitle(selectedPane) : null; // the tab that governs the pane region, so the strip's tabs and the pane they select are // one thing to a screen reader (A4); null while no tab owns this pane const tabPanelLabel = paneTabPanelLabel(snapshot, selectedPane, t); diff --git a/src/components/AgentSidebar.test.ts b/src/components/AgentSidebar.test.ts index e8d90794b2..f061d8825a 100644 --- a/src/components/AgentSidebar.test.ts +++ b/src/components/AgentSidebar.test.ts @@ -151,3 +151,52 @@ describe("AgentSidebar, what herdr-core reports", () => { expect(lines).toEqual([["t1", false, false], ["t2", true, false], ["t5", false, true], ["t3", false, false], ["t4", false, false]]); }); }); + +describe("AgentSidebar, Settings → Agent row title (#2014)", () => { + const saved = Object.getOwnPropertyDescriptor(globalThis, "window"); + const savedLanguages = Object.getOwnPropertyDescriptor(navigator, "languages"); + let stored: Record; + beforeEach(() => { + stored = {}; + Object.defineProperty(globalThis, "window", { configurable: true, value: { matchMedia: () => ({ matches: false }), localStorage: { getItem: (key: string) => stored[key] ?? null } } }); + Object.defineProperty(navigator, "languages", { configurable: true, value: ["en"] }); + }); + afterEach(() => { + if (saved) Object.defineProperty(globalThis, "window", saved); + else Reflect.deleteProperty(globalThis, "window"); + if (savedLanguages) Object.defineProperty(navigator, "languages", savedLanguages); + else Reflect.deleteProperty(navigator, "languages"); + }); + + /** checkout-api's one tab, labelled `tabLabel`, with the agents' own titles herdr reports */ + const inTab = (tabLabel: string, panes: HerdrPane[], titles: Record): Machine => { + const base = machine(panes); + const snap = base.snapshot!; + return { ...base, snapshot: { ...snap, tabs: snap.tabs.map((tab) => ({ ...tab, label: tabLabel })), agents: snap.agents.map((agent) => ({ ...agent, title: titles[agent.pane_id] ?? null })) } }; + }; + const rows = (html: string) => ({ titles: texts(html, "agent-title"), contexts: texts(html, "agent-context") }); + const choose = (agentRowTitle: string) => { stored["ovis:settings"] = JSON.stringify({ agentRowTitle }); }; + + test("Agent, the default: the agent's title names the row and a renamed tab stays on the line under it, as before", () => { + const html = render([inTab("api review", [pane("w1:p1")], { "w1:p1": "Idempotent payments" })]); + expect(rows(html)).toEqual({ titles: ["Idempotent payments"], contexts: ["checkout-api · api review"] }); + }); + + test("Tab name: a renamed tab names the row, the agent's title leads the line under it, and the tab is not said again", () => { + choose("tab"); + const html = render([inTab("api review", [pane("w1:p1")], { "w1:p1": "Idempotent payments" })]); + expect(rows(html)).toEqual({ titles: ["api review"], contexts: ["Idempotent payments · checkout-api"] }); + expect(tooltips(html)[0]).toEqual(["w1:p1", "api review", "Idempotent payments", "checkout-api", "claude"]); + expect(html).toContain('aria-label="More for api review"'); + }); + + test("Tab name: a pane's name takes the agent's title's place, a title the same as the tab is not said twice, and a tab herdr names by its place keeps the row", () => { + choose("tab"); + const labelled = render([inTab("api review", [pane("w1:p1", { label: "mine" })], { "w1:p1": "Idempotent payments" })]); + expect(rows(labelled)).toEqual({ titles: ["api review"], contexts: ["mine · checkout-api"] }); + const same = render([inTab("api review", [pane("w1:p1")], { "w1:p1": "api review" })]); + expect(rows(same)).toEqual({ titles: ["api review"], contexts: ["checkout-api"] }); + const numbered = render([inTab("1", [pane("w1:p1")], { "w1:p1": "Idempotent payments" })]); + expect(rows(numbered)).toEqual({ titles: ["Idempotent payments"], contexts: ["checkout-api"] }); + }); +}); diff --git a/src/components/AgentSidebar.tsx b/src/components/AgentSidebar.tsx index 76efbce741..ad1aba863e 100644 --- a/src/components/AgentSidebar.tsx +++ b/src/components/AgentSidebar.tsx @@ -4,14 +4,14 @@ import { Bell, BellOff, ChevronDown, ChevronRight, Ellipsis, Pin, PinOff, Termin import type { Machine } from "../../shared/machines.ts"; import { paneStorageId } from "../../shared/machines.ts"; import { paneTokenValues } from "../../shared/pane-tokens.ts"; -import type { AgentStatus, HerdrPane, PlanSummary } from "../../shared/protocol.ts"; +import type { AgentStatus, HerdrPane, PlanSummary, TabInfo } from "../../shared/protocol.ts"; import { currentAgentRowTokens, subscribeAgentRowTokens } from "../lib/agentRowTokens.ts"; import { useT } from "../lib/i18n.ts"; import { useAnnouncedText } from "../lib/useAnnouncedText.ts"; import { agentRoster, groupAgentRows, type AgentGroup, type AgentRow } from "../lib/agentRoster.ts"; import { currentPaneMutes, paneMuteKey, prunePaneMutes, setPaneMutes, subscribePaneMutes, togglePaneMute } from "../lib/paneMutes.ts"; -import { agentContext, agentTabName, paneMark } from "../lib/sidebarAgents.ts"; -import { useSettings } from "../lib/settings.ts"; +import { agentContext, agentTabName, paneMark, tabRowTitle } from "../lib/sidebarAgents.ts"; +import { useSettings, type AgentRowTitle } from "../lib/settings.ts"; import { useSidebarActivity } from "../lib/sidebarActivity.tsx"; import { machineFilter } from "../lib/sidebarFilter.ts"; import { useSidebarFilter } from "../lib/sidebarFilterContext.ts"; @@ -30,6 +30,8 @@ interface AgentRowBodyProps { /** the kind the mark draws, left off the second line: said for a screen reader, since the mark is aria-hidden */ markKind: string | null; title: string; + /** the row's own title, first on its second line, once its renamed tab titles it (Settings → Agent row title) */ + lead: string | null; /** the live name other tools address the agent by, when it has one */ name: string | null; context: string; @@ -75,7 +77,7 @@ export function blockedLabel(pane: Pick @@ -95,7 +97,9 @@ function AgentRowBody({ mark, markKind, title, name, context, tokens, background {t("Progress {percent}%", { percent: progress })} } - {!helper && (name || context) && + {!helper && (lead || name || context) && + {lead} + {lead && (name || context) && " · "} {name && {name}} {name && context && " · "} {context} @@ -129,6 +133,19 @@ function rowTitle({ pane, agent }: AgentRow["entry"]): string { return pane.label?.trim() || agent?.title?.trim() || pane.title?.trim() || displayPaneTitle(pane); } +/** + * The title a row shows, and what then leads its second line. Under Settings → Agent row title → + * Tab name a renamed tab titles it (lib/sidebarAgents.ts tabRowTitle), and the row's own title moves + * under it unless the tab says the same, or it is only the pane's id. + */ +function rowTitles({ machine, entry }: AgentRow, setting: AgentRowTitle): { title: string; lead: string | null; tabs: TabInfo[]; byTab: boolean } { + const tabs = machine.snapshot?.tabs.filter((candidate) => candidate.workspace_id === entry.workspace.workspace_id) ?? []; + const own = rowTitle(entry); + const tabTitle = tabRowTitle(setting, entry.tab, tabs); + if (tabTitle === null) return { title: own, lead: null, tabs, byTab: false }; + return { title: tabTitle, lead: own !== tabTitle && own !== entry.pane.pane_id ? own : null, tabs, byTab: true }; +} + /** Settings → Group agents by state: each group's heading */ function groupLabel(group: AgentGroup, t: (key: string) => string): string { switch (group) { @@ -199,9 +216,9 @@ export function AgentSidebar({ machines, selectedMachineId, selectedPaneId, stat const groups = useMemo(() => !settings.agentGroups ? null : groupAgentRows(split.rest, { sort: settings.agentGroupSort, status: ({ machine, entry }) => machine.state === "connected" ? activity.status(machine.id, entry.pane) : undefined, - name: ({ entry }) => rowTitle(entry), + name: (row) => rowTitles(row, settings.agentRowTitle).title, seqs: activity.seqs, - }), [settings.agentGroups, settings.agentGroupSort, split, activity]); + }), [settings.agentGroups, settings.agentGroupSort, settings.agentRowTitle, split, activity]); // a folded list (a phone's starts so) opens while the filter keeps rows in it, so a match or the selection is // seen, not only kept; its caret then folds and opens it for the filter's time, the list's own fold untouched const [filterFold, setFilterFold] = useState(null); @@ -240,24 +257,25 @@ export function AgentSidebar({ machines, selectedMachineId, selectedPaneId, stat const machineName = (machine: Machine): string | null => machines.length > 1 ? machine.name : null; // what a row says of its agent, for the row and for its open menu - const describe = ({ machine, entry }: AgentRow) => { + const describe = (row: AgentRow) => { + const { machine, entry } = row; const { pane, workspace, tab, agent, agentLabel } = entry; const online = machine.state === "connected"; - const title = rowTitle(entry); + const { title, lead, tabs, byTab } = rowTitles(row, settings.agentRowTitle); const name = agent?.name?.trim() || null; // the name has its own place on the line: the label beside it says the kind instead const kindLabel = name !== null && agentLabel === name ? entry.canonicalAgent ?? agent?.title?.trim() ?? null : agentLabel; - const tabs = machine.snapshot?.tabs.filter((candidate) => candidate.workspace_id === workspace.workspace_id) ?? []; const mark = paneMark(entry); const markAgent = mark && hasAgentMark(mark) ? mark : null; - const parts = { agentLabel: kindLabel, title, markAgent, machineName: machineName(machine), workspaceLabel: workspace.label, tabName: agentTabName(tab, tabs, t) }; + // a tab that titles the row is not said again in its place + const parts = { agentLabel: kindLabel, title, lead, markAgent, machineName: machineName(machine), workspaceLabel: workspace.label, tabName: byTab ? null : agentTabName(tab, tabs, t) }; const contextParts = agentContext(parts); const context = contextParts.join(" · "); const kind = kindLabel?.trim() || null; - const markKind = kind && kind !== title.trim() && !contextParts.includes(kind) ? kind : null; + const markKind = kind && kind !== title.trim() && kind !== lead && !contextParts.includes(kind) ? kind : null; // a PC that is away says nothing of its agents now, its plugins' words included const tokens = online ? paneTokenValues(rowTokens, entry.canonicalAgent, pane.tokens ?? agent?.tokens) : []; - return { online, title, name, mark, context, markKind, tokens }; + return { online, title, lead, name, mark, context, markKind, tokens }; }; // a helper reads under the agent that started it when that row is just above it in the same list (lib/agentRoster.ts) @@ -314,14 +332,14 @@ export function AgentSidebar({ machines, selectedMachineId, selectedPaneId, stat const selected = machine.id === selectedMachineId && pane.pane_id === selectedPaneId; // under the filter a row it did not match is there for the selection: drawn as context, and said so const filterContext = filter.active && machineFilter(filter, machine.id)?.matchedPanes.has(pane.pane_id) !== true; - const { online, title, name, mark, context, markKind, tokens } = describe(row); + const { online, title, lead, name, mark, context, markKind, tokens } = describe(row); // the row cuts a long value: the tooltip, and a phone's menu sheet, say each in full const blocked = online ? blockedNote(pane, t) : null; const blockedShort = online ? blockedLabel(pane, t) : null; const startedBy = parentRow ? t("Started by {name}", { name: describe(parentRow).title }) : null; const status = online ? activity.status(machine.id, pane) : undefined; const progress = online && pane.agent_progress !== undefined && (status === "working" || status === "blocked") ? t("Progress {percent}%", { percent: pane.agent_progress }) : null; - const tooltip = [...new Set([pane.pane_id, title, context, startedBy, blocked, progress, ...tokens, markKind, name, agent?.display_agent, pane.cwd, online ? null : stateWord(machine)].filter(Boolean))].join("\n"); + const tooltip = [...new Set([pane.pane_id, title, lead, context, startedBy, blocked, progress, ...tokens, markKind, name, agent?.display_agent, pane.cwd, online ? null : stateWord(machine)].filter(Boolean))].join("\n"); const pinKey = sidebarPinKey(machine.id, pane.pane_id); const menuOpen = menu?.key === pinKey; // one menu for the ⋯ and a right-click, which opens it at the pointer and leaves the focus where it was @@ -339,7 +357,7 @@ export function AgentSidebar({ machines, selectedMachineId, selectedPaneId, stat event.dataTransfer.setData(PANE_DRAG_TYPE, paneDragData({ machineId: machine.id, paneId: pane.pane_id })); }}> {/* a saved roster's state is not news: a PC that is away says nothing about its agents */} - 0} /> + 0} />
- {menu && menuShown && runPin(menu) } : { id: "pin", label: t("Pin agent"), icon: Pin, run: () => runPin(menu) }, diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index 160d079228..846b81080b 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -397,6 +397,9 @@ function AppearancePage() { update({ agentOrder })} options={[{ value: "workspace", label: t("Workspaces") }, { value: "activity", label: t("Activity") }]} /> + + update({ agentRowTitle })} options={[{ value: "agent", label: t("Agent") }, { value: "tab", label: t("Tab name") }]} /> + update({ agentGroups })} /> diff --git a/src/lib/i18n.ja.ts b/src/lib/i18n.ja.ts index c4cf94349b..350fd6d529 100644 --- a/src/lib/i18n.ja.ts +++ b/src/lib/i18n.ja.ts @@ -204,6 +204,8 @@ const BASE: Record = { "Submit": "送信", "Recommended": "推奨", "Agents": "エージェント", + "Agent": "エージェント", + "Tab name": "タブ名", "Check again": "再確認", "Setup guide": "セットアップガイド", "Could not confirm this browser is paired. Check that cookies are enabled, then try again.": "このブラウザーがペアリング済みであることを確認できませんでした。Cookie が有効か確認して、もう一度お試しください。", diff --git a/src/lib/i18n.ko.ts b/src/lib/i18n.ko.ts index 50a129b1e3..a4b502714a 100644 --- a/src/lib/i18n.ko.ts +++ b/src/lib/i18n.ko.ts @@ -202,6 +202,8 @@ const BASE: Record = { "Submit": "제출", "Recommended": "추천", "Agents": "에이전트", + "Agent": "에이전트", + "Tab name": "탭 이름", "Check again": "다시 확인", "Setup guide": "설정 안내", "Could not confirm this browser is paired. Check that cookies are enabled, then try again.": "이 브라우저의 페어링을 확인할 수 없습니다. 쿠키가 허용되어 있는지 확인한 뒤 다시 시도하세요.", diff --git a/src/lib/i18n.zh.ts b/src/lib/i18n.zh.ts index 2a89328897..dc2aed69f4 100644 --- a/src/lib/i18n.zh.ts +++ b/src/lib/i18n.zh.ts @@ -206,6 +206,8 @@ const BASE: Record = { "Submit": "提交", "Recommended": "推荐", "Agents": "代理", + "Agent": "Agent", + "Tab name": "标签页名称", "Check again": "重新检查", "Setup guide": "设置向导", "Could not confirm this browser is paired. Check that cookies are enabled, then try again.": "无法确认此浏览器已配对。请检查是否允许 Cookie,然后重试。", diff --git a/src/lib/i18n/settings.ja.ts b/src/lib/i18n/settings.ja.ts index 2488e4ccd7..5c60093b7f 100644 --- a/src/lib/i18n/settings.ja.ts +++ b/src/lib/i18n/settings.ja.ts @@ -125,6 +125,8 @@ export const SETTINGS_JA: Record = { "Show the Filter field above the sidebar's lists on this device. When off, Filter workspaces and agents in the command palette opens it.": "このデバイスで、サイドバーの一覧の上に絞り込み欄を表示します。オフのときは、コマンドパレットの「ワークスペースとエージェントを絞り込む」で開きます。", "Agents order": "エージェントの並び順", "Activity keeps a waiting agent on top, then the one that changed last; herdr's own order is not changed": "アクティビティ順では、応答待ちのエージェントを一番上に、その後は最近変化した順に並べます。herdr 自体の順序は変わりません", + "Agent row title": "エージェント行のタイトル", + "Title each agent row, and its pane's header, with the agent's own title or with the name you gave its tab. A tab still named by its number keeps the agent's title.": "各エージェント行とそのパネルのヘッダーのタイトルに、エージェント自身のタイトルか、タブに付けた名前を使います。まだ番号で呼ばれているタブはエージェントのタイトルのままです。", "Workspaces": "ワークスペース順", "Activity": "アクティビティ順", "Agent logos": "エージェントのロゴ", diff --git a/src/lib/i18n/settings.ko.ts b/src/lib/i18n/settings.ko.ts index 3f028b014e..10ed399bc2 100644 --- a/src/lib/i18n/settings.ko.ts +++ b/src/lib/i18n/settings.ko.ts @@ -125,6 +125,8 @@ export const SETTINGS_KO: Record = { "Show the Filter field above the sidebar's lists on this device. When off, Filter workspaces and agents in the command palette opens it.": "이 기기에서 사이드바 목록 위에 필터 입력란을 보여 줍니다. 끄면 명령 팔레트의 워크스페이스와 에이전트 필터로 엽니다.", "Agents order": "에이전트 정렬", "Activity keeps a waiting agent on top, then the one that changed last; herdr's own order is not changed": "활동순은 응답을 기다리는 에이전트를 맨 위에 두고, 그다음 가장 최근에 바뀐 순서로 놓습니다. herdr 자체의 순서는 바뀌지 않습니다", + "Agent row title": "에이전트 행 제목", + "Title each agent row, and its pane's header, with the agent's own title or with the name you gave its tab. A tab still named by its number keeps the agent's title.": "각 에이전트 행과 그 패널의 헤더 제목을 에이전트 자체의 제목이나 탭에 지어 준 이름으로 표시합니다. 아직 번호로만 불리는 탭은 에이전트의 제목을 그대로 씁니다.", "Workspaces": "워크스페이스순", "Activity": "활동순", "Agent logos": "에이전트 로고", diff --git a/src/lib/i18n/settings.zh.ts b/src/lib/i18n/settings.zh.ts index d41b35b6af..a3823f7f5f 100644 --- a/src/lib/i18n/settings.zh.ts +++ b/src/lib/i18n/settings.zh.ts @@ -125,6 +125,8 @@ export const SETTINGS_ZH: Record = { "Show the Filter field above the sidebar's lists on this device. When off, Filter workspaces and agents in the command palette opens it.": "在此设备的侧边栏列表上方显示筛选框。关闭时,可从命令面板的“筛选工作区和代理”打开。", "Agents order": "代理排序", "Activity keeps a waiting agent on top, then the one that changed last; herdr's own order is not changed": "按活动排序会把等待回应的代理放在最上面,其余按最近变化排序;herdr 自身的顺序不会改变", + "Agent row title": "代理行标题", + "Title each agent row, and its pane's header, with the agent's own title or with the name you gave its tab. A tab still named by its number keeps the agent's title.": "用代理自己的标题,或你为其标签页取的名称,作为每个代理行及其面板标题栏的标题。仍以编号命名的标签页保留代理的标题。", "Workspaces": "工作区顺序", "Activity": "按活动", "Agent logos": "代理标志", diff --git a/src/lib/i18n/terminal.ja.ts b/src/lib/i18n/terminal.ja.ts index a2b8325779..25a8de345e 100644 --- a/src/lib/i18n/terminal.ja.ts +++ b/src/lib/i18n/terminal.ja.ts @@ -86,7 +86,6 @@ export const TERMINAL_JA: Record = { "New terminal failed: {reason}": "新しいターミナルを開けませんでした: {reason}", "Tab {n}": "タブ {n}", "Close tab": "タブを閉じる", - "Tab name": "タブ名", "Actions for {tab}": "{tab} の操作", "Close tab {name}?": "タブ {name} を閉じますか?", "Close failed: {reason}": "閉じられませんでした: {reason}", diff --git a/src/lib/i18n/terminal.ko.ts b/src/lib/i18n/terminal.ko.ts index 5ba2a669f9..cd2a4d6428 100644 --- a/src/lib/i18n/terminal.ko.ts +++ b/src/lib/i18n/terminal.ko.ts @@ -86,7 +86,6 @@ export const TERMINAL_KO: Record = { "New terminal failed: {reason}": "새 터미널 열기 실패: {reason}", "Tab {n}": "탭 {n}", "Close tab": "탭 닫기", - "Tab name": "탭 이름", "Actions for {tab}": "{tab} 작업", "Close tab {name}?": "{name} 탭을 닫을까요?", "Close failed: {reason}": "닫기 실패: {reason}", diff --git a/src/lib/i18n/terminal.zh.ts b/src/lib/i18n/terminal.zh.ts index 26db4d7479..66151f3a8a 100644 --- a/src/lib/i18n/terminal.zh.ts +++ b/src/lib/i18n/terminal.zh.ts @@ -86,7 +86,6 @@ export const TERMINAL_ZH: Record = { "New terminal failed: {reason}": "新建终端失败:{reason}", "Tab {n}": "标签页 {n}", "Close tab": "关闭标签页", - "Tab name": "标签页名称", "Actions for {tab}": "{tab} 的操作", "Close tab {name}?": "关闭标签页 {name}?", "Close failed: {reason}": "关闭失败:{reason}", diff --git a/src/lib/i18n/workspace.ja.ts b/src/lib/i18n/workspace.ja.ts index 602ca5e9da..a93066e837 100644 --- a/src/lib/i18n/workspace.ja.ts +++ b/src/lib/i18n/workspace.ja.ts @@ -42,7 +42,6 @@ export const WORKSPACE_JA: Record = { "Resuming…": "再開中…", "Started {started} · last active {updated}": "開始 {started} · 最終使用 {updated}", "Close dialog": "ダイアログを閉じる", - "Agent": "エージェント", "Directory": "ディレクトリ", "absolute path or ~/…": "絶対パスまたは ~/…", "Optional workspace label": "ワークスペースのラベル (任意)", diff --git a/src/lib/i18n/workspace.ko.ts b/src/lib/i18n/workspace.ko.ts index b4b1979e24..6de26c2236 100644 --- a/src/lib/i18n/workspace.ko.ts +++ b/src/lib/i18n/workspace.ko.ts @@ -42,7 +42,6 @@ export const WORKSPACE_KO: Record = { "Resuming…": "이어하는 중…", "Started {started} · last active {updated}": "{started}에 시작 · 마지막 사용 {updated}", "Close dialog": "창 닫기", - "Agent": "에이전트", "Directory": "폴더", "absolute path or ~/…": "절대 경로 또는 ~/…", "Optional workspace label": "워크스페이스 이름 (선택)", diff --git a/src/lib/i18n/workspace.zh.ts b/src/lib/i18n/workspace.zh.ts index aa5c4e1582..badf0cc6bc 100644 --- a/src/lib/i18n/workspace.zh.ts +++ b/src/lib/i18n/workspace.zh.ts @@ -42,7 +42,6 @@ export const WORKSPACE_ZH: Record = { "Resuming…": "正在继续…", "Started {started} · last active {updated}": "开始于 {started} · 最后使用 {updated}", "Close dialog": "关闭对话框", - "Agent": "Agent", "Directory": "目录", "absolute path or ~/…": "绝对路径或 ~/…", "Optional workspace label": "工作区名称(可选)", diff --git a/src/lib/settings.legacy-fixture.ts b/src/lib/settings.legacy-fixture.ts index bf267b02e4..569d8ef141 100644 --- a/src/lib/settings.legacy-fixture.ts +++ b/src/lib/settings.legacy-fixture.ts @@ -9,7 +9,7 @@ import { CHAT_WIDTHS, DICTATION_LANGUAGES, DIFF_STYLES, MARKDOWN_WIDTHS, VOICE_B QUICK_REPLIES_MAX, QUICK_REPLY_MAX_CHARS, USAGE_KEYS_MAX, TERMINAL_FONT_MIN, TERMINAL_FONT_MAX, TERMINAL_WHEEL_SPEED_MIN, TERMINAL_WHEEL_SPEED_MAX, CHAT_FONT_MIN, CHAT_FONT_MAX, type Settings as CurrentSettings, type ChatWidth, type DictationLanguage, type VoiceButton } from "./settings.ts"; -type Settings = Omit; +type Settings = Omit; type Palette = "amber" | "charcoal" | "catppuccin"; const PALETTES: readonly Palette[] = ["amber", "charcoal", "catppuccin"]; const STORAGE_KEY = "ovis:settings"; diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts index 1ed688e3c2..cc71c36545 100644 --- a/src/lib/settings.test.ts +++ b/src/lib/settings.test.ts @@ -349,6 +349,16 @@ it("keeps sidebar rows on one line unless two lines were chosen", () => { } }); +it("titles agent rows by their agent unless Tab name was chosen, for every browser of the server", () => { + expect(DEFAULT_SETTINGS.agentRowTitle).toBe("agent"); + expect(sanitizeSettings({}).agentRowTitle).toBe("agent"); + expect(sanitizeSettings({ agentRowTitle: "tab" }).agentRowTitle).toBe("tab"); + for (const agentRowTitle of [null, "Tab", "pane", 1]) { + expect(sanitizeSettings({ agentRowTitle }).agentRowTitle).toBe("agent"); + } + expect(SETTING_SCOPE.agentRowTitle).toBe("shared"); +}); + it("draws agent logos in their brand colors unless Mono was chosen", () => { expect(DEFAULT_SETTINGS.agentMarks).toBe("color"); expect(sanitizeSettings({}).agentMarks).toBe("color"); diff --git a/src/lib/settings.ts b/src/lib/settings.ts index abfdd1e774..9e71663cf1 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -25,6 +25,8 @@ export type Density = "compact" | "comfortable"; export type SidebarRows = "one" | "two"; /** the Agents list's order: herdr's workspace order, or a waiting agent first and then the latest change */ export type AgentOrder = "workspace" | "activity"; +/** what titles an agent row and its pane's header: its agent's title, or the name someone gave its tab in herdr (lib/sidebarAgents.ts tabRowTitle) */ +export type AgentRowTitle = "agent" | "tab"; /** Settings → Within a state: how each state group of the Agents list is ordered (lib/agentRoster.ts) */ export const AGENT_GROUP_SORTS = ["wait", "recent", "name"] as const; export type AgentGroupSort = (typeof AGENT_GROUP_SORTS)[number]; @@ -87,6 +89,8 @@ export interface Settings { sidebarFilter: boolean; /** The Agents list's order. Activity is display-only: herdr's own order never changes. */ agentOrder: AgentOrder; + /** Tab name titles an agent's row and its pane's header with its renamed tab, as herdr's own agent rows lead with the tab (#2014) */ + agentRowTitle: AgentRowTitle; /** this device lists the agents above the workspaces, in groups by state (lib/agentRoster.ts groupAgentRows); off, the list is as agentOrder says */ agentGroups: boolean; /** the order within each state group while agentGroups is on */ @@ -233,7 +237,7 @@ type TelemetrySourceSettings = Omit; + | "expandThinking" | "foldWork" | "autoTabNames" | "voiceEngine" | "agentGroups" | "agentGroupSort" | "agentRowTitle">; /** Compare structured custom settings by content against this app's built-in defaults. */ function differsFromDefault(value: unknown, defaultValue: unknown): boolean { @@ -304,6 +308,8 @@ export function telemetrySettingsSnapshot(settings: Settings): TelemetrySettings */ export const SETTING_SCOPE = { theme: "shared", palette: "shared", darkTheme: "shared", lightTheme: "shared", language: "shared", sidebarRows: "shared", agentOrder: "shared", + // a tab's name is herdr's, the same in every browser + agentRowTitle: "shared", agentMarks: "shared", autoSendQueued: "shared", autoTabNames: "shared", showThinking: "shared", quickReplies: "shared", showQuickReplies: "shared", showUsage: "shared", usageCount: "shared", usageGlance: "shared", usageOrder: "shared", usageHidden: "shared", defaultView: "device", suggestionChip: "device", @@ -350,6 +356,7 @@ export const DEFAULT_SETTINGS: Settings = { sidebarRows: "one", sidebarFilter: true, agentOrder: "workspace", + agentRowTitle: "agent", agentGroups: false, agentGroupSort: "wait", agentMarks: "color", @@ -537,6 +544,7 @@ export function sanitizeSettings(raw: unknown): Settings { sidebarRows: record["sidebarRows"] === "one" || record["sidebarRows"] === "two" ? record["sidebarRows"] : DEFAULT_SETTINGS.sidebarRows, sidebarFilter: typeof record["sidebarFilter"] === "boolean" ? record["sidebarFilter"] : DEFAULT_SETTINGS.sidebarFilter, agentOrder: record["agentOrder"] === "workspace" || record["agentOrder"] === "activity" ? record["agentOrder"] : DEFAULT_SETTINGS.agentOrder, + agentRowTitle: record["agentRowTitle"] === "agent" || record["agentRowTitle"] === "tab" ? record["agentRowTitle"] : DEFAULT_SETTINGS.agentRowTitle, agentGroups: typeof record["agentGroups"] === "boolean" ? record["agentGroups"] : DEFAULT_SETTINGS.agentGroups, agentGroupSort: AGENT_GROUP_SORTS.includes(record["agentGroupSort"] as AgentGroupSort) ? record["agentGroupSort"] as AgentGroupSort : DEFAULT_SETTINGS.agentGroupSort, agentMarks: record["agentMarks"] === "color" || record["agentMarks"] === "mono" ? record["agentMarks"] : DEFAULT_SETTINGS.agentMarks, diff --git a/src/lib/sidebarAgents.test.ts b/src/lib/sidebarAgents.test.ts index 123cc18144..5ba98d686f 100644 --- a/src/lib/sidebarAgents.test.ts +++ b/src/lib/sidebarAgents.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import type { AgentInfo, HerdrPane, SessionSnapshot, TabInfo, WorkspaceInfo } from "../../shared/protocol.ts"; -import { agentContext, agentTabName, paneMark, sidebarAgents, workspaceAgentLabels } from "./sidebarAgents.ts"; +import { agentContext, agentTabName, paneMark, paneTabRowTitle, sidebarAgents, tabRowTitle, workspaceAgentLabels } from "./sidebarAgents.ts"; function workspace(id: string): WorkspaceInfo { return { workspace_id: id, active_tab_id: `${id}:t1`, label: id, number: 1, tab_count: 1, pane_count: 1, focused: false, agent_status: "idle" }; @@ -128,6 +128,11 @@ describe("agentContext", () => { expect(agentContext({ ...base, agentLabel: "Claude", markAgent: "claude" })).toEqual(["checkout-api"]); expect(agentContext({ ...base, agentLabel: "reviewer-bot", markAgent: "claude" })).toEqual(["reviewer-bot", "checkout-api"]); }); + + it("does not say the kind again after the row's own title leads the line (Agent row title: Tab name)", () => { + expect(agentContext({ ...base, title: "api review", lead: "claude" })).toEqual(["checkout-api"]); + expect(agentContext({ ...base, title: "api review", lead: "Idempotent payments" })).toEqual(["claude", "checkout-api"]); + }); }); describe("agentTabName", () => { @@ -149,3 +154,39 @@ describe("agentTabName", () => { expect(agentTabName(survivor, [survivor], t)).toBeNull(); }); }); + +describe("tabRowTitle", () => { + const renamed = { ...tab("w1:t1", "w1"), label: "api review" }; + + it("names an agent row by the tab someone renamed, only under Tab name", () => { + expect(tabRowTitle("agent", renamed, [renamed])).toBeNull(); + expect(tabRowTitle("tab", renamed, [renamed])).toBe("api review"); + expect(tabRowTitle("tab", { ...renamed, label: " api review " }, [renamed])).toBe("api review"); + expect(tabRowTitle("tab", null, [renamed])).toBeNull(); + }); + + it("keeps the row's own title for a tab herdr names by its place, the one that moved up after a close included", () => { + const first = { ...tab("w1:t1", "w1"), label: "1" }; + const second = { ...tab("w1:t2", "w1"), label: "2", number: 2 }; + expect(tabRowTitle("tab", first, [first, second])).toBeNull(); + expect(tabRowTitle("tab", second, [first, second])).toBeNull(); + // herdr relabels the survivor "1" and leaves its number at 2 + const survivor = { ...second, label: "1" }; + expect(tabRowTitle("tab", survivor, [survivor])).toBeNull(); + }); +}); + +describe("paneTabRowTitle", () => { + it("titles an agent pane's header as its row, never a shell's", () => { + const claude = pane("claude", { agent: "claude" }); + const shell = pane("shell"); + const named: SessionSnapshot = { ...snapshot([claude, shell]), tabs: [{ ...tab("w1:t1", "w1"), label: "api review" }] }; + expect(paneTabRowTitle("tab", named, claude)).toBe("api review"); + expect(paneTabRowTitle("agent", named, claude)).toBeNull(); + expect(paneTabRowTitle("tab", named, shell)).toBeNull(); + // an agent herdr lists before its pane says the kind has a row all the same + expect(paneTabRowTitle("tab", { ...named, agents: [agent(shell)] }, shell)).toBe("api review"); + expect(paneTabRowTitle("tab", { ...named, tabs: [{ ...tab("w1:t1", "w1"), label: "1" }] }, claude)).toBeNull(); + expect(paneTabRowTitle("tab", null, claude)).toBeNull(); + }); +}); diff --git a/src/lib/sidebarAgents.ts b/src/lib/sidebarAgents.ts index be56be4691..f6741393d8 100644 --- a/src/lib/sidebarAgents.ts +++ b/src/lib/sidebarAgents.ts @@ -1,4 +1,5 @@ -import type { AgentInfo, HerdrPane, SessionSnapshot, TabInfo, WorkspaceInfo } from "../../shared/protocol.ts"; +import type { AgentInfo, HerdrPane, PaneInfo, SessionSnapshot, TabInfo, WorkspaceInfo } from "../../shared/protocol.ts"; +import type { AgentRowTitle } from "./settings.ts"; import { customTabLabel, tabLabel } from "./tabName.ts"; export interface SidebarAgent { @@ -83,20 +84,43 @@ export function workspaceAgentLabels(agents: readonly SidebarAgent[]): Map candidate.tab_id === tab.tab_id) + 1 || tab.number; +} + +/** The tab an agent row names, as herdr's agents panel does: only when its workspace has two or more tabs or the tab was renamed. */ export function agentTabName(tab: TabInfo | null | undefined, workspaceTabs: readonly TabInfo[], t: Parameters[1]): string | null { if (!tab) return null; - const place = workspaceTabs.findIndex((candidate) => candidate.tab_id === tab.tab_id) + 1 || tab.number; + const place = tabPlace(tab, workspaceTabs); return workspaceTabs.length > 1 || customTabLabel(tab, place) ? tabLabel(tab, t, place) : null; } +/** + * Settings → Agent row title → Tab name: the name someone gave an agent's tab titles its row and its + * pane's header, as herdr's own agent rows lead with the tab (#2014). null under Agent, and for a + * tab herdr still names by its place, which keeps the row's own title. + */ +export function tabRowTitle(setting: AgentRowTitle, tab: TabInfo | null | undefined, workspaceTabs: readonly TabInfo[]): string | null { + return setting === "tab" && tab ? customTabLabel(tab, tabPlace(tab, workspaceTabs)) : null; +} + +/** The same for the pane a header shows: only an agent's pane has a row to match (sidebarAgents), never a shell's. */ +export function paneTabRowTitle(setting: AgentRowTitle, snapshot: SessionSnapshot | null, pane: PaneInfo): string | null { + if (setting !== "tab" || !snapshot) return null; + if (!nonblank(pane.agent) && !snapshot.agents.some((agent) => agent.pane_id === pane.pane_id)) return null; + const tabs = snapshot.tabs.filter((tab) => tab.workspace_id === pane.workspace_id); + return tabRowTitle(setting, tabs.find((tab) => tab.tab_id === pane.tab_id), tabs); +} + export interface AgentContextParts { agentLabel: string | null; /** the row's first line; an agent named like it is not said twice */ title: string; + /** what leads the second line once a tab titles the row (tabRowTitle): the row's own title, which the kind does not repeat either */ + lead?: string | null; /** the agent the row's mark draws, null for the generic mark: a kind the mark already says is not written again */ markAgent: string | null; /** null with a single PC: herdr does not name a lone machine either */ @@ -106,11 +130,11 @@ export interface AgentContextParts { } /** An agent row's second line: who it is, then where it runs, without repeating a word the row already says. */ -export function agentContext({ agentLabel, title, markAgent, machineName, workspaceLabel, tabName }: AgentContextParts): string[] { +export function agentContext({ agentLabel, title, lead, markAgent, machineName, workspaceLabel, tabName }: AgentContextParts): string[] { const agent = nonblank(agentLabel); const tab = nonblank(tabName); return [ - agent && agent !== title.trim() && agent.toLowerCase() !== markAgent?.toLowerCase() ? agent : null, + agent && agent !== title.trim() && agent !== nonblank(lead) && agent.toLowerCase() !== markAgent?.toLowerCase() ? agent : null, nonblank(machineName), workspaceLabel, tab && tab !== workspaceLabel ? tab : null, From 6231212fdb5e1dfb02d06fad1062fe54d4e2c75c Mon Sep 17 00:00:00 2001 From: Hako Date: Thu, 5 Nov 2026 19:17:12 +0900 Subject: [PATCH 2/3] fix(sidebar): keep a helper's own title and offer the tab title in the filter Under Agent row title: Tab name, a helper drawn under the agent that started it keeps its own title: its agent's row already names their tab, and a helper has no second line to move the title to, so two rows of one tab no longer read the same. An agent's name the same as the tab is not said again under it, the split view's fallback bar of a half takes the tab's name as the header does, and the Filter field suggests the tab's name that titles a row. Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 9 ++++++--- src/App.tsx | 2 +- src/components/AgentSidebar.test.ts | 17 +++++++++++++++++ src/components/AgentSidebar.tsx | 16 +++++++++------- src/components/MachineSidebar.tsx | 4 ++-- src/lib/sidebarFilter.test.ts | 12 ++++++++++++ src/lib/sidebarFilter.ts | 11 +++++++---- 7 files changed, 54 insertions(+), 17 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 254f90a800..3f95a564ac 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1290,9 +1290,12 @@ Alert rows of the table above from the `--droplet-*` roles in `src/styles.css`. `[ui.sidebar.agents]` rows put `tab` on the first line), so sessions of one agent read apart by the names their tabs were given. The row's own title (the pane's name, else the agent's) then leads its second line before the place, which no longer repeats the tab; a title the same as the - tab is not said twice. The header of an agent's pane takes the tab's name too, as its row does. - A tab herdr still names by its place keeps the row and the header as they are. The choice is - shared with the other browsers of the server (#2014). + tab is not said twice, nor is an agent's name the same as the tab. A helper drawn under the agent + that started it keeps its own title: its agent's row already names the tab, and a helper has no + second line. The header of an agent's pane takes the tab's name too, as its row does, and the + Filter field suggests it; the frames of a tab's panes keep their own titles under the tab strip + that names it. A tab herdr still names by its place keeps the row and the header as they are. The + choice is shared with the other browsers of the server (#2014). - Appearance's **Group agents by state** (off by default, kept on this device, as the sidebar filter is) moves the Agents list above the workspaces and splits its unpinned rows into groups: **Needs you** (blocked), **Working** (BG included), **Done** (a finish not read yet) diff --git a/src/App.tsx b/src/App.tsx index 193971749d..f2795f27f1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -920,7 +920,7 @@ export function App() { : undefined; // a half with no strip to carry its close (its pane gone, or the half crashed) keeps one in a bar of its own - const slotBar = splitOn ?
{pane ? displayPaneTitle(pane) : t("Pane closed")}{slotEnd}
: null; + const slotBar = splitOn ?
{pane ? paneTabRowTitle(settings.agentRowTitle, slotSnapshot, pane) ?? displayPaneTitle(pane) : t("Pane closed")}{slotEnd}
: null; return ( item.id)}>
{ const numbered = render([inTab("1", [pane("w1:p1")], { "w1:p1": "Idempotent payments" })]); expect(rows(numbered)).toEqual({ titles: ["Idempotent payments"], contexts: ["checkout-api"] }); }); + + test("Tab name: a helper under the agent that started it keeps its own title, which has no second line to move to", () => { + choose("tab"); + const html = render([inTab("api review", [pane("w1:p1"), pane("w1:p2", { parent_pane_id: "w1:p1" })], { "w1:p1": "Idempotent payments", "w1:p2": "Write the tests" })]); + expect(rows(html)).toEqual({ titles: ["api review", "Write the tests"], contexts: ["Idempotent payments · checkout-api"] }); + expect(tooltips(html)[1]).toContain("Started by api review"); + expect(html).toContain('aria-label="More for Write the tests"'); + }); + + test("Tab name: an agent whose name is its tab's is not named again under it", () => { + choose("tab"); + const base = inTab("api review", [pane("w1:p1")], { "w1:p1": "Idempotent payments" }); + const named = { ...base, snapshot: { ...base.snapshot!, agents: base.snapshot!.agents.map((agent) => ({ ...agent, name: "api review" })) } }; + const html = render([named]); + expect(rows(html)).toEqual({ titles: ["api review"], contexts: ["Idempotent payments · checkout-api"] }); + expect(html).not.toContain('class="agent-name"'); + }); }); diff --git a/src/components/AgentSidebar.tsx b/src/components/AgentSidebar.tsx index 9fe1261a94..66dc5f85cf 100644 --- a/src/components/AgentSidebar.tsx +++ b/src/components/AgentSidebar.tsx @@ -279,12 +279,13 @@ export function AgentSidebar({ machines, selectedMachineId, selectedPaneId, stat // herdr names a PC only beside another: the rows' line does the same const machineName = (machine: Machine): string | null => machines.length > 1 ? machine.name : null; - // what a row says of its agent, for the row and for its open menu - const describe = (row: AgentRow) => { + // what a row says of its agent, for the row and for its open menu. A helper keeps its own title: it reads + // under the agent that started it, whose row already names their tab, and it has no second line to move it to + const describe = (row: AgentRow, helper = false) => { const { machine, entry } = row; const { pane, workspace, tab, agent, agentLabel } = entry; const online = machine.state === "connected"; - const { title, lead, tabs, byTab } = rowTitles(row, settings.agentRowTitle); + const { title, lead, tabs, byTab } = rowTitles(row, helper ? "agent" : settings.agentRowTitle); const name = agent?.name?.trim() || null; // the name has its own place on the line: the label beside it says the kind instead const kindLabel = name !== null && agentLabel === name ? entry.canonicalAgent ?? agent?.title?.trim() ?? null : agentLabel; @@ -298,7 +299,8 @@ export function AgentSidebar({ machines, selectedMachineId, selectedPaneId, stat const markKind = kind && kind !== title.trim() && kind !== lead && !contextParts.includes(kind) ? kind : null; // a PC that is away says nothing of its agents now, its plugins' words included const tokens = online ? paneTokenValues(rowTokens, entry.canonicalAgent, pane.tokens ?? agent?.tokens) : []; - return { online, title, lead, name, mark, context, markKind, tokens }; + // nor is a name the same as the tab that titles the row + return { online, title, lead, name: byTab && name === title ? null : name, mark, context, markKind, tokens }; }; // a helper reads under the agent that started it when that row is just above it in the same list (lib/agentRoster.ts) @@ -355,11 +357,11 @@ export function AgentSidebar({ machines, selectedMachineId, selectedPaneId, stat const selected = machine.id === selectedMachineId && pane.pane_id === selectedPaneId; // under the filter a row it did not match is there for the selection: drawn as context, and said so const filterContext = filter.active && machineFilter(filter, machine.id)?.matchedPanes.has(pane.pane_id) !== true; - const { online, title, lead, name, mark, context, markKind, tokens } = describe(row); + const { online, title, lead, name, mark, context, markKind, tokens } = describe(row, depth > 0); // the row cuts a long value: the tooltip, and a phone's menu sheet, say each in full const blocked = online ? blockedNote(pane, t) : null; const blockedShort = online ? blockedLabel(pane, t) : null; - const startedBy = parentRow ? t("Started by {name}", { name: describe(parentRow).title }) : null; + const startedBy = parentRow ? t("Started by {name}", { name: describe(parentRow, depth > 1).title }) : null; const status = online ? activity.status(machine.id, pane) : undefined; const progress = online && pane.agent_progress !== undefined && (status === "working" || status === "blocked") ? t("Progress {percent}%", { percent: pane.agent_progress }) : null; const tooltip = [...new Set([pane.pane_id, title, lead, context, startedBy, blocked, progress, ...tokens, markKind, name, agent?.display_agent, pane.cwd, online ? null : stateWord(machine)].filter(Boolean))].join("\n"); @@ -397,7 +399,7 @@ export function AgentSidebar({ machines, selectedMachineId, selectedPaneId, stat // under the filter an empty list is the filter's doing, which the list above says once (MachineSidebar.tsx) const shownEmpty = useAnnouncedText(rows.length === 0 && !filter.active ? (loading ? t("Loading agents…") : t("No agents running")) : null); const menuPinned = menu !== null && pinned.includes(menu.key); - const menuShown = menuRow === undefined ? null : describe(menuRow); + const menuShown = menuRow === undefined ? null : describe(menuRow, (restDepths.get(menuRow) ?? 0) > 0); // a phone has no tooltip: its sheet says a blocked agent's whole reason, as the row's tooltip does const menuBlocked = menuRow !== undefined && menuShown?.online && activity.status(menuRow.machine.id, menuRow.entry.pane) === "blocked" ? blockedNote(menuRow.entry.pane, t) : null; const menuMuted = menuRow !== undefined && muted !== null && muted.includes(paneMuteKey(menuRow.machine.id, menuRow.entry.pane.pane_id)); diff --git a/src/components/MachineSidebar.tsx b/src/components/MachineSidebar.tsx index 1e674752a0..61f2686b11 100644 --- a/src/components/MachineSidebar.tsx +++ b/src/components/MachineSidebar.tsx @@ -74,12 +74,12 @@ export function MachineSidebar(props: Props) { const loneHostName = loneHost === null ? null : props.machines[0]!.name || null; const filter = useMemo(() => sidebarFilter(query, props.machines, { machineId: props.selectedMachineId, paneId: props.selectedPaneId }, t, branchLabels, unnamed), [query, props.machines, props.selectedMachineId, props.selectedPaneId, t, branchLabels, unnamed]); const filterView = useMemo(() => ({ filter, reportBranches }), [filter, reportBranches]); + const { settings } = useSettings(); // the field offers the labels the rows show, as the browser's own suggestions: nothing typed is kept - const suggestions = useMemo(() => filterSuggestions(props.machines, branchLabels, unnamed), [props.machines, branchLabels, unnamed]); + const suggestions = useMemo(() => filterSuggestions(props.machines, branchLabels, unnamed, settings.agentRowTitle), [props.machines, branchLabels, unnamed, settings.agentRowTitle]); // Settings → Appearance → Sidebar filter off leaves the field out until the palette's Filter workspaces and // agents asks for it (`filterRequest`); it stays while it holds words, and goes on its close, Escape in it // empty, the focus leaving the sidebar while it is empty, or the phone's drawer closing - const { settings } = useSettings(); const shell = useRef(null); const [filterAsked, setFilterAsked] = useState(false); const closable = !settings.sidebarFilter && filterAsked; diff --git a/src/lib/sidebarFilter.test.ts b/src/lib/sidebarFilter.test.ts index 281999c715..667c9c8802 100644 --- a/src/lib/sidebarFilter.test.ts +++ b/src/lib/sidebarFilter.test.ts @@ -241,6 +241,18 @@ describe("sidebarFilter", () => { expect(roster).toEqual(machines()); }); + it("suggests a renamed tab that titles an agent's row under Agent row title: Tab name, never a shell's tab", () => { + const roster = machines(); + roster[0]!.snapshot!.tabs[0]!.label = "api review"; + expect(filterSuggestions(roster)).not.toContain("api review"); + const suggested = filterSuggestions(roster, new Map(), new Set(), "tab"); + // the row's own title stays on its second line, so it is still offered, after the tab's + expect(suggested.slice(0, 4)).toEqual(["Laptop", "checkout-api", "export-guard", "flaky-retry-test"]); + expect(suggested.indexOf("api review")).toBe(suggested.indexOf("Idempotent payments") - 1); + // the shell in the "release" tab has no row + expect(suggested).not.toContain("release"); + }); + it("neither searches nor suggests the name of a PC drawn without its header", () => { const lone = machines().slice(0, 1); const unnamed = new Set(["local"]); diff --git a/src/lib/sidebarFilter.ts b/src/lib/sidebarFilter.ts index 5b22237d32..9c75179b26 100644 --- a/src/lib/sidebarFilter.ts +++ b/src/lib/sidebarFilter.ts @@ -27,7 +27,8 @@ import { rosterPanes } from "./dagPane.ts"; import type { Translate } from "./i18n.ts"; import { matchesQuery } from "./paletteSearch.ts"; import { shortPathTitle, stripPaneChrome } from "./paneName.ts"; -import { agentTabName, sidebarAgents, type SidebarAgent } from "./sidebarAgents.ts"; +import type { AgentRowTitle } from "./settings.ts"; +import { agentTabName, sidebarAgents, tabRowTitle, type SidebarAgent } from "./sidebarAgents.ts"; import type { WorkspaceGroup } from "./sidebarPins.ts"; import { worktreeGroups } from "./workspaceOrder.ts"; @@ -201,15 +202,17 @@ export function sidebarFilter(query: string, machines: readonly FilterMachine[], /** * What the field offers as it is typed in (the browser's suggestions under it): the names the rows show, as * the filter finds them, each once, in the roster's order. PC names, workspace names and the branches their - * rows say, and the title each agent row shows; never a path, a transcript or an id. A PC in `unnamed` - * (drawn without its header) offers no name. + * rows say, and the title each agent row shows (under Settings → Agent row title → Tab name, a renamed + * tab's first); never a path, a transcript or an id. A PC in `unnamed` (drawn without its header) offers no name. */ -export function filterSuggestions(machines: readonly FilterMachine[], branches: SidebarBranchLabels = new Map(), unnamed: ReadonlySet = new Set()): string[] { +export function filterSuggestions(machines: readonly FilterMachine[], branches: SidebarBranchLabels = new Map(), unnamed: ReadonlySet = new Set(), agentRowTitle: AgentRowTitle = "agent"): string[] { const labels = new Set(); for (const machine of machines) { if (!unnamed.has(machine.id)) labels.add(machine.name); for (const workspace of machine.snapshot?.workspaces ?? []) for (const label of workspaceLabels(workspace, branches.get(machine.id)?.get(workspace.workspace_id))) labels.add(label); for (const entry of sidebarAgents(machine.snapshot)) { + const tabTitle = tabRowTitle(agentRowTitle, entry.tab, machine.snapshot?.tabs.filter((tab) => tab.workspace_id === entry.workspace.workspace_id) ?? []); + if (tabTitle !== null) labels.add(tabTitle); const title = shownPaneTitle(entry.pane, entry.agent?.title); if (title !== null) labels.add(title); } From 112d31140392e21a5db32e32f01c5365ce8e7a18 Mon Sep 17 00:00:00 2001 From: Hako Date: Thu, 5 Nov 2026 21:53:05 +0900 Subject: [PATCH 3/3] fix(sidebar): keep a helper's own title in its header too Under Agent row title: Tab name, a helper's row kept its own title but its pane's header took the tab's name. Whether a pane is a helper (an agent another agent started) now comes from paneTabRowTitle alone, which the row, the header, the split view's bar and the Filter suggestions all read, so they agree wherever the helper is drawn. An agent name the same as the row's own title is not repeated after it, and each row's menu button names both titles, so two agents in one renamed tab are told apart by a screen reader. Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 5 +++-- src/components/AgentSidebar.test.ts | 15 +++++++++------ src/components/AgentSidebar.tsx | 25 ++++++++++++------------- src/lib/sidebarAgents.test.ts | 13 +++++++++++++ src/lib/sidebarAgents.ts | 18 ++++++++++++++---- src/lib/sidebarFilter.ts | 4 ++-- 6 files changed, 53 insertions(+), 27 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 0eae5e302d..1be7fb2fab 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1307,8 +1307,9 @@ Alert rows of the table above from the `--droplet-*` roles in `src/styles.css`. `[ui.sidebar.agents]` rows put `tab` on the first line), so sessions of one agent read apart by the names their tabs were given. The row's own title (the pane's name, else the agent's) then leads its second line before the place, which no longer repeats the tab; a title the same as the - tab is not said twice, nor is an agent's name the same as the tab. A helper drawn under the agent - that started it keeps its own title: its agent's row already names the tab, and a helper has no + tab is not said twice, nor is an agent's name the same as the tab or the row's own title, and each + row's ⋯ says both titles. A helper (an agent another agent started) keeps its own title, in its row + and its header alike: its agent's row already names the tab, and a helper drawn under it has no second line. The header of an agent's pane takes the tab's name too, as its row does, and the Filter field suggests it; the frames of a tab's panes keep their own titles under the tab strip that names it. A tab herdr still names by its place keeps the row and the header as they are. The diff --git a/src/components/AgentSidebar.test.ts b/src/components/AgentSidebar.test.ts index 448bfa5edf..42a8e86be9 100644 --- a/src/components/AgentSidebar.test.ts +++ b/src/components/AgentSidebar.test.ts @@ -224,7 +224,8 @@ describe("AgentSidebar, Settings → Agent row title (#2014)", () => { const html = render([inTab("api review", [pane("w1:p1")], { "w1:p1": "Idempotent payments" })]); expect(rows(html)).toEqual({ titles: ["api review"], contexts: ["Idempotent payments · checkout-api"] }); expect(tooltips(html)[0]).toEqual(["w1:p1", "api review", "Idempotent payments", "checkout-api", "claude"]); - expect(html).toContain('aria-label="More for api review"'); + // two agents in one renamed tab: each ⋯ says whose it is + expect(html).toContain('aria-label="More for api review · Idempotent payments"'); }); test("Tab name: a pane's name takes the agent's title's place, a title the same as the tab is not said twice, and a tab herdr names by its place keeps the row", () => { @@ -245,12 +246,14 @@ describe("AgentSidebar, Settings → Agent row title (#2014)", () => { expect(html).toContain('aria-label="More for Write the tests"'); }); - test("Tab name: an agent whose name is its tab's is not named again under it", () => { + test("Tab name: an agent whose name is its tab's, or its own title's, is not named again under it", () => { choose("tab"); const base = inTab("api review", [pane("w1:p1")], { "w1:p1": "Idempotent payments" }); - const named = { ...base, snapshot: { ...base.snapshot!, agents: base.snapshot!.agents.map((agent) => ({ ...agent, name: "api review" })) } }; - const html = render([named]); - expect(rows(html)).toEqual({ titles: ["api review"], contexts: ["Idempotent payments · checkout-api"] }); - expect(html).not.toContain('class="agent-name"'); + for (const name of ["api review", "Idempotent payments"]) { + const named = { ...base, snapshot: { ...base.snapshot!, agents: base.snapshot!.agents.map((agent) => ({ ...agent, name })) } }; + const html = render([named]); + expect(rows(html)).toEqual({ titles: ["api review"], contexts: ["Idempotent payments · checkout-api"] }); + expect(html).not.toContain('class="agent-name"'); + } }); }); diff --git a/src/components/AgentSidebar.tsx b/src/components/AgentSidebar.tsx index 5d7080d2bb..64c6f04ed3 100644 --- a/src/components/AgentSidebar.tsx +++ b/src/components/AgentSidebar.tsx @@ -11,7 +11,7 @@ import { useAnnouncedText } from "../lib/useAnnouncedText.ts"; import { agentRoster, groupAgentRows, splitBackgroundRows, type AgentGroup, type AgentRow } from "../lib/agentRoster.ts"; import { currentPaneMutes, paneMuteKey, prunePaneMutes, setPaneMutes, subscribePaneMutes, togglePaneMute } from "../lib/paneMutes.ts"; import { lockedPinKeys, toggleAgentLock } from "../lib/paneLocks.ts"; -import { agentContext, agentTabName, paneMark, tabRowTitle } from "../lib/sidebarAgents.ts"; +import { agentContext, agentTabName, paneMark, paneTabRowTitle } from "../lib/sidebarAgents.ts"; import { useSettings, type AgentRowTitle } from "../lib/settings.ts"; import { useSidebarActivity } from "../lib/sidebarActivity.tsx"; import { machineFilter } from "../lib/sidebarFilter.ts"; @@ -139,13 +139,13 @@ function rowTitle({ pane, agent }: AgentRow["entry"]): string { /** * The title a row shows, and what then leads its second line. Under Settings → Agent row title → - * Tab name a renamed tab titles it (lib/sidebarAgents.ts tabRowTitle), and the row's own title moves + * Tab name a renamed tab titles it (lib/sidebarAgents.ts paneTabRowTitle), and the row's own title moves * under it unless the tab says the same, or it is only the pane's id. */ function rowTitles({ machine, entry }: AgentRow, setting: AgentRowTitle): { title: string; lead: string | null; tabs: TabInfo[]; byTab: boolean } { const tabs = machine.snapshot?.tabs.filter((candidate) => candidate.workspace_id === entry.workspace.workspace_id) ?? []; const own = rowTitle(entry); - const tabTitle = tabRowTitle(setting, entry.tab, tabs); + const tabTitle = paneTabRowTitle(setting, machine.snapshot, entry.pane); if (tabTitle === null) return { title: own, lead: null, tabs, byTab: false }; return { title: tabTitle, lead: own !== tabTitle && own !== entry.pane.pane_id ? own : null, tabs, byTab: true }; } @@ -289,13 +289,12 @@ export function AgentSidebar({ machines, selectedMachineId, selectedPaneId, stat // herdr names a PC only beside another: the rows' line does the same const machineName = (machine: Machine): string | null => machines.length > 1 ? machine.name : null; - // what a row says of its agent, for the row and for its open menu. A helper keeps its own title: it reads - // under the agent that started it, whose row already names their tab, and it has no second line to move it to - const describe = (row: AgentRow, helper = false) => { + // what a row says of its agent, for the row and for its open menu + const describe = (row: AgentRow) => { const { machine, entry } = row; const { pane, workspace, tab, agent, agentLabel } = entry; const online = machine.state === "connected"; - const { title, lead, tabs, byTab } = rowTitles(row, helper ? "agent" : settings.agentRowTitle); + const { title, lead, tabs, byTab } = rowTitles(row, settings.agentRowTitle); const name = agent?.name?.trim() || null; // the name has its own place on the line: the label beside it says the kind instead const kindLabel = name !== null && agentLabel === name ? entry.canonicalAgent ?? agent?.title?.trim() ?? null : agentLabel; @@ -309,8 +308,8 @@ export function AgentSidebar({ machines, selectedMachineId, selectedPaneId, stat const markKind = kind && kind !== title.trim() && kind !== lead && !contextParts.includes(kind) ? kind : null; // a PC that is away says nothing of its agents now, its plugins' words included const tokens = online ? paneTokenValues(rowTokens, entry.canonicalAgent, pane.tokens ?? agent?.tokens) : []; - // nor is a name the same as the tab that titles the row - return { online, title, lead, name: byTab && name === title ? null : name, mark, context, markKind, tokens }; + // nor is a name the same as the tab that titles the row, or as the row's own title after it + return { online, title, lead, name: byTab && (name === title || name === lead) ? null : name, mark, context, markKind, tokens }; }; // a helper reads under the agent that started it when that row is just above it in the same list (lib/agentRoster.ts) @@ -367,11 +366,11 @@ export function AgentSidebar({ machines, selectedMachineId, selectedPaneId, stat const selected = machine.id === selectedMachineId && pane.pane_id === selectedPaneId; // under the filter a row it did not match is there for the selection: drawn as context, and said so const filterContext = filter.active && machineFilter(filter, machine.id)?.matchedPanes.has(pane.pane_id) !== true; - const { online, title, lead, name, mark, context, markKind, tokens } = describe(row, depth > 0); + const { online, title, lead, name, mark, context, markKind, tokens } = describe(row); // the row cuts a long value: the tooltip, and a phone's menu sheet, say each in full const blocked = online ? blockedNote(pane, t) : null; const blockedShort = online ? blockedLabel(pane, t) : null; - const startedBy = parentRow ? t("Started by {name}", { name: describe(parentRow, depth > 1).title }) : null; + const startedBy = parentRow ? t("Started by {name}", { name: describe(parentRow).title }) : null; const status = online ? activity.status(machine.id, pane) : undefined; const progress = online && pane.agent_progress !== undefined && (status === "working" || status === "blocked") ? t("Progress {percent}%", { percent: pane.agent_progress }) : null; const tooltip = [...new Set([pane.pane_id, title, lead, context, startedBy, blocked, progress, ...tokens, markKind, name, agent?.display_agent, pane.cwd, online ? null : stateWord(machine)].filter(Boolean))].join("\n"); @@ -395,7 +394,7 @@ export function AgentSidebar({ machines, selectedMachineId, selectedPaneId, stat 0} />
-
@@ -410,7 +409,7 @@ export function AgentSidebar({ machines, selectedMachineId, selectedPaneId, stat const shownEmpty = useAnnouncedText(rows.length === 0 && !filter.active ? (loading ? t("Loading agents…") : t("No agents running")) : null); const menuPinned = menu !== null && pinned.includes(menu.key); const menuLocked = menu !== null && locked.has(menu.key); - const menuShown = menuRow === undefined ? null : describe(menuRow, (restDepths.get(menuRow) ?? 0) > 0); + const menuShown = menuRow === undefined ? null : describe(menuRow); // a phone has no tooltip: its sheet says a blocked agent's whole reason, as the row's tooltip does const menuBlocked = menuRow !== undefined && menuShown?.online && activity.status(menuRow.machine.id, menuRow.entry.pane) === "blocked" ? blockedNote(menuRow.entry.pane, t) : null; const menuMuted = menuRow !== undefined && muted !== null && muted.includes(paneMuteKey(menuRow.machine.id, menuRow.entry.pane.pane_id)); diff --git a/src/lib/sidebarAgents.test.ts b/src/lib/sidebarAgents.test.ts index 5ba98d686f..1cda5e3b5c 100644 --- a/src/lib/sidebarAgents.test.ts +++ b/src/lib/sidebarAgents.test.ts @@ -189,4 +189,17 @@ describe("paneTabRowTitle", () => { expect(paneTabRowTitle("tab", { ...named, tabs: [{ ...tab("w1:t1", "w1"), label: "1" }] }, claude)).toBeNull(); expect(paneTabRowTitle("tab", null, claude)).toBeNull(); }); + + it("keeps a helper's own title, row and header alike: the agent that started it already names the tab", () => { + const lead = pane("lead", { agent: "claude" }); + const helper = pane("helper", { agent: "claude", parent_pane_id: "lead" }); + const shell = pane("shell"); + const fromShell = pane("from-shell", { agent: "codex", parent_pane_id: "shell" }); + const named: SessionSnapshot = { ...snapshot([lead, helper, shell, fromShell]), tabs: [{ ...tab("w1:t1", "w1"), label: "api review" }] }; + expect(paneTabRowTitle("tab", named, lead)).toBe("api review"); + expect(paneTabRowTitle("tab", named, helper)).toBeNull(); + // started from a shell, or by a pane no longer shown: no agent row names the tab for it + expect(paneTabRowTitle("tab", named, fromShell)).toBe("api review"); + expect(paneTabRowTitle("tab", named, { ...helper, parent_pane_id: "gone" })).toBe("api review"); + }); }); diff --git a/src/lib/sidebarAgents.ts b/src/lib/sidebarAgents.ts index f6741393d8..af535579bd 100644 --- a/src/lib/sidebarAgents.ts +++ b/src/lib/sidebarAgents.ts @@ -107,10 +107,20 @@ export function tabRowTitle(setting: AgentRowTitle, tab: TabInfo | null | undefi return setting === "tab" && tab ? customTabLabel(tab, tabPlace(tab, workspaceTabs)) : null; } -/** The same for the pane a header shows: only an agent's pane has a row to match (sidebarAgents), never a shell's. */ -export function paneTabRowTitle(setting: AgentRowTitle, snapshot: SessionSnapshot | null, pane: PaneInfo): string | null { - if (setting !== "tab" || !snapshot) return null; - if (!nonblank(pane.agent) && !snapshot.agents.some((agent) => agent.pane_id === pane.pane_id)) return null; +/** whether herdr reports an agent in this pane: only such a pane has a row (sidebarAgents), never a shell */ +function hasAgent(snapshot: SessionSnapshot, pane: PaneInfo): boolean { + return nonblank(pane.agent) !== null || snapshot.agents.some((agent) => agent.pane_id === pane.pane_id); +} + +/** + * The same for an agent's pane, its row and its header alike. A helper (an agent another agent started) + * keeps its own title: its agent's row already names their tab, and a helper drawn under it has no + * second line to move its own title to. + */ +export function paneTabRowTitle(setting: AgentRowTitle, snapshot: SessionSnapshot | null, pane: PaneInfo & Pick): string | null { + if (setting !== "tab" || !snapshot || !hasAgent(snapshot, pane)) return null; + const parent = pane.parent_pane_id === undefined ? undefined : snapshot.panes.find((candidate) => candidate.pane_id === pane.parent_pane_id); + if (parent !== undefined && parent.pane_id !== pane.pane_id && hasAgent(snapshot, parent)) return null; const tabs = snapshot.tabs.filter((tab) => tab.workspace_id === pane.workspace_id); return tabRowTitle(setting, tabs.find((tab) => tab.tab_id === pane.tab_id), tabs); } diff --git a/src/lib/sidebarFilter.ts b/src/lib/sidebarFilter.ts index 9c75179b26..2ee705894b 100644 --- a/src/lib/sidebarFilter.ts +++ b/src/lib/sidebarFilter.ts @@ -28,7 +28,7 @@ import type { Translate } from "./i18n.ts"; import { matchesQuery } from "./paletteSearch.ts"; import { shortPathTitle, stripPaneChrome } from "./paneName.ts"; import type { AgentRowTitle } from "./settings.ts"; -import { agentTabName, sidebarAgents, tabRowTitle, type SidebarAgent } from "./sidebarAgents.ts"; +import { agentTabName, paneTabRowTitle, sidebarAgents, type SidebarAgent } from "./sidebarAgents.ts"; import type { WorkspaceGroup } from "./sidebarPins.ts"; import { worktreeGroups } from "./workspaceOrder.ts"; @@ -211,7 +211,7 @@ export function filterSuggestions(machines: readonly FilterMachine[], branches: if (!unnamed.has(machine.id)) labels.add(machine.name); for (const workspace of machine.snapshot?.workspaces ?? []) for (const label of workspaceLabels(workspace, branches.get(machine.id)?.get(workspace.workspace_id))) labels.add(label); for (const entry of sidebarAgents(machine.snapshot)) { - const tabTitle = tabRowTitle(agentRowTitle, entry.tab, machine.snapshot?.tabs.filter((tab) => tab.workspace_id === entry.workspace.workspace_id) ?? []); + const tabTitle = paneTabRowTitle(agentRowTitle, machine.snapshot ?? null, entry.pane); if (tabTitle !== null) labels.add(tabTitle); const title = shownPaneTitle(entry.pane, entry.agent?.title); if (title !== null) labels.add(title);