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.
24 files+723−220/24 viewed
| 123 | 123 | there as it streams, so it shows in both places at once: start a question | |
| 124 | 124 | on an issue page, and read the answer later in Chat, on your phone, or | |
| 125 | 125 | 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). | |
| 127 | 128 | ||
| 128 | 129 | - **New conversation** leaves a mark in the direct message. From then on, | |
| 129 | 130 | g1t reads only what comes after it, and the panel shows only that; what | |
| 130 | 131 | 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). | |
| 131 | 135 | - **Stop**, or **Esc** while an answer is coming, ends the answer; what had | |
| 132 | 136 | come so far stays. Writing again while g1t is still answering does the | |
| 133 | 137 | same: g1t stops and answers your newest words, reading both. | |
| ⋯ | |||
| 147 | 151 | **⌘K** (**Ctrl+K**) puts you back in the box. On an empty conversation, | |
| 148 | 152 | the panel suggests a few questions for the page you are on. | |
| 149 | 153 | ||
| 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 | + | ||
| 150 | 187 | ## Limits | |
| 151 | 188 | ||
| 152 | 189 | - 20 questions a minute for each person; past that, g1t says to wait a | |
| 174 | 174 | does the same, and g1t answers your newest words. | |
| 175 | 175 | - **New conversation**, at the top, leaves a mark: g1t reads only what is | |
| 176 | 176 | 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). | |
| 177 | 180 | - Under its latest answer, **Answer again** has it answer anew, and | |
| 178 | 181 | **Copy** copies an answer. Each answer says which provider and model | |
| 179 | 182 | wrote it. |
| 338 | 338 | case "channel.updated": | |
| 339 | 339 | void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }); | |
| 340 | 340 | 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; | |
| 341 | 346 | } | |
| 342 | 347 | }, | |
| 343 | 348 | }); |
| 1 | − | import { ArrowUp, MessageSquare, RotateCcw, Settings2, Square } from "lucide-react"; | |
| 1 | + | import { ArrowUp, Ellipsis, MessageSquare, RotateCcw, Settings2, Square, Trash2 } from "lucide-react"; | |
| 2 | 2 | import { type FormEvent, type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 3 | import { Link, useLocation } from "react-router"; | |
| 4 | 4 | ||
| ⋯ | |||
| 6 | 6 | ||
| 7 | 7 | import { askPage, askWhere } from "../lib/ask"; | |
| 8 | 8 | 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"; | |
| 10 | 10 | import { Conversation, ConversationContent, ConversationEmptyState, ConversationScrollButton } from "./ai-elements/conversation"; | |
| 11 | 11 | import { Message, MessageContent } from "./ai-elements/message"; | |
| 12 | 12 | import type { ResponseContext } from "./ai-elements/response"; | |
| 13 | 13 | import { Suggestion, Suggestions } from "./ai-elements/suggestion"; | |
| 14 | 14 | import { AskedFrom, G1tAnswer } from "./chat/answer"; | |
| 15 | + | import { ClearG1tDialog } from "./chat/clear-g1t"; | |
| 15 | 16 | import { useChatLive } from "./chat/live"; | |
| 16 | 17 | import { MarkGlyph } from "./logo"; | |
| 17 | 18 | import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; | |
| 18 | 19 | import { Button } from "./ui/button"; | |
| 20 | + | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"; | |
| 19 | 21 | import { Hint } from "./ui/hint"; | |
| 20 | 22 | import { Kbd } from "./ui/kbd"; | |
| 21 | 23 | import { SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "./ui/sheet"; | |
| ⋯ | |||
| 33 | 35 | * service has g1t answer, and the answer streams into the direct message | |
| 34 | 36 | * over its live socket, so it shows here and in Chat at once. The panel | |
| 35 | 37 | * 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. | |
| 37 | 40 | */ | |
| 38 | 41 | ||
| 39 | 42 | type Setup = AskSetup & { owner: boolean }; | |
| ⋯ | |||
| 203 | 206 | [channel, slug], | |
| 204 | 207 | ); | |
| 205 | 208 | ||
| 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 }; | |
| 207 | 221 | } | |
| 208 | 222 | ||
| 209 | 223 | type G1tConversation = ReturnType<typeof useG1tConversation>; | |
| ⋯ | |||
| 240 | 254 | ||
| 241 | 255 | function AskPanel({ slug, open, conversation }: { slug: string; open: boolean; conversation: G1tConversation }) { | |
| 242 | 256 | 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); | |
| 244 | 259 | const [setup, setSetup] = useState<Setup | null>(null); | |
| 245 | 260 | const [setupError, setSetupError] = useState<string | null>(null); | |
| 246 | 261 | const [draft, setDraft] = useState(""); | |
| ⋯ | |||
| 299 | 314 | const context = useMemo<ResponseContext>(() => ({ slug, repo: where.repo ?? null }), [slug, where.repo?.namespace, where.repo?.name]); | |
| 300 | 315 | ||
| 301 | 316 | 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 | + | } | |
| 302 | 323 | if (!ready || busy || !text.trim()) return; | |
| 303 | 324 | setDraft(""); | |
| 304 | 325 | void ask(text, pathname); | |
| ⋯ | |||
| 362 | 383 | New conversation | |
| 363 | 384 | </Button> | |
| 364 | 385 | </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> | |
| 365 | 401 | </span> | |
| 366 | 402 | </div> | |
| 367 | 403 | </SheetHeader> | |
| ⋯ | |||
| 468 | 504 | </div> | |
| 469 | 505 | </div> | |
| 470 | 506 | <p className="mt-2 px-1 text-[0.6875rem] leading-relaxed text-faint"> | |
| 471 | − | Answers come from your workspace's own AI provider and can be wrong. Kept in your direct message with g1t.{" "} | |
| 507 | + | Answers come from your workspace's own AI provider and can be wrong. Kept in your direct message with g1t until you clear it.{" "} | |
| 472 | 508 | <span className="whitespace-nowrap max-sm:hidden"> | |
| 473 | 509 | <Kbd>Shift</Kbd> <Kbd>Enter</Kbd> for a new line. | |
| 474 | 510 | </span> | |
| 475 | 511 | </p> | |
| 476 | 512 | </form> | |
| 513 | + | <ClearG1tDialog | |
| 514 | + | open={clearing} | |
| 515 | + | onOpenChange={(next) => { | |
| 516 | + | setClearing(next); | |
| 517 | + | if (!next) requestAnimationFrame(() => input.current?.focus()); | |
| 518 | + | }} | |
| 519 | + | onClear={clear} | |
| 520 | + | /> | |
| 477 | 521 | </> | |
| 478 | 522 | ); | |
| 479 | 523 | } | |
| 50 | 50 | import { AskedFrom, ConversationMark, G1tAnswer } from "./answer"; | |
| 51 | 51 | import type { ResponseContext } from "../ai-elements/response"; | |
| 52 | 52 | 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"; | |
| 54 | 55 | import { FolioUnfurls } from "../folios/unfurl"; | |
| 55 | 56 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu"; | |
| 56 | 57 | import { threadLink } from "../../lib/write-up"; | |
| ⋯ | |||
| 236 | 237 | }, | |
| 237 | 238 | [send, data.channel.id], | |
| 238 | 239 | ); | |
| 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]); | |
| 239 | 255 | ||
| 240 | 256 | // The details panel: open by default on a wide screen, as last left. | |
| 241 | 257 | useEffect(() => { | |
| ⋯ | |||
| 384 | 400 | // @g1t shows it is thinking in its answer itself. | |
| 385 | 401 | if (event.member.kind === "system") return; | |
| 386 | 402 | 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(); | |
| 387 | 407 | } else if (event.type === "channel.updated") { | |
| 388 | 408 | // Renamed, a new topic, archived or back: everyone looking sees it at once. | |
| 389 | 409 | if (event.channel.id !== data.channel.id) return; | |
| ⋯ | |||
| 391 | 411 | refresh(); | |
| 392 | 412 | } | |
| 393 | 413 | }, | |
| 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], | |
| 395 | 415 | ); | |
| 396 | 416 | // Back after a drop: what was missed. | |
| 397 | 417 | const onReconnect = useCallback(async () => { | |
| ⋯ | |||
| 669 | 689 | onStar={() => void toggleStar()} | |
| 670 | 690 | onInfo={toggleInfo} | |
| 671 | 691 | onNewConversation={withG1t ? () => void g1tSend("g1t_reset") : undefined} | |
| 692 | + | onClear={withG1t ? () => setClearing(true) : undefined} | |
| 672 | 693 | /> | |
| 673 | 694 | <div | |
| 674 | 695 | ref={scroller} | |
| ⋯ | |||
| 762 | 783 | <span>{formerAgentLine(formerAgent)}</span> | |
| 763 | 784 | </Card> | |
| 764 | 785 | ) : 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 | + | /> | |
| 766 | 795 | ) : ( | |
| 767 | 796 | <Card className="flex flex-wrap items-center justify-between gap-3 px-4 py-3"> | |
| 768 | 797 | <p className="text-sm text-muted"> | |
| ⋯ | |||
| 836 | 865 | if (!done.ok) setMessages((now) => now.map((m) => (m.id === message.id ? { ...m, deleted_at: null } : m))); | |
| 837 | 866 | }} | |
| 838 | 867 | /> | |
| 868 | + | {withG1t && <ClearG1tDialog open={clearing} onOpenChange={setClearing} onClear={clearG1t} />} | |
| 839 | 869 | <BottomSheet open={editing != null && !editing.inline} onOpenChange={(open) => !open && setEditing(null)} title="Edit message"> | |
| 840 | 870 | {editing && !editing.inline && ( | |
| 841 | 871 | <div className="space-y-3 px-1 pt-1"> | |
| ⋯ | |||
| 863 | 893 | onStar, | |
| 864 | 894 | onInfo, | |
| 865 | 895 | onNewConversation, | |
| 896 | + | onClear, | |
| 866 | 897 | }: { | |
| 867 | 898 | /** In your direct message with @g1t: start a new conversation. */ | |
| 868 | 899 | onNewConversation?: () => void; | |
| 900 | + | /** In your direct message with @g1t: clear it, after asking (components/chat/clear-g1t.tsx). */ | |
| 901 | + | onClear?: () => void; | |
| 869 | 902 | /** A phone: back to the list of conversations. */ | |
| 870 | 903 | onBack: () => void; | |
| 871 | 904 | data: Loaded; | |
| ⋯ | |||
| 963 | 996 | </Button> | |
| 964 | 997 | </Hint> | |
| 965 | 998 | )} | |
| 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 | + | )} | |
| 966 | 1016 | {channel.kind === "dm" && ( | |
| 967 | 1017 | <Hint label={info ? "Hide details" : "Details"}> | |
| 968 | 1018 | <Button variant="ghost" size="icon-bar" onClick={onInfo} aria-pressed={info} aria-label="Details"> | |
| 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's answers with their steps and | |
| 46 | + | sources, and the notifications they sent you. g1t won't remember any of it. An answer still coming is stopped and thrown | |
| 47 | + | away. This can'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 | + | } |
| 35 | 35 | ||
| 36 | 36 | export function DropdownMenuItem({ | |
| 37 | 37 | className, | |
| 38 | + | variant = "default", | |
| 38 | 39 | ...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 | + | }) { | |
| 40 | 44 | return ( | |
| 41 | 45 | <Primitive.Item | |
| 46 | + | data-variant={variant} | |
| 42 | 47 | className={cn( | |
| 43 | 48 | // Taller rows under a finger, so each is a fair target. | |
| 44 | 49 | "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", | |
| 45 | 50 | "data-highlighted:bg-line data-highlighted:text-fg data-disabled:opacity-50", | |
| 46 | 51 | "[&_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", | |
| 47 | 53 | className, | |
| 48 | 54 | )} | |
| 49 | 55 | {...props} |
| 3 | 3 | ||
| 4 | 4 | import type { ChatLiveEvent, ChatMessage } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 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"; | |
| 7 | 7 | ||
| 8 | 8 | const g1t = { kind: "system" as const, id: "g1t", name: "g1t", display_name: "g1t", avatar: null, role: null }; | |
| 9 | 9 | const ana = { kind: "user" as const, id: "u1", name: "ana", display_name: "Ana", avatar: null, role: null }; | |
| ⋯ | |||
| 53 | 53 | assert.deepEqual(applyLive([message("msg_3")], { type: "message.created", message: message("msg_2") }, "chn_1").map((m) => m.id), ["msg_2", "msg_3"]); | |
| 54 | 54 | }); | |
| 55 | 55 | ||
| 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 | + | ||
| 56 | 71 | test("who g1t is, and answers cut off at their limit", () => { | |
| 57 | 72 | assert.ok(fromG1t({ author: g1t })); | |
| 58 | 73 | assert.ok(!fromG1t({ author: ana })); | |
| 57 | 57 | case "message.deleted": | |
| 58 | 58 | if (event.channel_id !== channelId) return list as ChatMessage[]; | |
| 59 | 59 | 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[]); | |
| 60 | 63 | default: | |
| 61 | 64 | return list as ChatMessage[]; | |
| 62 | 65 | } | |
| 63 | 66 | } | |
| 64 | 67 | ||
| 65 | 68 | /** | |
| 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 | + | /** | |
| 66 | 77 | * What a reader may ask first, from where they are: about the issue or | |
| 67 | 78 | * pull request in front of them, the repository, or their work. | |
| 68 | 79 | */ |
| 488 | 488 | sink.deliver(event.notification, { toast: event.toast, focused: focused() }); | |
| 489 | 489 | heard(event.notification, event.toast); | |
| 490 | 490 | 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; | |
| 491 | 495 | case "preferences": | |
| 492 | 496 | set({ preferences: event.preferences }); | |
| 493 | 497 | break; |
| 4 | 4 | * owner who can choose; with `?sessions`, whether sessions can start on the | |
| 5 | 5 | * workspace's Anthropic account. The conversation itself is the viewer's | |
| 6 | 6 | * 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 | |
| 8 | 8 | * there, with the provider an owner chose, on the workspace's own key. | |
| 9 | 9 | * | |
| 10 | 10 | * `POST -/ask` starts a session g1t proposed in an answer (the proposal |
| 86 | 86 | return chat.g1tRetry(slug, channel, viewer); | |
| 87 | 87 | case "g1t_stop": | |
| 88 | 88 | 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); | |
| 89 | 92 | case "edit": | |
| 90 | 93 | return chat.edit(slug, channel, viewer, sent.id ?? "", (sent.body ?? "").trim()); | |
| 91 | 94 | case "remove": |
| 421 | 421 | | { type: "typing"; channel_id: string; member: MemberProfile; until: string } | |
| 422 | 422 | | { type: "read"; channel_id: string; principal: Principal; last_read_id: string } | |
| 423 | 423 | | { 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 } | |
| 424 | 429 | | { type: "reaction.added" | "reaction.removed"; channel_id: string; message_id: string; emoji: string; member: MemberProfile }; | |
| 425 | 430 | ||
| 426 | 431 | /** | |
| ⋯ | |||
| 530 | 535 | g1tRetry(workspace: string, channelId: string, viewer: User): Promise<Result<null>>; | |
| 531 | 536 | /** Stops @g1t's answer still coming; what came stays. */ | |
| 532 | 537 | 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 }>>; | |
| 533 | 546 | }; | |
| 534 | 547 | ||
| 535 | 548 | /** | |
| ⋯ | |||
| 619 | 632 | g1tReset: (workspace, channelId, viewer) => call("g1t_reset", { workspace, channel_id: channelId, viewer }), | |
| 620 | 633 | g1tRetry: (workspace, channelId, viewer) => call("g1t_retry", { workspace, channel_id: channelId, viewer }), | |
| 621 | 634 | 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 }), | |
| 622 | 636 | }; | |
| 623 | 637 | } | |
| 240 | 240 | /** Your own presence, status and Do Not Disturb, on connect and after every change. */ | |
| 241 | 241 | | { type: "me"; me: OwnPresence } | |
| 242 | 242 | /** 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 }; | |
| 244 | 249 | ||
| 245 | 250 | /** | |
| 246 | 251 | * What a tab sends over the feed socket: plain `ping` every 25 s (answered | |
| ⋯ | |||
| 344 | 349 | watchRepo(user: Pick<User, "id">, repoId: string, repo: string): Promise<{ ok: boolean; until: string | null }>; | |
| 345 | 350 | /** For services: what changed in repositories (`crates/contracts/src/live.rs`), told to whoever watches each. */ | |
| 346 | 351 | 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 }>; | |
| 347 | 359 | }; | |
| 348 | 360 | ||
| 349 | 361 | async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> { | |
| ⋯ | |||
| 375 | 387 | markVisit: (user, workspace, at) => call("mark_visit", { user_id: user.id, workspace, at }), | |
| 376 | 388 | watchRepo: (user, repoId, repo) => call("watch_repo", { user_id: user.id, repo_id: repoId, repo }), | |
| 377 | 389 | repoChanged: (changes) => call("repo_changed", { changes }), | |
| 390 | + | forgetConversation: (user, workspace, channelId) => call("forget_conversation", { user_id: user.id, workspace, channel_id: channelId }), | |
| 378 | 391 | }; | |
| 379 | 392 | } | |
| 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 | + | }); |
| 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 | + | } |
| 167 | 167 | } | |
| 168 | 168 | ||
| 169 | 169 | /** | |
| 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 | + | /** | |
| 170 | 183 | * A page as a person's message may carry it: only what Ask reads, of the | |
| 171 | 184 | * shapes it reads. Null when nothing of it is usable. | |
| 172 | 185 | */ |
| 77 | 77 | conversation, | |
| 78 | 78 | g1tDmPerson, | |
| 79 | 79 | latestPage, | |
| 80 | + | latestQuestion, | |
| 80 | 81 | metaColumn, | |
| 81 | 82 | pageFrom, | |
| 82 | 83 | parseMeta, | |
| 83 | 84 | shownAnswer, | |
| 84 | 85 | startsAnswer, | |
| 85 | 86 | } from "./g1t.ts"; | |
| 87 | + | import { clearConversation, mayClear } from "./clear.ts"; | |
| 86 | 88 | import { internalAnswer } from "./internal.ts"; | |
| 87 | 89 | import { mentionedHandles, mentionsColumn } from "./mentions.ts"; | |
| 88 | 90 | import { messageBody, meterDay, pageOf, pageSize } from "./messages.ts"; | |
| ⋯ | |||
| 91 | 93 | import { mayCreateChannel, mayManageChannel, permissionsFor, rowFor, settingsChange, settingsOf, type SettingsRow } from "./settings.ts"; | |
| 92 | 94 | import { dmTitle, sidebarOrder, tally, type UnreadRow } from "./unread.ts"; | |
| 93 | 95 | // Live notifications and counts (services/notify). | |
| 94 | − | import { notifyMessage, notifyMuted, notifyRead } from "./notify.ts"; | |
| 96 | + | import { notifyCleared, notifyMessage, notifyMuted, notifyRead } from "./notify.ts"; | |
| 95 | 97 | ||
| 96 | 98 | export { ChannelRoom } from "./room.ts"; | |
| 97 | 99 | ||
| ⋯ | |||
| 1233 | 1235 | const rows = recent.results.reverse(); | |
| 1234 | 1236 | const messages = conversation(rows); | |
| 1235 | 1237 | 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); | |
| 1236 | 1240 | const written = await this.write(place, G1T_KEY, { body: "", card: null, thread_root: null }, { meta: { answer: { state: "thinking" } }, notify: false }); | |
| 1237 | 1241 | if (!written.ok) return; | |
| 1238 | 1242 | const { channel } = place; | |
| ⋯ | |||
| 1244 | 1248 | // The page of the newest question; one asked in Chat is on the direct message itself. | |
| 1245 | 1249 | page: { ...latestPage(rows), path: latestPage(rows)?.path ?? `/${place.slug}/-/chat/dm/${channel.id}` }, | |
| 1246 | 1250 | message: written.value, | |
| 1251 | + | question_id: question ?? undefined, | |
| 1247 | 1252 | }); | |
| 1248 | 1253 | } | |
| 1249 | 1254 | ||
| ⋯ | |||
| 1281 | 1286 | return written; | |
| 1282 | 1287 | } | |
| 1283 | 1288 | ||
| 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 | + | ||
| 1284 | 1314 | /** Answers the latest question again: g1t's last answer goes, and a new one is written. */ | |
| 1285 | 1315 | async g1tRetry(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<null>> { | |
| 1286 | 1316 | const found = await this.g1tPlace(a.workspace, a.channel_id, a.viewer); | |
| ⋯ | |||
| 1763 | 1793 | return Response.json(await service.g1tRetry(args)); | |
| 1764 | 1794 | case "g1t_stop": | |
| 1765 | 1795 | return Response.json(await service.g1tStop(args)); | |
| 1796 | + | case "g1t_clear": | |
| 1797 | + | return Response.json(await service.g1tClear(args)); | |
| 1766 | 1798 | default: | |
| 1767 | 1799 | return new Response("Unknown method\n", { status: 404 }); | |
| 1768 | 1800 | } | |
| 215 | 215 | { user_id: input.user_id, workspace: input.slug, counts: { channel_id: input.channel_id, unread: 0, mentions: 0, muted: input.muted }, notification: null }, | |
| 216 | 216 | ]); | |
| 217 | 217 | } | |
| 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 | 63 | messages: AskMessage[]; | |
| 64 | 64 | page: AskPage; | |
| 65 | 65 | 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; | |
| 66 | 72 | }; | |
| 67 | 73 | ||
| 68 | 74 | export class ChannelRoom extends DurableObject<RoomEnv> { | |
| 69 | 75 | /** When each socket last said it was typing; lost on hibernation, which only lets one more through. */ | |
| 70 | 76 | private typedAt = new WeakMap<WebSocket, number>(); | |
| 71 | 77 | /** 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; | |
| 73 | 79 | ||
| 74 | 80 | constructor(ctx: DurableObjectState, env: RoomEnv) { | |
| 75 | 81 | super(ctx, env); | |
| ⋯ | |||
| 129 | 135 | * answer is not cut short when the request that asked ends. | |
| 130 | 136 | */ | |
| 131 | 137 | 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 | + | } | |
| 132 | 143 | const times = answerAllowed((await this.ctx.storage.get<number[]>("answered")) ?? [], Date.now()); | |
| 133 | 144 | await this.ctx.storage.put("answered", times.times); | |
| 134 | 145 | if (!times.ok) { | |
| ⋯ | |||
| 160 | 171 | const job = await this.ctx.storage.get<AnswerJob>("job"); | |
| 161 | 172 | if (!job) return; | |
| 162 | 173 | 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. | |
| 164 | 176 | 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 | + | } | |
| 165 | 182 | const ask = integrationsClient(this.env.INTEGRATIONS as ServiceBinding); | |
| 166 | 183 | try { | |
| 167 | 184 | await runAnswer( | |
| ⋯ | |||
| 175 | 192 | ask: (signal) => ask.ask(job.actor, job.slug, job.messages, job.page, signal), | |
| 176 | 193 | typing: () => | |
| 177 | 194 | 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)), | |
| 181 | 200 | now: () => Date.now(), | |
| 182 | 201 | }, | |
| 183 | 202 | run.stop.signal, | |
| ⋯ | |||
| 189 | 208 | } | |
| 190 | 209 | } | |
| 191 | 210 | ||
| 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 | + | ||
| 192 | 246 | /** Saves an answer as it is now. */ | |
| 193 | 247 | private async store(id: string, body: string, answer: ChatAnswer): Promise<void> { | |
| 194 | 248 | await this.env.DB.prepare("UPDATE messages SET body = ?, meta = ? WHERE id = ? AND deleted_at IS NULL") | |
| ⋯ | |||
| 216 | 270 | return; | |
| 217 | 271 | } | |
| 218 | 272 | const at = new Date().toISOString(); | |
| 219 | − | await this.env.DB.batch([ | |
| 273 | + | const [saved] = await this.env.DB.batch([ | |
| 220 | 274 | 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), | |
| 222 | 276 | ]); | |
| 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; | |
| 223 | 279 | this.broadcast({ type: "message.updated", message: { ...message, body, answer } }); | |
| 224 | 280 | if (answer.state === "stopped") return; | |
| 225 | 281 | await notifyMessage( | |
| 49 | 49 | } from "@g1t/contracts"; | |
| 50 | 50 | ||
| 51 | 51 | import { applyCounts, totals } from "./counts.ts"; | |
| 52 | + | import { beforeForgetting, forgotKey } from "./forget.ts"; | |
| 52 | 53 | import { cleanDevice, sendExpo } from "./expopush.ts"; | |
| 53 | 54 | import { cleanNotification, decide, mergePreferences, pushPayload, readPreferences, type PushPayload, type TabState } from "./prefs.ts"; | |
| 54 | 55 | import { | |
| ⋯ | |||
| 515 | 516 | const told: FeedNotification[] = []; | |
| 516 | 517 | for (const item of items) { | |
| 517 | 518 | 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; | |
| 518 | 522 | if (item.counts?.channel_id && workspace) { | |
| 519 | 523 | const before = this.sql | |
| 520 | 524 | .exec<{ unread: number; mentions: number; muted: number }>( | |
| ⋯ | |||
| 545 | 549 | return { ok: true }; | |
| 546 | 550 | } | |
| 547 | 551 | ||
| 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 | + | ||
| 548 | 572 | /** The inbox count as it now is (events, after items arrive or are marked): every tab shows it at once. */ | |
| 549 | 573 | async setInbox(value: number): Promise<{ ok: boolean }> { | |
| 550 | 574 | const unread = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 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 | + | }); |
| 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 | + | } |
| 11 | 11 | * Who sends what: | |
| 12 | 12 | * - chat: `deliver`, for every message (counts for each person in the | |
| 13 | 13 | * 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); | |
| 14 | 16 | * - events: `notify`, for every inbox item, and `set_inbox`, the count after | |
| 15 | 17 | * items arrive or are marked, naming the person by username; | |
| 16 | 18 | * - the site: `subscribe`, `unsubscribe`, `status`, `set_preferences`, | |
| ⋯ | |||
| 44 | 46 | } from "@g1t/contracts"; | |
| 45 | 47 | ||
| 46 | 48 | import { FEED_USERNAME_HEADER, FEED_USER_ID_HEADER, Feed, type FeedEnv } from "./feed.ts"; | |
| 49 | + | import { cleanChannelId } from "./forget.ts"; | |
| 47 | 50 | import { onlyPeople } from "./presence.ts"; | |
| 48 | 51 | import { visitWorkspace } from "./visits.ts"; | |
| 49 | 52 | import { byRepo, cleanRepoId, cleanRepoPath } from "./watch.ts"; | |
| ⋯ | |||
| 137 | 140 | if (!repoId || !repo) return Response.json({ ok: false, until: null }); | |
| 138 | 141 | return Response.json(await stub.watchRepo(userId, repoId, repo)); | |
| 139 | 142 | } | |
| 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 | + | } | |
| 140 | 150 | case "test": | |
| 141 | 151 | return Response.json(await stub.test(typeof args.username === "string" ? args.username : "")); | |
| 142 | 152 | case "presence": | |
Comments
Sign in to comment.