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
224 changes: 106 additions & 118 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, 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,20 @@ import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsL
import { searchableSetting } from "./settingsSearch";
import { ProjectFavicon } from "../ProjectFavicon";

import { useRef, useState } from "react";
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 pendingActionsRef = useRef(new Set<string>());
const [pendingKeys, setPendingKeys] = useState<ReadonlySet<string>>(() => new Set());
const {
snapshots: archivedSnapshots,
error: archiveError,
Expand Down Expand Up @@ -122,52 +133,57 @@ export function ArchivedThreadsPanel() {
return { groups: sortedGroups, hasMultipleEnvironments };
})();

const handleArchivedThreadContextMenu = async (
threadRef: ScopedThreadRef,
position: { x: number; y: number },
) => {
const api = readLocalApi();
if (!api) return;
const clicked = await api.contextMenu.show(
[
{ id: "unarchive", label: "Unarchive" },
{ id: "delete", label: "Delete", destructive: true },
],
position,
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.",
}),
);
};

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 = async (action: ArchiveAction, thread: ArchivedThread) => {
const target = scopeThreadRef(thread.environmentId, thread.id);
const key = scopedThreadKey(target);
// Context menus can outlive their render, so checks and cleanup use the live set.
const pending = pendingActionsRef.current;
if (pending.has(key)) return;
pending.add(key);
setPendingKeys(new Set(pending));
const result = await settlePromise(async () => {
const result = await (action === "unarchive"
? unarchiveThread(target)
: confirmAndDeleteThread(target, thread.title));
if (result._tag === "Success") refreshArchivedThreads();
return result;
}).finally(() => {
pending.delete(key);
setPendingKeys(new Set(pending));
});
reportFailure(result._tag === "Failure" ? result : result.value, `Failed to ${action} thread`);
};

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 handleContextMenu = async (thread: ArchivedThread, position: { x: number; y: number }) => {
const pending = pendingActionsRef.current;
if (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 @@ -207,78 +223,50 @@ export function ArchivedThreadsPanel() {
title={title}
icon={<ProjectFavicon project={project} />}
>
{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(threadKey)}
onClick={() => void handleThreadAction(action, thread)}
/>
}
>
<Icon className="size-3.5" />
</TooltipTrigger>
<TooltipPopup>{label}</TooltipPopup>
</Tooltip>
))}
</div>
}
/>
);
})}
</SettingsSection>
))
)}
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/hooks/useThreadActions.permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,18 @@ describe("thread action permissions", () => {
},
);

it("names an archived thread in its delete confirmation and preserves it on cancel", async () => {
state.threads = [];
state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope);
state.confirm.mockResolvedValue(false);
await useThreadActions().confirmAndDeleteThread(target, "Archived release notes");
expect(state.confirm).toHaveBeenCalledWith(
expect.stringContaining('Delete thread "Archived release notes"?'),
{ variant: "destructive" },
);
expect(state.requests).toEqual([]);
});

it("deletes an archived thread only with its own environment's grant", async () => {
state.threads = [];
const actions = useThreadActions();
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/hooks/useThreadActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -910,14 +910,14 @@ export function useThreadActions() {
(result) => result._tag === "Success",
);

const confirmAndDeleteThread = async (target: ScopedThreadRef) => {
const confirmAndDeleteThread = async (target: ScopedThreadRef, knownTitle?: string) => {
const permissionFailure = threadOperationFailure(target);
if (permissionFailure) return permissionFailure;
const localApi = readLocalApi();
const resolved = resolveThreadTarget(target);

if (confirmThreadDelete && localApi) {
const title = resolved?.thread.title ?? "this thread";
const title = resolved?.thread.title ?? knownTitle ?? "this thread";
const confirmationResult = await settlePromise(() =>
localApi.dialogs.confirm(
[
Expand Down
Loading