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 && (
${totalCost.toFixed(4)}
)}
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,