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>
This commit is contained in:
Harshit Singh Bhandari 2026-05-10 19:41:48 +05:30 committed by GitHub
parent 845fffdfd9
commit 71326bc87e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 923 additions and 106 deletions

View File

@ -0,0 +1,5 @@
---
"@aoagents/ao-web": minor
---
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)

View File

@ -150,6 +150,66 @@ describe("writeMetadata + readMetadata", () => {
const meta = readMetadata(dataDir, "app-6");
expect(meta?.displayName).toBe("Refactor session manager");
});
it("serializes and reads back displayNameUserSet flag", () => {
writeMetadata(dataDir, "app-7", {
worktree: "/tmp/w",
branch: "feat/test",
status: "working",
displayName: "PR 1466 review",
displayNameUserSet: true,
});
const content = readFileSync(join(dataDir, "app-7.json"), "utf-8");
const parsed = JSON.parse(content);
expect(parsed.displayNameUserSet).toBe(true);
const meta = readMetadata(dataDir, "app-7");
expect(meta?.displayNameUserSet).toBe(true);
});
it("accepts on/off and true/false for displayNameUserSet (matches prAutoDetect)", () => {
// Defensive: storage paths that flow through unflattenFromStringRecord
// already convert "on"/"off" → boolean before write, but readMetadata
// should still tolerate the legacy string forms for parity with prAutoDetect.
for (const [stored, expected] of [
["on", true],
["off", false],
["true", true],
["false", false],
[true, true],
[false, false],
] as const) {
writeFileSync(
join(dataDir, `flag-${String(stored)}.json`),
JSON.stringify({
worktree: "/tmp/w",
branch: "feat/test",
status: "working",
displayNameUserSet: stored,
}),
"utf-8",
);
const meta = readMetadata(dataDir, `flag-${String(stored)}` as never);
expect(meta?.displayNameUserSet).toBe(expected);
}
});
it("omits displayNameUserSet when undefined and does not flag auto-derived sessions", () => {
writeMetadata(dataDir, "app-8", {
worktree: "/tmp/w",
branch: "feat/test",
status: "working",
displayName: "Auto-derived at spawn",
});
const content = readFileSync(join(dataDir, "app-8.json"), "utf-8");
const parsed = JSON.parse(content);
expect(parsed.displayNameUserSet).toBeUndefined();
const meta = readMetadata(dataDir, "app-8");
expect(meta?.displayNameUserSet).toBeUndefined();
});
});
describe("readMetadataRaw", () => {

View File

@ -174,6 +174,16 @@ export function readMetadata(dataDir: string, sessionId: SessionId): SessionMeta
pinnedSummary: raw["pinnedSummary"] as string | undefined,
userPrompt: raw["userPrompt"] as string | undefined,
displayName: raw["displayName"] as string | undefined,
displayNameUserSet:
raw["displayNameUserSet"] === "off" ||
raw["displayNameUserSet"] === "false" ||
raw["displayNameUserSet"] === false
? false
: raw["displayNameUserSet"] === "on" ||
raw["displayNameUserSet"] === "true" ||
raw["displayNameUserSet"] === true
? true
: undefined,
};
}
@ -218,7 +228,7 @@ const jsonFields = new Set([
function unflattenFromStringRecord(data: Record<string, string>): Record<string, unknown> {
const result: Record<string, unknown> = {};
const numberFields = new Set(["dashboardPort", "terminalWsPort", "directTerminalWsPort"]);
const booleanFields = new Set(["prAutoDetect"]);
const booleanFields = new Set(["prAutoDetect", "displayNameUserSet"]);
for (const [key, value] of Object.entries(data)) {
if (value === undefined || value === "") continue;
@ -281,6 +291,8 @@ export function writeMetadata(
if (metadata.pinnedSummary) data["pinnedSummary"] = metadata.pinnedSummary;
if (metadata.userPrompt) data["userPrompt"] = metadata.userPrompt;
if (metadata.displayName) data["displayName"] = metadata.displayName;
if (metadata.displayNameUserSet !== undefined)
data["displayNameUserSet"] = metadata.displayNameUserSet;
atomicWriteFileSync(path, serializeMetadata(data));
}

View File

@ -1764,13 +1764,26 @@ export interface SessionMetadata {
pinnedSummary?: string; // First quality summary, pinned for display stability
userPrompt?: string; // Prompt used when spawning without a tracker issue
/**
* Stable human-readable display name derived from task context at spawn time.
* Populated from issue title, user prompt, or orchestrator system prompt
* whichever was available when the session was created. Used by the dashboard
* as a fallback above humanized branch names so sessions are identifiable
* even when PR/issue enrichment is unavailable.
* Human-readable display name for the session.
*
* Populated automatically at spawn time from the best available task context
* (issue title, user prompt, or orchestrator system prompt). Can be
* overwritten later via the dashboard rename UI the session ID (`ao-N`)
* remains the canonical identifier; only display surfaces are affected.
*
* Whether this value should beat PR/issue titles in the dashboard depends
* on `displayNameUserSet` auto-derived values stay below live tracker
* signals, user-set values win over them.
*/
displayName?: string;
/**
* Set to `true` when the user explicitly renamed the session via the
* dashboard. The dashboard fallback chain promotes `displayName` above
* PR/issue titles only when this flag is true, so an auto-derived spawn-time
* `displayName` doesn't shadow a live PR title for sessions the user never
* touched.
*/
displayNameUserSet?: boolean;
}
// =============================================================================

View File

@ -5,6 +5,7 @@ import {
SessionNotRestorableError,
createInitialCanonicalLifecycle,
createActivitySignal,
updateMetadata,
type Session,
type SessionManager,
type OrchestratorConfig,
@ -209,10 +210,28 @@ vi.mock("@/lib/services", () => ({
getSCM: vi.fn(() => mockSCM),
}));
// Mock filesystem-touching core helpers so PATCH /api/sessions/:id doesn't
// write to the user's actual ~/.agent-orchestrator dir during tests. Spread
// the real module first so the rest of the test file (types, errors, etc.)
// keeps working. Factory must self-contain its mocks because vi.mock is
// hoisted above any module-level declarations.
vi.mock("@aoagents/ao-core", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
updateMetadata: vi.fn(),
getProjectSessionsDir: vi.fn(() => "/tmp/ao-test/sessions"),
readAgentReportAuditTrailAsync: vi.fn(async () => []),
};
});
// ── Import routes after mocking ───────────────────────────────────────
import { GET as sessionsGET } from "@/app/api/sessions/route";
import { GET as sessionDetailGET } from "@/app/api/sessions/[id]/route";
import {
GET as sessionDetailGET,
PATCH as sessionDetailPATCH,
} from "@/app/api/sessions/[id]/route";
import { POST as orchestratorsPOST, GET as orchestratorsGET } from "@/app/api/orchestrators/route";
import { POST as spawnPOST } from "@/app/api/spawn/route";
import { POST as sendPOST } from "@/app/api/sessions/[id]/send/route";
@ -356,7 +375,10 @@ describe("API Routes", () => {
});
it("prefers the most recently active live orchestrator for project-scoped worker navigation", async () => {
const deadLifecycle = createInitialCanonicalLifecycle("orchestrator", new Date("2026-04-19T11:00:00.000Z"));
const deadLifecycle = createInitialCanonicalLifecycle(
"orchestrator",
new Date("2026-04-19T11:00:00.000Z"),
);
deadLifecycle.session.state = "terminated";
deadLifecycle.session.reason = "runtime_missing";
deadLifecycle.session.terminatedAt = "2026-04-19T11:00:00.000Z";
@ -414,7 +436,10 @@ describe("API Routes", () => {
});
it("keeps dead orchestrators as the fallback project-scoped payload when none are live", async () => {
const deadLifecycle = createInitialCanonicalLifecycle("orchestrator", new Date("2026-04-19T11:00:00.000Z"));
const deadLifecycle = createInitialCanonicalLifecycle(
"orchestrator",
new Date("2026-04-19T11:00:00.000Z"),
);
deadLifecycle.session.state = "terminated";
deadLifecycle.session.reason = "runtime_missing";
deadLifecycle.session.terminatedAt = "2026-04-19T11:00:00.000Z";
@ -449,7 +474,10 @@ describe("API Routes", () => {
});
it("prefers the most recently active dead orchestrator when no live project orchestrator exists", async () => {
const olderDeadLifecycle = createInitialCanonicalLifecycle("orchestrator", new Date("2026-04-19T10:00:00.000Z"));
const olderDeadLifecycle = createInitialCanonicalLifecycle(
"orchestrator",
new Date("2026-04-19T10:00:00.000Z"),
);
olderDeadLifecycle.session.state = "terminated";
olderDeadLifecycle.session.reason = "runtime_missing";
olderDeadLifecycle.session.terminatedAt = "2026-04-19T10:00:00.000Z";
@ -458,7 +486,10 @@ describe("API Routes", () => {
olderDeadLifecycle.runtime.reason = "process_missing";
olderDeadLifecycle.runtime.lastObservedAt = "2026-04-19T10:00:00.000Z";
const newerDeadLifecycle = createInitialCanonicalLifecycle("orchestrator", new Date("2026-04-19T11:00:00.000Z"));
const newerDeadLifecycle = createInitialCanonicalLifecycle(
"orchestrator",
new Date("2026-04-19T11:00:00.000Z"),
);
newerDeadLifecycle.session.state = "terminated";
newerDeadLifecycle.session.reason = "runtime_missing";
newerDeadLifecycle.session.terminatedAt = "2026-04-19T11:00:00.000Z";
@ -522,17 +553,20 @@ describe("API Routes", () => {
},
}),
);
(mockSessionManager.listCached as ReturnType<typeof vi.fn>).mockResolvedValue(sessionsWithPRs);
(mockSessionManager.listCached as ReturnType<typeof vi.fn>).mockResolvedValue(
sessionsWithPRs,
);
const metadataSpy = vi
.spyOn(serialize, "enrichSessionsMetadata")
.mockResolvedValue(undefined);
const enrichSpy = vi
.spyOn(serialize, "enrichSessionPR")
.mockImplementation(
() => new Promise<void>((resolve) => { setTimeout(resolve, 1_000); }),
);
const enrichSpy = vi.spyOn(serialize, "enrichSessionPR").mockImplementation(
() =>
new Promise<void>((resolve) => {
setTimeout(resolve, 1_000);
}),
);
const responsePromise = sessionsGET(makeRequest("http://localhost:3000/api/sessions"));
@ -598,7 +632,9 @@ describe("API Routes", () => {
},
}),
];
(mockSessionManager.listCached as ReturnType<typeof vi.fn>).mockResolvedValue(sessionsWithPRs);
(mockSessionManager.listCached as ReturnType<typeof vi.fn>).mockResolvedValue(
sessionsWithPRs,
);
const metadataSpy = vi
.spyOn(serialize, "enrichSessionsMetadata")
@ -642,7 +678,8 @@ describe("API Routes", () => {
const runtimeTerminalLifecycle = createInitialCanonicalLifecycle("worker", new Date());
runtimeTerminalLifecycle.session.state = "terminated";
runtimeTerminalLifecycle.session.reason = "user_killed";
runtimeTerminalLifecycle.session.terminatedAt = runtimeTerminalLifecycle.session.lastTransitionAt;
runtimeTerminalLifecycle.session.terminatedAt =
runtimeTerminalLifecycle.session.lastTransitionAt;
runtimeTerminalLifecycle.runtime.state = "missing";
runtimeTerminalLifecycle.runtime.reason = "process_missing";
runtimeTerminalLifecycle.pr.state = "open";
@ -666,15 +703,15 @@ describe("API Routes", () => {
},
}),
];
(mockSessionManager.listCached as ReturnType<typeof vi.fn>).mockResolvedValue(sessionWithOpenPR);
(mockSessionManager.listCached as ReturnType<typeof vi.fn>).mockResolvedValue(
sessionWithOpenPR,
);
const metadataSpy = vi
.spyOn(serialize, "enrichSessionsMetadata")
.mockResolvedValue(undefined);
const enrichSpy = vi
.spyOn(serialize, "enrichSessionPR")
.mockResolvedValue(true);
const enrichSpy = vi.spyOn(serialize, "enrichSessionPR").mockResolvedValue(true);
const res = await sessionsGET(makeRequest("http://localhost:3000/api/sessions"));
@ -717,6 +754,107 @@ describe("API Routes", () => {
}, 10_000);
});
// ── PATCH /api/sessions/[id] ───────────────────────────────────────
describe("PATCH /api/sessions/[id]", () => {
function patchRequest(id: string, body: unknown): NextRequest {
return makeRequest(`http://localhost:3000/api/sessions/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.mocked(updateMetadata).mockReset();
});
it("persists a sanitized displayName and sets the user-set flag", async () => {
const res = await sessionDetailPATCH(
patchRequest("backend-7", { displayName: " PR 432 review " }),
{
params: Promise.resolve({ id: "backend-7" }),
},
);
expect(res.status).toBe(200);
expect(vi.mocked(updateMetadata)).toHaveBeenCalledTimes(1);
const [, sessionId, updates] = vi.mocked(updateMetadata).mock.calls[0];
expect(sessionId).toBe("backend-7");
// Whitespace is collapsed and trimmed before persist; flag is set so the
// dashboard knows to promote this name above PR/issue titles.
expect(updates).toEqual({ displayName: "PR 432 review", displayNameUserSet: "true" });
expect(mockSessionManager.invalidateCache).toHaveBeenCalled();
});
it("treats null displayName as a clear and unsets the user-set flag", async () => {
const res = await sessionDetailPATCH(patchRequest("backend-7", { displayName: null }), {
params: Promise.resolve({ id: "backend-7" }),
});
expect(res.status).toBe(200);
expect(vi.mocked(updateMetadata)).toHaveBeenCalledWith(expect.any(String), "backend-7", {
displayName: "",
displayNameUserSet: "",
});
});
it("strips control characters", async () => {
const res = await sessionDetailPATCH(patchRequest("backend-7", { displayName: "FooBar" }), {
params: Promise.resolve({ id: "backend-7" }),
});
expect(res.status).toBe(200);
expect(vi.mocked(updateMetadata)).toHaveBeenCalledWith(expect.any(String), "backend-7", {
displayName: "FooBar",
displayNameUserSet: "true",
});
});
it("truncates names longer than 80 characters", async () => {
const longName = "a".repeat(120);
const res = await sessionDetailPATCH(patchRequest("backend-7", { displayName: longName }), {
params: Promise.resolve({ id: "backend-7" }),
});
expect(res.status).toBe(200);
const [, , updates] = vi.mocked(updateMetadata).mock.calls[0];
expect((updates as { displayName: string }).displayName.length).toBe(80);
});
it("returns 400 when displayName field is missing", async () => {
const res = await sessionDetailPATCH(patchRequest("backend-7", {}), {
params: Promise.resolve({ id: "backend-7" }),
});
expect(res.status).toBe(400);
expect(vi.mocked(updateMetadata)).not.toHaveBeenCalled();
});
it("returns 400 when displayName is not a string or null", async () => {
const res = await sessionDetailPATCH(patchRequest("backend-7", { displayName: 42 }), {
params: Promise.resolve({ id: "backend-7" }),
});
expect(res.status).toBe(400);
expect(vi.mocked(updateMetadata)).not.toHaveBeenCalled();
});
it("returns 400 on invalid JSON", async () => {
const req = makeRequest("http://localhost:3000/api/sessions/backend-7", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: "{not json",
});
const res = await sessionDetailPATCH(req, { params: Promise.resolve({ id: "backend-7" }) });
expect(res.status).toBe(400);
});
it("returns 404 when the session is unknown", async () => {
const res = await sessionDetailPATCH(patchRequest("does-not-exist", { displayName: "x" }), {
params: Promise.resolve({ id: "does-not-exist" }),
});
expect(res.status).toBe(404);
expect(vi.mocked(updateMetadata)).not.toHaveBeenCalled();
});
});
describe("GET /api/runtime/terminal", () => {
function withEnv(overrides: Record<string, string | undefined>, fn: () => Promise<void>) {
const saved: Record<string, string | undefined> = {};
@ -1042,7 +1180,9 @@ describe("API Routes", () => {
});
it("returns 500 when list fails", async () => {
(mockSessionManager.list as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("boom"));
(mockSessionManager.list as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("boom"),
);
const res = await orchestratorsGET(
makeRequest("http://localhost:3000/api/orchestrators?project=my-app"),
);

View File

@ -205,6 +205,7 @@ export function makeSession(overrides: Partial<DashboardSession> = {}): Dashboar
issueTitle: null,
userPrompt: null,
displayName: null,
displayNameUserSet: false,
summary: "Test session",
summaryIsFallback: false,
createdAt: new Date().toISOString(),

View File

@ -1,5 +1,10 @@
import { type NextRequest } from "next/server";
import { getProjectSessionsDir, readAgentReportAuditTrailAsync } from "@aoagents/ao-core";
import {
getProjectSessionsDir,
isOpenCodeSessionManager,
readAgentReportAuditTrailAsync,
updateMetadata,
} from "@aoagents/ao-core";
import { getServices } from "@/lib/services";
import {
sessionToDashboard,
@ -8,10 +13,13 @@ import {
enrichSessionsMetadata,
} from "@/lib/serialize";
import { settlesWithin } from "@/lib/async-utils";
import { stripControlChars, validateIdentifier } from "@/lib/validation";
import { getCorrelationId, jsonWithCorrelation, recordApiObservation } from "@/lib/observability";
const AGENT_REPORT_AUDIT_TIMEOUT_MS = 1000;
const METADATA_ENRICH_TIMEOUT_MS = 3000;
/** Max length of the user-set display name. Matches the spawn-time derivation cap. */
const DISPLAY_NAME_MAX_LENGTH = 80;
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const correlationId = getCorrelationId(_request);
@ -29,9 +37,11 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
const project = resolveProject(coreSession, config.projects);
if (project) {
const sessionsDir = getProjectSessionsDir(coreSession.projectId);
const auditPromise = readAgentReportAuditTrailAsync(sessionsDir, coreSession.id).then((audit) => {
dashboardSession.agentReportAudit = audit;
});
const auditPromise = readAgentReportAuditTrailAsync(sessionsDir, coreSession.id).then(
(audit) => {
dashboardSession.agentReportAudit = audit;
},
);
await settlesWithin(auditPromise, AGENT_REPORT_AUDIT_TIMEOUT_MS);
}
@ -83,3 +93,118 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
return jsonWithCorrelation({ error: "Internal server error" }, { status: 500 }, correlationId);
}
}
/**
* PATCH /api/sessions/:id update mutable fields on a session.
*
* Currently supports:
* - `displayName` (string | null): user-set rename. Empty string or `null`
* clears the field, reverting the session to its default title.
*/
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const correlationId = getCorrelationId(request);
const startedAt = Date.now();
const { id } = await params;
const idErr = validateIdentifier(id, "id");
if (idErr) {
return jsonWithCorrelation({ error: idErr }, { status: 400 }, correlationId);
}
let body: Record<string, unknown> | null;
try {
body = (await request.json()) as Record<string, unknown>;
} catch {
return jsonWithCorrelation(
{ error: "Invalid JSON in request body" },
{ status: 400 },
correlationId,
);
}
if (!body || typeof body !== "object") {
return jsonWithCorrelation({ error: "Invalid request body" }, { status: 400 }, correlationId);
}
// Only one mutable field for now — keep validation explicit so adding
// future fields here is a deliberate edit.
if (!Object.prototype.hasOwnProperty.call(body, "displayName")) {
return jsonWithCorrelation(
{ error: "displayName is required" },
{ status: 400 },
correlationId,
);
}
const raw = body["displayName"];
if (raw !== null && typeof raw !== "string") {
return jsonWithCorrelation(
{ error: "displayName must be a string or null" },
{ status: 400 },
correlationId,
);
}
// Empty / null / whitespace-only ⇒ clear the field (revert to default).
// Otherwise sanitize: strip control chars, collapse whitespace, trim, cap length.
const cleaned =
raw === null
? ""
: stripControlChars(raw).replace(/\s+/g, " ").trim().slice(0, DISPLAY_NAME_MAX_LENGTH);
try {
const { config, sessionManager } = await getServices();
const coreSession = await sessionManager.get(id);
if (!coreSession) {
return jsonWithCorrelation({ error: "Session not found" }, { status: 404 }, correlationId);
}
const sessionsDir = getProjectSessionsDir(coreSession.projectId);
// Empty string in updateMetadata removes the key — exactly the "revert to
// default" semantic. The user-set flag tracks provenance: we set it when
// the user types a name, clear it when they revert, so the dashboard's
// fallback chain knows whether to promote `displayName` over PR/issue
// titles or treat it as an auto-derived spawn artifact.
updateMetadata(sessionsDir, id, {
displayName: cleaned,
displayNameUserSet: cleaned === "" ? "" : "true",
});
if (isOpenCodeSessionManager(sessionManager)) {
sessionManager.invalidateCache();
}
const updated = await sessionManager.get(id);
const dashboardSession = updated
? sessionToDashboard(updated)
: sessionToDashboard(coreSession);
recordApiObservation({
config,
method: "PATCH",
path: "/api/sessions/[id]",
correlationId,
startedAt,
outcome: "success",
statusCode: 200,
projectId: coreSession.projectId,
sessionId: id,
});
return jsonWithCorrelation(dashboardSession, { status: 200 }, correlationId);
} catch (error) {
const { config } = await getServices().catch(() => ({ config: undefined }));
if (config) {
recordApiObservation({
config,
method: "PATCH",
path: "/api/sessions/[id]",
correlationId,
startedAt,
outcome: "failure",
statusCode: 500,
sessionId: id,
reason: error instanceof Error ? error.message : "Internal server error",
});
}
return jsonWithCorrelation({ error: "Internal server error" }, { status: 500 }, correlationId);
}
}

View File

@ -4536,6 +4536,53 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
text-transform: lowercase;
}
/* Rename pencil button sits at the rightmost edge of a session row.
Hidden by default; revealed on row hover (via Tailwind `group-hover`)
and on focus so keyboard users can reach it. */
.project-sidebar__sess-rename-btn {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
margin-left: 4px;
border: 0;
background: transparent;
cursor: pointer;
color: var(--color-text-muted);
border-radius: 0;
transition:
opacity 80ms,
color 80ms,
background 80ms;
}
.project-sidebar__sess-rename-btn:hover {
color: var(--color-text-primary);
background: var(--sidebar-row-hover);
}
/* Inline rename input replaces the label while editing. Inherits the
row's font sizing so the input visually swaps in without layout shift. */
.project-sidebar__sess-rename-input {
flex: 1;
min-width: 0;
font-size: 11.5px;
font-family: inherit;
color: var(--color-text-primary);
background: var(--color-bg-primary);
border: 1px solid var(--color-border-strong);
border-radius: 0;
padding: 1px 6px;
outline: none;
}
.project-sidebar__sess-rename-input:focus {
border-color: var(--color-accent-amber);
}
/* Sidebar settings popover */
.project-sidebar__settings-wrap {
position: relative;
@ -7644,11 +7691,34 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
white-space: nowrap;
color: var(--color-text-secondary);
}
.topbar-zone-pill__value { font-weight: 600; font-variant-numeric: tabular-nums; }
.topbar-zone-pill__label { opacity: 0.85; }
.topbar-zone-pill--merge { background: color-mix(in srgb, var(--color-status-ready) 12%, transparent); color: var(--color-status-ready); }
.topbar-zone-pill--respond { background: color-mix(in srgb, var(--color-status-error) 12%, transparent); color: var(--color-status-error); }
.topbar-zone-pill--review { background: color-mix(in srgb, var(--color-accent-orange) 12%, transparent); color: var(--color-accent-orange); }
.topbar-zone-pill--working { background: color-mix(in srgb, var(--color-accent-blue) 12%, transparent); color: var(--color-accent-blue); }
.topbar-zone-pill--pending { background: color-mix(in srgb, var(--color-status-attention) 12%, transparent); color: var(--color-status-attention); }
.topbar-zone-pill--done { background: color-mix(in srgb, var(--color-text-tertiary) 14%, transparent); color: var(--color-text-tertiary); }
.topbar-zone-pill__value {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.topbar-zone-pill__label {
opacity: 0.85;
}
.topbar-zone-pill--merge {
background: color-mix(in srgb, var(--color-status-ready) 12%, transparent);
color: var(--color-status-ready);
}
.topbar-zone-pill--respond {
background: color-mix(in srgb, var(--color-status-error) 12%, transparent);
color: var(--color-status-error);
}
.topbar-zone-pill--review {
background: color-mix(in srgb, var(--color-accent-orange) 12%, transparent);
color: var(--color-accent-orange);
}
.topbar-zone-pill--working {
background: color-mix(in srgb, var(--color-accent-blue) 12%, transparent);
color: var(--color-accent-blue);
}
.topbar-zone-pill--pending {
background: color-mix(in srgb, var(--color-status-attention) 12%, transparent);
color: var(--color-status-attention);
}
.topbar-zone-pill--done {
background: color-mix(in srgb, var(--color-text-tertiary) 14%, transparent);
color: var(--color-text-tertiary);
}

View File

@ -170,6 +170,12 @@ function ProjectSidebarInner({
const [showKilled, setShowKilled] = useState(false);
const [showDone, setShowDone] = useState(false);
const [showSessionId, setShowSessionId] = useState<boolean>(loadShowSessionId);
// Inline session-rename state. Only one row is edited at a time. `pendingRenames`
// mirrors the in-flight / just-saved value so the new label appears immediately
// without waiting for the next SSE refresh.
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const [editingValue, setEditingValue] = useState("");
const [pendingRenames, setPendingRenames] = useState<Map<string, string>>(new Map());
const [settingsOpen, setSettingsOpen] = useState(false);
const [projectMenuOpenId, setProjectMenuOpenId] = useState<string | null>(null);
const [projectSettingsProjectId, setProjectSettingsProjectId] = useState<string | null>(null);
@ -291,6 +297,76 @@ function ProjectSidebarInner({
return map;
}, [sessions, prefixByProject, allPrefixes, visibleProjects, showKilled, showDone]);
// Clear an optimistic rename once the prop session.displayName catches up.
// Without this, we'd keep masking the server value forever after a save.
useEffect(() => {
if (pendingRenames.size === 0 || !sessions) return;
const next = new Map(pendingRenames);
let changed = false;
for (const session of sessions) {
const pending = next.get(session.id);
if (pending !== undefined && (session.displayName ?? "") === pending) {
next.delete(session.id);
changed = true;
}
}
if (changed) setPendingRenames(next);
}, [sessions, pendingRenames]);
const startRename = (session: DashboardSession, currentTitle: string) => {
// Prefer the in-flight optimistic value over the prop — if the user opens
// rename while a previous PATCH is still propagating, the prop still shows
// the pre-rename value but we want the input to start from the latest.
// Auto-derived displayName isn't pre-filled (user-set flag absent) — start
// from the live title so the user types over the visible label.
const pending = pendingRenames.get(session.id);
const initial =
pending ?? (session.displayNameUserSet ? (session.displayName ?? "") : "");
setEditingSessionId(session.id);
setEditingValue(initial || currentTitle);
};
const cancelRename = () => {
setEditingSessionId(null);
setEditingValue("");
};
const submitRename = async (sessionId: string) => {
// Guard against double-submit. submitRename is wired to both Enter (which
// unmounts the input) and onBlur (which can fire during that unmount in
// some browsers); without this, both paths would fire a PATCH.
if (editingSessionId !== sessionId) return;
// Trim, but allow empty — empty means "revert to default" on the server.
const next = editingValue.trim();
setEditingSessionId(null);
setEditingValue("");
setPendingRenames((prev) => {
const map = new Map(prev);
map.set(sessionId, next);
return map;
});
try {
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ displayName: next === "" ? null : next }),
});
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(body?.error ?? "Failed to rename session");
}
} catch {
// Roll back the optimistic update so the row reverts to the prop value.
// The user sees the original name return — no further notification is
// needed for this niche failure path.
setPendingRenames((prev) => {
const map = new Map(prev);
map.delete(sessionId);
return map;
});
}
};
const navigate = (url: string, session?: DashboardSession) => {
if (session) {
try {
@ -707,49 +783,130 @@ function ProjectSidebarInner({
visibleSessions.map((session) => {
const level = getAttentionLevel(session);
const isSessionActive = activeSessionId === session.id;
const title = session.branch ?? getSessionTitle(session);
// Display precedence: optimistic rename (just-saved value)
// → user-set displayName → branch → fallback chain.
// Auto-derived displayName (displayNameUserSet=false) is
// intentionally skipped here so PR/issue titles surfaced
// by getSessionTitle aren't shadowed — mirrors the gate in
// format.ts:getSessionTitle.
const pending = pendingRenames.get(session.id);
const effectiveDisplayName =
pending !== undefined
? pending
: session.displayNameUserSet
? (session.displayName ?? "")
: "";
const title =
effectiveDisplayName !== ""
? effectiveDisplayName
: (session.branch ?? getSessionTitle(session));
const sessionHref = projectSessionPath(project.id, session.id);
const isEditing = editingSessionId === session.id;
if (isEditing) {
return (
<div
key={session.id}
className={cn(
"project-sidebar__sess-row",
isSessionActive && "project-sidebar__sess-row--active",
)}
data-editing="true"
>
<SessionDot level={level} />
<input
type="text"
autoFocus
value={editingValue}
onChange={(e) => setEditingValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void submitRename(session.id);
} else if (e.key === "Escape") {
e.preventDefault();
cancelRename();
}
}}
onFocus={(e) => e.currentTarget.select()}
onBlur={() => void submitRename(session.id)}
maxLength={80}
aria-label={`Rename ${session.id}`}
className="project-sidebar__sess-rename-input"
/>
</div>
);
}
return (
<a
<div
key={session.id}
href={sessionHref}
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
navigate(sessionHref, session);
}}
className={cn(
"project-sidebar__sess-row",
"project-sidebar__sess-row group",
isSessionActive && "project-sidebar__sess-row--active",
)}
aria-current={isSessionActive ? "page" : undefined}
aria-label={`Open ${title}`}
>
<SessionDot level={level} />
<div className="flex-1 min-w-0">
<span
className={cn(
"project-sidebar__sess-label",
isSessionActive && "project-sidebar__sess-label--active",
)}
>
{title}
</span>
{showSessionId ? (
<div className="project-sidebar__sess-meta">
<span className="project-sidebar__sess-id">{session.id}</span>
<span className="project-sidebar__sess-status">
{LEVEL_LABELS[level]}
</span>
</div>
<a
href={sessionHref}
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
navigate(sessionHref, session);
}}
className="project-sidebar__sess-link flex flex-1 min-w-0 items-center gap-[7px]"
aria-current={isSessionActive ? "page" : undefined}
aria-label={`Open ${title}`}
>
<SessionDot level={level} />
<div className="flex-1 min-w-0">
<span
className={cn(
"project-sidebar__sess-label",
isSessionActive && "project-sidebar__sess-label--active",
)}
>
{title}
</span>
{showSessionId ? (
<div className="project-sidebar__sess-meta">
<span className="project-sidebar__sess-id">{session.id}</span>
<span className="project-sidebar__sess-status">
{LEVEL_LABELS[level]}
</span>
</div>
) : null}
</div>
{!showSessionId ? (
<span className="project-sidebar__sess-status project-sidebar__sess-status--inline">
{LEVEL_LABELS[level]}
</span>
) : null}
</div>
{!showSessionId ? (
<span className="project-sidebar__sess-status project-sidebar__sess-status--inline">
{LEVEL_LABELS[level]}
</span>
) : null}
</a>
</a>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
startRename(session, title);
}}
className="project-sidebar__sess-rename-btn opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100"
title="Rename session"
aria-label={`Rename ${session.id}`}
>
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
</svg>
</button>
</div>
);
})
) : error ? (

View File

@ -80,7 +80,9 @@ describe("ProjectSidebar", () => {
/>,
);
expect(screen.getByRole("button", { name: /switch to (dark|light) mode/i })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /switch to (dark|light) mode/i }),
).toBeInTheDocument();
});
it("renders a collapsed empty rail when collapsed with no projects", () => {
@ -477,9 +479,7 @@ describe("ProjectSidebar", () => {
fireEvent.click(screen.getByRole("button", { name: /Project actions for Project Two/i }));
expect(
await screen.findByRole("menuitem", { name: "Open orchestrator" }),
).toBeInTheDocument();
expect(await screen.findByRole("menuitem", { name: "Open orchestrator" })).toBeInTheDocument();
});
it("omits 'Open orchestrator' from the menu when no orchestrator entry exists for the project", async () => {
@ -548,4 +548,178 @@ describe("ProjectSidebar", () => {
expect(screen.getByLabelText("Loading sessions")).toBeInTheDocument();
expect(screen.queryByText("No sessions shown")).not.toBeInTheDocument();
});
// ── Rename worker sessions ───────────────────────────────────────────
describe("session rename", () => {
function renderWithSession(displayName: string | null = null) {
// When a displayName is supplied, treat it as a user-set rename so the
// sidebar renders it as the row label (auto-derived names are gated).
return render(
<ProjectSidebar
projects={projects}
sessions={[
makeSession({
id: "worker-1",
projectId: "project-1",
summary: "Review API changes",
displayName,
displayNameUserSet: displayName !== null,
branch: null,
status: "needs_input",
activity: "waiting_input",
}),
]}
activeProjectId="project-1"
activeSessionId="worker-1"
/>,
);
}
it("renders a pencil button for each worker session row", () => {
renderWithSession();
expect(screen.getByRole("button", { name: /rename worker-1/i })).toBeInTheDocument();
});
it("opens an inline input prefilled with the displayed title on pencil click", () => {
renderWithSession();
fireEvent.click(screen.getByRole("button", { name: /rename worker-1/i }));
const input = screen.getByRole("textbox", { name: /rename worker-1/i }) as HTMLInputElement;
expect(input.value).toBe("Review API changes");
});
it("PATCHes the new name and shows it immediately on Enter", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: "worker-1", displayName: "PR 1466 review" }),
});
vi.stubGlobal("fetch", fetchMock);
renderWithSession();
fireEvent.click(screen.getByRole("button", { name: /rename worker-1/i }));
const input = screen.getByRole("textbox", { name: /rename worker-1/i });
fireEvent.change(input, { target: { value: "PR 1466 review" } });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
"/api/sessions/worker-1",
expect.objectContaining({
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ displayName: "PR 1466 review" }),
}),
);
});
// Optimistic update: the new name appears without waiting for SSE.
expect(screen.getByRole("link", { name: "Open PR 1466 review" })).toBeInTheDocument();
});
it("sends null to clear when the input is empty (revert to default)", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: "worker-1", displayName: null }),
});
vi.stubGlobal("fetch", fetchMock);
renderWithSession("Existing rename");
fireEvent.click(screen.getByRole("button", { name: /rename worker-1/i }));
const input = screen.getByRole("textbox", { name: /rename worker-1/i });
fireEvent.change(input, { target: { value: " " } });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
"/api/sessions/worker-1",
expect.objectContaining({
body: JSON.stringify({ displayName: null }),
}),
);
});
});
it("cancels on Escape without firing PATCH", () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
renderWithSession("Existing rename");
fireEvent.click(screen.getByRole("button", { name: /rename worker-1/i }));
const input = screen.getByRole("textbox", { name: /rename worker-1/i });
fireEvent.change(input, { target: { value: "Half-typed change" } });
fireEvent.keyDown(input, { key: "Escape" });
expect(fetchMock).not.toHaveBeenCalled();
// Input is gone; original name is back.
expect(screen.queryByRole("textbox", { name: /rename worker-1/i })).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "Open Existing rename" })).toBeInTheDocument();
});
it("does not fire a duplicate PATCH if Enter and onBlur both trigger", async () => {
// Some browsers fire blur during input unmount after the Enter handler
// already cleared editing state. The submitRename guard should swallow
// the second call.
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: "worker-1", displayName: "Renamed" }),
});
vi.stubGlobal("fetch", fetchMock);
renderWithSession();
fireEvent.click(screen.getByRole("button", { name: /rename worker-1/i }));
const input = screen.getByRole("textbox", { name: /rename worker-1/i });
fireEvent.change(input, { target: { value: "Renamed" } });
fireEvent.keyDown(input, { key: "Enter" });
// Simulate the post-unmount blur cascade.
fireEvent.blur(input);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
it("does not shadow PR title with auto-derived displayName (displayNameUserSet=false)", () => {
// Regression: an auto-derived displayName captured at spawn time must not
// beat a live PR title in the sidebar. Mirrors the gate in getSessionTitle.
render(
<ProjectSidebar
projects={projects}
sessions={[
makeSession({
id: "worker-1",
projectId: "project-1",
displayName: "Stale spawn-time label",
displayNameUserSet: false,
branch: null,
pr: makePR({ title: "feat: live PR title" }),
}),
]}
activeProjectId="project-1"
activeSessionId="worker-1"
/>,
);
expect(screen.getByRole("link", { name: "Open feat: live PR title" })).toBeInTheDocument();
expect(screen.queryByText("Stale spawn-time label")).not.toBeInTheDocument();
});
it("rolls back the optimistic name when the PATCH fails", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: async () => ({ error: "Failed to rename session" }),
});
vi.stubGlobal("fetch", fetchMock);
renderWithSession("Original");
fireEvent.click(screen.getByRole("button", { name: /rename worker-1/i }));
const input = screen.getByRole("textbox", { name: /rename worker-1/i });
fireEvent.change(input, { target: { value: "Optimistic name" } });
fireEvent.keyDown(input, { key: "Enter" });
// Settles back to the prop value once the failed PATCH resolves.
await waitFor(() => {
expect(screen.getByRole("link", { name: "Open Original" })).toBeInTheDocument();
});
});
});
});

View File

@ -29,6 +29,7 @@ function makeSession(overrides?: Partial<DashboardSession>): DashboardSession {
issueTitle: null,
userPrompt: null,
displayName: null,
displayNameUserSet: false,
summary: null,
summaryIsFallback: false,
createdAt: new Date().toISOString(),
@ -113,7 +114,7 @@ describe("humanizeBranch", () => {
// ---------------------------------------------------------------------------
describe("getSessionTitle", () => {
it("returns PR title when available (highest priority)", () => {
it("returns PR title when available and no displayName is set", () => {
const session = makeSession({
summary: "Agent summary",
issueTitle: "Issue title",
@ -195,6 +196,52 @@ describe("getSessionTitle", () => {
expect(getSessionTitle(session)).toBe("Infer Project Id");
});
it("prefers user-set displayName above every other signal (rename wins)", () => {
// When displayNameUserSet is true, the user has explicitly renamed this
// session and their label must beat live PR / issue titles.
const session = makeSession({
displayName: "PR 1466 review",
displayNameUserSet: true,
issueTitle: "Add user authentication",
branch: "feat/auth",
pr: {
number: 1466,
title: "feat: add auth",
url: "https://github.com/x/y/pull/1466",
state: "open",
} as DashboardSession["pr"],
});
expect(getSessionTitle(session)).toBe("PR 1466 review");
});
it("does NOT promote auto-derived displayName above PR title", () => {
// Spawn-time auto-derived displayName must not shadow a live PR title —
// a stale spawn label would make sessions hard to identify days later.
const session = makeSession({
displayName: "Auto-derived at spawn",
displayNameUserSet: false,
issueTitle: "Add user authentication",
branch: "feat/auth",
pr: {
number: 1466,
title: "feat: add auth",
url: "https://github.com/x/y/pull/1466",
state: "open",
} as DashboardSession["pr"],
});
expect(getSessionTitle(session)).toBe("feat: add auth");
});
it("does NOT promote auto-derived displayName above issue title", () => {
const session = makeSession({
displayName: "Auto-derived at spawn",
displayNameUserSet: false,
issueTitle: "Add user authentication",
branch: "feat/auth",
});
expect(getSessionTitle(session)).toBe("Add user authentication");
});
it("returns displayName when no PR / issue title / user prompt", () => {
const session = makeSession({
id: "ao-5",
@ -218,8 +265,7 @@ describe("getSessionTitle", () => {
id: "ao-42",
summary: null,
issueTitle: null,
userPrompt:
"Add rate limiting to /api/upload\n\nUse a sliding-window counter keyed by IP.",
userPrompt: "Add rate limiting to /api/upload\n\nUse a sliding-window counter keyed by IP.",
displayName: "Add rate limiting to /api/upload",
branch: "session/ao-42",
});
@ -239,10 +285,13 @@ describe("getSessionTitle", () => {
expect(getSessionTitle(session)).toBe("Fix the race condition");
});
it("prefers issue title over displayName when both are present", () => {
it("prefers issue title over auto-derived displayName when both are present", () => {
// Auto-derived displayName must stay below live tracker titles so a stale
// spawn-time value doesn't shadow the current issue title.
const session = makeSession({
issueTitle: "Live issue title",
displayName: "Stale captured display name",
displayNameUserSet: false,
branch: "feat/auth",
});
expect(getSessionTitle(session)).toBe("Live issue title");

View File

@ -50,41 +50,45 @@ export function humanizeBranch(branch: string, sessionId?: string): string {
* Compute the best display title for a session card.
*
* Fallback chain (ordered by signal quality):
* 1. PR title human-visible deliverable name
* 2. Issue title human-written task description (live from tracker)
* 3. Display name cleaned, single-line, 80-char-truncated task
* context captured at spawn time. Sits above
* `userPrompt` because for prompt-only sessions both
* are populated from the same `spawnConfig.prompt`
* `displayName` is the cleaned version and should
* win so kanban cards don't show raw multi-line
* prompts.
* 4. User prompt raw freeform spawn instructions (fallback for
* sessions spawned before `displayName` existed)
* 5. Humanized branch stable task identifier when no explicit title exists
* (skipped when it collapses to just the session ID)
* 6. Pinned summary first quality summary, stable across agent updates
* 7. Quality summary live summary, but can drift as the session evolves
* 8. Any summary even a fallback excerpt is better than nothing
* 9. Status text absolute fallback
* 1. User-set display name only when `displayNameUserSet` is true.
* An explicit rename always wins so the user's
* chosen label isn't shadowed by tracker signals.
* 2. PR title human-visible deliverable name
* 3. Issue title human-written task description (live from tracker)
* 4. Auto-derived display captured at spawn from issue title / user prompt /
* name orchestrator system prompt. Stays BELOW PR/issue
* titles so a stale spawn-time value doesn't
* shadow the live deliverable name.
* 5. User prompt raw freeform spawn instructions (fallback for
* sessions spawned before `displayName` existed)
* 6. Humanized branch stable task identifier when no explicit title
* exists (skipped when it collapses to just the
* session ID)
* 7. Pinned summary first quality summary, stable across agent updates
* 8. Quality summary live summary, but can drift as the session evolves
* 9. Any summary even a fallback excerpt is better than nothing
* 10. Status text absolute fallback
*/
export function getSessionTitle(session: DashboardSession): string {
// 1. PR title — always best
// 1. User-set rename — wins over everything when explicitly flagged.
if (session.displayName && session.displayNameUserSet) {
return session.displayName;
}
// 2. PR title
if (session.pr?.title) return session.pr.title;
// 2. Issue title — human-written task description
// 3. Issue title — human-written task description
if (session.issueTitle) return session.issueTitle;
// 3. Display name — persisted at spawn time from issue title / user prompt /
// orchestrator system prompt. Intentionally ordered ABOVE `userPrompt`
// because for prompt-only sessions both are derived from the same
// `spawnConfig.prompt`: `displayName` is the single-line, 80-char-truncated
// version and `userPrompt` is the raw multi-line original. If `userPrompt`
// were checked first, the raw prompt would always shadow the cleaned one on
// kanban cards and session tabs.
// 4. Auto-derived displayName — captured at spawn time. Sits below PR/issue
// but above userPrompt: for prompt-only sessions both are populated from the
// same `spawnConfig.prompt`; `displayName` is the cleaned single-line,
// 80-char-truncated version and should win so kanban cards don't show raw
// multi-line prompts.
if (session.displayName) return session.displayName;
// 4. User prompt — raw freeform spawn instructions. Only reached when
// 5. User prompt — raw freeform spawn instructions. Only reached when
// `displayName` is absent (older sessions spawned before this field existed,
// or sessions where derivation failed).
if (session.userPrompt) return session.userPrompt;

View File

@ -181,6 +181,7 @@ export function sessionToDashboard(session: Session): DashboardSession {
issueTitle: null, // Will be enriched by enrichSessionIssueTitle()
userPrompt: session.metadata["userPrompt"] ?? null,
displayName: session.metadata["displayName"] ?? null,
displayNameUserSet: session.metadata["displayNameUserSet"] === "true",
summary,
summaryIsFallback: agentSummary ? (session.agentInfo?.summaryIsFallback ?? false) : false,
createdAt: session.createdAt.toISOString(),

View File

@ -106,6 +106,12 @@ export interface DashboardSession {
* remain identifiable even when PR/issue enrichment is unavailable.
*/
displayName: string | null;
/**
* True when `displayName` was explicitly set by the user via the rename UI.
* Auto-derived spawn-time values leave this false. {@link getSessionTitle}
* uses it to decide whether `displayName` should beat live PR/issue titles.
*/
displayNameUserSet: boolean;
summary: string | null;
/** True when the summary is a low-quality fallback (e.g. truncated spawn prompt) */
summaryIsFallback: boolean;