diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 1991dc502283..2ef9277f650c 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -181,6 +181,8 @@ import { buildMultiSelectThreadContextMenuItems, deleteSelectedThreadEntries, getSidebarThreadIdsToPrewarm, + hasUnseenCompletion, + hasParseableCompletion, resolveAdjacentThreadId, isContextMenuPointerDown, isSidebarNestedLinkClick, @@ -1204,6 +1206,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 +1906,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 }) => hasParseableCompletion(thread) && !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 +2025,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec clearSelection, deleteThread, markThreadUnread, + markThreadVisited, removeFromSelection, ], ); @@ -2252,7 +2278,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: !hasParseableCompletion(thread), + }, { id: "copy-path", label: "Copy Path" }, { id: "copy-thread-id", label: "Copy Thread ID" }, { id: "project-settings", label: "Project settings" }, @@ -2303,6 +2338,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 +2395,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..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, @@ -326,15 +327,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?: { @@ -353,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 2796b2f7f885..c2cb79c807d9 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -476,11 +476,23 @@ export async function archiveSelectedThreadEntries< } export function buildMultiSelectThreadContextMenuItems(input: { + /** Every selected thread — labels the whole-selection actions. */ count: number; hasRunningThread: boolean; -}): readonly ContextMenuItem<"mark-unread" | "archive" | "delete">[] { + /** + * 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">[] { 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})`, @@ -645,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 90f65ea2b941..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, @@ -3824,22 +3825,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 }) => hasParseableCompletion(thread) && !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 +3866,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 +3895,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 +3918,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 +3950,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 +3994,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 +4056,7 @@ export default function Sidebar() { confirmThreadDelete, deleteThread, markThreadUnread, + markThreadVisited, performSnooze, removeFromSelection, serverConfigs, @@ -4099,6 +4130,11 @@ export default function Sidebar() { isRegeneratingTitle, isRunning: thread.session?.status === "running" && thread.session.activeTurnId != null, + isUnread: hasUnseenCompletion({ + ...thread, + lastVisitedAt: useUiStateStore.getState().threadLastVisitedAtById[threadKey], + }), + canMarkUnread: hasParseableCompletion(thread), supports: { settlement: supportsSettlement, autoSettleOptOut: supportsAutoSettleOptOut, @@ -4215,6 +4251,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 +4353,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..99ae9cf750f4 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 { hasParseableCompletion, 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: hasParseableCompletion(thread), 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, 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", () => {