From bf3090ef2381f6a6cf51192481e524e0cfcdf9d3 Mon Sep 17 00:00:00 2001 From: Antisophy <293439221+Antisophy@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:20:04 -0700 Subject: [PATCH 1/2] feat(web): rename tasks from the banner Task titles are auto-generated from the first user message and drift out of date as long-lived tasks evolve, leaving the sidebar hard to navigate. Add a Rename button to the banner (next to the session buttons) that swaps to an input pre-filled with the current name; Enter or the Apply button commits, Escape cancels, and empty names are ignored. The input pre-selects the name so typing replaces it immediately, and its width tracks the typed string (field-sizing: content where implemented, with the size attribute as an approximate fallback, both floored at the default 20-character width). On the backend, a rename_task websocket message updates the title in memory and SQLite and rebroadcasts it via the existing title_update path, so the sidebar and all connected clients update live. --- source/cydo/server/app.d | 16 +++++++++ web/src/app.tsx | 2 ++ web/src/components/SessionView.tsx | 10 ++++++ web/src/components/SystemBanner.tsx | 55 +++++++++++++++++++++++++++++ web/src/connection.ts | 4 +++ web/src/styles.css | 36 +++++++++++++++++++ web/src/useExportedTaskManager.ts | 1 + web/src/useSessionManager.ts | 7 ++++ 8 files changed, 131 insertions(+) diff --git a/source/cydo/server/app.d b/source/cydo/server/app.d index f42bf0d0..ac45bb47 100644 --- a/source/cydo/server/app.d +++ b/source/cydo/server/app.d @@ -1167,6 +1167,7 @@ class App case "edit_raw_event": handleEditRawEvent(ws, json); break; case "set_archived": handleSetArchivedMsg(ws, json); break; case "set_draft": handleSetDraftMsg(ws, json); break; + case "rename_task": handleRenameTaskMsg(json); break; case "delete_task": handleDeleteTaskMsg(json); break; case "ask_user_response": workflowTools.handleAskUserResponse(json); break; case "permission_prompt_response": workflowTools.handlePermissionPromptResponse(json); break; @@ -1723,6 +1724,21 @@ class App archiveManager.handleSetArchived(ws, json.tid, archived); } + private void handleRenameTaskMsg(WsMessage json) + { + auto tid = json.tid; + if (tid < 0 || tid !in tasks) + return; + import std.string : strip; + string title = json.content.json !is null ? jsonParse!string(json.content.json) : ""; + title = title.strip; + if (title.length == 0) + return; // an empty name would just look broken in the sidebar; ignore + tasks[tid].title = title; + persistence.setTitle(tid, title); + broadcastTitleUpdate(tid, title); + } + private void handleSetDraftMsg(WebSocketAdapter senderWs, WsMessage json) { auto tid = json.tid; diff --git a/web/src/app.tsx b/web/src/app.tsx index f3f2ca23..42952c53 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -30,6 +30,7 @@ function AppContent() { interrupt, stop, closeStdin, + renameTask, resume, promote, fork, @@ -458,6 +459,7 @@ function AppContent() { onInterrupt={interrupt} onStop={stop} onCloseStdin={closeStdin} + onRename={renameTask} onResume={resume} onPromote={promote} onFork={fork} diff --git a/web/src/components/SessionView.tsx b/web/src/components/SessionView.tsx index 48d1713d..9edcf2fb 100644 --- a/web/src/components/SessionView.tsx +++ b/web/src/components/SessionView.tsx @@ -43,6 +43,7 @@ interface Props { onInterrupt: (uuid: string) => void; onStop: (uuid: string) => void; onCloseStdin: (uuid: string) => void; + onRename?: (uuid: string, title: string) => void; onResume: (uuid: string) => void; onPromote?: (tid: number) => void; onFork: (tid: number, afterUuid: string) => void; @@ -86,6 +87,7 @@ function SessionViewInner({ onInterrupt, onStop, onCloseStdin, + onRename, onResume, onPromote, onFork, @@ -460,6 +462,14 @@ function SessionViewInner({ onCloseStdin={() => { onCloseStdin(task.uuid); }} + taskTitle={task.title} + onRename={ + onRename + ? (title: string) => { + onRename(task.uuid, title); + } + : undefined + } taskType={task.taskType} onToggleSidebar={onToggleSidebar} hasGlobalAttention={hasGlobalAttention} diff --git a/web/src/components/SystemBanner.tsx b/web/src/components/SystemBanner.tsx index 45112fb1..abc15c22 100644 --- a/web/src/components/SystemBanner.tsx +++ b/web/src/components/SystemBanner.tsx @@ -30,6 +30,8 @@ interface Props { onResume?: () => void; exportMode?: boolean; claudeUsage?: AgentUsageMessage; + taskTitle?: string; + onRename?: (title: string) => void; } export function normalizeSessionStatus(status?: string | null): string | null { @@ -142,8 +144,17 @@ export function SystemBanner({ onResume, exportMode, claudeUsage, + taskTitle, + onRename, }: Props) { const [detailsOpen, setDetailsOpen] = useState(false); + const [renaming, setRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(""); + const applyRename = () => { + const trimmed = renameValue.trim(); + if (trimmed.length > 0 && onRename) onRename(trimmed); + setRenaming(false); + }; const liveStatus = normalizeSessionStatus(sessionStatus); let processingText: string | null = null; if (alive && stdinClosed) { @@ -311,6 +322,50 @@ export function SystemBanner({ : "Archive"} )} + {onRename && + (renaming ? ( + <> + { + // focus + select once per mount (inline refs re-run every + // render; reselecting would clobber in-progress typing) + if (el && !el.dataset.autoSelected) { + el.dataset.autoSelected = "1"; + el.focus(); + el.select(); + } + }} + onInput={(e) => { + setRenameValue(e.currentTarget.value); + }} + onKeyDown={(e) => { + if (e.key === "Enter") applyRename(); + else if (e.key === "Escape") setRenaming(false); + }} + /> + + + ) : ( + + ))} {totalCost > 0 && ( )} diff --git a/web/src/connection.ts b/web/src/connection.ts index 09c2f306..1bd523a7 100644 --- a/web/src/connection.ts +++ b/web/src/connection.ts @@ -258,6 +258,10 @@ export class Connection { this.send(JSON.stringify({ type: "set_draft", tid, content: draft })); } + renameTask(tid: number, title: string) { + this.send(JSON.stringify({ type: "rename_task", tid, content: title })); + } + deleteTask(tid: number) { this.send(JSON.stringify({ type: "delete_task", tid })); } diff --git a/web/src/styles.css b/web/src/styles.css index 3fe03143..4d790d7e 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -743,6 +743,42 @@ body, color: var(--accent); } +.btn-banner-rename, +.btn-banner-apply { + background: none; + border: 1px solid var(--border); + color: var(--text-dim); + font-size: 12px; + padding: 2px 8px; + cursor: pointer; +} + +.btn-banner-rename:hover, +.btn-banner-apply:hover { + border-color: var(--accent); + color: var(--accent); +} + +.banner-rename-input { + /* width tracks the typed string via the size attribute; allow shrinking + when the banner is crowded */ + min-width: 0; + background: none; + border: 1px solid var(--border); + color: var(--text); + font-size: 12px; + padding: 2px 8px; +} + +@supports (field-sizing: content) { + .banner-rename-input { + /* pixel-exact content tracking where implemented (the size attribute is + then ignored for sizing and serves only as the fallback elsewhere) */ + field-sizing: content; + min-width: 20ch; /* match the size-attribute fallback's floor */ + } +} + .sidebar-archive-node { color: var(--text-dim); font-style: italic; diff --git a/web/src/useExportedTaskManager.ts b/web/src/useExportedTaskManager.ts index 78364ef2..e439f822 100644 --- a/web/src/useExportedTaskManager.ts +++ b/web/src/useExportedTaskManager.ts @@ -222,6 +222,7 @@ export function useExportedTaskManager(): TaskManager { interrupt: noop, stop: noop, closeStdin: noop, + renameTask: noop, resume: noop, promote: noop, fork: noop, diff --git a/web/src/useSessionManager.ts b/web/src/useSessionManager.ts index 3c620c80..dc634681 100644 --- a/web/src/useSessionManager.ts +++ b/web/src/useSessionManager.ts @@ -127,6 +127,7 @@ export interface TaskManager { interrupt: (uuid: string) => void; stop: (uuid: string) => void; closeStdin: (uuid: string) => void; + renameTask: (uuid: string, title: string) => void; resume: (uuid: string) => void; promote: (tid: number) => void; fork: (tid: number, afterUuid: string) => void; @@ -1879,6 +1880,11 @@ export function useTaskManager( if (tid !== null) connRef.current?.sendCloseStdin(tid); }, []); + const renameTask = useCallback((uuid: string, title: string) => { + const tid = liveStates.get(uuid)?.tid ?? null; + if (tid !== null) connRef.current?.renameTask(tid, title); + }, []); + const fork = useCallback((tid: number, afterUuid: string) => { connRef.current?.forkTask(tid, afterUuid); }, []); @@ -2196,6 +2202,7 @@ export function useTaskManager( interrupt, stop, closeStdin, + renameTask, resume, promote, fork, From ffcbe65f6a039c886573099983ff34de1251d878 Mon Sep 17 00:00:00 2001 From: Antisophy <293439221+Antisophy@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:46:56 -0700 Subject: [PATCH 2/2] fix(tasks): don't let a late auto-title overwrite a manual rename Title generation runs as an async one-shot after the first message; its completion handler applied the generated title unconditionally, so a manual rename issued while generation was still in flight would be clobbered when the one-shot landed. Capture the title at generation start and apply the result only if the title is still unchanged. Add an e2e spec covering the rename flow end to end against each agent (it surfaced this race): the input opens pre-filled with the current title, Enter and the Apply button each commit a new name to the sidebar, Escape cancels without renaming, and the renamed title survives a page reload, proving it came back from the server store. --- source/cydo/workflow/tasks/derived_text.d | 4 ++ tests/e2e/task-rename.spec.ts | 65 +++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 tests/e2e/task-rename.spec.ts diff --git a/source/cydo/workflow/tasks/derived_text.d b/source/cydo/workflow/tasks/derived_text.d index dd4c8332..9737c738 100644 --- a/source/cydo/workflow/tasks/derived_text.d +++ b/source/cydo/workflow/tasks/derived_text.d @@ -115,6 +115,7 @@ public: if (prompt.length == 0) return; + auto titleAtStart = td.title; auto titleHandle = host_.agentForTask(tid).completeOneShot(prompt, "small", td.launch); td.titleGenHandle = titleHandle.promise; td.titleGenKill = titleHandle.cancel; @@ -125,6 +126,9 @@ public: task.titleGenHandle = null; task.titleGenKill = null; task.titleGenDone = true; + // a manual rename during generation wins over the stale auto-title + if (task.title != titleAtStart) + return; if (title.length > 0 && title.length < 200) { task.title = title; diff --git a/tests/e2e/task-rename.spec.ts b/tests/e2e/task-rename.spec.ts new file mode 100644 index 00000000..87d6b1f5 --- /dev/null +++ b/tests/e2e/task-rename.spec.ts @@ -0,0 +1,65 @@ +import { + test, + expect, + enterSession, + sendMessage, + assistantText, + responseTimeout, +} from "./fixtures"; + +test("task rename from the banner", async ({ page, agentType }) => { + const timeout = responseTimeout(agentType); + + await enterSession(page); + await sendMessage(page, 'Please reply with "rename-me"'); + await expect(assistantText(page, "rename-me")).toBeVisible({ timeout }); + + // the starting title is the first message or the one-shot result, + // depending on what the agent's title generation produced; read it live + const sidebarLabel = page.locator(".sidebar-item .sidebar-label"); + await expect(sidebarLabel.filter({ hasText: "rename-me" })).toBeVisible({ + timeout, + }); + const initialTitle = + (await sidebarLabel + .filter({ hasText: "rename-me" }) + .first() + .textContent()) ?? ""; + expect(initialTitle).toContain("rename-me"); + + // the input opens pre-filled with the current name; Enter commits + await page.locator(".btn-banner-rename").click(); + const input = page.locator(".banner-rename-input"); + await expect(input).toHaveValue(initialTitle); + await input.fill("renamed by enter"); + await input.press("Enter"); + await expect(input).toHaveCount(0); + await expect( + sidebarLabel.filter({ hasText: "renamed by enter" }), + ).toBeVisible(); + + // the Apply button commits too + await page.locator(".btn-banner-rename").click(); + await expect(input).toHaveValue("renamed by enter"); + await input.fill("renamed by apply"); + await page.locator(".btn-banner-apply").click(); + await expect(input).toHaveCount(0); + await expect( + sidebarLabel.filter({ hasText: "renamed by apply" }), + ).toBeVisible(); + + // Escape cancels without renaming + await page.locator(".btn-banner-rename").click(); + await input.fill("discarded name"); + await input.press("Escape"); + await expect(input).toHaveCount(0); + await expect( + sidebarLabel.filter({ hasText: "renamed by apply" }), + ).toBeVisible(); + + // the rename came back from the server store, not client state + await page.reload(); + await expect(sidebarLabel.filter({ hasText: "renamed by apply" })).toBeVisible( + { timeout }, + ); +});