diff --git a/source/cydo/server/app.d b/source/cydo/server/app.d index f42bf0d..ac45bb4 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/source/cydo/workflow/tasks/derived_text.d b/source/cydo/workflow/tasks/derived_text.d index dd4c833..9737c73 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 0000000..87d6b1f --- /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 }, + ); +}); diff --git a/web/src/app.tsx b/web/src/app.tsx index f3f2ca2..42952c5 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 48d1713..9edcf2f 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 45112fb..abc15c2 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 09c2f30..1bd523a 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 3fe0314..4d790d7 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 78364ef..e439f82 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 3c620c8..dc63468 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,