Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1301,6 +1301,19 @@ 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, 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
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)
Expand Down
5 changes: 5 additions & 0 deletions changelog.d/agent-row-title-tab-name.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion shared/preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", "agentLocks", "lockedAgents", "autoSendQueued", "autoTabNames",
"theme", "palette", "darkTheme", "lightTheme", "language", "sidebarRows", "agentOrder", "agentRowTitle", "agentMarks", "agentLocks", "lockedAgents", "autoSendQueued", "autoTabNames",
"showThinking", "quickReplies", "showQuickReplies", "showUsage", "usageCount",
"usageGlance", "usageOrder", "usageHidden", "claudeLaunchMode", "codexLaunchMode",
] as const;
Expand Down
6 changes: 4 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { lastViewedPane } from "./lib/lastViewedPane.ts";
import { currentSidebarPins, movedSidebarPins, pinnedWorkspaceIds } from "./lib/sidebarPins.ts";
import { ReorderContext } from "./lib/machineContext.tsx";
import { loadSettings, 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";
Expand Down Expand Up @@ -376,7 +377,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);
Expand Down Expand Up @@ -939,7 +941,7 @@ export function App() {
</button>
</div> : 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 ? <div className="pane-slot-bar"><span className="pane-slot-title">{pane ? displayPaneTitle(pane) : t("Pane closed")}</span>{slotEnd}</div> : null;
const slotBar = splitOn ? <div className="pane-slot-bar"><span className="pane-slot-title">{pane ? paneTabRowTitle(settings.agentRowTitle, slotSnapshot, pane) ?? displayPaneTitle(pane) : t("Pane closed")}</span>{slotEnd}</div> : null;
return (
<MachineContext.Provider key={slot} value={machineId}><AppLayoutContext.Provider value={appLayoutOf(herdr)}><SlotSessions slot={slot} machineIds={machines.map((item) => item.id)}><PaneFollowContext.Provider value={sameTabs && !active}>
<div
Expand Down
69 changes: 69 additions & 0 deletions src/components/AgentSidebar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,72 @@ describe("AgentSidebar background agents", () => {
expect(html).not.toContain("agent-background-toggle");
});
});

describe("AgentSidebar, Settings → Agent row title (#2014)", () => {
const saved = Object.getOwnPropertyDescriptor(globalThis, "window");
const savedLanguages = Object.getOwnPropertyDescriptor(navigator, "languages");
let stored: Record<string, string>;
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<string, string>): 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"]);
// 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", () => {
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"] });
});

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, 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" });
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"');
}
});
});
Loading
Loading