Skip to content
Open
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
69 changes: 69 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import {
archiveSelectedThreadEntries,
buildMultiSelectThreadContextMenuItems,
createThreadJumpHintVisibilityController,
getSidebarThreadIdsToPrewarm,
getVisibleSidebarThreadIds,
Expand Down Expand Up @@ -37,6 +39,73 @@ import {

const localEnvironmentId = EnvironmentId.make("environment-local");

describe("archiveSelectedThreadEntries", () => {
const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const;
const success = { _tag: "Success" } as const;
const failure = { _tag: "Failure" } as const;

it("records every entry after full success", async () => {
const outcome = await archiveSelectedThreadEntries({
entries,
archive: async (_entry, onArchived) => {
onArchived();
return success;
},
});

expect(outcome).toEqual({
archivedThreadKeys: ["one", "two", "three"],
mutationFailure: null,
followupFailures: [],
});
});

it("stops at a mutation failure and retains prior successes", async () => {
const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => {
if (entry.threadKey === "two") return failure;
onArchived();
return success;
});
const outcome = await archiveSelectedThreadEntries({ entries, archive });

expect(archive).toHaveBeenCalledTimes(2);
expect(outcome).toEqual({
archivedThreadKeys: ["one"],
mutationFailure: failure,
followupFailures: [],
});
});

it("continues after a post-archive failure", async () => {
const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => {
onArchived();
return entry.threadKey === "two" ? failure : success;
});
const outcome = await archiveSelectedThreadEntries({ entries, archive });

expect(archive).toHaveBeenCalledTimes(3);
expect(outcome).toEqual({
archivedThreadKeys: ["one", "two", "three"],
mutationFailure: null,
followupFailures: [failure],
});
});
});

describe("buildMultiSelectThreadContextMenuItems", () => {
it("offers bulk archive with the selected count", () => {
expect(
buildMultiSelectThreadContextMenuItems({ count: 3, hasRunningThread: false }),
).toContainEqual({ id: "archive", label: "Archive (3)", disabled: false });
});

it("disables bulk archive when a selected thread is running", () => {
expect(
buildMultiSelectThreadContextMenuItems({ count: 2, hasRunningThread: true }),
).toContainEqual({ id: "archive", label: "Archive (2)", disabled: true });
});
});

