Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 45 additions & 3 deletions apps/web/src/components/LegacySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ import {
buildMultiSelectThreadContextMenuItems,
deleteSelectedThreadEntries,
getSidebarThreadIdsToPrewarm,
hasUnseenCompletion,
hasParseableCompletion,
resolveAdjacentThreadId,
isContextMenuPointerDown,
isSidebarNestedLinkClick,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -2000,6 +2025,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
clearSelection,
deleteThread,
markThreadUnread,
markThreadVisited,
removeFromSelection,
],
);
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -2354,6 +2395,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
handleNewThread,
isMobile,
markThreadUnread,
markThreadVisited,
memberProjectByScopedKey,
project.projectKey,
project.workspaceRoot,
Expand Down
52 changes: 50 additions & 2 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
getFallbackThreadIdAfterDelete,
getProjectSortTimestamp,
hasUnseenCompletion,
hasParseableCompletion,
isContextMenuPointerDown,
isSidebarNestedLinkClick,
isTrailingDoubleClick,
Expand Down Expand Up @@ -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?: {
Expand All @@ -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(
Expand Down
28 changes: 26 additions & 2 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})`,
Expand Down Expand Up @@ -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);
Expand Down
66 changes: 54 additions & 12 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ import {
formatWorkingDurationLabel,
firstValidTimestampMs,
hasUnseenCompletion,
hasParseableCompletion,
isSidebarNestedLinkClick,
isTrailingDoubleClick,
orderItemsByPreferredIds,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
);
Expand Down Expand Up @@ -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,
Expand All @@ -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 };
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -4026,6 +4056,7 @@ export default function Sidebar() {
confirmThreadDelete,
deleteThread,
markThreadUnread,
markThreadVisited,
performSnooze,
removeFromSelection,
serverConfigs,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -4312,6 +4353,7 @@ export default function Sidebar() {
deleteThread,
handleMultiSelectContextMenu,
markThreadUnread,
markThreadVisited,
openProjectSettings,
projectScopeKey,
projectByKey,
Expand Down
Loading
Loading