authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-26 18:57:40-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 19:13:24-08:00
log0d67424f6945fb451e9b7639861cdac8b18a0c3b
treedee629d5ea66259e9c36a2c4fdd4c4532ca2b8bc
parent1245ce984ba9c79a8eb658f749425d3086bc667b

Add Mark unread thread action to sidebar context menu

- 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 color

4 files changed, 134 insertions(+), 3 deletions(-)

apps/web/src/components/Sidebar.tsx+12-1
......@@ -281,7 +281,17 @@ export default function Sidebar() {
281281 async (threadId: ThreadId, position: { x: number; y: number }) => {
282282 const api = readNativeApi();
283283 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 }
285295 if (clicked !== "delete") return;
286296
287297 const thread = state.threads.find((t) => t.id === threadId);
......@@ -380,6 +390,7 @@ export default function Sidebar() {
380390 },
381391 [
382392 appSettings.confirmThreadDelete,
393 dispatch,
383394 navigate,
384395 removeWorktreeMutation,
385396 routeThreadId,
apps/web/src/contextMenuFallback.ts+4-2
......@@ -41,8 +41,10 @@ export function showContextMenuFallback<T extends string>(
4141 const btn = document.createElement("button");
4242 btn.type = "button";
4343 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";
4648 btn.addEventListener("click", () => cleanup(item.id));
4749 menu.appendChild(btn);
4850 }
apps/web/src/store.test.ts created+94
......@@ -0,0 +1,94 @@
1import { ProjectId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";
3
4import { reducer, type AppState } from "./store";
5import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types";
6
7function 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
38function 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
56describe("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 {
3333type Action =
3434 | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel }
3535 | { type: "MARK_THREAD_VISITED"; threadId: ThreadId; visitedAt?: string }
36 | { type: "MARK_THREAD_UNREAD"; threadId: ThreadId }
3637 | { type: "TOGGLE_PROJECT"; projectId: Project["id"] }
3738 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId }
3839 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean }
......@@ -504,6 +505,29 @@ export function reducer(state: AppState, action: Action): AppState {
504505 };
505506 }
506507
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
507531 case "TOGGLE_PROJECT":
508532 return {
509533 ...state,