Skip to content

Commit 71326bc

Browse files
feat(web): allow renaming worker sessions in the sidebar (#1748)
* feat(web): allow renaming worker sessions in the sidebar Closes #1647 Adds an inline rename UX to each worker session row in the sidebar. A small pencil button appears on row hover; clicking it swaps the label for an input pre-filled with the current title. Enter persists via PATCH /api/sessions/:id, Escape cancels, and an empty value clears the field — reverting the session to its default title. The rename writes to the existing displayName metadata field, which is now the highest-priority signal in getSessionTitle so a user-chosen label always beats PR/issue titles. The session ID (ao-N) remains canonical — only display surfaces are affected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web): gate displayName promotion on user-set flag PR review flagged that promoting `displayName` to the top of `getSessionTitle` regressed every existing session: spawn-time auto-derived `displayName` would shadow live PR/issue titles for sessions the user never explicitly renamed. Adds a `displayNameUserSet` boolean flag to SessionMetadata and DashboardSession. The dashboard fallback chain promotes `displayName` above PR/issue titles only when this flag is true; auto-derived spawn-time values stay at their original position (below PR/issue, above userPrompt). PATCH /api/sessions/:id sets `displayNameUserSet=true` when the user types a name, and clears it when they revert. Sidebar gates its displayName preference on the flag too, so non-renamed rows keep the existing branch-first behavior. Also addresses review #3 (rename-while-pending pre-fill) and #4 (double-submit guard on Enter+blur). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web): address review feedback on session rename PR - ProjectSidebar: gate effective displayName on displayNameUserSet so auto-derived spawn-time names no longer shadow live PR/issue titles in the sidebar (mirrors the gate already in format.ts:getSessionTitle). Adds a regression test. - ProjectSidebar: drop unreachable `?? currentTitle` from startRename initial value — the right side of the nullish-coalescing always returns a string, so the fallback is already handled by the `|| currentTitle` on the next line. - ProjectSidebar: reveal rename pencil on `group-focus-within` so keyboard users tabbing through the session links discover the affordance, not just pointer users. - globals.css: change rename button + input border-radius from 3px to 0 to match the repo's --radius-base: 0 design rule for UI controls. - core/metadata: accept legacy "on"/"off" strings for displayNameUserSet in readMetadata for parity with prAutoDetect (defensive — the storage write path already converts to boolean via unflattenFromStringRecord). Adds coverage for all six accepted forms. - web/serialize: drop dead `=== "on"` check on displayNameUserSet — Session.metadata is Record<string, string> and the value can only ever be "true" / "false" after flattenToStringRecord. Refs #1647. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 845fffd commit 71326bc

14 files changed

Lines changed: 923 additions & 106 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@aoagents/ao-web": minor
3+
---
4+
5+
Add inline rename for worker sessions in the sidebar. Each worker row now shows a small pencil button on hover; clicking it swaps the label for an input pre-filled with the current title. Enter persists via `PATCH /api/sessions/:id`, Escape cancels, and an empty value reverts the session to its default title. The rename is written to the existing `displayName` metadata field and is now the highest-priority signal in `getSessionTitle`, so a user-chosen label always beats PR/issue titles. The session ID (`ao-N`) remains the canonical identifier — only display surfaces change. (#1647)

packages/core/src/__tests__/metadata.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,66 @@ describe("writeMetadata + readMetadata", () => {
150150
const meta = readMetadata(dataDir, "app-6");
151151
expect(meta?.displayName).toBe("Refactor session manager");
152152
});
153+
154+
it("serializes and reads back displayNameUserSet flag", () => {
155+
writeMetadata(dataDir, "app-7", {
156+
worktree: "/tmp/w",
157+
branch: "feat/test",
158+
status: "working",
159+
displayName: "PR 1466 review",
160+
displayNameUserSet: true,
161+
});
162+
163+
const content = readFileSync(join(dataDir, "app-7.json"), "utf-8");
164+
const parsed = JSON.parse(content);
165+
expect(parsed.displayNameUserSet).toBe(true);
166+
167+
const meta = readMetadata(dataDir, "app-7");
168+
expect(meta?.displayNameUserSet).toBe(true);
169+
});
170+
171+
it("accepts on/off and true/false for displayNameUserSet (matches prAutoDetect)", () => {
172+
// Defensive: storage paths that flow through unflattenFromStringRecord
173+
// already convert "on"/"off" → boolean before write, but readMetadata
174+
// should still tolerate the legacy string forms for parity with prAutoDetect.
175+
for (const [stored, expected] of [
176+
["on", true],
177+
["off", false],
178+
["true", true],
179+
["false", false],
180+
[true, true],
181+
[false, false],
182+
] as const) {
183+
writeFileSync(
184+
join(dataDir, `flag-${String(stored)}.json`),
185+
JSON.stringify({
186+
worktree: "/tmp/w",
187+
branch: "feat/test",
188+
status: "working",
189+
displayNameUserSet: stored,
190+
}),
191+
"utf-8",
192+
);
193+
const meta = readMetadata(dataDir, `flag-${String(stored)}` as never);
194+
expect(meta?.displayNameUserSet).toBe(expected);
195+
}
196+
});
197+
198+
it("omits displayNameUserSet when undefined and does not flag auto-derived sessions", () => {
199+
writeMetadata(dataDir, "app-8", {
200+
worktree: "/tmp/w",
201+
branch: "feat/test",
202+
status: "working",
203+
displayName: "Auto-derived at spawn",
204+
});
205+
206+
const content = readFileSync(join(dataDir, "app-8.json"), "utf-8");
207+
const parsed = JSON.parse(content);
208+
expect(parsed.displayNameUserSet).toBeUndefined();
209+
210+
const meta = readMetadata(dataDir, "app-8");
211+
expect(meta?.displayNameUserSet).toBeUndefined();
212+
});
153213
});
154214

