| author | |
| committer | |
| log | 0d67424f6945fb451e9b7639861cdac8b18a0c3b |
| tree | dee629d5ea66259e9c36a2c4fdd4c4532ca2b8bc |
| parent | 1245ce984ba9c79a8eb658f749425d3086bc667b |
- Add `mark-unread` option to thread context menu and dispatch reducer action
- Implement `MARK_THREAD_UNREAD` to set `lastVisitedAt` just before latest completion
- Add reducer tests for unread behavior and no-op when no completed turn
- Update fallback context menu styling so only delete uses destructive color4 files changed, 134 insertions(+), 3 deletions(-)
apps/web/src/components/Sidebar.tsx+12-1| ... | ... | @@ -281,7 +281,17 @@ export default function Sidebar() { |
| 281 | 281 | async (threadId: ThreadId, position: { x: number; y: number }) => { |
| 282 | 282 | const api = readNativeApi(); |
| 283 | 283 | if (!api) return; |
| 284 | const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position); | |
| 284 | const clicked = await api.contextMenu.show( | |
| 285 | [ | |
| 286 | { id: "mark-unread", label: "Mark unread" }, | |
| 287 | { id: "delete", label: "Delete" }, | |
| 288 | ], | |
| 289 | position, | |
| 290 | ); | |
| 291 | if (clicked === "mark-unread") { | |
| 292 | dispatch({ type: "MARK_THREAD_UNREAD", threadId }); | |
| 293 | return; | |
| 294 | } | |
| 285 | 295 | if (clicked !== "delete") return; |
| 286 | 296 | |
| 287 | 297 | const thread = state.threads.find((t) => t.id === threadId); |
| ... | ... | @@ -380,6 +390,7 @@ export default function Sidebar() { |
| 380 | 390 | }, |
| 381 | 391 | [ |
| 382 | 392 | appSettings.confirmThreadDelete, |
| 393 | dispatch, | |
| 383 | 394 | navigate, |
| 384 | 395 | removeWorktreeMutation, |
| 385 | 396 | routeThreadId, |
apps/web/src/contextMenuFallback.ts+4-2| ... | ... | @@ -41,8 +41,10 @@ export function showContextMenuFallback<T extends string>( |
| 41 | 41 | const btn = document.createElement("button"); |
| 42 | 42 | btn.type = "button"; |
| 43 | 43 | btn.textContent = item.label; |
| 44 | btn.className = | |
| 45 | "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default"; | |
| 44 | const isDeleteAction = item.id === "delete"; | |
| 45 | btn.className = isDeleteAction | |
| 46 | ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default" | |
| 47 | : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default"; | |
| 46 | 48 | btn.addEventListener("click", () => cleanup(item.id)); |
| 47 | 49 | menu.appendChild(btn); |
| 48 | 50 | } |
apps/web/src/store.test.ts created+94| ... | ... | @@ -0,0 +1,94 @@ |
| 1 | import { ProjectId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vitest"; | |
| 3 | ||
| 4 | import { reducer, type AppState } from "./store"; | |
| 5 | import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types"; | |
| 6 | ||
| 7 | function makeThread(overrides: Partial<Thread> = {}): Thread { | |
| 8 | return { | |
| 9 | id: ThreadId.makeUnsafe("thread-1"), | |
| 10 | codexThreadId: null, | |
| 11 | projectId: ProjectId.makeUnsafe("project-1"), | |
| 12 | title: "Thread", | |
| 13 | model: "gpt-5-codex", | |
| 14 | terminalOpen: false, | |
| 15 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, | |
| 16 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 17 | runningTerminalIds: [], | |
| 18 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, | |
| 19 | terminalGroups: [ | |
| 20 | { | |
| 21 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 22 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 23 | }, | |
| 24 | ], | |
| 25 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 26 | session: null, | |
| 27 | messages: [], | |
| 28 | turnDiffSummaries: [], | |
| 29 | activities: [], | |
| 30 | error: null, | |
| 31 | createdAt: "2026-02-13T00:00:00.000Z", | |
| 32 | branch: null, | |
| 33 | worktreePath: null, | |
| 34 | ...overrides, | |
| 35 | }; | |
| 36 | } | |
| 37 | ||
| 38 | function makeState(thread: Thread): AppState { | |
| 39 | return { | |
| 40 | projects: [ | |
| 41 | { | |
| 42 | id: ProjectId.makeUnsafe("project-1"), | |
| 43 | name: "Project", | |
| 44 | cwd: "/tmp/project", | |
| 45 | model: "gpt-5-codex", | |
| 46 | expanded: true, | |
| 47 | scripts: [], | |
| 48 | }, | |
| 49 | ], | |
| 50 | threads: [thread], | |
| 51 | threadsHydrated: true, | |
| 52 | runtimeMode: "full-access", | |
| 53 | }; | |
| 54 | } | |
| 55 | ||
| 56 | describe("store reducer", () => { | |
| 57 | it("marks a completed thread as unread by moving lastVisitedAt before completion", () => { | |
| 58 | const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z"; | |
| 59 | const initialState = makeState( | |
| 60 | makeThread({ | |
| 61 | latestTurnCompletedAt, | |
| 62 | lastVisitedAt: "2026-02-25T12:35:00.000Z", | |
| 63 | }), | |
| 64 | ); | |
| 65 | ||
| 66 | const next = reducer(initialState, { | |
| 67 | type: "MARK_THREAD_UNREAD", | |
| 68 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 69 | }); | |
| 70 | ||
| 71 | const updatedThread = next.threads[0]; | |
| 72 | expect(updatedThread).toBeDefined(); | |
| 73 | expect(updatedThread?.lastVisitedAt).toBe("2026-02-25T12:29:59.999Z"); | |
| 74 | expect(Date.parse(updatedThread?.lastVisitedAt ?? "")).toBeLessThan( | |
| 75 | Date.parse(latestTurnCompletedAt), | |
| 76 | ); | |
| 77 | }); | |
| 78 | ||
| 79 | it("does not change a thread without a completed turn", () => { | |
| 80 | const initialState = makeState( | |
| 81 | makeThread({ | |
| 82 | latestTurnCompletedAt: undefined, | |
| 83 | lastVisitedAt: "2026-02-25T12:35:00.000Z", | |
| 84 | }), | |
| 85 | ); | |
| 86 | ||
| 87 | const next = reducer(initialState, { | |
| 88 | type: "MARK_THREAD_UNREAD", | |
| 89 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 90 | }); | |
| 91 | ||
| 92 | expect(next).toEqual(initialState); | |
| 93 | }); | |
| 94 | }); |
apps/web/src/store.ts+24| ... | ... | @@ -33,6 +33,7 @@ import { |
| 33 | 33 | type Action = |
| 34 | 34 | | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel } |
| 35 | 35 | | { type: "MARK_THREAD_VISITED"; threadId: ThreadId; visitedAt?: string } |
| 36 | | { type: "MARK_THREAD_UNREAD"; threadId: ThreadId } | |
| 36 | 37 | | { type: "TOGGLE_PROJECT"; projectId: Project["id"] } |
| 37 | 38 | | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId } |
| 38 | 39 | | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean } |
| ... | ... | @@ -504,6 +505,29 @@ export function reducer(state: AppState, action: Action): AppState { |
| 504 | 505 | }; |
| 505 | 506 | } |
| 506 | 507 | |
| 508 | case "MARK_THREAD_UNREAD": { | |
| 509 | return { | |
| 510 | ...state, | |
| 511 | threads: updateThread(state.threads, action.threadId, (thread) => { | |
| 512 | if (!thread.latestTurnCompletedAt) { | |
| 513 | return thread; | |
| 514 | } | |
| 515 | const latestTurnCompletedAtMs = Date.parse(thread.latestTurnCompletedAt); | |
| 516 | if (Number.isNaN(latestTurnCompletedAtMs)) { | |
| 517 | return thread; | |
| 518 | } | |
| 519 | const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString(); | |
| 520 | if (thread.lastVisitedAt === unreadVisitedAt) { | |
| 521 | return thread; | |
| 522 | } | |
| 523 | return { | |
| 524 | ...thread, | |
| 525 | lastVisitedAt: unreadVisitedAt, | |
| 526 | }; | |
| 527 | }), | |
| 528 | }; | |
| 529 | } | |
| 530 | ||
| 507 | 531 | case "TOGGLE_PROJECT": |
| 508 | 532 | return { |
| 509 | 533 | ...state, |