Skip to content

Commit 983c909

Browse files
Let a conversation be deleted, and its thread with it (#197)
* feat: let a conversation be deleted, and its Intelligence thread with it Nothing removed a channel, so the roster only ever grew (#196). Adds DELETE /api/channels/:channelId: the channel row, its memberships, agent links, and thread mapping go through the FK cascades that already existed for them, and the deployment asks Intelligence to permanently delete the thread itself. The local delete commits first. A rejected or unreachable upstream thread delete is non-fatal and leaves the channel gone from the roster either way, with an audit row (channel.deleted) naming the thread and whether Intelligence actually forgot it - a channel gone locally with an orphaned thread is a smaller, more honest failure than one still sitting in the roster with its history silently wiped. Removal fans out over the existing channel_activity NOTIFY topic so other open tabs see it live. The sidebar row gets an options menu with a two-button confirm dialog (new alert-dialog.tsx, wrapping @base-ui/react/alert-dialog). * Keep the roster memoized, attribute the row, and stop a failed delete vanishing The confirm button was AlertDialogAction, which renders the primitive's Close: it shut the dialog the instant it was pressed, before the request it started was answered. A delete that failed then reported nothing at all, leaving the conversation in the roster with no explanation, and "Deleting…" could never appear. A plain button waits for the answer, closes on success, and shows the server's message otherwise. DELETE answers 200 with historyLeftBehind rather than a bare 204. The thread deletion is the half that can fail on its own, and 204 said the whole act succeeded whichever way it went, so a screen had no way to avoid claiming a message history was gone while it was still on the platform. The roster row lost its memo for a stated reason that does not hold: memo compares props and says nothing about a hook, and use-channel-events preserves row identity precisely so rows do not re-render. The audit row dropped the actor in single-user mode, believing audit_events.actor_user_id has a foreign key into users. It has none, and initializeDevActorUser writes that row at start-up anyway. Single-user is the mode .env.example ships switched on, so that was the default row, and it recorded that a conversation was deleted but not by whom. Also move a tab that is looking at the channel another tab just deleted, which was left on a route that no longer resolves, and cover the audit row, which nothing tested. --------- Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
1 parent 574b4cd commit 983c909

10 files changed

Lines changed: 833 additions & 36 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,29 @@ one they are, so those match too.
235235
No configuration changes and nothing is stored differently; a deployment that was already on the
236236
light theme sees no difference at all.
237237

238+
### A conversation can be deleted
239+
240+
Nothing removed a channel. Starting one was the only lever the product gave a person, and every
241+
conversation with every coworker sat in the roster forever, growing on every message the way
242+
`DEFAULT_CHANNEL_PAGE`'s own note already described: a page that was instant in a demo returns
243+
thousands of rows for anybody who has actually been using the product a while, one that never shrinks
244+
again.
245+
246+
Deleting a channel now removes it for good. The channel row goes, and its memberships, its linked
247+
coworkers, and its Intelligence thread mapping go with it through the same foreign-key cascades that
248+
already existed for them — no migration needed, only a query that finally uses them. The deployment
249+
also asks Intelligence to permanently delete the thread itself, so the message history is not just
250+
unlisted, it is gone from the platform too.
251+
252+
A thread the platform refuses to delete does not hold the channel hostage. The local removal already
253+
committed by the time that call runs, so a rejected or unreachable upstream delete leaves the channel
254+
gone from the roster regardless, with an audit row (`channel.deleted`) naming the thread and whether
255+
Intelligence actually forgot it. A channel that is gone locally with an orphaned thread still on the
256+
platform is a smaller, more honest failure than a channel sitting in the roster with its history
257+
silently wiped out from under it, and the audit trail is where an administrator finds the one that
258+
did not clean up completely. `DELETE /api/channels/:channelId` answers with `historyLeftBehind`, so a
259+
screen showing the outcome does not have to guess which of the two happened.
260+
238261
## 0.0.4
239262

240263
### A click citing a ref this deployment cannot resolve is refused
Lines changed: 149 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,34 @@
1-
import { Link } from "@tanstack/react-router";
2-
import { memo } from "react";
1+
import { IconDots } from "@tabler/icons-react";
2+
import { useMutation, useQueryClient } from "@tanstack/react-query";
3+
import { Link, useNavigate, useParams } from "@tanstack/react-router";
4+
import { memo, useState } from "react";
5+
import { deleteChannelMutationOptions } from "@/lib/channels/mutations";
36
import { ChannelAvatar } from "../channels/avatar";
7+
import {
8+
AlertDialog,
9+
AlertDialogCancel,
10+
AlertDialogContent,
11+
AlertDialogDescription,
12+
AlertDialogFooter,
13+
AlertDialogHeader,
14+
AlertDialogTitle,
15+
} from "../ui/alert-dialog";
16+
import { Button } from "../ui/button";
17+
import {
18+
DropdownMenu,
19+
DropdownMenuContent,
20+
DropdownMenuGroup,
21+
DropdownMenuItem,
22+
DropdownMenuTrigger,
23+
} from "../ui/dropdown-menu";
424

525
/**
626
* Memoized roster row. `use-channel-events` preserves unchanged row identity, and
727
* `content-visibility` keeps off-screen rows cheap without virtualization.
28+
*
29+
* State inside a row is no reason to drop the memo: `memo` compares the props it is handed and has
30+
* nothing to say about a hook. Dropping it re-renders every row in the roster whenever the sidebar
31+
* renders, which is the cost the identity-preserving patch in `use-channel-events` exists to avoid.
832
*/
933
export const Channel = memo(function Channel({
1034
channelId,
@@ -19,32 +43,132 @@ export const Channel = memo(function Channel({
1943
lastMessage?: string;
2044
lastMessageAt?: string;
2145
}) {
46+
const queryClient = useQueryClient();
47+
const navigate = useNavigate();
48+
// `strict: false`: this row renders in the sidebar on every screen, not only while its own
49+
// channel is open, so there may be no `channelId` route param to read at all.
50+
const { channelId: openChannelId } = useParams({ strict: false });
51+
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
52+
const deleteChannel = useMutation(deleteChannelMutationOptions(queryClient));
53+
54+
const handleDelete = async () => {
55+
// Navigate away first: the row this menu lives on unmounts the moment the list invalidates,
56+
// and a screen still pointed at a channel id that no longer resolves is worse than a screen
57+
// that moved on a beat early.
58+
if (openChannelId === channelId) {
59+
await navigate({ to: "/" });
60+
}
61+
try {
62+
await deleteChannel.mutateAsync(channelId);
63+
/*
64+
* Closed on success rather than left to the unmount.
65+
*
66+
* The row does go away when the roster invalidates, taking this dialog with it, but that is a
67+
* side effect of a cache write and not something this component controls.
68+
*/
69+
setDeleteDialogOpen(false);
70+
} catch {
71+
/*
72+
* Left open, deliberately. A delete that failed leaves the row exactly where it was, so
73+
* closing would return the person to a roster that still lists the conversation they just
74+
* asked to be rid of, with nothing anywhere saying why. The message is rendered below;
75+
* `mutateAsync` rejects rather than swallowing, which is why this catch exists at all.
76+
*/
77+
}
78+
};
79+
2280
return (
23-
<Link
24-
to="/channel/$channelId"
25-
params={{ channelId }}
26-
type="button"
27-
className="flex flex-row py-2 px-2 gap-2 items-center w-full hover:bg-foreground/5 rounded-lg [contain-intrinsic-size:auto_3.25rem] [content-visibility:auto]"
28-
activeProps={{
29-
className: "bg-foreground/5",
30-
}}
31-
>
32-
<div className="">
33-
<ChannelAvatar participantIds={participantIds} size={32} />
34-
</div>
35-
<div className="flex-col min-w-0 flex-1">
36-
<div className="flex flex-row items-center justify-between gap-2">
37-
<span className="text-[14px] tracking-[-1%] truncate">{name}</span>
38-
<div className="text-[12px] text-muted-foreground/70">
39-
{lastMessageAt}
40-
</div>
81+
<div className="group/channel relative">
82+
<Link
83+
to="/channel/$channelId"
84+
params={{ channelId }}
85+
type="button"
86+
className="flex flex-row py-2 px-2 gap-2 items-center w-full hover:bg-foreground/5 rounded-lg [contain-intrinsic-size:auto_3.25rem] [content-visibility:auto]"
87+
activeProps={{
88+
className: "bg-foreground/5",
89+
}}
90+
>
91+
<div className="">
92+
<ChannelAvatar participantIds={participantIds} size={32} />
4193
</div>
42-
<div className="mt-px flex h-4 items-center gap-1.5">
43-
<span className="min-w-0 flex-1 truncate text-[12px] leading-4 text-muted-foreground">
44-
{lastMessage}
45-
</span>
94+
<div className="flex-col min-w-0 flex-1">
95+
<div className="flex flex-row items-center justify-between gap-2">
96+
<span className="text-[14px] tracking-[-1%] truncate">{name}</span>
97+
<div className="group-hover/channel:invisible text-[12px] text-muted-foreground/70">
98+
{lastMessageAt}
99+
</div>
100+
</div>
101+
<div className="mt-px flex h-4 items-center gap-1.5">
102+
<span className="min-w-0 flex-1 truncate text-[12px] leading-4 text-muted-foreground">
103+
{lastMessage}
104+
</span>
105+
</div>
46106
</div>
107+
</Link>
108+
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/channel:opacity-100 focus-within:opacity-100">
109+
<DropdownMenu>
110+
<DropdownMenuTrigger
111+
render={
112+
<Button
113+
variant="ghost"
114+
size="icon-sm"
115+
aria-label={`Options for ${name}`}
116+
>
117+
<IconDots />
118+
</Button>
119+
}
120+
/>
121+
<DropdownMenuContent align="end">
122+
<DropdownMenuGroup>
123+
{/* Only opens the dialog below; the menu closes on click, too early to confirm anything. */}
124+
<DropdownMenuItem
125+
onClick={() => setDeleteDialogOpen(true)}
126+
variant="destructive"
127+
>
128+
Delete
129+
</DropdownMenuItem>
130+
</DropdownMenuGroup>
131+
</DropdownMenuContent>
132+
</DropdownMenu>
47133
</div>
48-
</Link>
134+
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
135+
<AlertDialogContent>
136+
<AlertDialogHeader>
137+
<AlertDialogTitle>Delete this conversation?</AlertDialogTitle>
138+
<AlertDialogDescription>
139+
This deletes your conversation with{" "}
140+
<span className="font-medium text-foreground">{name}</span>,
141+
including its message history. This cannot be undone.
142+
</AlertDialogDescription>
143+
</AlertDialogHeader>
144+
{deleteChannel.isError ? (
145+
<p className="text-destructive text-sm" role="alert">
146+
{deleteChannel.error.message}
147+
</p>
148+
) : null}
149+
<AlertDialogFooter>
150+
<AlertDialogCancel disabled={deleteChannel.isPending}>
151+
Cancel
152+
</AlertDialogCancel>
153+
{/*
154+
* A plain button, not `AlertDialogAction`.
155+
*
156+
* That one renders the primitive's `Close`, so it shuts the dialog the instant it is
157+
* pressed, before the request it starts has been answered. Nothing then reports a
158+
* delete that failed: the dialog is gone, the conversation is still in the roster, and
159+
* the person is left to work out for themselves that the thing they asked for did not
160+
* happen. It also means "Deleting…" below could never appear.
161+
*/}
162+
<Button
163+
disabled={deleteChannel.isPending}
164+
onClick={() => void handleDelete()}
165+
variant="destructive"
166+
>
167+
{deleteChannel.isPending ? "Deleting…" : "Delete"}
168+
</Button>
169+
</AlertDialogFooter>
170+
</AlertDialogContent>
171+
</AlertDialog>
172+
</div>
49173
);
50174
});
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
2+
import type * as React from "react";
3+
4+
import { Button } from "@/components/ui/button";
5+
import { cn } from "@/lib/utils";
6+
7+
/**
8+
* A modal for an action a click cannot undo. Built on `AlertDialogRoot` rather than `Dialog`'s
9+
* `DialogRoot`: it carries `role="alertdialog"` and is announced immediately, and has no corner
10+
* close button — the only way out is one of the footer's own buttons.
11+
*/
12+
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
13+
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
14+
}
15+
16+
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
17+
return (
18+
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
19+
);
20+
}
21+
22+
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
23+
return (
24+
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
25+
);
26+
}
27+
28+
function AlertDialogOverlay({
29+
className,
30+
...props
31+
}: AlertDialogPrimitive.Backdrop.Props) {
32+
return (
33+
<AlertDialogPrimitive.Backdrop
34+
data-slot="alert-dialog-overlay"
35+
className={cn(
36+
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
37+
className,
38+
)}
39+
{...props}
40+
/>
41+
);
42+
}
43+
44+
function AlertDialogContent({
45+
className,
46+
...props
47+
}: AlertDialogPrimitive.Popup.Props) {
48+
return (
49+
<AlertDialogPortal>
50+
<AlertDialogOverlay />
51+
<AlertDialogPrimitive.Popup
52+
data-slot="alert-dialog-content"
53+
className={cn(
54+
"-translate-x-1/2 -translate-y-1/2 fixed top-1/2 left-1/2 z-50 flex w-[calc(100%-2rem)] max-w-md flex-col gap-4 rounded-xl border border-border bg-popover p-5 text-sm text-popover-foreground shadow-lg transition duration-150 ease-out data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-starting-style:scale-[0.98] data-starting-style:opacity-0",
55+
className,
56+
)}
57+
{...props}
58+
/>
59+
</AlertDialogPortal>
60+
);
61+
}
62+
63+
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
64+
return (
65+
<div
66+
data-slot="alert-dialog-header"
67+
className={cn("flex flex-col gap-1", className)}
68+
{...props}
69+
/>
70+
);
71+
}
72+
73+
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
74+
return (
75+
<div
76+
data-slot="alert-dialog-footer"
77+
className={cn("flex flex-row items-center justify-end gap-2", className)}
78+
{...props}
79+
/>
80+
);
81+
}
82+
83+
function AlertDialogTitle({
84+
className,
85+
...props
86+
}: AlertDialogPrimitive.Title.Props) {
87+
return (
88+
<AlertDialogPrimitive.Title
89+
data-slot="alert-dialog-title"
90+
className={cn("font-heading font-medium text-base text-foreground", className)}
91+
{...props}
92+
/>
93+
);
94+
}
95+
96+
function AlertDialogDescription({
97+
className,
98+
...props
99+
}: AlertDialogPrimitive.Description.Props) {
100+
return (
101+
<AlertDialogPrimitive.Description
102+
data-slot="alert-dialog-description"
103+
className={cn("text-muted-foreground text-sm text-pretty", className)}
104+
{...props}
105+
/>
106+
);
107+
}
108+
109+
/** The button that answers "no" or "not now." Closes without running anything else. */
110+
function AlertDialogCancel({
111+
className,
112+
...props
113+
}: React.ComponentProps<typeof Button>) {
114+
return (
115+
<AlertDialogPrimitive.Close
116+
render={<Button variant="outline" className={className} {...props} />}
117+
/>
118+
);
119+
}
120+
121+
/**
122+
* The button that carries out the action, styled destructive by default since that is the only
123+
* reason this component exists rather than the ordinary `Dialog`. Pass `onClick` to run the action;
124+
* closing is automatic, the same as `AlertDialogCancel`.
125+
*/
126+
function AlertDialogAction({
127+
className,
128+
variant = "destructive",
129+
...props
130+
}: React.ComponentProps<typeof Button>) {
131+
return (
132+
<AlertDialogPrimitive.Close
133+
render={<Button variant={variant} className={className} {...props} />}
134+
/>
135+
);
136+
}
137+
138+
export {
139+
AlertDialog,
140+
AlertDialogAction,
141+
AlertDialogCancel,
142+
AlertDialogContent,
143+
AlertDialogDescription,
144+
AlertDialogFooter,
145+
AlertDialogHeader,
146+
AlertDialogOverlay,
147+
AlertDialogPortal,
148+
AlertDialogTitle,
149+
AlertDialogTrigger,
150+
};

0 commit comments

Comments
 (0)