155215
describe("readMetadataRaw", () => {

packages/core/src/metadata.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,16 @@ export function readMetadata(dataDir: string, sessionId: SessionId): SessionMeta
174174
pinnedSummary: raw["pinnedSummary"] as string | undefined,
175175
userPrompt: raw["userPrompt"] as string | undefined,
176176
displayName: raw["displayName"] as string | undefined,
177+
displayNameUserSet:
178+
raw["displayNameUserSet"] === "off" ||
179+
raw["displayNameUserSet"] === "false" ||
180+
raw["displayNameUserSet"] === false
181+
? false
182+
: raw["displayNameUserSet"] === "on" ||
183+
raw["displayNameUserSet"] === "true" ||
184+
raw["displayNameUserSet"] === true
185+
? true
186+
: undefined,
177187
};
178188
}
179189

@@ -218,7 +228,7 @@ const jsonFields = new Set([
218228
function unflattenFromStringRecord(data: Record<string, string>): Record<string, unknown> {
219229
const result: Record<string, unknown> = {};
220230
const numberFields = new Set(["dashboardPort", "terminalWsPort", "directTerminalWsPort"]);
221-
const booleanFields = new Set(["prAutoDetect"]);
231+
const booleanFields = new Set(["prAutoDetect", "displayNameUserSet"]);
222232

223233
for (const [key, value] of Object.entries(data)) {
224234
if (value === undefined || value === "") continue;
@@ -281,6 +291,8 @@ export function writeMetadata(
281291
if (metadata.pinnedSummary) data["pinnedSummary"] = metadata.pinnedSummary;
282292
if (metadata.userPrompt) data["userPrompt"] = metadata.userPrompt;
283293
if (metadata.displayName) data["displayName"] = metadata.displayName;
294+
if (metadata.displayNameUserSet !== undefined)
295+
data["displayNameUserSet"] = metadata.displayNameUserSet;
284296

285297
atomicWriteFileSync(path, serializeMetadata(data));
286298
}

packages/core/src/types.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1764,13 +1764,26 @@ export interface SessionMetadata {
17641764
pinnedSummary?: string; // First quality summary, pinned for display stability
17651765
userPrompt?: string; // Prompt used when spawning without a tracker issue
17661766
/**
1767-
* Stable human-readable display name derived from task context at spawn time.
1768-
* Populated from issue title, user prompt, or orchestrator system prompt —
1769-
* whichever was available when the session was created. Used by the dashboard
1770-
* as a fallback above humanized branch names so sessions are identifiable
1771-
* even when PR/issue enrichment is unavailable.
1767+
* Human-readable display name for the session.
1768+
*
1769+
* Populated automatically at spawn time from the best available task context
1770+
* (issue title, user prompt, or orchestrator system prompt). Can be
1771+
* overwritten later via the dashboard rename UI — the session ID (`ao-N`)
1772+
* remains the canonical identifier; only display surfaces are affected.
1773+
*
1774+
* Whether this value should beat PR/issue titles in the dashboard depends
1775+
* on `displayNameUserSet` — auto-derived values stay below live tracker
1776+
* signals, user-set values win over them.
17721777
*/
17731778
displayName?: string;
1779+
/**
1780+
* Set to `true` when the user explicitly renamed the session via the
1781+
* dashboard. The dashboard fallback chain promotes `displayName` above
1782+
* PR/issue titles only when this flag is true, so an auto-derived spawn-time
1783+
* `displayName` doesn't shadow a live PR title for sessions the user never
1784+
* touched.
1785+
*/
1786+
displayNameUserSet?: boolean;
17741787
}
17751788

17761789
// =============================================================================

0 commit comments

Comments
 (0)