import {
ArchiveIcon,
ArchiveX,
ChevronDownIcon,
InfoIcon,
LoaderIcon,
PlusIcon,
RefreshCwIcon,
Undo2Icon,
XIcon,
} from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
PROVIDER_DISPLAY_NAMES,
type ProviderKind,
type ServerProvider,
type ServerProviderModel,
ThreadId,
} from "@t3tools/contracts";
import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
import { normalizeModelSlug } from "@t3tools/shared/model";
import { Equal } from "effect";
import { APP_VERSION } from "../../branding";
import {
canCheckForUpdate,
getDesktopUpdateButtonTooltip,
getDesktopUpdateInstallConfirmationMessage,
isDesktopUpdateButtonDisabled,
resolveDesktopUpdateButtonAction,
} from "../../components/desktopUpdate.logic";
import { ProviderModelPicker } from "../chat/ProviderModelPicker";
import { TraitsPicker } from "../chat/TraitsPicker";
import { resolveAndPersistPreferredEditor } from "../../editorPreferences";
import { isElectron } from "../../env";
import { useTheme } from "../../hooks/useTheme";
import { useSettings, useUpdateSettings } from "../../hooks/useSettings";
import { useThreadActions } from "../../hooks/useThreadActions";
import {
setDesktopUpdateStateQueryData,
useDesktopUpdateState,
} from "../../lib/desktopUpdateReactQuery";
import {
MAX_CUSTOM_MODEL_LENGTH,
getCustomModelOptionsByProvider,
resolveAppModelSelectionState,
} from "../../modelSelection";
import { ensureNativeApi, readNativeApi } from "../../nativeApi";
import { useStore } from "../../store";
import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat";
import { cn } from "../../lib/utils";
import { Button } from "../ui/button";
import { Collapsible, CollapsibleContent } from "../ui/collapsible";
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
import { Input } from "../ui/input";
import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
import { Switch } from "../ui/switch";
import { toastManager } from "../ui/toast";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { ProjectFavicon } from "../ProjectFavicon";
import {
useServerAvailableEditors,
useServerKeybindingsConfigPath,
useServerProviders,
} from "../../rpc/serverState";
const THEME_OPTIONS = [
{
value: "system",
label: "System",
},
{
value: "light",
label: "Light",
},
{
value: "dark",
label: "Dark",
},
] as const;
const TIMESTAMP_FORMAT_LABELS = {
locale: "System default",
"12-hour": "12-hour",
"24-hour": "24-hour",
} as const;
type InstallProviderSettings = {
provider: ProviderKind;
title: string;
binaryPlaceholder: string;
binaryDescription: ReactNode;
homePathKey?: "codexHomePath";
homePlaceholder?: string;
homeDescription?: ReactNode;
};
const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [
{
provider: "codex",
title: "Codex",
binaryPlaceholder: "Codex binary path",
binaryDescription: "Path to the Codex binary",
homePathKey: "codexHomePath",
homePlaceholder: "CODEX_HOME",
homeDescription: "Optional custom Codex home and config directory.",
},
{
provider: "claudeAgent",
title: "Claude",
binaryPlaceholder: "Claude binary path",
binaryDescription: "Path to the Claude binary",
},
] as const;
const PROVIDER_STATUS_STYLES = {
disabled: {
dot: "bg-amber-400",
},
error: {
dot: "bg-destructive",
},
ready: {
dot: "bg-success",
},
warning: {
dot: "bg-warning",
},
} as const;
function getProviderSummary(provider: ServerProvider | undefined) {
if (!provider) {
return {
headline: "Checking provider status",
detail: "Waiting for the server to report installation and authentication details.",
};
}
if (!provider.enabled) {
return {
headline: "Disabled",
detail:
provider.message ?? "This provider is installed but disabled for new sessions in T3 Code.",
};
}
if (!provider.installed) {
return {
headline: "Not found",
detail: provider.message ?? "CLI not detected on PATH.",
};
}
if (provider.auth.status === "authenticated") {
const authLabel = provider.auth.label ?? provider.auth.type;
return {
headline: authLabel ? `Authenticated · ${authLabel}` : "Authenticated",
detail: provider.message ?? null,
};
}
if (provider.auth.status === "unauthenticated") {
return {
headline: "Not authenticated",
detail: provider.message ?? null,
};
}
if (provider.status === "warning") {
return {
headline: "Needs attention",
detail:
provider.message ?? "The provider is installed, but the server could not fully verify it.",
};
}
if (provider.status === "error") {
return {
headline: "Unavailable",
detail: provider.message ?? "The provider failed its startup checks.",
};
}
return {
headline: "Available",
detail: provider.message ?? "Installed and ready, but authentication could not be verified.",
};
}
function getProviderVersionLabel(version: string | null | undefined) {
if (!version) return null;
return version.startsWith("v") ? version : `v${version}`;
}
function useRelativeTimeTick(intervalMs = 1_000) {
const [tick, setTick] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setTick(Date.now()), intervalMs);
return () => clearInterval(id);
}, [intervalMs]);
return tick;
}
function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) {
useRelativeTimeTick();
const lastCheckedRelative = lastCheckedAt ? formatRelativeTime(lastCheckedAt) : null;
if (!lastCheckedRelative) {
return null;
}
return (
{lastCheckedRelative.suffix ? (
<>
Checked {lastCheckedRelative.value}{" "}
{lastCheckedRelative.suffix}
>
) : (
<>Checked {lastCheckedRelative.value}>
)}
);
}
function SettingsSection({
title,
icon,
headerAction,
children,
}: {
title: string;
icon?: ReactNode;
headerAction?: ReactNode;
children: ReactNode;
}) {
return (
{icon}
{title}
{headerAction}
{children}
);
}
function SettingsRow({
title,
description,
status,
resetAction,
control,
children,
}: {
title: ReactNode;
description: string;
status?: ReactNode;
resetAction?: ReactNode;
control?: ReactNode;
children?: ReactNode;
}) {
return (
{title}
{resetAction}
{description}
{status ?
{status}
: null}
{control ? (
{control}
) : null}
{children}
);
}
function SettingResetButton({ label, onClick }: { label: string; onClick: () => void }) {
return (
{
event.stopPropagation();
onClick();
}}
>
}
/>
Reset to default
);
}
function SettingsPageContainer({ children }: { children: ReactNode }) {
return (
);
}
function AboutVersionTitle() {
return (
Version
{APP_VERSION}
);
}
function AboutVersionSection() {
const queryClient = useQueryClient();
const updateStateQuery = useDesktopUpdateState();
const updateState = updateStateQuery.data ?? null;
const handleButtonClick = useCallback(() => {
const bridge = window.desktopBridge;
if (!bridge) return;
const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none";
if (action === "download") {
void bridge
.downloadUpdate()
.then((result) => {
setDesktopUpdateStateQueryData(queryClient, result.state);
})
.catch((error: unknown) => {
toastManager.add({
type: "error",
title: "Could not download update",
description: error instanceof Error ? error.message : "Download failed.",
});
});
return;
}
if (action === "install") {
const confirmed = window.confirm(
getDesktopUpdateInstallConfirmationMessage(
updateState ?? { availableVersion: null, downloadedVersion: null },
),
);
if (!confirmed) return;
void bridge
.installUpdate()
.then((result) => {
setDesktopUpdateStateQueryData(queryClient, result.state);
})
.catch((error: unknown) => {
toastManager.add({
type: "error",
title: "Could not install update",
description: error instanceof Error ? error.message : "Install failed.",
});
});
return;
}
if (typeof bridge.checkForUpdate !== "function") return;
void bridge
.checkForUpdate()
.then((result) => {
setDesktopUpdateStateQueryData(queryClient, result.state);
if (!result.checked) {
toastManager.add({
type: "error",
title: "Could not check for updates",
description:
result.state.message ?? "Automatic updates are not available in this build.",
});
}
})
.catch((error: unknown) => {
toastManager.add({
type: "error",
title: "Could not check for updates",
description: error instanceof Error ? error.message : "Update check failed.",
});
});
}, [queryClient, updateState]);
const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none";
const buttonTooltip = updateState ? getDesktopUpdateButtonTooltip(updateState) : null;
const buttonDisabled =
action === "none"
? !canCheckForUpdate(updateState)
: isDesktopUpdateButtonDisabled(updateState);
const actionLabel: Record = { download: "Download", install: "Install" };
const statusLabel: Record = {
checking: "Checking…",
downloading: "Downloading…",
"up-to-date": "Up to Date",
};
const buttonLabel =
actionLabel[action] ?? statusLabel[updateState?.status ?? ""] ?? "Check for Updates";
const description =
action === "download" || action === "install"
? "Update available."
: "Current version of the application.";
return (
}
description={description}
control={
{buttonLabel}
}
/>
{buttonTooltip ? {buttonTooltip} : null}
}
/>
);
}
export function useSettingsRestore(onRestored?: () => void) {
const { theme, setTheme } = useTheme();
const settings = useSettings();
const { resetSettings } = useUpdateSettings();
const isGitWritingModelDirty = !Equal.equals(
settings.textGenerationModelSelection ?? null,
DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
);
const areProviderSettingsDirty = PROVIDER_SETTINGS.some((providerSettings) => {
const currentSettings = settings.providers[providerSettings.provider];
const defaultSettings = DEFAULT_UNIFIED_SETTINGS.providers[providerSettings.provider];
return !Equal.equals(currentSettings, defaultSettings);
});
const changedSettingLabels = useMemo(
() => [
...(theme !== "system" ? ["Theme"] : []),
...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat
? ["Time format"]
: []),
...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap
? ["Diff line wrapping"]
: []),
...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming
? ["Assistant output"]
: []),
...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode
? ["New thread mode"]
: []),
...(settings.confirmThreadArchive !== DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive
? ["Archive confirmation"]
: []),
...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete
? ["Delete confirmation"]
: []),
...(isGitWritingModelDirty ? ["Git writing model"] : []),
...(areProviderSettingsDirty ? ["Providers"] : []),
],
[
areProviderSettingsDirty,
isGitWritingModelDirty,
settings.confirmThreadArchive,
settings.confirmThreadDelete,
settings.defaultThreadEnvMode,
settings.diffWordWrap,
settings.enableAssistantStreaming,
settings.timestampFormat,
theme,
],
);
const restoreDefaults = useCallback(async () => {
if (changedSettingLabels.length === 0) return;
const api = readNativeApi();
const confirmed = await (api ?? ensureNativeApi()).dialogs.confirm(
["Restore default settings?", `This will reset: ${changedSettingLabels.join(", ")}.`].join(
"\n",
),
);
if (!confirmed) return;
setTheme("system");
resetSettings();
onRestored?.();
}, [changedSettingLabels, onRestored, resetSettings, setTheme]);
return {
changedSettingLabels,
restoreDefaults,
};
}
export function GeneralSettingsPanel() {
const { theme, setTheme } = useTheme();
const settings = useSettings();
const { updateSettings } = useUpdateSettings();
const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);
const [openKeybindingsError, setOpenKeybindingsError] = useState(null);
const [openProviderDetails, setOpenProviderDetails] = useState>({
codex: Boolean(
settings.providers.codex.binaryPath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.binaryPath ||
settings.providers.codex.homePath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.homePath ||
settings.providers.codex.customModels.length > 0,
),
claudeAgent: Boolean(
settings.providers.claudeAgent.binaryPath !==
DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent.binaryPath ||
settings.providers.claudeAgent.customModels.length > 0,
),
});
const [customModelInputByProvider, setCustomModelInputByProvider] = useState<
Record
>({
codex: "",
claudeAgent: "",
});
const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState<
Partial>
>({});
const [isRefreshingProviders, setIsRefreshingProviders] = useState(false);
const refreshingRef = useRef(false);
const modelListRefs = useRef>>({});
const refreshProviders = useCallback(() => {
if (refreshingRef.current) return;
refreshingRef.current = true;
setIsRefreshingProviders(true);
void ensureNativeApi()
.server.refreshProviders()
.catch((error: unknown) => {
console.warn("Failed to refresh providers", error);
})
.finally(() => {
refreshingRef.current = false;
setIsRefreshingProviders(false);
});
}, []);
const keybindingsConfigPath = useServerKeybindingsConfigPath();
const availableEditors = useServerAvailableEditors();
const serverProviders = useServerProviders();
const codexHomePath = settings.providers.codex.homePath;
const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders);
const textGenProvider = textGenerationModelSelection.provider;
const textGenModel = textGenerationModelSelection.model;
const textGenModelOptions = textGenerationModelSelection.options;
const gitModelOptionsByProvider = getCustomModelOptionsByProvider(
settings,
serverProviders,
textGenProvider,
textGenModel,
);
const isGitWritingModelDirty = !Equal.equals(
settings.textGenerationModelSelection ?? null,
DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
);
const openKeybindingsFile = useCallback(() => {
if (!keybindingsConfigPath) return;
setOpenKeybindingsError(null);
setIsOpeningKeybindings(true);
const editor = resolveAndPersistPreferredEditor(availableEditors ?? []);
if (!editor) {
setOpenKeybindingsError("No available editors found.");
setIsOpeningKeybindings(false);
return;
}
void ensureNativeApi()
.shell.openInEditor(keybindingsConfigPath, editor)
.catch((error) => {
setOpenKeybindingsError(
error instanceof Error ? error.message : "Unable to open keybindings file.",
);
})
.finally(() => {
setIsOpeningKeybindings(false);
});
}, [availableEditors, keybindingsConfigPath]);
const addCustomModel = useCallback(
(provider: ProviderKind) => {
const customModelInput = customModelInputByProvider[provider];
const customModels = settings.providers[provider].customModels;
const normalized = normalizeModelSlug(customModelInput, provider);
if (!normalized) {
setCustomModelErrorByProvider((existing) => ({
...existing,
[provider]: "Enter a model slug.",
}));
return;
}
if (
serverProviders
.find((candidate) => candidate.provider === provider)
?.models.some((option) => !option.isCustom && option.slug === normalized)
) {
setCustomModelErrorByProvider((existing) => ({
...existing,
[provider]: "That model is already built in.",
}));
return;
}
if (normalized.length > MAX_CUSTOM_MODEL_LENGTH) {
setCustomModelErrorByProvider((existing) => ({
...existing,
[provider]: `Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`,
}));
return;
}
if (customModels.includes(normalized)) {
setCustomModelErrorByProvider((existing) => ({
...existing,
[provider]: "That custom model is already saved.",
}));
return;
}
updateSettings({
providers: {
...settings.providers,
[provider]: {
...settings.providers[provider],
customModels: [...customModels, normalized],
},
},
});
setCustomModelInputByProvider((existing) => ({
...existing,
[provider]: "",
}));
setCustomModelErrorByProvider((existing) => ({
...existing,
[provider]: null,
}));
const el = modelListRefs.current[provider];
if (!el) return;
const scrollToEnd = () => el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
requestAnimationFrame(scrollToEnd);
const observer = new MutationObserver(() => {
scrollToEnd();
observer.disconnect();
});
observer.observe(el, { childList: true, subtree: true });
setTimeout(() => observer.disconnect(), 2_000);
},
[customModelInputByProvider, serverProviders, settings, updateSettings],
);
const removeCustomModel = useCallback(
(provider: ProviderKind, slug: string) => {
updateSettings({
providers: {
...settings.providers,
[provider]: {
...settings.providers[provider],
customModels: settings.providers[provider].customModels.filter(
(model) => model !== slug,
),
},
},
});
setCustomModelErrorByProvider((existing) => ({
...existing,
[provider]: null,
}));
},
[settings, updateSettings],
);
const providerCards = PROVIDER_SETTINGS.map((providerSettings) => {
const liveProvider = serverProviders.find(
(candidate) => candidate.provider === providerSettings.provider,
);
const providerConfig = settings.providers[providerSettings.provider];
const defaultProviderConfig = DEFAULT_UNIFIED_SETTINGS.providers[providerSettings.provider];
const statusKey = liveProvider?.status ?? (providerConfig.enabled ? "warning" : "disabled");
const summary = getProviderSummary(liveProvider);
const models: ReadonlyArray =
liveProvider?.models ??
providerConfig.customModels.map((slug) => ({
slug,
name: slug,
isCustom: true,
capabilities: null,
}));
return {
provider: providerSettings.provider,
title: providerSettings.title,
binaryPlaceholder: providerSettings.binaryPlaceholder,
binaryDescription: providerSettings.binaryDescription,
homePathKey: providerSettings.homePathKey,
homePlaceholder: providerSettings.homePlaceholder,
homeDescription: providerSettings.homeDescription,
binaryPathValue: providerConfig.binaryPath,
isDirty: !Equal.equals(providerConfig, defaultProviderConfig),
liveProvider,
models,
providerConfig,
statusStyle: PROVIDER_STATUS_STYLES[statusKey],
summary,
versionLabel: getProviderVersionLabel(liveProvider?.version),
};
});
const lastCheckedAt =
serverProviders.length > 0
? serverProviders.reduce(
(latest, provider) => (provider.checkedAt > latest ? provider.checkedAt : latest),
serverProviders[0]!.checkedAt,
)
: null;
return (
setTheme("system")} />
) : null
}
control={
}
/>
updateSettings({
timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
})
}
/>
) : null
}
control={
}
/>
updateSettings({
diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap,
})
}
/>
) : null
}
control={
updateSettings({ diffWordWrap: Boolean(checked) })}
aria-label="Wrap diff lines by default"
/>
}
/>
updateSettings({
enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming,
})
}
/>
) : null
}
control={
updateSettings({ enableAssistantStreaming: Boolean(checked) })
}
aria-label="Stream assistant messages"
/>
}
/>
updateSettings({
defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode,
})
}
/>
) : null
}
control={
}
/>
updateSettings({
confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive,
})
}
/>
) : null
}
control={
updateSettings({ confirmThreadArchive: Boolean(checked) })
}
aria-label="Confirm thread archiving"
/>
}
/>
updateSettings({
confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete,
})
}
/>
) : null
}
control={
updateSettings({ confirmThreadDelete: Boolean(checked) })
}
aria-label="Confirm thread deletion"
/>
}
/>
updateSettings({
textGenerationModelSelection:
DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,
})
}
/>
) : null
}
control={
{
updateSettings({
textGenerationModelSelection: resolveAppModelSelectionState(
{
...settings,
textGenerationModelSelection: { provider, model },
},
serverProviders,
),
});
}}
/>
provider.provider === textGenProvider)
?.models ?? []
}
model={textGenModel}
prompt=""
onPromptChange={() => {}}
modelOptions={textGenModelOptions}
allowPromptInjectedEffort={false}
triggerVariant="outline"
triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
onModelOptionsChange={(nextOptions) => {
updateSettings({
textGenerationModelSelection: resolveAppModelSelectionState(
{
...settings,
textGenerationModelSelection: {
provider: textGenProvider,
model: textGenModel,
...(nextOptions ? { options: nextOptions } : {}),
},
},
serverProviders,
),
});
}}
/>
}
/>
void refreshProviders()}
aria-label="Refresh provider status"
>
{isRefreshingProviders ? (
) : (
)}
}
/>
Refresh provider status
}
>
{providerCards.map((providerCard) => {
const customModelInput = customModelInputByProvider[providerCard.provider];
const customModelError = customModelErrorByProvider[providerCard.provider] ?? null;
const providerDisplayName =
PROVIDER_DISPLAY_NAMES[providerCard.provider] ?? providerCard.title;
return (
{providerDisplayName}
{providerCard.versionLabel ? (
{providerCard.versionLabel}
) : null}
{providerCard.isDirty ? (
{
updateSettings({
providers: {
...settings.providers,
[providerCard.provider]:
DEFAULT_UNIFIED_SETTINGS.providers[providerCard.provider],
},
});
setCustomModelErrorByProvider((existing) => ({
...existing,
[providerCard.provider]: null,
}));
}}
/>
) : null}
{providerCard.summary.headline}
{providerCard.summary.detail ? ` - ${providerCard.summary.detail}` : null}
{
const isDisabling = !checked;
const shouldClearModelSelection =
isDisabling && textGenProvider === providerCard.provider;
updateSettings({
providers: {
...settings.providers,
[providerCard.provider]: {
...settings.providers[providerCard.provider],
enabled: Boolean(checked),
},
},
...(shouldClearModelSelection
? {
textGenerationModelSelection:
DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,
}
: {}),
});
}}
aria-label={`Enable ${providerDisplayName}`}
/>
setOpenProviderDetails((existing) => ({
...existing,
[providerCard.provider]: open,
}))
}
>
{providerCard.homePathKey ? (
) : null}
Models
{providerCard.models.length} model
{providerCard.models.length === 1 ? "" : "s"} available.
{
modelListRefs.current[providerCard.provider] = el;
}}
className="mt-2 max-h-40 overflow-y-auto pb-1"
>
{providerCard.models.map((model) => {
const caps = model.capabilities;
const capLabels: string[] = [];
if (caps?.supportsFastMode) capLabels.push("Fast mode");
if (caps?.supportsThinkingToggle) capLabels.push("Thinking");
if (
caps?.reasoningEffortLevels &&
caps.reasoningEffortLevels.length > 0
) {
capLabels.push("Reasoning");
}
const hasDetails = capLabels.length > 0 || model.name !== model.slug;
return (
{model.name}
{hasDetails ? (
}
>
{model.slug}
{capLabels.length > 0 ? (
{capLabels.map((label) => (
{label}
))}
) : null}
) : null}
{model.isCustom ? (
custom
) : null}
);
})}
{customModelError ? (
{customModelError}
) : null}
);
})}
{keybindingsConfigPath ?? "Resolving keybindings path..."}
{openKeybindingsError ? (
{openKeybindingsError}
) : (
Opens in your preferred editor.
)}
>
}
control={
}
/>
{isElectron ? (
) : (
}
description="Current version of the application."
/>
)}
);
}
export function ArchivedThreadsPanel() {
const projects = useStore((store) => store.projects);
const threads = useStore((store) => store.threads);
const { unarchiveThread, confirmAndDeleteThread } = useThreadActions();
const archivedGroups = useMemo(() => {
const projectById = new Map(projects.map((project) => [project.id, project] as const));
return [...projectById.values()]
.map((project) => ({
project,
threads: threads
.filter((thread) => thread.projectId === project.id && thread.archivedAt !== null)
.toSorted((left, right) => {
const leftKey = left.archivedAt ?? left.createdAt;
const rightKey = right.archivedAt ?? right.createdAt;
return rightKey.localeCompare(leftKey) || right.id.localeCompare(left.id);
}),
}))
.filter((group) => group.threads.length > 0);
}, [projects, threads]);
const handleArchivedThreadContextMenu = useCallback(
async (threadId: ThreadId, position: { x: number; y: number }) => {
const api = readNativeApi();
if (!api) return;
const clicked = await api.contextMenu.show(
[
{ id: "unarchive", label: "Unarchive" },
{ id: "delete", label: "Delete", destructive: true },
],
position,
);
if (clicked === "unarchive") {
try {
await unarchiveThread(threadId);
} catch (error) {
toastManager.add({
type: "error",
title: "Failed to unarchive thread",
description: error instanceof Error ? error.message : "An error occurred.",
});
}
return;
}
if (clicked === "delete") {
await confirmAndDeleteThread(threadId);
}
},
[confirmAndDeleteThread, unarchiveThread],
);
return (
{archivedGroups.length === 0 ? (
No archived threads
Archived threads will appear here.
) : (
archivedGroups.map(({ project, threads: projectThreads }) => (
}
>
{projectThreads.map((thread) => (
{
event.preventDefault();
void handleArchivedThreadContextMenu(thread.id, {
x: event.clientX,
y: event.clientY,
});
}}
>
{thread.title}
Archived {formatRelativeTimeLabel(thread.archivedAt ?? thread.createdAt)}
{" \u00b7 Created "}
{formatRelativeTimeLabel(thread.createdAt)}
))}
))
)}
);
}