| 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,7 +281,17 @@ export default function Sidebar() { |
| 281 | async (threadId: ThreadId, position: { x: number; y: number }) => { | 281 | async (threadId: ThreadId, position: { x: number; y: number }) => { |
| 282 | const api = readNativeApi(); | 282 | const api = readNativeApi(); |
| 283 | if (!api) return; | 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 | if (clicked !== "delete") return; | 295 | if (clicked !== "delete") return; |
| 286 | 296 | ||
| 287 | const thread = state.threads.find((t) => t.id === threadId); | 297 | const thread = state.threads.find((t) => t.id === threadId); |
| ... | @@ -380,6 +390,7 @@ export default function Sidebar() { | ... | @@ -380,6 +390,7 @@ export default function Sidebar() { |
| 380 | }, | 390 | }, |
| 381 | [ | 391 | [ |
| 382 | appSettings.confirmThreadDelete, | 392 | appSettings.confirmThreadDelete, |
| 393 | dispatch, | ||
| 383 | navigate, | 394 | navigate, |
| 384 | removeWorktreeMutation, | 395 | removeWorktreeMutation, |
| 385 | routeThreadId, | 396 | routeThreadId, |
apps/web/src/contextMenuFallback.ts+4-2| ... | @@ -41,8 +41,10 @@ export function showContextMenuFallback<T extends string>( | ... | @@ -41,8 +41,10 @@ export function showContextMenuFallback<T extends string>( |
| 41 | const btn = document.createElement("button"); | 41 | const btn = document.createElement("button"); |
| 42 | btn.type = "button"; | 42 | btn.type = "button"; |
| 43 | btn.textContent = item.label; | 43 | btn.textContent = item.label; |
| 44 | btn.className = | 44 | const isDeleteAction = item.id === "delete"; |
| 45 | "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default"; | 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 | btn.addEventListener("click", () => cleanup(item.id)); | 48 | btn.addEventListener("click", () => cleanup(item.id)); |
| 47 | menu.appendChild(btn); | 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,6 +33,7 @@ import { |
| 33 | type Action = | 33 | type Action = |
| 34 | | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel } | 34 | | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel } |
| 35 | | { type: "MARK_THREAD_VISITED"; threadId: ThreadId; visitedAt?: string } | 35 | | { type: "MARK_THREAD_VISITED"; threadId: ThreadId; visitedAt?: string } |
| 36 | | { type: "MARK_THREAD_UNREAD"; threadId: ThreadId } | ||
| 36 | | { type: "TOGGLE_PROJECT"; projectId: Project["id"] } | 37 | | { type: "TOGGLE_PROJECT"; projectId: Project["id"] } |
| 37 | | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId } | 38 | | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId } |
| 38 | | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean } | 39 | | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean } |
| ... | @@ -504,6 +505,29 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -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 | case "TOGGLE_PROJECT": | 531 | case "TOGGLE_PROJECT": |
| 508 | return { | 532 | return { |
| 509 | ...state, | 533 | ...state, |