describe("resolveSidebarStageBadgeLabel", () => {
it("returns Nightly for nightly primary server versions", () => {
expect(
Expand Down
50 changes: 50 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as React from "react";
import type { ContextMenuItem } from "@t3tools/contracts";
import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
import {
getThreadSortTimestamp,
Expand Down Expand Up @@ -26,6 +27,55 @@ type SidebarProject = {

export type ThreadTraversalDirection = "previous" | "next";

export async function archiveSelectedThreadEntries<
TEntry extends { readonly threadKey: string },
TResult extends { readonly _tag: "Success" | "Failure" },
>(input: {
entries: readonly TEntry[];
archive: (entry: TEntry, onArchived: () => void) => Promise<TResult>;
}): Promise<{
archivedThreadKeys: readonly string[];
mutationFailure: Extract<TResult, { readonly _tag: "Failure" }> | null;
followupFailures: readonly Extract<TResult, { readonly _tag: "Failure" }>[];
}> {
const archivedThreadKeys: string[] = [];
const followupFailures: Extract<TResult, { readonly _tag: "Failure" }>[] = [];

for (const entry of input.entries) {
let didArchive = false;
const result = await input.archive(entry, () => {
didArchive = true;
});
if (didArchive || result._tag === "Success") {
archivedThreadKeys.push(entry.threadKey);
}
if (result._tag === "Success") continue;
const failure = result as Extract<TResult, { readonly _tag: "Failure" }>;
if (didArchive) {
followupFailures.push(failure);
continue;
}
return { archivedThreadKeys, mutationFailure: failure, followupFailures };
}

return { archivedThreadKeys, mutationFailure: null, followupFailures };
}

export function buildMultiSelectThreadContextMenuItems(input: {
count: number;
hasRunningThread: boolean;
}): readonly ContextMenuItem<"mark-unread" | "archive" | "delete">[] {
return [
{ id: "mark-unread", label: `Mark unread (${input.count})` },
{
id: "archive",
label: `Archive (${input.count})`,
disabled: input.hasRunningThread,
},
{ id: "delete", label: `Delete (${input.count})`, destructive: true },
];
}

export interface ThreadStatusPill {
label:
| "Working"
Expand Down
69 changes: 58 additions & 11 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ import {
import { useThreadSelectionStore } from "../threadSelectionStore";
import { useOpenAddProjectCommandPalette } from "../commandPaletteContext";
import {
archiveSelectedThreadEntries,
buildMultiSelectThreadContextMenuItems,
getSidebarThreadIdsToPrewarm,
resolveAdjacentThreadId,
isContextMenuPointerDown,
Expand Down Expand Up @@ -1772,24 +1774,69 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys];
if (threadKeys.length === 0) return;
const count = threadKeys.length;
const selectedThreadEntries = threadKeys.flatMap((threadKey) => {
const threadRef = parseScopedThreadKey(threadKey);
const thread = threadRef ? readThreadShell(threadRef) : null;
return threadRef && thread ? [{ threadKey, threadRef, thread }] : [];
});
const hasRunningThread = selectedThreadEntries.some(
({ thread }) => thread.session?.status === "running" && thread.session.activeTurnId != null,
);

const clicked = await api.contextMenu.show(
[
{ id: "mark-unread", label: `Mark unread (${count})` },
{ id: "delete", label: `Delete (${count})`, destructive: true },
],
buildMultiSelectThreadContextMenuItems({ count, hasRunningThread }),
position,
);

if (clicked === "mark-unread") {
for (const threadKey of threadKeys) {
const thread = sidebarThreadByKeyRef.current.get(threadKey);
markThreadUnread(threadKey, thread?.latestTurn?.completedAt);
for (const { threadKey, thread } of selectedThreadEntries) {
markThreadUnread(threadKey, thread.latestTurn?.completedAt);
}
clearSelection();
return;
}

if (clicked === "archive") {
if (appSettingsConfirmThreadArchive) {
const confirmed = await api.dialogs.confirm(
`Archive ${count} thread${count === 1 ? "" : "s"}?`,
);
if (!confirmed) return;
}

const archiveOutcome = await archiveSelectedThreadEntries({
entries: selectedThreadEntries,
archive: ({ threadRef }, onArchived) => archiveThread(threadRef, { onArchived }),
});
for (const failure of archiveOutcome.followupFailures) {
if (isAtomCommandInterrupted(failure)) continue;
const error = squashAtomCommandFailure(failure);
toastManager.add(
stackedThreadToast({
type: "error",
title: "Thread archived, but navigation failed",
description: error instanceof Error ? error.message : "An error occurred.",
}),
);
}
if (archiveOutcome.mutationFailure) {
removeFromSelection(archiveOutcome.archivedThreadKeys);
if (!isAtomCommandInterrupted(archiveOutcome.mutationFailure)) {
const error = squashAtomCommandFailure(archiveOutcome.mutationFailure);
toastManager.add(
stackedThreadToast({
type: "error",
title: "Failed to archive threads",
description: error instanceof Error ? error.message : "An error occurred.",
}),
);
}
return;
}
removeFromSelection(threadKeys);
Comment thread
theduke marked this conversation as resolved.
return;
Comment thread
cursor[bot] marked this conversation as resolved.
}

if (clicked !== "delete") return;

if (appSettingsConfirmThreadDelete) {
Expand All @@ -1803,10 +1850,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
}

const deletedThreadKeys = new Set(threadKeys);
for (const threadKey of threadKeys) {
const thread = sidebarThreadByKeyRef.current.get(threadKey);
if (!thread) continue;
const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), {
for (const { threadRef } of selectedThreadEntries) {
const result = await deleteThread(threadRef, {
deletedThreadKeys,
});
if (result._tag === "Failure") {
Expand All @@ -1826,7 +1871,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
removeFromSelection(threadKeys);
},
[
appSettingsConfirmThreadArchive,
appSettingsConfirmThreadDelete,
archiveThread,
clearSelection,
deleteThread,
markThreadUnread,
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/hooks/useThreadActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export function useThreadActions() {
}, [router]);

const archiveThread = useCallback(
async (target: ScopedThreadRef) => {
async (target: ScopedThreadRef, opts: { onArchived?: () => void } = {}) => {
const resolved = resolveThreadTarget(target);
if (!resolved) return AsyncResult.success(undefined);
const { thread, threadRef } = resolved;
Expand All @@ -115,6 +115,8 @@ export function useThreadActions() {
if (archiveResult._tag === "Failure") {
return archiveResult;
}
refreshArchivedThreadsForEnvironment(threadRef.environmentId);
opts.onArchived?.();

if (shouldNavigateToDraft) {
const navigationResult = await settlePromise(() =>
Expand All @@ -123,11 +125,9 @@ export function useThreadActions() {
if (navigationResult._tag === "Failure") {
return navigationResult;
}
refreshArchivedThreadsForEnvironment(threadRef.environmentId);
return archiveResult;
}

refreshArchivedThreadsForEnvironment(threadRef.environmentId);
return archiveResult;
},
[archiveThreadMutation, getCurrentRouteThreadRef, resolveThreadTarget],
Expand Down
Loading