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
107 changes: 75 additions & 32 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -752,9 +752,7 @@ export default function Sidebar() {
"This permanently clears conversation history for this thread.",
].join("\n"),
);
if (!confirmed) {
return;
}
if (!confirmed) return;
}
await deleteThread(threadId);
},
Expand Down Expand Up @@ -821,6 +819,37 @@ export default function Sidebar() {
],
);

const removeProject = useCallback(
async (projectId: ProjectId): Promise<void> => {
const api = readNativeApi();
if (!api) return;
const project = projects.find((entry) => entry.id === projectId);
if (!project) return;

try {
const projectDraftThread = getDraftThreadByProjectId(projectId);
if (projectDraftThread) {
clearComposerDraftForThread(projectDraftThread.threadId);
}
clearProjectDraftThreadId(projectId);
await api.orchestration.dispatchCommand({
type: "project.delete",
commandId: newCommandId(),
projectId,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error removing project.";
console.error("Failed to remove project", { projectId, error });
toastManager.add({
type: "error",
title: `Failed to remove "${project.name}"`,
description: message,
});
}
},
[clearComposerDraftForThread, clearProjectDraftThreadId, getDraftThreadByProjectId, projects],
);

const handleThreadClick = useCallback(
(event: MouseEvent, threadId: ThreadId, orderedProjectThreadIds: readonly ThreadId[]) => {
const isMac = isMacPlatform(navigator.platform);
Expand Down Expand Up @@ -874,45 +903,59 @@ export default function Sidebar() {

const projectThreads = threads.filter((thread) => thread.projectId === projectId);
if (projectThreads.length > 0) {
toastManager.add({
const warningToastId = toastManager.add({
type: "warning",
title: "Project is not empty",
description: "Delete all threads in this project before removing it.",
data: {
actionLayout: "stacked-end",
actionVariant: "destructive",
},
actionProps: {
children: "Delete anyway",
onClick: () => {
void (async () => {
toastManager.close(warningToastId);
await new Promise<void>((resolve) => {
window.setTimeout(resolve, 180);
});
const confirmed = await api.dialogs.confirm(
[
`Remove project "${project.name}" and delete its ${projectThreads.length} thread${
projectThreads.length === 1 ? "" : "s"
}?`,
"This will permanently clear conversation history for those threads.",
"This action cannot be undone.",
].join("\n"),
);
if (!confirmed) return;

const deletedThreadIds = new Set<ThreadId>(
projectThreads.map((thread) => thread.id),
);
for (const thread of projectThreads) {
await deleteThread(thread.id, { deletedThreadIds });
}
await removeProject(projectId);
})().catch((error) => {
toastManager.add({
type: "error",
title: `Failed to remove "${project.name}"`,
description:
error instanceof Error ? error.message : "Unknown error removing project.",
});
});
},
},
});
return;
}

const confirmed = await api.dialogs.confirm(`Remove project "${project.name}"?`);
if (!confirmed) return;

try {
const projectDraftThread = getDraftThreadByProjectId(projectId);
if (projectDraftThread) {
clearComposerDraftForThread(projectDraftThread.threadId);
}
clearProjectDraftThreadId(projectId);
await api.orchestration.dispatchCommand({
type: "project.delete",
commandId: newCommandId(),
projectId,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error removing project.";
console.error("Failed to remove project", { projectId, error });
toastManager.add({
type: "error",
title: `Failed to remove "${project.name}"`,
description: message,
});
}
await removeProject(projectId);
},
[
clearComposerDraftForThread,
clearProjectDraftThreadId,
getDraftThreadByProjectId,
projects,
threads,
],
[deleteThread, projects, removeProject, threads],
);

const projectDnDSensors = useSensors(
Expand Down
41 changes: 37 additions & 4 deletions apps/web/src/components/ui/toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ type ThreadToastData = {
threadId?: ThreadId | null;
tooltipStyle?: boolean;
dismissAfterVisibleMs?: number;
actionLayout?: "inline" | "stacked-end";
actionVariant?:
| "default"
| "destructive"
| "destructive-outline"
| "ghost"
| "link"
| "outline"
| "secondary";
};

const toastManager = Toast.createToastManager<ThreadToastData>();
Expand Down Expand Up @@ -196,6 +205,9 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
visibleIndex,
visibleToastLayout.items.length,
);
const stackedActionLayout =
toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end";
const actionVariant = toast.data?.actionVariant ?? "default";

return (
<Toast.Root
Expand Down Expand Up @@ -268,7 +280,10 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
/>
<Toast.Content
className={cn(
"pointer-events-auto flex items-center justify-between gap-1.5 overflow-hidden px-3.5 py-3 text-sm transition-opacity duration-250 data-expanded:opacity-100",
"pointer-events-auto overflow-hidden px-3.5 text-sm transition-opacity duration-250 data-expanded:opacity-100",
stackedActionLayout
? "flex flex-col gap-2 py-2.5"
: "flex items-center justify-between gap-1.5 py-3",
hideCollapsedContent &&
"not-data-expanded:pointer-events-none not-data-expanded:opacity-0",
)}
Expand Down Expand Up @@ -296,7 +311,11 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
</div>
{toast.actionProps && (
<Toast.Action
className={cn(buttonVariants({ size: "xs" }), "shrink-0")}
className={cn(
buttonVariants({ size: "xs", variant: actionVariant }),
"shrink-0",
stackedActionLayout && "self-end",
)}
data-slot="toast-action"
>
{toast.actionProps.children}
Expand Down Expand Up @@ -333,6 +352,9 @@ function AnchoredToasts() {
const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null;
const tooltipStyle = toast.data?.tooltipStyle ?? false;
const positionerProps = toast.positionerProps;
const stackedActionLayout =
toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end";
const actionVariant = toast.data?.actionVariant ?? "default";

if (!positionerProps?.anchor) {
return null;
Expand Down Expand Up @@ -361,7 +383,14 @@ function AnchoredToasts() {
<Toast.Title data-slot="toast-title" />
</Toast.Content>
) : (
<Toast.Content className="pointer-events-auto flex items-center justify-between gap-1.5 overflow-hidden px-3.5 py-3 text-sm">
<Toast.Content
className={cn(
"pointer-events-auto overflow-hidden px-3.5 text-sm",
stackedActionLayout
? "flex flex-col gap-2 py-2.5"
: "flex items-center justify-between gap-1.5 py-3",
)}
>
<div className="flex min-w-0 flex-1 gap-2">
{Icon && (
<div
Expand All @@ -385,7 +414,11 @@ function AnchoredToasts() {
</div>
{toast.actionProps && (
<Toast.Action
className={cn(buttonVariants({ size: "xs" }), "shrink-0")}
className={cn(
buttonVariants({ size: "xs", variant: actionVariant }),
"shrink-0",
stackedActionLayout && "self-end",
)}
data-slot="toast-action"
>
{toast.actionProps.children}
Expand Down