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
292 changes: 175 additions & 117 deletions apps/web/src/components/settings/ArchivedThreadsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { settingsScopeEnvironmentLabels } from "./settingsScopeAxis";
import { Spinner } from "~/components/ui/spinner";
import { ArchiveIcon, ArchiveX } from "lucide-react";
import { type ScopedThreadRef } from "@t3tools/contracts";
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { ArchiveIcon, ArchiveX, EllipsisIcon, Trash2Icon } from "lucide-react";
import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment";
import { presentThreadShell } from "@t3tools/client-runtime/state/shell";
import {
type AtomCommandResult,
isAtomCommandInterrupted,
settlePromise,
squashAtomCommandFailure,
Expand All @@ -20,9 +20,24 @@ import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsL
import { searchableSetting } from "./settingsSearch";
import { ProjectFavicon } from "../ProjectFavicon";

import { useRef, useState } from "react";
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";

const ARCHIVE_ACTIONS = [
{ action: "unarchive", label: "Unarchive", Icon: ArchiveX },
{ action: "delete", label: "Delete", Icon: Trash2Icon },
] as const;

type ArchiveAction = (typeof ARCHIVE_ACTIONS)[number]["action"];

export function ArchivedThreadsPanel() {
const { scope, groups: projectGroups, environments } = useSettingsScope();
const { unarchiveThread, confirmAndDeleteThread } = useThreadActions();
const { unarchiveThread, unarchiveThreads, confirmAndDeleteThread, deleteArchivedThreads } =
useThreadActions();
// null locks the whole archive; thread keys allow unrelated rows to run independently.
const pendingActionsRef = useRef(new Set<string | null>());
const [pendingKeys, setPendingKeys] = useState<ReadonlySet<string | null>>(() => new Set());
const {
snapshots: archivedSnapshots,
error: archiveError,
Expand Down Expand Up @@ -122,52 +137,94 @@ export function ArchivedThreadsPanel() {
return { groups: sortedGroups, hasMultipleEnvironments };
})();

const handleArchivedThreadContextMenu = async (
threadRef: ScopedThreadRef,
position: { x: number; y: number },
type ArchivedThread = (typeof archivedGroups)[number]["threads"][number];

const reportFailure = (result: AtomCommandResult<unknown, unknown> | null, title: string) => {
if (result?._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
const error = squashAtomCommandFailure(result);
toastManager.add(
stackedThreadToast({
type: "error",
title,
description: error instanceof Error ? error.message : "An error occurred.",
}),
);
};

const runAction = async (
key: string | null,
action: ArchiveAction,
run: () => Promise<AtomCommandResult<unknown, unknown> | null>,
) => {
const api = readLocalApi();
if (!api) return;
const clicked = await api.contextMenu.show(
[
{ id: "unarchive", label: "Unarchive" },
{ id: "delete", label: "Delete", destructive: true },
],
position,
// Context menus can outlive their render, so checks and cleanup use the live set.
const pending = pendingActionsRef.current;
if (pending.has(null) || pending.has(key) || (key === null && pending.size > 0)) return;
pending.add(key);
setPendingKeys(new Set(pending));
const result = await settlePromise(run).finally(() => {
pending.delete(key);
setPendingKeys(new Set(pending));
});
reportFailure(
result._tag === "Failure" ? result : result.value,
`Failed to ${action} thread${key === null ? "s" : ""}`,
);
};

if (clicked === "unarchive") {
const result = await unarchiveThread(threadRef);
if (result._tag === "Success") {
refreshArchivedThreads();
} else if (!isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
toastManager.add(
stackedThreadToast({
type: "error",
title: "Failed to unarchive thread",
description: error instanceof Error ? error.message : "An error occurred.",
}),
);
}
return;
}
const handleThreadAction = (action: ArchiveAction, thread: ArchivedThread) => {
const target = scopeThreadRef(thread.environmentId, thread.id);
return runAction(scopedThreadKey(target), action, async () => {
const result = await (action === "unarchive"
? unarchiveThread(target)
: confirmAndDeleteThread(target, thread.title));
if (result._tag === "Success") refreshArchivedThreads();
return result;
});
};

if (clicked === "delete") {
const result = await confirmAndDeleteThread(threadRef);
if (result._tag === "Success") {
refreshArchivedThreads();
} else if (!isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
toastManager.add(
stackedThreadToast({
type: "error",
title: "Failed to delete thread",
description: error instanceof Error ? error.message : "An error occurred.",
}),
);
const handleBulkAction = (
action: ArchiveAction,
threads: readonly ArchivedThread[],
title = "",
) => {
if (threads.length === 0) return;
return runAction(null, action, async () => {
if (action === "delete") {
const api = readLocalApi();
if (
!api ||
!(await api.dialogs.confirm(
`Delete ${threads.length} archived thread${threads.length === 1 ? "" : "s"}${title}?\nThis permanently clears conversation history for these threads.`,
{ variant: "destructive" },
))
)
return null;
}
}
const targets = threads.map((thread) => scopeThreadRef(thread.environmentId, thread.id));
return action === "unarchive" ? unarchiveThreads(targets) : deleteArchivedThreads(targets);
});
};

const handleContextMenu = async (thread: ArchivedThread, position: { x: number; y: number }) => {
const pending = pendingActionsRef.current;
if (
pending.has(null) ||
pending.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)))
)
return;
const result = await settlePromise(async () => {
const clicked = await readLocalApi()?.contextMenu.show(
ARCHIVE_ACTIONS.map(({ action, label }) => ({
id: action,
label,
destructive: action === "delete",
})),
position,
);
if (clicked === "unarchive" || clicked === "delete")
await handleThreadAction(clicked, thread);
});
reportFailure(result, "Archived thread action failed");
};

return (
Expand Down Expand Up @@ -206,79 +263,80 @@ export function ArchivedThreadsPanel() {
id={index === 0 ? searchableSetting("archive").id : undefined}
title={title}
icon={<ProjectFavicon project={project} />}
headerAction={
<Menu>
<MenuTrigger
render={
<Button
type="button"
variant="ghost-muted"
size="icon-xs"
disabled={pendingKeys.size > 0}
aria-label={`Archived thread actions for ${title}`}
/>
}
>
<EllipsisIcon className="size-3.5" />
</MenuTrigger>
<MenuPopup align="end">
{ARCHIVE_ACTIONS.map(({ action, label, Icon }) => (
<MenuItem
key={action}
variant={action === "delete" ? "destructive" : "default"}
onClick={() => void handleBulkAction(action, projectThreads, ` in ${title}`)}
>
<Icon />
{label} all in project
</MenuItem>
))}
</MenuPopup>
</Menu>
}
>
{projectThreads.map((thread) => (
<SettingsRow
key={`${thread.environmentId}:${thread.id}`}
onContextMenu={(event) => {
event.preventDefault();
void (async () => {
const result = await settlePromise(() =>
handleArchivedThreadContextMenu(
scopeThreadRef(thread.environmentId, thread.id),
{
x: event.clientX,
y: event.clientY,
},
),
);
if (result._tag === "Failure") {
const error = squashAtomCommandFailure(result);
toastManager.add(
stackedThreadToast({
type: "error",
title: "Archived thread action failed",
description:
error instanceof Error ? error.message : "An error occurred.",
}),
);
}
})();
}}
title={thread.title}
description={
<span className={hasMultipleEnvironments ? "block truncate" : undefined}>
{hasMultipleEnvironments ? `${thread.environmentLabel} · ` : null}
Archived <RelativeTime timestamp={thread.archivedAt ?? thread.createdAt} />
{" \u00b7 Created "}
<RelativeTime timestamp={thread.createdAt} />
</span>
}
control={
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
onClick={() => {
void (async () => {
const result = await unarchiveThread(
scopeThreadRef(thread.environmentId, thread.id),
);
if (result._tag === "Success") {
refreshArchivedThreads();
return;
}
if (!isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
toastManager.add(
stackedThreadToast({
type: "error",
title: "Failed to unarchive thread",
description:
error instanceof Error ? error.message : "An error occurred.",
}),
);
}
})();
}}
>
<ArchiveX className="size-3.5" />
<span>Unarchive</span>
</Button>
}
/>
))}
{projectThreads.map((thread) => {
const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
return (
<SettingsRow
key={`${thread.environmentId}:${thread.id}`}
onContextMenu={(event) => {
event.preventDefault();
void handleContextMenu(thread, { x: event.clientX, y: event.clientY });
}}
title={thread.title}
description={
<span className={hasMultipleEnvironments ? "block truncate" : undefined}>
{hasMultipleEnvironments ? `${thread.environmentLabel} · ` : null}
Archived <RelativeTime timestamp={thread.archivedAt ?? thread.createdAt} />
{" \u00b7 Created "}
<RelativeTime timestamp={thread.createdAt} />
</span>
}
control={
<div className="flex shrink-0 items-center gap-2">
{ARCHIVE_ACTIONS.map(({ action, label, Icon }) => (
<Tooltip key={action}>
<TooltipTrigger
render={
<Button
type="button"
variant={action === "delete" ? "destructive-outline" : "outline"}
size="icon-sm"
aria-label={`${label} ${thread.title}`}
disabled={pendingKeys.has(null) || pendingKeys.has(threadKey)}
onClick={() => void handleThreadAction(action, thread)}
/>
}
>
<Icon className="size-3.5" />
</TooltipTrigger>
<TooltipPopup>{label}</TooltipPopup>
</Tooltip>
))}
</div>
}
/>
);
})}
</SettingsSection>
))
)}
Expand Down
Loading
Loading