Skip to content
Merged
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
34 changes: 34 additions & 0 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1600,6 +1600,7 @@ export default function Sidebar() {
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
const timestampFormat = useClientSettings((s) => s.timestampFormat);
const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
Expand All @@ -1611,6 +1612,7 @@ export default function Sidebar() {
pinThread,
unpinThread,
reorderPinnedThread,
archiveThread,
deleteThread,
} = useThreadActions();
const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
Expand Down Expand Up @@ -2962,6 +2964,8 @@ export default function Sidebar() {
isSnoozed,
canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }),
isRegeneratingTitle,
isRunning:
thread.session?.status === "running" && thread.session.activeTurnId != null,
supports: {
settlement: supportsSettlement,
snooze: supportsSnooze,
Expand Down Expand Up @@ -3065,6 +3069,34 @@ export default function Sidebar() {
case "copy-thread-id":
copyThreadIdToClipboard(thread.id, { threadId: thread.id });
return;
case "archive": {
if (confirmThreadArchive) {
const confirmed = await settlePromise(() =>
api.dialogs.confirm(`Archive thread "${thread.title}"?`),
);
if (confirmed._tag === "Failure" || !confirmed.value) return;
}
let didArchive = false;
const result = await archiveThread(threadRef, {
onArchived: () => {
didArchive = true;
},
});
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
toastManager.add(
stackedThreadToast({
type: "error",
title: didArchive
? "Thread archived, but navigation failed"
: "Failed to archive thread",
description: error instanceof Error ? error.message : "An error occurred.",
}),
);
return;
}
return;
}
case "delete": {
if (confirmThreadDelete) {
const confirmed = await settlePromise(() =>
Expand Down Expand Up @@ -3098,12 +3130,14 @@ export default function Sidebar() {
})();
},
[
archiveThread,
attemptPin,
attemptSettle,
attemptSnooze,
attemptUnpin,
attemptUnsettle,
attemptUnsnooze,
confirmThreadArchive,
confirmThreadDelete,
copyBranchToClipboard,
copyPathToClipboard,
Expand Down
27 changes: 26 additions & 1 deletion apps/web/src/components/threadActionMenu.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const baseState: ThreadActionMenuState = {
isSnoozed: false,
canSnoozeNow: true,
isRegeneratingTitle: false,
isRunning: false,
supports: { settlement: true, snooze: true, pinning: true, titleRegeneration: true },
snoozePresets: [
{ id: "hour", label: "In 1 hour", whenLabel: "3:00 PM", snoozedUntil: "2026-08-07T15:00:00Z" },
Expand All @@ -26,7 +27,7 @@ describe("buildThreadActionMenuItems", () => {
...baseState,
supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false },
}),
).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "delete"]);
).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "archive", "delete"]);
});

it("includes branch items only for threads with a branch", () => {
Expand Down Expand Up @@ -63,4 +64,28 @@ describe("buildThreadActionMenuItems", () => {
const items = buildThreadActionMenuItems({ ...baseState, branch: "main" });
expect(items.at(-1)).toMatchObject({ id: "delete", destructive: true });
});

it("offers archive as a non-destructive action right before delete", () => {
const items = buildThreadActionMenuItems(baseState);
const archiveItem = items.at(-2);
expect(archiveItem?.id).toBe("archive");
expect(archiveItem?.destructive).toBeFalsy();
expect(items.at(-1)?.id).toBe("delete");
});

it("keeps archive available even when the environment lacks every other capability", () => {
expect(
ids({
...baseState,
supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false },
}),
).toContain("archive");
});

it("disables archive while the thread is running", () => {
const archiveItem = buildThreadActionMenuItems({ ...baseState, isRunning: true }).find(
(item) => item.id === "archive",
);
expect(archiveItem?.disabled).toBe(true);
});
});
9 changes: 9 additions & 0 deletions apps/web/src/components/threadActionMenu.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type ThreadActionMenuId =
| "copy-path"
| "copy-branch"
| "copy-thread-id"
| "archive"
| "delete";

export interface ThreadActionMenuState {
Expand All @@ -30,6 +31,8 @@ export interface ThreadActionMenuState {
readonly isSnoozed: boolean;
readonly canSnoozeNow: boolean;
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 supports: {
readonly settlement: boolean;
readonly snooze: boolean;
Expand Down Expand Up @@ -102,6 +105,12 @@ export function buildThreadActionMenuItems(
{ id: "copy-path", label: "Copy path", icon: "copy" },
...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []),
{ id: "copy-thread-id", label: "Copy thread ID", icon: "copy" },
// Archive removes the thread from the sidebar while keeping its
// conversation under Settings > Archived threads — distinct from Settle
// (stays visible in the Settled shelf) and Delete (clears history for
// good), so it sits beside Delete without borrowing its destructive
// styling.
{ id: "archive", label: "Archive thread", disabled: state.isRunning },
{ id: "delete", label: "Delete", destructive: true, icon: "trash" },
];
}
26 changes: 26 additions & 0 deletions apps/web/src/hooks/useThreadActionMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export function useThreadActionMenu(input: {
unsnoozeThread,
pinThread,
unpinThread,
archiveThread,
deleteThread,
} = useThreadActions();
const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
Expand All @@ -81,6 +82,7 @@ export function useThreadActionMenu(input: {
const markThreadUnread = useUiStateStore((s) => s.markThreadUnread);
const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
const timestampFormat = useClientSettings((s) => s.timestampFormat);
const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({
onCopy: ({ path }) => {
Expand Down Expand Up @@ -137,6 +139,7 @@ export function useThreadActionMenu(input: {
isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),
canSnoozeNow: canSnooze(thread, { now: now.toISOString() }),
isRegeneratingTitle,
isRunning: thread.session?.status === "running" && thread.session.activeTurnId != null,
supports,
snoozePresets,
});
Expand Down Expand Up @@ -251,6 +254,27 @@ export function useThreadActionMenu(input: {
case "copy-thread-id":
copyThreadIdToClipboard(thread.id, { threadId: thread.id });
return;
case "archive": {
if (confirmThreadArchive) {
const confirmed = await settlePromise(() =>
api.dialogs.confirm(`Archive thread "${thread.title}"?`),
);
if (confirmed._tag === "Failure" || !confirmed.value) return;
}
let didArchive = false;
const result = await archiveThread(threadRef, {
onArchived: () => {
didArchive = true;
},
});
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
failureToast(
didArchive ? "Thread archived, but navigation failed" : "Failed to archive thread",
squashAtomCommandFailure(result),
);
}
return;
}
case "delete": {
if (confirmThreadDelete) {
const confirmed = await settlePromise(() =>
Expand Down Expand Up @@ -283,8 +307,10 @@ export function useThreadActionMenu(input: {
})();
},
[
archiveThread,
autoSettleAfterDays,
changeRequestState,
confirmThreadArchive,
confirmThreadDelete,
copyBranchToClipboard,
copyPathToClipboard,
Expand Down
Loading