Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
47 changes: 44 additions & 3 deletions apps/web/src/components/LegacySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ import {
buildMultiSelectThreadContextMenuItems,
deleteSelectedThreadEntries,
getSidebarThreadIdsToPrewarm,
hasUnseenCompletion,
resolveAdjacentThreadId,
isContextMenuPointerDown,
isSidebarNestedLinkClick,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -2000,6 +2024,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
clearSelection,
deleteThread,
markThreadUnread,
markThreadVisited,
removeFromSelection,
],
);
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -2354,6 +2394,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
handleNewThread,
isMobile,
markThreadUnread,
markThreadVisited,
memberProjectByScopedKey,
project.projectKey,
project.workspaceRoot,
Expand Down
25 changes: 23 additions & 2 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?: {
Expand Down
11 changes: 9 additions & 2 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})`,
Expand Down
65 changes: 53 additions & 12 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
);
Expand Down Expand Up @@ -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,
Expand All @@ -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 };
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -4026,6 +4055,7 @@ export default function Sidebar() {
confirmThreadDelete,
deleteThread,
markThreadUnread,
markThreadVisited,
performSnooze,
removeFromSelection,
serverConfigs,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -4312,6 +4352,7 @@ export default function Sidebar() {
deleteThread,
handleMultiSelectContextMenu,
markThreadUnread,
markThreadVisited,
openProjectSettings,
projectScopeKey,
projectByKey,
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/components/threadActionMenu.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const baseState: ThreadActionMenuState = {
canSnoozeNow: true,
isRegeneratingTitle: false,
isRunning: false,
isUnread: false,
canMarkUnread: true,
supports: {
settlement: true,
autoSettleOptOut: true,
Expand Down Expand Up @@ -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");
Expand Down
12 changes: 11 additions & 1 deletion apps/web/src/components/threadActionMenu.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type ThreadActionMenuId =
| "unsnooze"
| "rename"
| "regenerate-title"
| "mark-read"
| "mark-unread"
| "copy"
| "copy-path"
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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
? [
{
Expand Down
Loading
Loading