Skip to content
Merged
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
6 changes: 5 additions & 1 deletion src/main/app-controls/mcp/toolRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -702,14 +702,18 @@ 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;
};
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 () => {
Expand Down
2 changes: 1 addition & 1 deletion src/main/app-controls/mcp/tools/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
5 changes: 4 additions & 1 deletion src/main/remote/RemoteAccessServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"] },
Expand Down
1 change: 0 additions & 1 deletion src/main/remote/server/threadCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,6 @@ export async function applyRemoteThreadCommand(
updateRemoteThread(command.threadId, (thread) => ({
...thread,
title: command.title,
updatedAt: new Date().toISOString(),
}));
return false;
case "acknowledge":
Expand Down
25 changes: 25 additions & 0 deletions src/renderer/state/appStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/state/slices/threadSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ export const createThreadSlice: SliceCreator<ThreadSlice> = (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) =>
Expand Down
7 changes: 6 additions & 1 deletion src/renderer/views/ExperimentView/ExperimentView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,12 @@ describe("ExperimentView", () => {

render(<ExperimentView experimentId={experiment.id} />);

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", () => {
Expand Down
3 changes: 1 addition & 2 deletions src/renderer/views/ExperimentView/ExperimentView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}),
}));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<SortableThreadItem
thread={makeThread()}
threadIndex={1}
project={project}
showWorktreeBadge={false}
editingThreadId="thread-1"
setEditingThreadId={vi.fn<(id: string | null) => void>()}
group="flat:__flat__"
projectTag={<span>{project.name}</span>}
/>,
);

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(
<SortableThreadItem
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export function SortableThreadItem(props: {
const statusTone = getStatusTone(thread, { hasBackgroundActivity });

const stacked = projectTag != null;
const isEditing = editingThreadId === thread.id;
const titleNode = thread.done ? (
<span className="opacity-50 line-through">{thread.title}</span>
) : (
Expand All @@ -88,6 +89,18 @@ export function SortableThreadItem(props: {
showProjectBadge: stacked,
projectName: project.name,
};
const titleContent = isEditing ? (
<InlineRenameInput
initialValue={thread.title}
onCommit={(newTitle) => {
renameThread(thread.id, newTitle);
props.setEditingThreadId(null);
}}
onCancel={() => props.setEditingThreadId(null)}
/>
) : (
titleNode
);

return (
<div ref={ref} className="relative w-full pb-0.5">
Expand All @@ -105,16 +118,7 @@ export function SortableThreadItem(props: {
<ThreadProviderIcon thread={thread} tone={statusTone} className="size-3.5 shrink-0" />
}
label={
editingThreadId === thread.id ? (
<InlineRenameInput
initialValue={thread.title}
onCommit={(newTitle) => {
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).
Expand All @@ -123,7 +127,7 @@ export function SortableThreadItem(props: {
// span, so anything flush with its right edge gets cut.
<span className="flex flex-col gap-0.5 pr-0.5">
<span className="flex h-[18px] items-center gap-1.5">
<span className="min-w-0 flex-1 truncate">{titleNode}</span>
<span className="min-w-0 flex-1 truncate">{titleContent}</span>
{hasDraft && <DraftIndicator />}
{/* No padding here: the time slot carries the 2px inset that
matches the git badge's own p-0.5, so both rows' icon
Expand All @@ -139,6 +143,8 @@ export function SortableThreadItem(props: {
</span>
</span>
</span>
) : isEditing ? (
titleContent
) : (
<span className="flex items-center gap-1.5">
<span className="min-w-0 truncate">{titleNode}</span>
Expand All @@ -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)}
Expand Down