Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions source/cydo/server/app.d
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions source/cydo/workflow/tasks/derived_text.d
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
65 changes: 65 additions & 0 deletions tests/e2e/task-rename.spec.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
});
2 changes: 2 additions & 0 deletions web/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ function AppContent() {
interrupt,
stop,
closeStdin,
renameTask,
resume,
promote,
fork,
Expand Down Expand Up @@ -458,6 +459,7 @@ function AppContent() {
onInterrupt={interrupt}
onStop={stop}
onCloseStdin={closeStdin}
onRename={renameTask}
onResume={resume}
onPromote={promote}
onFork={fork}
Expand Down
10 changes: 10 additions & 0 deletions web/src/components/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -86,6 +87,7 @@ function SessionViewInner({
onInterrupt,
onStop,
onCloseStdin,
onRename,
onResume,
onPromote,
onFork,
Expand Down Expand Up @@ -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}
Expand Down
55 changes: 55 additions & 0 deletions web/src/components/SystemBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -311,6 +322,50 @@ export function SystemBanner({
: "Archive"}
</button>
)}
{onRename &&
(renaming ? (
<>
<input
class="banner-rename-input"
value={renameValue}
size={Math.max(20, renameValue.length + 1)}
ref={(el) => {
// 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);
}}
/>
<button
class="btn-banner-apply"
onClick={applyRename}
title="Apply new task name (Enter)"
>
Apply
</button>
</>
) : (
<button
class="btn-banner-rename"
onClick={() => {
setRenameValue(taskTitle ?? "");
setRenaming(true);
}}
title="Rename task"
>
Rename
</button>
))}
{totalCost > 0 && (
<span class="banner-cost">${totalCost.toFixed(4)}</span>
)}
Expand Down
4 changes: 4 additions & 0 deletions web/src/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
}
Expand Down
36 changes: 36 additions & 0 deletions web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions web/src/useExportedTaskManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ export function useExportedTaskManager(): TaskManager {
interrupt: noop,
stop: noop,
closeStdin: noop,
renameTask: noop,
resume: noop,
promote: noop,
fork: noop,
Expand Down
7 changes: 7 additions & 0 deletions web/src/useSessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}, []);
Expand Down Expand Up @@ -2196,6 +2202,7 @@ export function useTaskManager(
interrupt,
stop,
closeStdin,
renameTask,
resume,
promote,
fork,
Expand Down