From 0af1caad12b03c2d5206af9123e3cc8ae88aeaeb Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sun, 9 Aug 2026 15:32:11 -0700 Subject: [PATCH] fix(threads): preserve updatedAt when renaming a thread - stop overwriting updatedAt in renderer threadSlice, MCP update_thread, and remote rename command - keep updatedAt intact when ExperimentView assigns candidate titles - refactor SortableThreadItem editing state to preserve row metadata during inline rename --- .../app-controls/mcp/toolRegistry.test.ts | 6 +++- src/main/app-controls/mcp/tools/threads.ts | 2 +- src/main/remote/RemoteAccessServer.test.ts | 5 ++- src/main/remote/server/threadCommands.ts | 1 - src/renderer/state/appStore.test.ts | 25 ++++++++++++++ src/renderer/state/slices/threadSlice.ts | 2 +- .../ExperimentView/ExperimentView.test.tsx | 7 +++- .../views/ExperimentView/ExperimentView.tsx | 3 +- .../SortableThreadItem.test.tsx | 20 +++++++++++ .../SortableThreadItem/SortableThreadItem.tsx | 34 ++++++++++--------- 10 files changed, 81 insertions(+), 24 deletions(-) diff --git a/src/main/app-controls/mcp/toolRegistry.test.ts b/src/main/app-controls/mcp/toolRegistry.test.ts index 1b58a6798..50410d3ca 100644 --- a/src/main/app-controls/mcp/toolRegistry.test.ts +++ b/src/main/app-controls/mcp/toolRegistry.test.ts @@ -702,7 +702,7 @@ describe("Poracode app control tools — threads", () => { it("update_thread persists the DB row even when a renderer is connected", async () => { const threads = [makeThread({ id: "a" })]; - const { ctx, updateThreadRow } = context({ threads, rendererConnected: true }); + const { ctx, updateThreadRow, updatedRows } = context({ threads, rendererConnected: true }); const result = (await dispatchTool("update_thread", { threadId: "a", rename: "New" }, ctx)) as { applied: string[]; note?: string; @@ -710,6 +710,10 @@ describe("Poracode app control tools — threads", () => { expect(result.applied).toEqual(["rename"]); expect(result.note).toBeUndefined(); expect(updateThreadRow).toHaveBeenCalledWith("a", expect.any(Function)); + expect(updatedRows.at(-1)).toMatchObject({ + title: "New", + updatedAt: "2026-01-01T00:00:00.000Z", + }); }); it("open_thread notes when no UI is connected instead of reporting success", async () => { diff --git a/src/main/app-controls/mcp/tools/threads.ts b/src/main/app-controls/mcp/tools/threads.ts index d56f4f771..8894461fe 100644 --- a/src/main/app-controls/mcp/tools/threads.ts +++ b/src/main/app-controls/mcp/tools/threads.ts @@ -473,7 +473,7 @@ export const threadTools: ToolDomain = { // Ordered so `applied` preserves rename→group→done→starred→archived. applyField(parsed.rename, "rename", (title) => ({ command: { kind: "rename", threadId, title }, - mutate: (thread) => ({ ...thread, title, updatedAt: stamp() }), + mutate: (thread) => ({ ...thread, title }), })); applyField(parsed.group, "group", (group) => ({ command: { kind: "set-group", threadId, groupId: group, groupName: group }, diff --git a/src/main/remote/RemoteAccessServer.test.ts b/src/main/remote/RemoteAccessServer.test.ts index be087257b..702b41702 100644 --- a/src/main/remote/RemoteAccessServer.test.ts +++ b/src/main/remote/RemoteAccessServer.test.ts @@ -3141,7 +3141,10 @@ describe("RemoteAccessServer", () => { }); expect(renameResponse.status).toBe(200); expect(dispatched).toEqual([{ kind: "rename", threadId: "thread-1", title: "New title" }]); - expect(db.threads()[0]?.title).toBe("New title"); + expect(db.threads()[0]).toMatchObject({ + title: "New title", + updatedAt: "2026-01-01T00:00:00.000Z", + }); await expect(readWs()).resolves.toMatchObject({ type: "event", event: { type: "remote-threads-changed", threadIds: ["thread-1"] }, diff --git a/src/main/remote/server/threadCommands.ts b/src/main/remote/server/threadCommands.ts index 34295dd22..7c4c8b882 100644 --- a/src/main/remote/server/threadCommands.ts +++ b/src/main/remote/server/threadCommands.ts @@ -137,7 +137,6 @@ export async function applyRemoteThreadCommand( updateRemoteThread(command.threadId, (thread) => ({ ...thread, title: command.title, - updatedAt: new Date().toISOString(), })); return false; case "acknowledge": diff --git a/src/renderer/state/appStore.test.ts b/src/renderer/state/appStore.test.ts index e4ac2ca31..f43595cbb 100644 --- a/src/renderer/state/appStore.test.ts +++ b/src/renderer/state/appStore.test.ts @@ -262,6 +262,31 @@ describe("appStore runtime config sync", () => { expect(stored?.doneAt).toBe("2026-05-10T12:00:00.000Z"); }); + it("preserves updatedAt when renaming a thread", () => { + const project = useAppStore.getState().addProject({ + kind: "windows", + path: "C:\\repo", + }); + const thread = useAppStore.getState().createThread({ + projectId: project.id, + agentKind: "codex", + config: { model: "gpt-5.4" }, + prompt: "hello", + }); + useAppStore.setState((state) => ({ + threads: state.threads.map((entry) => + entry.id === thread.id ? { ...entry, updatedAt: "2026-04-01T00:00:00.000Z" } : entry, + ), + })); + + useAppStore.getState().renameThread(thread.id, "Renamed"); + + expect(useAppStore.getState().threads[0]).toMatchObject({ + title: "Renamed", + updatedAt: "2026-04-01T00:00:00.000Z", + }); + }); + it("accepts a real runtime config change after the pending edit is submitted", () => { const project = useAppStore.getState().addProject({ kind: "windows", diff --git a/src/renderer/state/slices/threadSlice.ts b/src/renderer/state/slices/threadSlice.ts index eac8fd849..5396f2ce3 100644 --- a/src/renderer/state/slices/threadSlice.ts +++ b/src/renderer/state/slices/threadSlice.ts @@ -301,7 +301,7 @@ export const createThreadSlice: SliceCreator = (set) => ({ renameThread: (threadId, title) => set((state) => ({ threads: state.threads.map((thread) => - thread.id === threadId ? { ...thread, title, updatedAt: new Date().toISOString() } : thread, + thread.id === threadId ? { ...thread, title } : thread, ), })), setThreadWorktree: (threadId, worktreePath, worktreeBranch, options) => diff --git a/src/renderer/views/ExperimentView/ExperimentView.test.tsx b/src/renderer/views/ExperimentView/ExperimentView.test.tsx index f25326270..990471f59 100644 --- a/src/renderer/views/ExperimentView/ExperimentView.test.tsx +++ b/src/renderer/views/ExperimentView/ExperimentView.test.tsx @@ -79,7 +79,12 @@ describe("ExperimentView", () => { render(); - await waitFor(() => expect(useAppStore.getState().threads[0]?.title).toBe("model-a · codex")); + await waitFor(() => + expect(useAppStore.getState().threads[0]).toMatchObject({ + title: "model-a · codex", + updatedAt: "2026-07-16T00:00:00.000Z", + }), + ); }); it("allows discarding while a candidate is running", () => { diff --git a/src/renderer/views/ExperimentView/ExperimentView.tsx b/src/renderer/views/ExperimentView/ExperimentView.tsx index c6febe564..21839afd1 100644 --- a/src/renderer/views/ExperimentView/ExperimentView.tsx +++ b/src/renderer/views/ExperimentView/ExperimentView.tsx @@ -99,11 +99,10 @@ export function ExperimentView(props: { experimentId: string }) { } } if (nextTitles.size > 0) { - const updatedAt = new Date().toISOString(); useAppStore.setState((state) => ({ threads: state.threads.map((thread) => { const title = nextTitles.get(thread.id); - return title ? { ...thread, title, updatedAt } : thread; + return title ? { ...thread, title } : thread; }), })); } diff --git a/src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/SortableThreadItem.test.tsx b/src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/SortableThreadItem.test.tsx index 54b471651..aff7a6027 100644 --- a/src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/SortableThreadItem.test.tsx +++ b/src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/SortableThreadItem.test.tsx @@ -335,6 +335,26 @@ describe("SortableThreadItem", () => { expect(screen.getByRole("button", { name: "Git status for Project" })).toBeInTheDocument(); }); + it("keeps the flat-list row metadata while renaming only its title", () => { + render( + void>()} + group="flat:__flat__" + projectTag={{project.name}} + />, + ); + + expect(screen.getByRole("textbox", { name: "Rename thread" })).toHaveValue("Thread 1"); + expect(screen.getByText("Project")).toBeInTheDocument(); + expect(screen.getByTestId("sync-badge")).toHaveTextContent("project-1:project"); + expect(screen.getByRole("button", { name: "Git status for Project" })).toBeInTheDocument(); + }); + it("omits the project git badge in grouped lists, where the project header carries it", () => { render( {thread.title} ) : ( @@ -88,6 +89,18 @@ export function SortableThreadItem(props: { showProjectBadge: stacked, projectName: project.name, }; + const titleContent = isEditing ? ( + { + renameThread(thread.id, newTitle); + props.setEditingThreadId(null); + }} + onCancel={() => props.setEditingThreadId(null)} + /> + ) : ( + titleNode + ); return (
@@ -105,16 +118,7 @@ export function SortableThreadItem(props: { } label={ - editingThreadId === thread.id ? ( - { - renameThread(thread.id, newTitle); - props.setEditingThreadId(null); - }} - onCancel={() => props.setEditingThreadId(null)} - /> - ) : stacked ? ( + stacked ? ( // Two-line flat-list row: each line owns its right-side cluster, // so the bottom badges never reserve width from the title line. // Line heights match the text (16px title, 14px meta). @@ -123,7 +127,7 @@ export function SortableThreadItem(props: { // span, so anything flush with its right edge gets cut. - {titleNode} + {titleContent} {hasDraft && } {/* No padding here: the time slot carries the 2px inset that matches the git badge's own p-0.5, so both rows' icon @@ -139,6 +143,8 @@ export function SortableThreadItem(props: { + ) : isEditing ? ( + titleContent ) : ( {titleNode} @@ -147,11 +153,7 @@ export function SortableThreadItem(props: { ) } tooltip={ - editingThreadId === thread.id - ? undefined - : stacked - ? `${thread.title} — ${project.name}` - : thread.title + isEditing ? undefined : stacked ? `${thread.title} — ${project.name}` : thread.title } isActive={isCurrentThread} onPress={() => openThread(thread.id)}