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

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

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
16 changes: 16 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,21 @@ type SidebarProject = {

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

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
58 changes: 47 additions & 11 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ import {
import { useThreadSelectionStore } from "../threadSelectionStore";
import { useOpenAddProjectCommandPalette } from "../commandPaletteContext";
import {
buildMultiSelectThreadContextMenuItems,
getSidebarThreadIdsToPrewarm,
resolveAdjacentThreadId,
isContextMenuPointerDown,
Expand Down Expand Up @@ -1772,24 +1773,59 @@ 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 archivedThreadKeys: string[] = [];
for (const { threadKey, threadRef } of selectedThreadEntries) {
const result = await archiveThread(threadRef);
if (result._tag === "Failure") {
removeFromSelection(archivedThreadKeys);
if (!isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
toastManager.add(
stackedThreadToast({
type: "error",
title: "Failed to archive threads",
description: error instanceof Error ? error.message : "An error occurred.",
}),
);
}
return;
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}
archivedThreadKeys.push(threadKey);
}
removeFromSelection(archivedThreadKeys);
return;
Comment thread
cursor[bot] marked this conversation as resolved.
}

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

if (appSettingsConfirmThreadDelete) {
Expand All @@ -1803,10 +1839,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 +1860,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
removeFromSelection(threadKeys);
},
[
appSettingsConfirmThreadArchive,
appSettingsConfirmThreadDelete,
archiveThread,
clearSelection,
deleteThread,
markThreadUnread,
Expand Down
Loading