diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2e4d9023887f..0f05fd689ce5 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -281,7 +281,17 @@ export default function Sidebar() { async (threadId: ThreadId, position: { x: number; y: number }) => { const api = readNativeApi(); if (!api) return; - const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position); + const clicked = await api.contextMenu.show( + [ + { id: "mark-unread", label: "Mark unread" }, + { id: "delete", label: "Delete" }, + ], + position, + ); + if (clicked === "mark-unread") { + dispatch({ type: "MARK_THREAD_UNREAD", threadId }); + return; + } if (clicked !== "delete") return; const thread = state.threads.find((t) => t.id === threadId); @@ -380,6 +390,7 @@ export default function Sidebar() { }, [ appSettings.confirmThreadDelete, + dispatch, navigate, removeWorktreeMutation, routeThreadId, diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 0dcbb13be582..0e020ee3afa5 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -41,8 +41,10 @@ export function showContextMenuFallback( const btn = document.createElement("button"); btn.type = "button"; btn.textContent = item.label; - btn.className = - "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default"; + const isDeleteAction = item.id === "delete"; + btn.className = isDeleteAction + ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default" + : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default"; btn.addEventListener("click", () => cleanup(item.id)); menu.appendChild(btn); } diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts new file mode 100644 index 000000000000..0de9bfc2660e --- /dev/null +++ b/apps/web/src/store.test.ts @@ -0,0 +1,94 @@ +import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { reducer, type AppState } from "./store"; +import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types"; + +function makeThread(overrides: Partial = {}): Thread { + return { + id: ThreadId.makeUnsafe("thread-1"), + codexThreadId: null, + projectId: ProjectId.makeUnsafe("project-1"), + title: "Thread", + model: "gpt-5-codex", + terminalOpen: false, + terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + runningTerminalIds: [], + activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, + terminalGroups: [ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + }, + ], + activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + session: null, + messages: [], + turnDiffSummaries: [], + activities: [], + error: null, + createdAt: "2026-02-13T00:00:00.000Z", + branch: null, + worktreePath: null, + ...overrides, + }; +} + +function makeState(thread: Thread): AppState { + return { + projects: [ + { + id: ProjectId.makeUnsafe("project-1"), + name: "Project", + cwd: "/tmp/project", + model: "gpt-5-codex", + expanded: true, + scripts: [], + }, + ], + threads: [thread], + threadsHydrated: true, + runtimeMode: "full-access", + }; +} + +describe("store reducer", () => { + it("marks a completed thread as unread by moving lastVisitedAt before completion", () => { + const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z"; + const initialState = makeState( + makeThread({ + latestTurnCompletedAt, + lastVisitedAt: "2026-02-25T12:35:00.000Z", + }), + ); + + const next = reducer(initialState, { + type: "MARK_THREAD_UNREAD", + threadId: ThreadId.makeUnsafe("thread-1"), + }); + + const updatedThread = next.threads[0]; + expect(updatedThread).toBeDefined(); + expect(updatedThread?.lastVisitedAt).toBe("2026-02-25T12:29:59.999Z"); + expect(Date.parse(updatedThread?.lastVisitedAt ?? "")).toBeLessThan( + Date.parse(latestTurnCompletedAt), + ); + }); + + it("does not change a thread without a completed turn", () => { + const initialState = makeState( + makeThread({ + latestTurnCompletedAt: undefined, + lastVisitedAt: "2026-02-25T12:35:00.000Z", + }), + ); + + const next = reducer(initialState, { + type: "MARK_THREAD_UNREAD", + threadId: ThreadId.makeUnsafe("thread-1"), + }); + + expect(next).toEqual(initialState); + }); +}); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 65b71c869745..9dcfc4469920 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -33,6 +33,7 @@ import { type Action = | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel } | { type: "MARK_THREAD_VISITED"; threadId: ThreadId; visitedAt?: string } + | { type: "MARK_THREAD_UNREAD"; threadId: ThreadId } | { type: "TOGGLE_PROJECT"; projectId: Project["id"] } | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId } | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean } @@ -504,6 +505,29 @@ export function reducer(state: AppState, action: Action): AppState { }; } + case "MARK_THREAD_UNREAD": { + return { + ...state, + threads: updateThread(state.threads, action.threadId, (thread) => { + if (!thread.latestTurnCompletedAt) { + return thread; + } + const latestTurnCompletedAtMs = Date.parse(thread.latestTurnCompletedAt); + if (Number.isNaN(latestTurnCompletedAtMs)) { + return thread; + } + const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString(); + if (thread.lastVisitedAt === unreadVisitedAt) { + return thread; + } + return { + ...thread, + lastVisitedAt: unreadVisitedAt, + }; + }), + }; + } + case "TOGGLE_PROJECT": return { ...state,