Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

You can clear your conversation with @g1t, for good. "New conversation" only leaves a mark that Ask reads back from; Clear conversation (the ⋯ menu in the Ask panel and in the @g1t direct message's header, or /clear in either composer, after a confirming dialog) deletes it: every message in your direct message with g1t, thread replies and the empty rows deleted messages left, g1t's answers with their steps, tool reads and sources, the pages questions were asked from, their reactions, and the read mark and last activity that pointed at them, in one D1 batch; the room's pending answer job and an answer still being written (which is thrown away, never sent to the provider, and never notifies); and your kept notifications and unread counts from it (notify's forget_conversation, which also drops a late notification about a message from before the clear). Only the one person in their own direct message with g1t can clear it: any other conversation is refused, someone else's is not found, and owners cannot clear another person's. Clearing again deletes nothing (cleared: 0). The empty direct message and its members stay, so Ask and Chat open it as before; chat_meter (counts with no text or person, for billing), issues, pull requests and sessions an answer started, what the workspace's AI provider already received and pushes already shown on a device stay. Open tabs and the mobile app empty the conversation on channel.cleared. Each answer job now carries the question it read and is dropped if that question is gone. The Ask guide has "Clear the conversation" with what is deleted and kept. Deploy notify before chat. Checked: chat's 75 tests (new: scoping, clearing only that conversation, clearing twice, Ask's context after a clear) and typecheck, notify's 49 and typecheck, contracts' 48, the site's typecheck (tsc -b --force), its tests and a production build, mobile's typecheck and the docs build.

syntaqxcommitted Parent4ea0da7Browse files
24 files+723−220/24 viewed
+38−1
123123 there as it streams, so it shows in both places at once: start a question
124124 on an issue page, and read the answer later in Chat, on your phone, or
125125 in the desktop app. **Open in Chat** at the top of the panel takes you to
126−it. Nobody else can read it, and it stays until you delete it.
126+it. Nobody else can read it, and it stays until you delete it or
127+[clear it](#clear-the-conversation).
127128
128129 - **New conversation** leaves a mark in the direct message. From then on,
129130 g1t reads only what comes after it, and the panel shows only that; what
130131 came before stays in Chat to read.
132+- **Clear conversation…**, in the panel's **⋯** menu, deletes the whole
133+ conversation for good. Typing `/clear` and pressing **Enter** does the
134+ same. See [Clear the conversation](#clear-the-conversation).
131135 - **Stop**, or **Esc** while an answer is coming, ends the answer; what had
132136 come so far stays. Writing again while g1t is still answering does the
133137 same: g1t stops and answers your newest words, reading both.
147151 **⌘K** (**Ctrl+K**) puts you back in the box. On an empty conversation,
148152 the panel suggests a few questions for the page you are on.
149153
154+## Clear the conversation
155+
156+**New conversation** only changes what g1t reads: everything said before
157+the mark is still kept, and still in Chat. To delete it, clear the
158+conversation:
159+
160+1. Open **Ask** in the top bar, or your direct message with **g1t** in
161+ Chat.
162+2. Choose **⋯ → Clear conversation…**, or type `/clear` in the box and
163+ press **Enter**.
164+3. Choose **Clear conversation** to confirm.
165+
166+Everything in your direct message with g1t is deleted for good, in Ask
167+and in Chat, on every device. It can't be undone, and g1t remembers none
168+of it: the next question starts with nothing before it.
169+
170+| Deleted | Kept |
171+| --- | --- |
172+| Your questions, and the page each was asked from | The direct message itself, empty, so Ask and Chat open it as before |
173+| g1t's answers, with their steps, sources and proposed sessions | Chat's daily count of messages and bytes for the workspace, which has no text and no names in it and is kept for billing |
174+| Thread replies, reactions, "New conversation" marks, and deleted messages' empty places | Issues, pull requests and sessions you started from an answer: they are your workspace's, not the conversation's |
175+| Your read mark and unread count for it | What your AI provider was sent to answer: it went to the provider, under your workspace's agreement with them |
176+| The notifications its answers sent you, in g1t's notifications panel | Browser and phone notifications already shown on a device |
177+
178+An answer still being written when you clear is stopped and thrown away,
179+not saved: it never comes back to the conversation, and it does not
180+notify you. A question you send after clearing is answered as the start
181+of a new conversation.
182+
183+Clearing is only ever your own: nobody else can clear your conversation
184+with g1t, not even a workspace owner, and you can't clear anyone else's
185+or a channel. Clearing a conversation that is already empty does nothing.
186+
150187 ## Limits
151188
152189 - 20 questions a minute for each person; past that, g1t says to wait a
+3−0
174174 does the same, and g1t answers your newest words.
175175 - **New conversation**, at the top, leaves a mark: g1t reads only what is
176176 said after it. Everything before stays to read.
177+- **⋯ → Clear conversation…**, at the top, or `/clear` typed in the box,
178+ deletes everything in it for good, after asking: see
179+ [Clear the conversation](/guides/ask/#clear-the-conversation).
177180 - Under its latest answer, **Answer again** has it answer anew, and
178181 **Copy** copies an answer. Each answer says which provider and model
179182 wrote it.
+5−0
338338 case "channel.updated":
339339 void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) });
340340 break;
341+ case "channel.cleared":
342+ // The person cleared their conversation with @g1t (in the site): nothing in it is left.
343+ updatePages(queries, chatKeys.messages(slug, live.channel_id, null), () => []);
344+ void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) });
345+ break;
341346 }
342347 },
343348 });
+50−6
1−import { ArrowUp, MessageSquare, RotateCcw, Settings2, Square } from "lucide-react";
1+import { ArrowUp, Ellipsis, MessageSquare, RotateCcw, Settings2, Square, Trash2 } from "lucide-react";
22 import { type FormEvent, type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
33 import { Link, useLocation } from "react-router";
44
66
77 import { askPage, askWhere } from "../lib/ask";
88 import { browserTimeZone } from "../lib/time-zone";
9−import { anyAnswering, applyLive, fromG1t, suggestions } from "../lib/g1t-chat";
9+import { anyAnswering, applyLive, fromG1t, isClearCommand, suggestions } from "../lib/g1t-chat";
1010 import { Conversation, ConversationContent, ConversationEmptyState, ConversationScrollButton } from "./ai-elements/conversation";
1111 import { Message, MessageContent } from "./ai-elements/message";
1212 import type { ResponseContext } from "./ai-elements/response";
1313 import { Suggestion, Suggestions } from "./ai-elements/suggestion";
1414 import { AskedFrom, G1tAnswer } from "./chat/answer";
15+import { ClearG1tDialog } from "./chat/clear-g1t";
1516 import { useChatLive } from "./chat/live";
1617 import { MarkGlyph } from "./logo";
1718 import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
1819 import { Button } from "./ui/button";
20+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu";
1921 import { Hint } from "./ui/hint";
2022 import { Kbd } from "./ui/kbd";
2123 import { SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "./ui/sheet";
3335 * service has g1t answer, and the answer streams into the direct message
3436 * over its live socket, so it shows here and in Chat at once. The panel
3537 * shows the current conversation, since the latest "New conversation";
36− * everything before it stays in Chat.
38+ * everything before it stays in Chat. Clear conversation (the menu, or
39+ * `/clear`) deletes all of it, here and in Chat, for good.
3740 */
3841
3942 type Setup = AskSetup & { owner: boolean };
203206 [channel, slug],
204207 );
205208
206− return { channelId, messages, loaded, problem, ask, act, load, onEvent };
209+ /** Deletes the whole conversation for good (the chat service's `g1t_clear`): null when done, else why not. */
210+ const clear = useCallback(async (): Promise<string | null> => {
211+ const id = await channel();
212+ if (!id) return "g1t could not be reached. Check your connection and try again.";
213+ const done = await chatApi<{ cleared: number }>(slug, { intent: "g1t_clear", channel_id: id });
214+ if (!done.ok) return done.error.message;
215+ setMessages([]);
216+ setProblem(null);
217+ return null;
218+ }, [channel, slug]);
219+
220+ return { channelId, messages, loaded, problem, ask, act, clear, load, onEvent };
207221 }
208222
209223 type G1tConversation = ReturnType<typeof useG1tConversation>;
240254
241255 function AskPanel({ slug, open, conversation }: { slug: string; open: boolean; conversation: G1tConversation }) {
242256 const { pathname } = useLocation();
243− const { channelId, messages, loaded, problem, ask, act, load, onEvent } = conversation;
257+ const { channelId, messages, loaded, problem, ask, act, clear, load, onEvent } = conversation;
258+ const [clearing, setClearing] = useState(false);
244259 const [setup, setSetup] = useState<Setup | null>(null);
245260 const [setupError, setSetupError] = useState<string | null>(null);
246261 const [draft, setDraft] = useState("");
299314 const context = useMemo<ResponseContext>(() => ({ slug, repo: where.repo ?? null }), [slug, where.repo?.namespace, where.repo?.name]);
300315
301316 const send = (text: string) => {
317+ // `/clear` asks to clear the conversation; it is never sent to g1t.
318+ if (isClearCommand(text)) {
319+ setDraft("");
320+ setClearing(true);
321+ return;
322+ }
302323 if (!ready || busy || !text.trim()) return;
303324 setDraft("");
304325 void ask(text, pathname);
362383 New conversation
363384 </Button>
364385 </Hint>
386+ <DropdownMenu>
387+ <Hint label="More">
388+ <DropdownMenuTrigger asChild>
389+ <Button variant="ghost" size="icon-xs" aria-label="More">
390+ <Ellipsis size={14} />
391+ </Button>
392+ </DropdownMenuTrigger>
393+ </Hint>
394+ <DropdownMenuContent align="end">
395+ <DropdownMenuItem variant="destructive" disabled={messages.length === 0} onSelect={() => setClearing(true)}>
396+ <Trash2 />
397+ Clear conversation…
398+ </DropdownMenuItem>
399+ </DropdownMenuContent>
400+ </DropdownMenu>
365401 </span>
366402 </div>
367403 </SheetHeader>
468504 </div>
469505 </div>
470506 <p className="mt-2 px-1 text-[0.6875rem] leading-relaxed text-faint">
471− Answers come from your workspace&apos;s own AI provider and can be wrong. Kept in your direct message with g1t.{" "}
507+ Answers come from your workspace&apos;s own AI provider and can be wrong. Kept in your direct message with g1t until you clear it.{" "}
472508 <span className="whitespace-nowrap max-sm:hidden">
473509 <Kbd>Shift</Kbd> <Kbd>Enter</Kbd> for a new line.
474510 </span>
475511 </p>
476512 </form>
513+ <ClearG1tDialog
514+ open={clearing}
515+ onOpenChange={(next) => {
516+ setClearing(next);
517+ if (!next) requestAnimationFrame(() => input.current?.focus());
518+ }}
519+ onClear={clear}
520+ />
477521 </>
478522 );
479523 }
+53−3
5050 import { AskedFrom, ConversationMark, G1tAnswer } from "./answer";
5151 import type { ResponseContext } from "../ai-elements/response";
5252 import { Suggestion, Suggestions } from "../ai-elements/suggestion";
53−import { anyAnswering, applyLive, fromG1t, suggestions } from "../../lib/g1t-chat";
53+import { anyAnswering, applyLive, fromG1t, isClearCommand, suggestions } from "../../lib/g1t-chat";
54+import { ClearG1tDialog } from "./clear-g1t";
5455 import { FolioUnfurls } from "../folios/unfurl";
5556 import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
5657 import { threadLink } from "../../lib/write-up";
236237 },
237238 [send, data.channel.id],
238239 );
240+ // Clear conversation (the header's menu, or `/clear`): everything here deleted for good.
241+ const [clearing, setClearing] = useState(false);
242+ const emptied = useCallback(() => {
243+ setMessages([]);
244+ setOlder(null);
245+ setThread((now) => (now ? [] : now));
246+ setThreadRoot(null);
247+ }, []);
248+ const clearG1t = useCallback(async (): Promise<string | null> => {
249+ const done = await send<{ cleared: number }>({ intent: "g1t_clear", channel_id: data.channel.id });
250+ if (!done.ok) return done.error.message;
251+ emptied();
252+ refresh();
253+ return null;
254+ }, [send, data.channel.id, emptied, refresh]);
239255
240256 // The details panel: open by default on a wide screen, as last left.
241257 useEffect(() => {
384400 // @g1t shows it is thinking in its answer itself.
385401 if (event.member.kind === "system") return;
386402 setTyping((now) => new Map(now).set(`${event.member.kind}:${event.member.id}`, { member: event.member, until: new Date(event.until).getTime() }));
403+ } else if (event.type === "channel.cleared") {
404+ // Cleared, here or in another tab: nothing in it is left.
405+ if (event.channel_id !== data.channel.id) return;
406+ emptied();
387407 } else if (event.type === "channel.updated") {
388408 // Renamed, a new topic, archived or back: everyone looking sees it at once.
389409 if (event.channel.id !== data.channel.id) return;
391411 refresh();
392412 }
393413 },
394− [data.channel.id, data.channel.kind, muted, threadId, me?.id, countReply, refresh],
414+ [data.channel.id, data.channel.kind, muted, threadId, me?.id, countReply, refresh, emptied],
395415 );
396416 // Back after a drop: what was missed.
397417 const onReconnect = useCallback(async () => {
669689 onStar={() => void toggleStar()}
670690 onInfo={toggleInfo}
671691 onNewConversation={withG1t ? () => void g1tSend("g1t_reset") : undefined}
692+ onClear={withG1t ? () => setClearing(true) : undefined}
672693 />
673694 <div
674695 ref={scroller}
762783 <span>{formerAgentLine(formerAgent)}</span>
763784 </Card>
764785 ) : joined ? (
765− <Composer draftKey={data.channel.id} placeholder={withG1t ? "Ask g1t about your work" : placeholder} people={people} onSend={(body) => void post(body, null)} onTyping={onTyping} autoFocus />
786+ <Composer
787+ draftKey={data.channel.id}
788+ placeholder={withG1t ? "Ask g1t about your work" : placeholder}
789+ people={people}
790+ // To @g1t, `/clear` asks to clear the conversation; it is never sent.
791+ onSend={(body) => (withG1t && isClearCommand(body) ? setClearing(true) : void post(body, null))}
792+ onTyping={onTyping}
793+ autoFocus
794+ />
766795 ) : (
767796 <Card className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
768797 <p className="text-sm text-muted">
836865 if (!done.ok) setMessages((now) => now.map((m) => (m.id === message.id ? { ...m, deleted_at: null } : m)));
837866 }}
838867 />
868+ {withG1t && <ClearG1tDialog open={clearing} onOpenChange={setClearing} onClear={clearG1t} />}
839869 <BottomSheet open={editing != null && !editing.inline} onOpenChange={(open) => !open && setEditing(null)} title="Edit message">
840870 {editing && !editing.inline && (
841871 <div className="space-y-3 px-1 pt-1">
863893 onStar,
864894 onInfo,
865895 onNewConversation,
896+ onClear,
866897 }: {
867898 /** In your direct message with @g1t: start a new conversation. */
868899 onNewConversation?: () => void;
900+ /** In your direct message with @g1t: clear it, after asking (components/chat/clear-g1t.tsx). */
901+ onClear?: () => void;
869902 /** A phone: back to the list of conversations. */
870903 onBack: () => void;
871904 data: Loaded;
963996 </Button>
964997 </Hint>
965998 )}
999+ {onClear && (
1000+ <DropdownMenu>
1001+ <Hint label="Conversation actions">
1002+ <DropdownMenuTrigger asChild>
1003+ <Button variant="ghost" size="icon-bar" aria-label="Conversation actions">
1004+ <Ellipsis size={16} />
1005+ </Button>
1006+ </DropdownMenuTrigger>
1007+ </Hint>
1008+ <DropdownMenuContent align="end">
1009+ <DropdownMenuItem variant="destructive" onSelect={onClear}>
1010+ <Trash2 />
1011+ Clear conversation…
1012+ </DropdownMenuItem>
1013+ </DropdownMenuContent>
1014+ </DropdownMenu>
1015+ )}
9661016 {channel.kind === "dm" && (
9671017 <Hint label={info ? "Hide details" : "Details"}>
9681018 <Button variant="ghost" size="icon-bar" onClick={onInfo} aria-pressed={info} aria-label="Details">
+77−0
1+import { useState } from "react";
2+
3+import {
4+ AlertDialog,
5+ AlertDialogAction,
6+ AlertDialogCancel,
7+ AlertDialogContent,
8+ AlertDialogDescription,
9+ AlertDialogFooter,
10+ AlertDialogHeader,
11+ AlertDialogTitle,
12+} from "../ui/alert-dialog";
13+
14+/**
15+ * Confirms clearing your conversation with @g1t (docs.g1t.sh/guides/ask/,
16+ * "Clear the conversation"): the Ask panel's menu, the direct message's
17+ * menu in Chat, and `/clear` typed in either open it. `onClear` deletes
18+ * it; the dialog stays open, saying what went wrong, if that fails.
19+ */
20+export function ClearG1tDialog({
21+ open,
22+ onOpenChange,
23+ onClear,
24+}: {
25+ open: boolean;
26+ onOpenChange: (open: boolean) => void;
27+ /** Clears it: null when done, else what went wrong. */
28+ onClear: () => Promise<string | null>;
29+}) {
30+ const [busy, setBusy] = useState(false);
31+ const [error, setError] = useState<string | null>(null);
32+ return (
33+ <AlertDialog
34+ open={open}
35+ onOpenChange={(next) => {
36+ if (busy) return;
37+ setError(null);
38+ onOpenChange(next);
39+ }}
40+ >
41+ <AlertDialogContent>
42+ <AlertDialogHeader>
43+ <AlertDialogTitle>Clear your conversation with g1t?</AlertDialogTitle>
44+ <AlertDialogDescription>
45+ Everything you and g1t said is deleted for good, in Ask and in Chat: your questions, g1t&apos;s answers with their steps and
46+ sources, and the notifications they sent you. g1t won&apos;t remember any of it. An answer still coming is stopped and thrown
47+ away. This can&apos;t be undone.
48+ </AlertDialogDescription>
49+ </AlertDialogHeader>
50+ {error && (
51+ <p role="alert" className="text-sm text-danger">
52+ {error}
53+ </p>
54+ )}
55+ <AlertDialogFooter>
56+ <AlertDialogCancel disabled={busy}>Cancel</AlertDialogCancel>
57+ <AlertDialogAction
58+ disabled={busy}
59+ onClick={(event) => {
60+ // Closed only once it is done.
61+ event.preventDefault();
62+ setBusy(true);
63+ setError(null);
64+ void onClear().then((problem) => {
65+ setBusy(false);
66+ if (problem) setError(problem);
67+ else onOpenChange(false);
68+ });
69+ }}
70+ >
71+ {busy ? "Clearing…" : "Clear conversation"}
72+ </AlertDialogAction>
73+ </AlertDialogFooter>
74+ </AlertDialogContent>
75+ </AlertDialog>
76+ );
77+}
+7−1
3535
3636 export function DropdownMenuItem({
3737 className,
38+ variant = "default",
3839 ...props
39−}: ComponentProps<typeof Primitive.Item>) {
40+}: ComponentProps<typeof Primitive.Item> & {
41+ /** `destructive`: an item that deletes something, in the danger color (shadcn's variant). */
42+ variant?: "default" | "destructive";
43+}) {
4044 return (
4145 <Primitive.Item
46+ data-variant={variant}
4247 className={cn(
4348 // Taller rows under a finger, so each is a fair target.
4449 "flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-fg/90 outline-none select-none pointer-coarse:min-h-10",
4550 "data-highlighted:bg-line data-highlighted:text-fg data-disabled:opacity-50",
4651 "[&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-faint",
52+ "data-[variant=destructive]:text-danger data-[variant=destructive]:data-highlighted:bg-danger/10 data-[variant=destructive]:data-highlighted:text-danger data-[variant=destructive]:[&_svg]:text-danger",
4753 className,
4854 )}
4955 {...props}
+16−1
33
44 import type { ChatLiveEvent, ChatMessage } from "@g1t/contracts";
55
6−import { askedFrom, answerLabel, anyAnswering, applyLive, cutOff, fromG1t, refTarget, suggestions } from "./g1t-chat.ts";
6+import { askedFrom, answerLabel, anyAnswering, applyLive, cutOff, fromG1t, isClearCommand, refTarget, suggestions } from "./g1t-chat.ts";
77
88 const g1t = { kind: "system" as const, id: "g1t", name: "g1t", display_name: "g1t", avatar: null, role: null };
99 const ana = { kind: "user" as const, id: "u1", name: "ana", display_name: "Ana", avatar: null, role: null };
5353 assert.deepEqual(applyLive([message("msg_3")], { type: "message.created", message: message("msg_2") }, "chn_1").map((m) => m.id), ["msg_2", "msg_3"]);
5454 });
5555
56+test("a cleared conversation empties, and only that one", () => {
57+ const list = [message("msg_1"), message("msg_2", { author: g1t, answer: { state: "streaming" } })];
58+ assert.deepEqual(applyLive(list, { type: "channel.cleared", channel_id: "chn_1" }, "chn_1"), []);
59+ assert.equal(applyLive(list, { type: "channel.cleared", channel_id: "chn_2" }, "chn_1").length, 2);
60+ // An answer's late frame after the clear brings nothing back.
61+ assert.deepEqual(applyLive([], { type: "message.streaming", channel_id: "chn_1", id: "msg_2", body: "Two", answer: { state: "streaming" } }, "chn_1"), []);
62+});
63+
64+test("/clear, typed to g1t, is the command and nothing else is", () => {
65+ assert.ok(isClearCommand("/clear"));
66+ assert.ok(isClearCommand(" /CLEAR \n"));
67+ assert.ok(!isClearCommand("/clear the cache for me"));
68+ assert.ok(!isClearCommand("clear"));
69+});
70+
5671 test("who g1t is, and answers cut off at their limit", () => {
5772 assert.ok(fromG1t({ author: g1t }));
5873 assert.ok(!fromG1t({ author: ana }));
+11−0
5757 case "message.deleted":
5858 if (event.channel_id !== channelId) return list as ChatMessage[];
5959 return list.filter((m) => m.id !== event.id);
60+ case "channel.cleared":
61+ // The person cleared the conversation: nothing in it is left.
62+ return event.channel_id === channelId ? [] : (list as ChatMessage[]);
6063 default:
6164 return list as ChatMessage[];
6265 }
6366 }
6467
6568 /**
69+ * Whether what was typed to @g1t is `/clear`: it opens Clear conversation
70+ * (components/chat/clear-g1t.tsx) rather than being sent to g1t.
71+ */
72+export function isClearCommand(text: string): boolean {
73+ return text.trim().toLowerCase() === "/clear";
74+}
75+
76+/**
6677 * What a reader may ask first, from where they are: about the issue or
6778 * pull request in front of them, the repository, or their work.
6879 */
+4−0
488488 sink.deliver(event.notification, { toast: event.toast, focused: focused() });
489489 heard(event.notification, event.toast);
490490 break;
491+ case "forgotten":
492+ // A conversation cleared (your direct message with @g1t): its notifications are gone everywhere.
493+ set({ recent: state.recent.filter((n) => !(n.channel_id === event.channel_id && n.workspace.toLowerCase() === event.workspace)) });
494+ break;
491495 case "preferences":
492496 set({ preferences: event.preferences });
493497 break;
+1−1
44 * owner who can choose; with `?sessions`, whether sessions can start on the
55 * workspace's Anthropic account. The conversation itself is the viewer's
66 * direct message with @g1t in chat (`-/chat/api`, intents `g1t_dm`, `post`,
7− * `g1t_stop`, `g1t_reset`, `g1t_retry`): the chat service has g1t answer
7+ * `g1t_stop`, `g1t_reset`, `g1t_retry`, `g1t_clear`): the chat service has g1t answer
88 * there, with the provider an owner chose, on the workspace's own key.
99 *
1010 * `POST -/ask` starts a session g1t proposed in an answer (the proposal
+3−0
8686 return chat.g1tRetry(slug, channel, viewer);
8787 case "g1t_stop":
8888 return chat.g1tStop(slug, channel, viewer);
89+ // Deletes the viewer's own conversation with @g1t for good; the chat service refuses any other.
90+ case "g1t_clear":
91+ return chat.g1tClear(slug, channel, viewer);
8992 case "edit":
9093 return chat.edit(slug, channel, viewer, sent.id ?? "", (sent.body ?? "").trim());
9194 case "remove":
+14−0
421421 | { type: "typing"; channel_id: string; member: MemberProfile; until: string }
422422 | { type: "read"; channel_id: string; principal: Principal; last_read_id: string }
423423 | { type: "channel.updated"; channel: Channel }
424+ /**
425+ * Everything in the conversation was deleted for good: the person
426+ * cleared their direct message with @g1t. Every message shown goes.
427+ */
428+ | { type: "channel.cleared"; channel_id: string }
424429 | { type: "reaction.added" | "reaction.removed"; channel_id: string; message_id: string; emoji: string; member: MemberProfile };
425430
426431 /**
530535 g1tRetry(workspace: string, channelId: string, viewer: User): Promise<Result<null>>;
531536 /** Stops @g1t's answer still coming; what came stays. */
532537 g1tStop(workspace: string, channelId: string, viewer: User): Promise<Result<null>>;
538+ /**
539+ * Clears the viewer's own direct message with @g1t: every message in
540+ * it, g1t's answers and their steps, reactions and the notifications
541+ * from it are deleted for good, and an answer still coming is thrown
542+ * away. `cleared` is how many messages went; 0 when it was empty, so
543+ * clearing again is harmless. Refused for any other conversation.
544+ */
545+ g1tClear(workspace: string, channelId: string, viewer: User): Promise<Result<{ cleared: number }>>;
533546 };
534547
535548 /**
619632 g1tReset: (workspace, channelId, viewer) => call("g1t_reset", { workspace, channel_id: channelId, viewer }),
620633 g1tRetry: (workspace, channelId, viewer) => call("g1t_retry", { workspace, channel_id: channelId, viewer }),
621634 g1tStop: (workspace, channelId, viewer) => call("g1t_stop", { workspace, channel_id: channelId, viewer }),
635+ g1tClear: (workspace, channelId, viewer) => call("g1t_clear", { workspace, channel_id: channelId, viewer }),
622636 };
623637 }
+14−1
240240 /** Your own presence, status and Do Not Disturb, on connect and after every change. */
241241 | { type: "me"; me: OwnPresence }
242242 /** What changed in repositories you are watching the pages of (`watchRepo`). */
243− | { type: "repo"; changes: RepoChange[] };
243+ | { type: "repo"; changes: RepoChange[] }
244+ /**
245+ * The person cleared a conversation (their direct message with @g1t):
246+ * the notifications from it are gone, so tabs drop them too.
247+ */
248+ | { type: "forgotten"; workspace: string; channel_id: string };
244249
245250 /**
246251 * What a tab sends over the feed socket: plain `ping` every 25 s (answered
344349 watchRepo(user: Pick<User, "id">, repoId: string, repo: string): Promise<{ ok: boolean; until: string | null }>;
345350 /** For services: what changed in repositories (`crates/contracts/src/live.rs`), told to whoever watches each. */
346351 repoChanged(changes: RepoChange[]): Promise<{ ok: boolean }>;
352+ /**
353+ * For chat, after the person cleared a conversation: the notifications
354+ * kept from it are deleted, its counts go to nothing, and a
355+ * notification of a message from before the clear that arrives late is
356+ * dropped. `workspace` is the slug.
357+ */
358+ forgetConversation(user: Pick<User, "id">, workspace: string, channelId: string): Promise<{ ok: boolean }>;
347359 };
348360
349361 async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> {
375387 markVisit: (user, workspace, at) => call("mark_visit", { user_id: user.id, workspace, at }),
376388 watchRepo: (user, repoId, repo) => call("watch_repo", { user_id: user.id, repo_id: repoId, repo }),
377389 repoChanged: (changes) => call("repo_changed", { changes }),
390+ forgetConversation: (user, workspace, channelId) => call("forget_conversation", { user_id: user.id, workspace, channel_id: channelId }),
378391 };
379392 }
+160−0
1+import assert from "node:assert/strict";
2+import { readFileSync, readdirSync } from "node:fs";
3+import { DatabaseSync } from "node:sqlite";
4+import { test } from "node:test";
5+
6+import { G1T_KEY } from "@g1t/contracts/chat";
7+
8+import { clearConversation, mayClear } from "./clear.ts";
9+import { conversation, latestQuestion, type SaidRow } from "./g1t.ts";
10+
11+/** D1, as far as a clear uses it, over node's SQLite with chat's migrations. */
12+function fakeD1(): D1Database & { raw: DatabaseSync } {
13+ const db = new DatabaseSync(":memory:");
14+ const dir = new URL("../migrations/", import.meta.url);
15+ for (const file of readdirSync(dir).sort()) db.exec(readFileSync(new URL(file, dir), "utf8"));
16+ type Statement = { sql: string; params: unknown[] };
17+ const run = (s: Statement) => {
18+ const done = db.prepare(s.sql).run(...(s.params as never[]));
19+ return { success: true, meta: { changes: Number(done.changes) }, results: [] };
20+ };
21+ const statement = (sql: string, params: unknown[] = []): Statement & Record<string, unknown> => ({
22+ sql,
23+ params,
24+ bind: (...values: unknown[]) => statement(sql, values),
25+ first: async () => (db.prepare(sql).get(...(params as never[])) as unknown) ?? null,
26+ run: async () => run({ sql, params }),
27+ all: async () => ({ results: db.prepare(sql).all(...(params as never[])) }),
28+ });
29+ return {
30+ raw: db,
31+ prepare: (sql: string) => statement(sql),
32+ // One transaction, as D1's batch is.
33+ batch: async (statements: Statement[]) => {
34+ db.exec("BEGIN");
35+ try {
36+ const results = statements.map(run);
37+ db.exec("COMMIT");
38+ return results;
39+ } catch (error) {
40+ db.exec("ROLLBACK");
41+ throw error;
42+ }
43+ },
44+ } as unknown as D1Database & { raw: DatabaseSync };
45+}
46+
47+const AT = "2026-10-14T12:00:00.000Z";
48+const ANA = "user:u_ana";
49+const BEN = "user:u_ben";
50+
51+function channel(db: DatabaseSync, id: string, kind: "channel" | "dm", members: string[]) {
52+ db.prepare("INSERT INTO channels (id, workspace_id, kind, name, created_by, created_at, last_message_at) VALUES (?, 'wsp_1', ?, ?, ?, ?, ?)").run(
53+ id,
54+ kind,
55+ kind === "channel" ? id : null,
56+ members[0]!,
57+ AT,
58+ AT,
59+ );
60+ for (const principal of members) {
61+ db.prepare("INSERT INTO channel_members (channel_id, principal, last_read_id, joined_at) VALUES (?, ?, 'msg_9', ?)").run(id, principal, AT);
62+ }
63+}
64+
65+function say(db: DatabaseSync, channelId: string, id: string, author: string, body: string, extra: { meta?: string; thread_root?: string; deleted?: boolean } = {}) {
66+ db.prepare("INSERT INTO messages (id, channel_id, author, body, thread_root, created_at, deleted_at, meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(
67+ id,
68+ channelId,
69+ author,
70+ body,
71+ extra.thread_root ?? null,
72+ AT,
73+ extra.deleted ? AT : null,
74+ extra.meta ?? null,
75+ );
76+}
77+
78+const answer = (text: string) => JSON.stringify({ answer: { state: "done", steps: [{ type: "tool", id: "t1", label: "Read pull request #7", status: "done", summary: text }] } });
79+
80+/** Ana's direct message with g1t, Ben's, and a channel they share. */
81+function workspace() {
82+ const d1 = fakeD1();
83+ const db = d1.raw;
84+ channel(db, "chn_ana_g1t", "dm", [ANA, G1T_KEY]);
85+ channel(db, "chn_ben_g1t", "dm", [BEN, G1T_KEY]);
86+ channel(db, "chn_general", "channel", [ANA, BEN]);
87+ say(db, "chn_ana_g1t", "msg_01", ANA, "What is the launch code word?", { meta: JSON.stringify({ page: { path: "/acme/web/pull/7" } }) });
88+ say(db, "chn_ana_g1t", "msg_02", G1T_KEY, "It is swordfish.", { meta: answer("swordfish") });
89+ say(db, "chn_ana_g1t", "msg_03", ANA, "a reply in a thread", { thread_root: "msg_02" });
90+ say(db, "chn_ana_g1t", "msg_04", G1T_KEY, "New conversation", { meta: JSON.stringify({ reset: true }) });
91+ say(db, "chn_ana_g1t", "msg_05", ANA, "", { deleted: true });
92+ db.prepare("INSERT INTO reactions (message_id, principal, emoji, created_at) VALUES ('msg_02', ?, 'thumbsup', ?)").run(ANA, AT);
93+ say(db, "chn_ben_g1t", "msg_11", BEN, "Ben's own question");
94+ say(db, "chn_ben_g1t", "msg_12", G1T_KEY, "Ben's answer", { meta: answer("ben") });
95+ say(db, "chn_general", "msg_21", ANA, "hello team");
96+ db.prepare("INSERT INTO reactions (message_id, principal, emoji, created_at) VALUES ('msg_21', ?, 'wave', ?)").run(BEN, AT);
97+ db.prepare("INSERT INTO chat_meter (workspace_id, day, messages, bytes) VALUES ('wsp_1', '2026-10-14', 8, 120)").run();
98+ return d1;
99+}
100+
101+const count = (db: DatabaseSync, sql: string, ...params: string[]) => Number((db.prepare(sql).get(...params) as { n: number }).n);
102+
103+test("only the one person in a direct message with g1t may clear it, and only their own", () => {
104+ assert.ok(mayClear({ channelKind: "dm", members: [ANA, G1T_KEY], viewer: ANA }));
105+ // Someone else's direct message with g1t, whoever asks.
106+ assert.ok(!mayClear({ channelKind: "dm", members: [BEN, G1T_KEY], viewer: ANA }));
107+ // A channel, a direct message between people, a group with g1t in it.
108+ assert.ok(!mayClear({ channelKind: "channel", members: [ANA, G1T_KEY], viewer: ANA }));
109+ assert.ok(!mayClear({ channelKind: "dm", members: [ANA, BEN], viewer: ANA }));
110+ assert.ok(!mayClear({ channelKind: "dm", members: [ANA, BEN, G1T_KEY], viewer: ANA }));
111+ // g1t itself is not a person who clears.
112+ assert.ok(!mayClear({ channelKind: "dm", members: [ANA, G1T_KEY], viewer: G1T_KEY }));
113+});
114+
115+test("clearing deletes everything in that conversation and nothing anywhere else", async () => {
116+ const d1 = workspace();
117+ const db = d1.raw;
118+ const done = await clearConversation(d1, "chn_ana_g1t");
119+ // Every row, before and after the mark, thread replies and deleted ones too.
120+ assert.equal(done.messages, 5);
121+ assert.equal(count(db, "SELECT COUNT(*) AS n FROM messages WHERE channel_id = ?", "chn_ana_g1t"), 0);
122+ assert.equal(count(db, "SELECT COUNT(*) AS n FROM reactions WHERE message_id = 'msg_02'"), 0);
123+ assert.equal(count(db, "SELECT COUNT(*) AS n FROM messages WHERE body LIKE '%swordfish%' OR meta LIKE '%swordfish%'"), 0);
124+ // The direct message stays, empty, with its two members and nothing read or said last.
125+ const kept = db.prepare("SELECT last_message_at FROM channels WHERE id = 'chn_ana_g1t'").get() as { last_message_at: string | null };
126+ assert.equal(kept.last_message_at, null);
127+ assert.equal(count(db, "SELECT COUNT(*) AS n FROM channel_members WHERE channel_id = 'chn_ana_g1t' AND last_read_id IS NULL"), 2);
128+ // Ben's conversation with g1t, the shared channel and its reactions, and the meter are untouched.
129+ assert.equal(count(db, "SELECT COUNT(*) AS n FROM messages WHERE channel_id = ?", "chn_ben_g1t"), 2);
130+ assert.equal(count(db, "SELECT COUNT(*) AS n FROM messages WHERE channel_id = ?", "chn_general"), 1);
131+ assert.equal(count(db, "SELECT COUNT(*) AS n FROM reactions WHERE message_id = 'msg_21'"), 1);
132+ assert.equal(count(db, "SELECT COUNT(*) AS n FROM channel_members WHERE channel_id = 'chn_ben_g1t' AND last_read_id = 'msg_9'"), 2);
133+ assert.equal(count(db, "SELECT messages AS n FROM chat_meter WHERE workspace_id = 'wsp_1'"), 8);
134+});
135+
136+test("clearing again, or an empty conversation, deletes nothing and is no error", async () => {
137+ const d1 = workspace();
138+ assert.equal((await clearConversation(d1, "chn_ana_g1t")).messages, 5);
139+ assert.equal((await clearConversation(d1, "chn_ana_g1t")).messages, 0);
140+ assert.equal((await clearConversation(d1, "chn_nobody")).messages, 0);
141+ assert.equal(count(d1.raw, "SELECT COUNT(*) AS n FROM messages"), 3);
142+});
143+
144+test("after a clear, what g1t reads holds none of the old conversation", async () => {
145+ const d1 = workspace();
146+ const db = d1.raw;
147+ const read = () => db.prepare("SELECT id, author, kind, body, meta, deleted_at FROM messages WHERE channel_id = 'chn_ana_g1t' AND thread_root IS NULL ORDER BY id").all() as SaidRow[];
148+ // Before: a question after the mark would still read nothing of before it, but the history is there.
149+ say(db, "chn_ana_g1t", "msg_06", ANA, "And before that?");
150+ assert.ok(read().some((row) => row.body.includes("swordfish")));
151+ await clearConversation(d1, "chn_ana_g1t");
152+ assert.deepEqual(conversation(read()), []);
153+ assert.equal(latestQuestion(read()), null);
154+ // The next question is read on its own.
155+ say(db, "chn_ana_g1t", "msg_31", ANA, "What did I ask you before?");
156+ const asked = conversation(read());
157+ assert.deepEqual(asked, [{ role: "user", content: "What did I ask you before?" }]);
158+ assert.ok(!JSON.stringify(asked).includes("swordfish"));
159+ assert.equal(latestQuestion(read()), "msg_31");
160+});
+56−0
1+/**
2+ * Clearing a person's conversation with @g1t (docs.g1t.sh/guides/ask/,
3+ * "Clear the conversation"): everything said in their direct message with
4+ * g1t is deleted for good, so g1t remembers none of it and the history is
5+ * gone from Ask and from Chat.
6+ *
7+ * "New conversation" only leaves a mark (src/g1t.ts `conversation`); this
8+ * deletes. What goes, from chat's own database: every message in the
9+ * direct message (top-level and thread replies, g1t's answers with their
10+ * steps and sources, the pages questions were asked from, marks, and the
11+ * empty rows deleted messages left), their reactions, and the read mark
12+ * and last activity that pointed at them. The direct message itself and
13+ * its two members stay, empty, so the Ask panel and Chat open it as
14+ * before. `chat_meter` stays: it counts messages and bytes per workspace
15+ * per day for billing, with no text and no person in it.
16+ *
17+ * The rest of a clear is the service's (src/index.ts `g1tClear`): the
18+ * channel's room throws away an answer still being written
19+ * (src/room.ts `clear`), and notify forgets the person's notifications
20+ * from it.
21+ *
22+ * Who may clear is decided here too, purely, so it is tested on its own
23+ * (clear.test.ts): only the one person in a direct message with g1t, in
24+ * their own.
25+ */
26+import { g1tDmPerson } from "./g1t.ts";
27+
28+/**
29+ * Whether `viewer` (a member key, `user:<id>`) may clear this
30+ * conversation: it is a direct message of exactly them and g1t. Never a
31+ * channel, a group message, or anyone else's direct message with g1t,
32+ * whatever their role.
33+ */
34+export function mayClear(input: { channelKind: "channel" | "dm"; members: readonly string[]; viewer: string }): boolean {
35+ return input.channelKind === "dm" && g1tDmPerson(input.members) === input.viewer;
36+}
37+
38+/** The part of D1 a clear uses. */
39+type Db = Pick<D1Database, "prepare" | "batch">;
40+
41+/**
42+ * Deletes everything said in one conversation, in one transaction (a D1
43+ * batch), and says how many messages went. Clearing again finds nothing
44+ * and deletes nothing: 0. The caller checks it is the viewer's own
45+ * direct message with g1t ([`mayClear`]); this touches only `channelId`.
46+ */
47+export async function clearConversation(db: Db, channelId: string): Promise<{ messages: number }> {
48+ const results = await db.batch([
49+ db.prepare("DELETE FROM reactions WHERE message_id IN (SELECT id FROM messages WHERE channel_id = ?)").bind(channelId),
50+ db.prepare("DELETE FROM messages WHERE channel_id = ?").bind(channelId),
51+ // Nothing is left to have read, or to have been said last.
52+ db.prepare("UPDATE channel_members SET last_read_id = NULL WHERE channel_id = ?").bind(channelId),
53+ db.prepare("UPDATE channels SET last_message_at = NULL WHERE id = ?").bind(channelId),
54+ ]);
55+ return { messages: Number(results[1]?.meta?.changes ?? 0) };
56+}
+13−0
167167 }
168168
169169 /**
170+ * The id of the question an answer is for: the person's newest message.
171+ * Clearing the conversation deletes it, which is how the room knows an
172+ * answer it was handed read what is gone (src/room.ts).
173+ */
174+export function latestQuestion(rows: readonly SaidRow[]): string | null {
175+ for (let at = rows.length - 1; at >= 0; at--) {
176+ const row = rows[at]!;
177+ if (row.author.startsWith("user:") && !row.deleted_at) return row.id;
178+ }
179+ return null;
180+}
181+
182+/**
170183 * A page as a person's message may carry it: only what Ask reads, of the
171184 * shapes it reads. Null when nothing of it is usable.
172185 */
+33−1
7777 conversation,
7878 g1tDmPerson,
7979 latestPage,
80+ latestQuestion,
8081 metaColumn,
8182 pageFrom,
8283 parseMeta,
8384 shownAnswer,
8485 startsAnswer,
8586 } from "./g1t.ts";
87+import { clearConversation, mayClear } from "./clear.ts";
8688 import { internalAnswer } from "./internal.ts";
8789 import { mentionedHandles, mentionsColumn } from "./mentions.ts";
8890 import { messageBody, meterDay, pageOf, pageSize } from "./messages.ts";
9193 import { mayCreateChannel, mayManageChannel, permissionsFor, rowFor, settingsChange, settingsOf, type SettingsRow } from "./settings.ts";
9294 import { dmTitle, sidebarOrder, tally, type UnreadRow } from "./unread.ts";
9395 // Live notifications and counts (services/notify).
94−import { notifyMessage, notifyMuted, notifyRead } from "./notify.ts";
96+import { notifyCleared, notifyMessage, notifyMuted, notifyRead } from "./notify.ts";
9597
9698 export { ChannelRoom } from "./room.ts";
9799
12331235 const rows = recent.results.reverse();
12341236 const messages = conversation(rows);
12351237 if (!messages.length) return;
1238+ // The question it answers: a clear that deletes it also throws this answer away (src/room.ts).
1239+ const question = latestQuestion(rows);
12361240 const written = await this.write(place, G1T_KEY, { body: "", card: null, thread_root: null }, { meta: { answer: { state: "thinking" } }, notify: false });
12371241 if (!written.ok) return;
12381242 const { channel } = place;
12441248 // The page of the newest question; one asked in Chat is on the direct message itself.
12451249 page: { ...latestPage(rows), path: latestPage(rows)?.path ?? `/${place.slug}/-/chat/dm/${channel.id}` },
12461250 message: written.value,
1251+ question_id: question ?? undefined,
12471252 });
12481253 }
12491254
12811286 return written;
12821287 }
12831288
1289+ /**
1290+ * "Clear conversation": deletes everything in the viewer's own direct
1291+ * message with @g1t for good (src/clear.ts), so g1t remembers none of
1292+ * it. Only that person, in their own; never a channel or anyone else's.
1293+ * Then the room throws away an answer still being written, so it cannot
1294+ * write what it read back in, and notify forgets their notifications
1295+ * from it. Clearing an empty conversation does nothing and says 0.
1296+ */
1297+ async g1tClear(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<{ cleared: number }>> {
1298+ const found = await this.place(a.workspace, a.channel_id, a.viewer, "member");
1299+ if (!found.ok) return found;
1300+ const place = found.value;
1301+ const me = userKey(a.viewer!);
1302+ if (!mayClear({ channelKind: place.channel.kind, members: await this.memberKeys(place.channel.id), viewer: me })) {
1303+ return fail("invalid", "Only your own direct message with g1t can be cleared.");
1304+ }
1305+ // Deleted first, then the room: an answer that reads after this finds its question gone (src/room.ts).
1306+ const { messages } = await clearConversation(this.db, place.channel.id);
1307+ await this.room(place.channel.id).clear(place.channel.id);
1308+ await notifyCleared(this.env.NOTIFY, { slug: place.slug, channel_id: place.channel.id, user_id: a.viewer!.id }).catch((error: unknown) =>
1309+ console.error("chat could not have notify forget", place.channel.id, error),
1310+ );
1311+ return ok({ cleared: messages });
1312+ }
1313+
12841314 /** Answers the latest question again: g1t's last answer goes, and a new one is written. */
12851315 async g1tRetry(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<null>> {
12861316 const found = await this.g1tPlace(a.workspace, a.channel_id, a.viewer);
17631793 return Response.json(await service.g1tRetry(args));
17641794 case "g1t_stop":
17651795 return Response.json(await service.g1tStop(args));
1796+ case "g1t_clear":
1797+ return Response.json(await service.g1tClear(args));
17661798 default:
17671799 return new Response("Unknown method\n", { status: 404 });
17681800 }
+15−0
215215 { user_id: input.user_id, workspace: input.slug, counts: { channel_id: input.channel_id, unread: 0, mentions: 0, muted: input.muted }, notification: null },
216216 ]);
217217 }
218+
219+/**
220+ * After the person cleared their conversation with @g1t (src/clear.ts):
221+ * notify forgets the notifications it kept from it (their text is a
222+ * preview of messages that no longer exist), and its counts go to nothing.
223+ */
224+export async function notifyCleared(notify: Notify | undefined, input: { slug: string; channel_id: string; user_id: string }): Promise<void> {
225+ if (!notify) return;
226+ const response = await notify.fetch("https://service/rpc/forget_conversation", {
227+ method: "POST",
228+ headers: { "content-type": "application/json" },
229+ body: JSON.stringify({ user_id: input.user_id, workspace: input.slug, channel_id: input.channel_id }),
230+ });
231+ if (!response.ok) throw new Error(`forget_conversation failed with status ${response.status}`);
232+}
+63−7
6363 messages: AskMessage[];
6464 page: AskPage;
6565 message: ChatMessage;
66+ /**
67+ * The person's newest message the answer read. Gone when it is looked
68+ * for: the conversation was cleared after it was read, and the answer
69+ * is thrown away (`clear`). Absent on a job handed over before it was.
70+ */
71+ question_id?: string;
6672 };
6773
6874 export class ChannelRoom extends DurableObject<RoomEnv> {
6975 /** When each socket last said it was typing; lost on hibernation, which only lets one more through. */
7076 private typedAt = new WeakMap<WebSocket, number>();
7177 /** The answer being written now, to stop; one at a time per room. */
72− private running: { id: string; stop: AbortController; superseded: boolean } | null = null;
78+ private running: { id: string; stop: AbortController; superseded: boolean; cleared: boolean } | null = null;
7379
7480 constructor(ctx: DurableObjectState, env: RoomEnv) {
7581 super(ctx, env);
129135 * answer is not cut short when the request that asked ends.
130136 */
131137 async startAnswer(job: AnswerJob): Promise<void> {
138+ // Read before a clear that has deleted its question since: thrown away, never asked.
139+ if (await this.questionGone(job)) {
140+ await this.discard(job.channel.id, job.message.id);
141+ return;
142+ }
132143 const times = answerAllowed((await this.ctx.storage.get<number[]>("answered")) ?? [], Date.now());
133144 await this.ctx.storage.put("answered", times.times);
134145 if (!times.ok) {
160171 const job = await this.ctx.storage.get<AnswerJob>("job");
161172 if (!job) return;
162173 await this.ctx.storage.delete("job");
163− const run = { id: job.message.id, stop: new AbortController(), superseded: false };
174+ const run = { id: job.message.id, stop: new AbortController(), superseded: false, cleared: false };
175+ // Running before it looks again, so a clear from now on finds it and stops it.
164176 this.running = run;
177+ if (await this.questionGone(job)) {
178+ if (this.running === run) this.running = null;
179+ await this.discard(job.channel.id, job.message.id);
180+ return;
181+ }
165182 const ask = integrationsClient(this.env.INTEGRATIONS as ServiceBinding);
166183 try {
167184 await runAnswer(
175192 ask: (signal) => ask.ask(job.actor, job.slug, job.messages, job.page, signal),
176193 typing: () =>
177194 this.broadcast({ type: "typing", channel_id: job.channel.id, member: G1T_PROFILE, until: new Date(Date.now() + G1T_TYPING_MS).toISOString() }),
178− show: (body, answer) => this.broadcast({ type: "message.streaming", channel_id: job.channel.id, id: job.message.id, body, answer }),
179− save: (body, answer) => this.store(job.message.id, body, answer),
180− finish: (body, answer) => this.end(job, body, answer, run.superseded),
195+ show: (body, answer) => {
196+ if (!run.cleared) this.broadcast({ type: "message.streaming", channel_id: job.channel.id, id: job.message.id, body, answer });
197+ },
198+ save: (body, answer) => (run.cleared ? Promise.resolve() : this.store(job.message.id, body, answer)),
199+ finish: (body, answer) => (run.cleared ? this.discard(job.channel.id, job.message.id) : this.end(job, body, answer, run.superseded)),
181200 now: () => Date.now(),
182201 },
183202 run.stop.signal,
189208 }
190209 }
191210
211+ /**
212+ * The conversation was just cleared (src/index.ts `g1tClear`, after its
213+ * rows were deleted): an answer waiting to start is dropped, one being
214+ * written is stopped and thrown away, not saved, so nothing it read
215+ * comes back; and every tab looking empties. What it had already sent
216+ * the workspace's AI provider has gone to that provider.
217+ */
218+ async clear(channelId: string): Promise<void> {
219+ const pending = await this.ctx.storage.get<AnswerJob>("job");
220+ await this.ctx.storage.delete("job");
221+ const ids = new Set<string>();
222+ if (pending) ids.add(pending.message.id);
223+ if (this.running) {
224+ this.running.cleared = true;
225+ this.running.stop.abort();
226+ ids.add(this.running.id);
227+ }
228+ // Written after the rows went, an answer's own message would outlive the clear.
229+ for (const id of ids) await this.env.DB.prepare("DELETE FROM messages WHERE id = ?").bind(id).run();
230+ this.broadcast({ type: "channel.cleared", channel_id: channelId });
231+ }
232+
233+ /** Whether the question a job answers has been deleted by a clear. */
234+ private async questionGone(job: AnswerJob): Promise<boolean> {
235+ if (!job.question_id) return false;
236+ const row = await this.env.DB.prepare("SELECT id FROM messages WHERE id = ?").bind(job.question_id).first<{ id: string }>();
237+ return !row;
238+ }
239+
240+ /** An answer to a cleared conversation: its message goes for good, and tabs drop it. */
241+ private async discard(channelId: string, id: string): Promise<void> {
242+ await this.env.DB.prepare("DELETE FROM messages WHERE id = ?").bind(id).run();
243+ this.broadcast({ type: "message.deleted", channel_id: channelId, id });
244+ }
245+
192246 /** Saves an answer as it is now. */
193247 private async store(id: string, body: string, answer: ChatAnswer): Promise<void> {
194248 await this.env.DB.prepare("UPDATE messages SET body = ?, meta = ? WHERE id = ? AND deleted_at IS NULL")
216270 return;
217271 }
218272 const at = new Date().toISOString();
219− await this.env.DB.batch([
273+ const [saved] = await this.env.DB.batch([
220274 this.env.DB.prepare("UPDATE messages SET body = ?, meta = ? WHERE id = ?").bind(body, metaColumn({ answer }), message.id),
221− this.env.DB.prepare("UPDATE channels SET last_message_at = ? WHERE id = ?").bind(at, channel.id),
275+ this.env.DB.prepare("UPDATE channels SET last_message_at = ? WHERE id = ? AND EXISTS (SELECT 1 FROM messages WHERE id = ?)").bind(at, channel.id, message.id),
222276 ]);
277+ // Its message is gone: the conversation was cleared meanwhile. Nobody is shown or told it.
278+ if (saved && saved.meta && saved.meta.changes === 0) return;
223279 this.broadcast({ type: "message.updated", message: { ...message, body, answer } });
224280 if (answer.state === "stopped") return;
225281 await notifyMessage(
+24−0
4949 } from "@g1t/contracts";
5050
5151 import { applyCounts, totals } from "./counts.ts";
52+import { beforeForgetting, forgotKey } from "./forget.ts";
5253 import { cleanDevice, sendExpo } from "./expopush.ts";
5354 import { cleanNotification, decide, mergePreferences, pushPayload, readPreferences, type PushPayload, type TabState } from "./prefs.ts";
5455 import {
515516 const told: FeedNotification[] = [];
516517 for (const item of items) {
517518 const workspace = String(item.workspace ?? "").toLowerCase();
519+ // Of a message from a conversation cleared since: it is gone, so neither counted nor told.
520+ const channel = item.counts?.channel_id ?? item.notification?.channel_id ?? null;
521+ if (channel && workspace && item.notification && beforeForgetting(item.notification, this.meta(forgotKey(workspace, channel)))) continue;
518522 if (item.counts?.channel_id && workspace) {
519523 const before = this.sql
520524 .exec<{ unread: number; mentions: number; muted: number }>(
545549 return { ok: true };
546550 }
547551
552+ /**
553+ * The person cleared a conversation (chat's `g1t_clear`): the
554+ * notifications kept from it go, its counts go to nothing, and from now
555+ * on a late one of a message from before is dropped (src/forget.ts).
556+ * Pushes already shown by a browser or phone stay there: they cannot be
557+ * taken back.
558+ */
559+ async forgetConversation(workspace: string, channelId: string): Promise<{ ok: boolean; forgotten: number }> {
560+ const slug = workspace.toLowerCase();
561+ let forgotten = 0;
562+ this.ctx.storage.transactionSync(() => {
563+ forgotten = this.sql.exec("DELETE FROM notifications WHERE json_extract(json, '$.channel_id') = ?", channelId).rowsWritten;
564+ this.sql.exec("UPDATE counts SET unread = 0, mentions = 0 WHERE workspace = ? AND channel_id = ?", slug, channelId);
565+ this.setMeta(forgotKey(slug, channelId), new Date().toISOString());
566+ });
567+ this.send({ type: "forgotten", workspace: slug, channel_id: channelId });
568+ this.send(this.counts(slug));
569+ return { ok: true, forgotten };
570+ }
571+
548572 /** The inbox count as it now is (events, after items arrive or are marked): every tab shows it at once. */
549573 async setInbox(value: number): Promise<{ ok: boolean }> {
550574 const unread = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
+21−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { beforeForgetting, cleanChannelId, forgotKey } from "./forget.ts";
5+
6+test("a notification of a message from before the clear is dropped; one after is told", () => {
7+ const forgot = "2026-10-14T12:00:00.000Z";
8+ assert.ok(beforeForgetting({ created_at: "2026-10-14T11:59:59.000Z" }, forgot));
9+ assert.ok(beforeForgetting({ created_at: forgot }, forgot));
10+ assert.ok(!beforeForgetting({ created_at: "2026-10-14T12:00:00.001Z" }, forgot));
11+ // Without a time it counts as before; a conversation never cleared drops nothing.
12+ assert.ok(beforeForgetting({ created_at: "soon" }, forgot));
13+ assert.ok(!beforeForgetting({ created_at: "2026-10-14T11:00:00.000Z" }, null));
14+});
15+
16+test("forgetting is kept per workspace and conversation, and takes only chat's ids", () => {
17+ assert.equal(forgotKey("Acme", "chn_1"), "forgot:acme:chn_1");
18+ assert.notEqual(forgotKey("acme", "chn_1"), forgotKey("other", "chn_1"));
19+ assert.equal(cleanChannelId("chn_01jabc"), "chn_01jabc");
20+ for (const bad of ["", "chn 1", "chn_1' OR 1=1", "x".repeat(65), 7, null]) assert.equal(cleanChannelId(bad), null, String(bad));
21+});
+32−0
1+/**
2+ * A conversation the person cleared (chat's `g1t_clear`, their direct
3+ * message with @g1t): the feed deletes the notifications it kept from it,
4+ * whose text previews messages that no longer exist, and remembers when,
5+ * so a notification of a message from before the clear that arrives late
6+ * (an answer that ended as the clear ran) is dropped rather than kept.
7+ *
8+ * Pure, so it is tested on its own (forget.test.ts); src/feed.ts keeps
9+ * the times in its `meta` table under `forgotKey`.
10+ */
11+import type { FeedNotification } from "@g1t/contracts";
12+
13+/** The `meta` key a forgotten conversation's time is kept under. */
14+export function forgotKey(workspace: string, channelId: string): string {
15+ return `forgot:${workspace.toLowerCase()}:${channelId}`;
16+}
17+
18+/** A channel id as chat makes them: what forgetting takes, and nothing else. */
19+export function cleanChannelId(value: unknown): string | null {
20+ return typeof value === "string" && /^[a-z0-9_]{1,64}$/.test(value) ? value : null;
21+}
22+
23+/**
24+ * Whether a notification is of a message said before its conversation
25+ * was cleared at `forgotAt` (RFC 3339): such a message is gone. One with
26+ * no time, or a bad one, counts as before: it is dropped.
27+ */
28+export function beforeForgetting(notification: Pick<FeedNotification, "created_at">, forgotAt: string | null): boolean {
29+ if (!forgotAt) return false;
30+ const at = Date.parse(notification.created_at);
31+ return !Number.isFinite(at) || at <= Date.parse(forgotAt);
32+}
+10−0
1111 * Who sends what:
1212 * - chat: `deliver`, for every message (counts for each person in the
1313 * conversation, a notification for those it is for) and every read;
14+ * `forget_conversation`, when someone clears their direct message with
15+ * @g1t (src/forget.ts);
1416 * - events: `notify`, for every inbox item, and `set_inbox`, the count after
1517 * items arrive or are marked, naming the person by username;
1618 * - the site: `subscribe`, `unsubscribe`, `status`, `set_preferences`,
4446 } from "@g1t/contracts";
4547
4648 import { FEED_USERNAME_HEADER, FEED_USER_ID_HEADER, Feed, type FeedEnv } from "./feed.ts";
49+import { cleanChannelId } from "./forget.ts";
4750 import { onlyPeople } from "./presence.ts";
4851 import { visitWorkspace } from "./visits.ts";
4952 import { byRepo, cleanRepoId, cleanRepoPath } from "./watch.ts";
137140 if (!repoId || !repo) return Response.json({ ok: false, until: null });
138141 return Response.json(await stub.watchRepo(userId, repoId, repo));
139142 }
143+ case "forget_conversation": {
144+ // For chat, after the person cleared their direct message with @g1t (src/forget.ts).
145+ const channelId = cleanChannelId(args.channel_id);
146+ const workspace = visitWorkspace(args.workspace);
147+ if (!channelId || !workspace) return Response.json({ ok: false });
148+ return Response.json(await stub.forgetConversation(workspace, channelId));
149+ }
140150 case "test":
141151 return Response.json(await stub.test(typeof args.username === "string" ? args.username : ""));
142152 case "presence":

Comments

Sign in to comment.