From 5bc8086253fd481bfaa62521e84b823386763c2b Mon Sep 17 00:00:00 2001 From: Vedant Mahajan <113882531+Vedant817@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:28:26 +0530 Subject: [PATCH 1/3] fix(web): threads can be marked read again, not just unread Marking a thread unread had no way back. The only thing that cleared the unread completion was opening the thread, which navigates away from the list the action was invoked from, so the workflow the feature exists to support was unreachable. The action menu also had no unread input, so it could not render a toggle even in principle. The per-thread action menu now takes `isUnread` and offers Mark read or Mark unread, matching the pin/unpin and settle/unsettle pairs already in the same builder. Both sidebars, both multi-select menus, and the chat header menu dispatch it; the multi-select items count only the threads they would affect. Marking read stamps the turn's completion time, the same monotonic write ChatView already uses, so it clears the unread state in place without navigating. Mark unread is also disabled when a thread has no completed turn. It previously no-opped silently, since the unread marker is derived from the completion timestamp. Built with opencode/space-bunny-free in T3 Code through the OpenCode harness. --- apps/web/src/components/LegacySidebar.tsx | 47 +++++++++++++- apps/web/src/components/Sidebar.logic.test.ts | 25 ++++++- apps/web/src/components/Sidebar.logic.ts | 11 +++- apps/web/src/components/Sidebar.tsx | 65 +++++++++++++++---- .../components/threadActionMenu.logic.test.ts | 16 +++++ .../src/components/threadActionMenu.logic.ts | 12 +++- apps/web/src/hooks/useThreadActionMenu.ts | 14 ++++ 7 files changed, 170 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 1991dc502283..ea8b4c3df02b 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -181,6 +181,7 @@ import { buildMultiSelectThreadContextMenuItems, deleteSelectedThreadEntries, getSidebarThreadIdsToPrewarm, + hasUnseenCompletion, resolveAdjacentThreadId, isContextMenuPointerDown, isSidebarNestedLinkClick, @@ -1204,6 +1205,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop); const { isMobile, setOpenMobile } = useSidebar(); const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); + const markThreadVisited = useUiStateStore((state) => state.markThreadVisited); const setProjectExpanded = useUiStateStore((state) => state.setProjectExpanded); const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); const rangeSelectTo = useThreadSelectionStore((state) => state.rangeSelectTo); @@ -1903,19 +1905,41 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const hasRunningThread = selectedThreadEntries.some( ({ thread }) => thread.session?.status === "running" && thread.session.activeTurnId != null, ); + const lastVisitedAtById = useUiStateStore.getState().threadLastVisitedAtById; + const unreadEntries = selectedThreadEntries.filter(({ threadKey, thread }) => + hasUnseenCompletion({ ...thread, lastVisitedAt: lastVisitedAtById[threadKey] }), + ); + const unreadKeys = new Set(unreadEntries.map(({ threadKey }) => threadKey)); + const readEntries = selectedThreadEntries.filter( + ({ threadKey, thread }) => thread.latestTurn?.completedAt && !unreadKeys.has(threadKey), + ); const clicked = await api.contextMenu.show( - buildMultiSelectThreadContextMenuItems({ count, hasRunningThread }), + buildMultiSelectThreadContextMenuItems({ + count, + hasRunningThread, + unreadCount: unreadEntries.length, + readCount: readEntries.length, + }), position, ); if (clicked === "mark-unread") { - for (const { threadKey, thread } of selectedThreadEntries) { + for (const { threadKey, thread } of readEntries) { markThreadUnread(threadKey, thread.latestTurn?.completedAt); } clearSelection(); return; } + if (clicked === "mark-read") { + for (const { threadKey, thread } of unreadEntries) { + if (thread.latestTurn?.completedAt) { + markThreadVisited(threadKey, thread.latestTurn.completedAt); + } + } + clearSelection(); + return; + } if (clicked === "archive") { if (appSettingsConfirmThreadArchive) { @@ -2000,6 +2024,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec clearSelection, deleteThread, markThreadUnread, + markThreadVisited, removeFromSelection, ], ); @@ -2252,7 +2277,16 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ? [{ id: "new-thread-on-branch", label: `New thread on ${thread.branch}` }] : []), { id: "rename", label: "Rename thread" }, - { id: "mark-unread", label: "Mark unread" }, + hasUnseenCompletion({ + ...thread, + lastVisitedAt: useUiStateStore.getState().threadLastVisitedAtById[threadKey], + }) + ? { id: "mark-read", label: "Mark read" } + : { + id: "mark-unread", + label: "Mark unread", + disabled: thread.latestTurn?.completedAt == null, + }, { id: "copy-path", label: "Copy Path" }, { id: "copy-thread-id", label: "Copy Thread ID" }, { id: "project-settings", label: "Project settings" }, @@ -2303,6 +2337,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec markThreadUnread(threadKey, thread.latestTurn?.completedAt); return; } + if (clicked === "mark-read") { + if (thread.latestTurn?.completedAt) { + markThreadVisited(threadKey, thread.latestTurn.completedAt); + } + return; + } if (clicked === "copy-path") { if (!threadWorkspacePath) { toastManager.add( @@ -2354,6 +2394,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec handleNewThread, isMobile, markThreadUnread, + markThreadVisited, memberProjectByScopedKey, project.projectKey, project.workspaceRoot, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index fede3c183448..add4a781dbf9 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -326,15 +326,36 @@ describe("buildBulkTitleRegenerationContextMenuItem", () => { describe("buildMultiSelectThreadContextMenuItems", () => { it("offers bulk archive with the selected count", () => { expect( - buildMultiSelectThreadContextMenuItems({ count: 3, hasRunningThread: false }), + buildMultiSelectThreadContextMenuItems({ + count: 3, + hasRunningThread: false, + unreadCount: 0, + readCount: 3, + }), ).toContainEqual({ id: "archive", label: "Archive (3)", disabled: false }); }); it("disables bulk archive when a selected thread is running", () => { expect( - buildMultiSelectThreadContextMenuItems({ count: 2, hasRunningThread: true }), + buildMultiSelectThreadContextMenuItems({ + count: 2, + hasRunningThread: true, + unreadCount: 0, + readCount: 2, + }), ).toContainEqual({ id: "archive", label: "Archive (2)", disabled: true }); }); + + it("offers the applicable read actions for a mixed selection", () => { + const items = buildMultiSelectThreadContextMenuItems({ + count: 3, + hasRunningThread: false, + unreadCount: 1, + readCount: 2, + }); + expect(items).toContainEqual({ id: "mark-read", label: "Mark read (1)" }); + expect(items).toContainEqual({ id: "mark-unread", label: "Mark unread (2)" }); + }); }); function makeLatestTurn(overrides?: { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 2796b2f7f885..e7cca6318ff3 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -478,9 +478,16 @@ export async function archiveSelectedThreadEntries< export function buildMultiSelectThreadContextMenuItems(input: { count: number; hasRunningThread: boolean; -}): readonly ContextMenuItem<"mark-unread" | "archive" | "delete">[] { + unreadCount: number; + readCount: number; +}): readonly ContextMenuItem<"mark-read" | "mark-unread" | "archive" | "delete">[] { return [ - { id: "mark-unread", label: `Mark unread (${input.count})` }, + ...(input.unreadCount > 0 + ? [{ id: "mark-read" as const, label: `Mark read (${input.unreadCount})` }] + : []), + ...(input.readCount > 0 + ? [{ id: "mark-unread" as const, label: `Mark unread (${input.readCount})` }] + : []), { id: "archive", label: `Archive (${input.count})`, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 90f65ea2b941..d69d1a045017 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3824,22 +3824,38 @@ export default function Sidebar() { // Snooze (N) is offered when every selected thread can actually take // it — a mixed selection with blocked-on-you work would half-apply. const selectionNow = new Date(); + const lastVisitedAtById = useUiStateStore.getState().threadLastVisitedAtById; const selectedThreads = threadKeys.flatMap((threadKey) => { const thread = threadByKeyRef.current.get(threadKey); - return thread ? [thread] : []; + return thread + ? [ + { + threadKey, + thread, + isUnread: hasUnseenCompletion({ + ...thread, + lastVisitedAt: lastVisitedAtById[threadKey], + }), + }, + ] + : []; }); + const unreadThreads = selectedThreads.filter(({ isUnread }) => isUnread); + const readThreads = selectedThreads.filter( + ({ thread, isUnread }) => thread.latestTurn?.completedAt && !isUnread, + ); const canSnoozeSelection = selectedThreads.every( - (thread) => + ({ thread }) => serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && canSnooze(thread, { now: selectionNow.toISOString() }), ); const titleRegenerationThreads = selectedThreads.filter( - (thread) => + ({ thread }) => serverConfigs.get(thread.environmentId)?.environment.capabilities .threadTitleRegeneration === true, ); const regeneratableTitleThreads = titleRegenerationThreads.filter( - (thread) => thread.titleRegeneration == null, + ({ thread }) => thread.titleRegeneration == null, ); const titleRegenerationMenuItem = buildBulkTitleRegenerationContextMenuItem({ supportedCount: titleRegenerationThreads.length, @@ -3849,7 +3865,7 @@ export default function Sidebar() { // on a mixed selection the unpinned rows are untouched, and the item // is omitted entirely when nothing selected is pinned. const pinnedSelectedThreads = selectedThreads.filter( - (thread) => + ({ thread }) => serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === true && thread.pinnedAt != null, ); @@ -3878,7 +3894,12 @@ export default function Sidebar() { ] : []), ...(titleRegenerationMenuItem ? [titleRegenerationMenuItem] : []), - { id: "mark-unread", label: `Mark unread (${count})` }, + ...(unreadThreads.length > 0 + ? [{ id: "mark-read", label: `Mark read (${unreadThreads.length})` }] + : []), + ...(readThreads.length > 0 + ? [{ id: "mark-unread", label: `Mark unread (${readThreads.length})` }] + : []), { id: "delete", label: `Delete (${count})`, destructive: true }, ], position, @@ -3896,7 +3917,7 @@ export default function Sidebar() { const coSnoozingKeys = new Set(threadKeys); clearSelection(); const outcomes = await Promise.all( - selectedThreads.map(async (thread) => { + selectedThreads.map(async ({ thread }) => { const threadRef = scopeThreadRef(thread.environmentId, thread.id); const outcome = await performSnooze(threadRef, preset, { coSnoozingKeys }); return { outcome, threadRef }; @@ -3928,14 +3949,14 @@ export default function Sidebar() { } if (clicked.value === "unpin") { // Each unpin reports its own failure, like the single-row action. - for (const thread of pinnedSelectedThreads) { + for (const { thread } of pinnedSelectedThreads) { attemptUnpin(scopeThreadRef(thread.environmentId, thread.id)); } clearSelection(); return; } if (clicked.value === "regenerate-title") { - for (const thread of regeneratableTitleThreads) { + for (const { thread } of regeneratableTitleThreads) { const result = await updateThreadMetadata({ environmentId: thread.environmentId, input: { threadId: thread.id, regenerateTitle: true }, @@ -3972,9 +3993,17 @@ export default function Sidebar() { return; } if (clicked.value === "mark-unread") { - for (const threadKey of threadKeys) { - const thread = threadByKeyRef.current.get(threadKey); - markThreadUnread(threadKey, thread?.latestTurn?.completedAt); + for (const { threadKey, thread } of readThreads) { + markThreadUnread(threadKey, thread.latestTurn?.completedAt); + } + clearSelection(); + return; + } + if (clicked.value === "mark-read") { + for (const { threadKey, thread } of unreadThreads) { + if (thread.latestTurn?.completedAt) { + markThreadVisited(threadKey, thread.latestTurn.completedAt); + } } clearSelection(); return; @@ -4026,6 +4055,7 @@ export default function Sidebar() { confirmThreadDelete, deleteThread, markThreadUnread, + markThreadVisited, performSnooze, removeFromSelection, serverConfigs, @@ -4099,6 +4129,11 @@ export default function Sidebar() { isRegeneratingTitle, isRunning: thread.session?.status === "running" && thread.session.activeTurnId != null, + isUnread: hasUnseenCompletion({ + ...thread, + lastVisitedAt: useUiStateStore.getState().threadLastVisitedAtById[threadKey], + }), + canMarkUnread: thread.latestTurn?.completedAt != null, supports: { settlement: supportsSettlement, autoSettleOptOut: supportsAutoSettleOptOut, @@ -4215,6 +4250,11 @@ export default function Sidebar() { case "mark-unread": markThreadUnread(threadKey, thread.latestTurn?.completedAt); return; + case "mark-read": + if (thread.latestTurn?.completedAt) { + markThreadVisited(threadKey, thread.latestTurn.completedAt); + } + return; case "copy-path": if (!threadWorkspacePath) { toastManager.add( @@ -4312,6 +4352,7 @@ export default function Sidebar() { deleteThread, handleMultiSelectContextMenu, markThreadUnread, + markThreadVisited, openProjectSettings, projectScopeKey, projectByKey, diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index c5ebeda55627..fb3388b1b389 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -12,6 +12,8 @@ const baseState: ThreadActionMenuState = { canSnoozeNow: true, isRegeneratingTitle: false, isRunning: false, + isUnread: false, + canMarkUnread: true, supports: { settlement: true, autoSettleOptOut: true, @@ -97,6 +99,20 @@ describe("buildThreadActionMenuItems", () => { expect(ids(baseState)).toEqual(expect.arrayContaining(["pin", "settle", "snooze"])); }); + it("offers Mark read instead of Mark unread when a completion is unseen", () => { + const items = buildThreadActionMenuItems({ ...baseState, isUnread: true }); + expect(items).toContainEqual({ id: "mark-read", label: "Mark read", icon: "mail-open" }); + expect(items.some((item) => item.id === "mark-unread")).toBe(false); + expect(ids(baseState)).toContain("mark-unread"); + }); + + it("disables Mark unread when the thread has no completed turn", () => { + const item = buildThreadActionMenuItems({ ...baseState, canMarkUnread: false }).find( + (candidate) => candidate.id === "mark-unread", + ); + expect(item?.disabled).toBe(true); + }); + it("offers auto-settle as a submenu with the current option checked", () => { const find = (state: ThreadActionMenuState) => buildThreadActionMenuItems(state).find((item) => item.id === "auto-settle"); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index 838fc91a636c..4d4a2406cd1e 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -22,6 +22,7 @@ export type ThreadActionMenuId = | "unsnooze" | "rename" | "regenerate-title" + | "mark-read" | "mark-unread" | "copy" | "copy-path" @@ -50,6 +51,8 @@ export interface ThreadActionMenuState { readonly isRegeneratingTitle: boolean; /** Archive rejects a thread with an active turn, so disable it here rather than let the action fail. */ readonly isRunning: boolean; + readonly isUnread: boolean; + readonly canMarkUnread: boolean; readonly supports: { readonly settlement: boolean; /** Server understands thread.auto-settle.set. */ @@ -126,7 +129,14 @@ export function buildThreadActionMenuItems( }, ] : []), - { id: "mark-unread", label: "Mark unread", icon: "mail-open" }, + state.isUnread + ? { id: "mark-read", label: "Mark read", icon: "mail-open" } + : { + id: "mark-unread", + label: "Mark unread", + icon: "mail-open", + disabled: !state.canMarkUnread, + }, ...(state.projectFilter ? [ { diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 4a99fa8f5c73..c5588cae0aef 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -12,6 +12,7 @@ import { useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { resolveSnoozePresets } from "../components/Sidebar.snooze"; +import { hasUnseenCompletion } from "../components/Sidebar.logic"; import { buildThreadActionMenuItems, type ThreadActionMenuId, @@ -98,6 +99,7 @@ export function useThreadActionMenu(input: { }); const handleNewThread = useNewThreadHandler(); const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); + const markThreadVisited = useUiStateStore((s) => s.markThreadVisited); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const timestampFormat = useClientSettings((s) => s.timestampFormat); @@ -153,6 +155,12 @@ export function useThreadActionMenu(input: { canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), isRegeneratingTitle, isRunning: thread.session?.status === "running" && thread.session.activeTurnId != null, + isUnread: hasUnseenCompletion({ + ...thread, + lastVisitedAt: + useUiStateStore.getState().threadLastVisitedAtById[scopedThreadKey(threadRef)], + }), + canMarkUnread: thread.latestTurn?.completedAt != null, supports, snoozePresets, }); @@ -250,6 +258,11 @@ export function useThreadActionMenu(input: { case "mark-unread": markThreadUnread(scopedThreadKey(threadRef), thread.latestTurn?.completedAt); return; + case "mark-read": + if (thread.latestTurn?.completedAt) { + markThreadVisited(scopedThreadKey(threadRef), thread.latestTurn.completedAt); + } + return; case "copy-path": { const workspacePath = thread.worktreePath ?? projectCwd; if (!workspacePath) { @@ -337,6 +350,7 @@ export function useThreadActionMenu(input: { handleNewThread, logicalProjectKeyByPhysicalKey, markThreadUnread, + markThreadVisited, onStartRename, pinThread, projectCwd, From ef6b7e5099d0bbbe3d8c4b26cd260be7761f9d4b Mon Sep 17 00:00:00 2001 From: Vedant Mahajan <113882531+Vedant817@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:00:37 +0530 Subject: [PATCH 2/3] docs(web): explain why bulk read counts differ from the selection total The bulk read actions take their own counts rather than \count\, which looks redundant next to the archive and delete labels. Note what each one counts so a caller does not pass the selection total for all three and label a mixed selection with a count it would not change. --- apps/web/src/components/Sidebar.logic.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index e7cca6318ff3..1f9167dae823 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -476,8 +476,13 @@ export async function archiveSelectedThreadEntries< } export function buildMultiSelectThreadContextMenuItems(input: { + /** Every selected thread — labels the whole-selection actions. */ count: number; hasRunningThread: boolean; + /** + * Only the selected threads the action would change, so a mixed selection + * cannot label the count for threads it leaves alone. + */ unreadCount: number; readCount: number; }): readonly ContextMenuItem<"mark-read" | "mark-unread" | "archive" | "delete">[] { From 86c84f026b0ba40a12d969a71aa2484db641f429 Mon Sep 17 00:00:00 2001 From: Vedant Mahajan <113882531+Vedant817@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:19:30 +0530 Subject: [PATCH 3/3] fix(web): gate Mark unread on a parseable completion timestamp \markThreadUnread\ and \markThreadVisited\ both drop a NaN rather than write a bad timestamp, and \hasUnseenCompletion\ already treats an unparsable completion as not-unread. The new eligibility checks only tested for non-null, so a thread with an unparsable completion offered an enabled action that then changed nothing \u2014 the same silent no-op this series set out to remove. The bulk menus went further and counted such a thread as eligible, so the \(N)\ label could promise changes it skipped. Add one shared \hasParseableCompletion\ predicate and use it for every single-thread and bulk eligibility check, so the gate means the same thing the store does. --- apps/web/src/components/LegacySidebar.tsx | 5 ++-- apps/web/src/components/Sidebar.logic.test.ts | 27 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 12 +++++++++ apps/web/src/components/Sidebar.tsx | 5 ++-- apps/web/src/hooks/useThreadActionMenu.ts | 4 +-- apps/web/src/uiStateStore.test.ts | 3 +++ 6 files changed, 50 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index ea8b4c3df02b..2ef9277f650c 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -182,6 +182,7 @@ import { deleteSelectedThreadEntries, getSidebarThreadIdsToPrewarm, hasUnseenCompletion, + hasParseableCompletion, resolveAdjacentThreadId, isContextMenuPointerDown, isSidebarNestedLinkClick, @@ -1911,7 +1912,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ); const unreadKeys = new Set(unreadEntries.map(({ threadKey }) => threadKey)); const readEntries = selectedThreadEntries.filter( - ({ threadKey, thread }) => thread.latestTurn?.completedAt && !unreadKeys.has(threadKey), + ({ threadKey, thread }) => hasParseableCompletion(thread) && !unreadKeys.has(threadKey), ); const clicked = await api.contextMenu.show( @@ -2285,7 +2286,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec : { id: "mark-unread", label: "Mark unread", - disabled: thread.latestTurn?.completedAt == null, + disabled: !hasParseableCompletion(thread), }, { id: "copy-path", label: "Copy Path" }, { id: "copy-thread-id", label: "Copy Thread ID" }, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index add4a781dbf9..62cb7d1c1d87 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -18,6 +18,7 @@ import { getFallbackThreadIdAfterDelete, getProjectSortTimestamp, hasUnseenCompletion, + hasParseableCompletion, isContextMenuPointerDown, isSidebarNestedLinkClick, isTrailingDoubleClick, @@ -374,6 +375,32 @@ function makeLatestTurn(overrides?: { }; } +describe("hasParseableCompletion", () => { + const base = { + hasActionableProposedPlan: false, + hasPendingApprovals: false, + hasPendingUserInput: false, + interactionMode: "default" as const, + session: null, + }; + + it("accepts a real completion timestamp", () => { + expect(hasParseableCompletion({ ...base, latestTurn: makeLatestTurn() })).toBe(true); + }); + + it("rejects a missing or unparsable completion, which the store would drop", () => { + expect( + hasParseableCompletion({ ...base, latestTurn: makeLatestTurn({ completedAt: null }) }), + ).toBe(false); + expect( + hasParseableCompletion({ + ...base, + latestTurn: makeLatestTurn({ completedAt: "not-a-date" }), + }), + ).toBe(false); + }); +}); + describe("hasUnseenCompletion", () => { it("returns true when a thread completed after its last visit", () => { expect( diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 1f9167dae823..c2cb79c807d9 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -657,6 +657,18 @@ export function useThreadJumpHintVisibility(): { }; } +/** + * Read state is stamped from the turn's completion timestamp, and both + * `markThreadUnread` and `markThreadVisited` drop a NaN rather than write a + * bad one. A non-null but unparsable completion would therefore enable an + * action that then changes nothing, so eligibility has to mean the same + * thing the store does. + */ +export function hasParseableCompletion(thread: ThreadStatusInput): boolean { + const completedAt = thread.latestTurn?.completedAt; + return completedAt != null && !Number.isNaN(Date.parse(completedAt)); +} + export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { if (!thread.latestTurn?.completedAt) return false; const completedAt = Date.parse(thread.latestTurn.completedAt); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d69d1a045017..399973208853 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -165,6 +165,7 @@ import { formatWorkingDurationLabel, firstValidTimestampMs, hasUnseenCompletion, + hasParseableCompletion, isSidebarNestedLinkClick, isTrailingDoubleClick, orderItemsByPreferredIds, @@ -3842,7 +3843,7 @@ export default function Sidebar() { }); const unreadThreads = selectedThreads.filter(({ isUnread }) => isUnread); const readThreads = selectedThreads.filter( - ({ thread, isUnread }) => thread.latestTurn?.completedAt && !isUnread, + ({ thread, isUnread }) => hasParseableCompletion(thread) && !isUnread, ); const canSnoozeSelection = selectedThreads.every( ({ thread }) => @@ -4133,7 +4134,7 @@ export default function Sidebar() { ...thread, lastVisitedAt: useUiStateStore.getState().threadLastVisitedAtById[threadKey], }), - canMarkUnread: thread.latestTurn?.completedAt != null, + canMarkUnread: hasParseableCompletion(thread), supports: { settlement: supportsSettlement, autoSettleOptOut: supportsAutoSettleOptOut, diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index c5588cae0aef..99ae9cf750f4 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -12,7 +12,7 @@ import { useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { resolveSnoozePresets } from "../components/Sidebar.snooze"; -import { hasUnseenCompletion } from "../components/Sidebar.logic"; +import { hasParseableCompletion, hasUnseenCompletion } from "../components/Sidebar.logic"; import { buildThreadActionMenuItems, type ThreadActionMenuId, @@ -160,7 +160,7 @@ export function useThreadActionMenu(input: { lastVisitedAt: useUiStateStore.getState().threadLastVisitedAtById[scopedThreadKey(threadRef)], }), - canMarkUnread: thread.latestTurn?.completedAt != null, + canMarkUnread: hasParseableCompletion(thread), supports, snoozePresets, }); diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 4d68a3231b31..3c2aa09d3873 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -54,6 +54,9 @@ describe("uiStateStore pure functions", () => { expect(next.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:29:59.999Z"); expect(markThreadUnread(next, threadId, null)).toBe(next); + // An unparsable completion is dropped, so callers must gate on it: the + // sidebar's hasParseableCompletion is the matching check. + expect(markThreadUnread(initialState, threadId, "not-a-date")).toBe(initialState); }); it("resolves project expansion from logical, physical, and legacy preference keys", () => {