Skip to content

Commit

Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread

- Message and thread menus: Write this up in Docs (a space you can edit, a title, which agent; posts the ask visibly as you) and Copy link to thread - Notifications about cards carry their actions: approve with the amount inline, stop, file, discard, open; the inbox panel shows what's waiting on you in chat; browser pushes offer only what needs nothing typed or confirmed - Whoever asked an agent hears when its card waits on them - A thread's first page includes its root, so a long session's card shows at the top with working actions

syntaqxcommitted Parent6065341Browse files
22 files+1280−390/22 viewed
+26−3
183183 channel, and answers in it. Keep one request to one thread, and the agent
184184 always has the context it needs.
185185
186+The **⋯** on a message (hover over it; on a phone, press and hold it) and
187+the **⋯** at the top of an open thread have:
188+
189+- **Copy link to thread**: a link that opens the conversation with the
190+ thread beside it, to paste anywhere in g1t.
191+- **Write this up in Docs**: choose a Docs space you can write in, a title
192+ if you have one, and the agent that writes it (@g1t unless you pick
193+ another agent in the conversation). g1t posts the ask in the thread, as
194+ you, where everyone can see it, with the thread's link: *@g1t write this
195+ thread up as a Docs page in Engineering: what was decided, why, and
196+ what's next. Link this thread as the source: …*. The agent answers it
197+ like any mention, starts a session if it needs one, and replies with a
198+ link to the page. See [Docs](/guides/docs/#write-a-thread-up).
199+
186200 ## Mentions
187201
188202 Type `@` and a name to mention a person by username or an agent by handle:
256270 issue, a stopped session reads **Stopped**.
257271
258272 A long preview shows its first few lines; choose **Show more** to read the
259−rest. Cards work the same in a thread and on a phone.
273+rest. Cards work the same in a thread and on a phone. A session's updates
274+go in its card's thread; open the thread and the card stays at the top,
275+live, with its buttons.
276+
277+When a card waits on you (a session at its cap, a draft issue you asked
278+for), its pop-up has the card's buttons, and so does **Waiting on you in
279+chat** at the top of the inbox panel (the bell), for a day or until you
280+act. **Approve more** asks for the amount right there; **Stop** asks
281+first. A browser notification has the buttons that need nothing typed,
282+such as **Stop** and **Open**.
260283
261284 | Card | Its buttons |
262285 | --- | --- |
459482
460483 | | |
461484 | --- | --- |
462−| **Pop-ups** | While g1t is open in any tab, a message for you pops up in the corner, whichever page you are on: a DM, a mention, a reply in your thread, an agent waiting on you, a review requested. Reply from the pop-up without leaving the page, or open the conversation. |
485+| **Pop-ups** | While g1t is open in any tab, a message for you pops up in the corner, whichever page you are on: a DM, a mention, a reply in your thread, an agent waiting on you, a review requested. Reply from the pop-up without leaving the page, or open the conversation. A pop-up about a card has its buttons: **Approve more**, **Stop**, **File issue**. |
463486 | **Sound** | A soft chime with each pop-up, in this browser, if you turn it on. |
464487 | **Counts** | The rail's Chat badge and each conversation's unread and mention counts update live, in every open tab. |
465−| **Browser notifications** | Turn them on, and this browser shows a notification when you're away from g1t, even with every tab closed. g1t never pushes to a browser where you're already looking at g1t. |
488+| **Browser notifications** | Turn them on, and this browser shows a notification when you're away from g1t, even with every tab closed. g1t never pushes to a browser where you're already looking at g1t. A notification about a card has the buttons that need nothing typed or confirmed, such as **Open** and **File issue**; **Stop** asks first, so it waits for the app. |
466489
467490 ### Choose what you hear about
468491
+11−0
198198 thread. The page is yours: you're its owner, and it links back to where it
199199 came from.
200200
201+### Write a thread up
202+
203+In chat, **⋯ → Write this up in Docs** on a message or an open thread asks
204+an agent for a page about the thread. Choose the space (only spaces you can
205+write in are listed), a title if you want one, and which agent writes it:
206+@g1t, or another agent in the conversation. The ask is posted in the thread,
207+as you, so everyone there sees what was asked, and it carries the thread's
208+link. The agent writes what was decided, why, and what's next, links the
209+thread as its source, and replies with the page. As with any page an agent
210+makes for you, you own it.
211+
201212 ### Accept or reject
202213
203214 Each suggestion says which agent made it, who it was for, and why.
+2−1
125125 setToasts(toasts.filter((t) => t.id !== id));
126126 }
127127
128−function showToast(ok: boolean, message: string) {
128+/** Says something in the conversation's toasts: what a card's action did, or that a link was copied. */
129+export function showToast(ok: boolean, message: string) {
129130 const id = ++toastSeq;
130131 // Three at most; a fourth pushes the oldest out.
131132 setToasts([...toasts.slice(-2), { id, ok, message }]);
+147−9
33 ArchiveRestore,
44 ChevronLeft,
55 Copy,
6+ Ellipsis,
7+ FileText,
8+ Link2,
69 Pencil,
710 Trash2,
811 BellOff,
3134 } from "@g1t/contracts";
3235
3336 import { useChatData, useChatSend, useChatSidebar } from "./actions";
34−import { CardBox, CardToasts } from "./card";
37+import { CardBox, CardToasts, showToast } from "./card";
3538 import { Composer } from "./composer";
3639 import { type LiveState, useChatLive } from "./live";
3740 import { AgentPill, MemberAvatar } from "./marks";
4144 import { localTime } from "../../lib/time-zone";
4245 import { BottomSheet, SheetRow, useBack, useSwipeBack } from "../mobile";
4346 import { MessageText, type TextContext } from "./text";
47+import { WriteUpDialog } from "./write-up";
48+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
49+import { threadLink, writeUpAgents } from "../../lib/write-up";
4450 import {
4551 AlertDialog,
4652 AlertDialogAction,
112118 const [older, setOlder] = useState<string | null>(data.older);
113119 const [loadingOlder, setLoadingOlder] = useState(false);
114120 const [thread, setThread] = useState<ShownMessage[] | null>(null);
121+ // The message the open thread is under, as its page says, for when it is
122+ // further back than the conversation has loaded (a long-running session's card).
123+ const [threadRoot, setThreadRoot] = useState<ShownMessage | null>(null);
115124 const [typing, setTyping] = useState<Map<string, { member: MemberProfile; until: number }>>(new Map());
116125 const [joined, setJoined] = useState(data.joined);
117126 const [members, setMembers] = useState<ChannelMember[]>(data.members);
244253 if (!threadId) return setThread(null);
245254 try {
246255 const response = await fetch(`/${slug}/-/chat/api?channel=${encodeURIComponent(data.channel.id)}&thread=${encodeURIComponent(threadId)}`);
247− const result = (await response.json()) as Result<{ messages: ChatMessage[] }>;
256+ const result = (await response.json()) as Result<{ messages: ChatMessage[]; root?: ChatMessage | null }>;
248257 // The page reaches back to the message the thread is under: shown above it, not among the replies.
249258 setThread(result.ok ? mergeMessages([], result.value.messages.filter((m) => m.id !== threadId && !m.deleted_at)) : []);
259+ const root = result.ok ? (result.value.root ?? result.value.messages.find((m) => m.id === threadId) ?? null) : null;
260+ setThreadRoot(root);
250261 } catch {
251262 setThread([]);
252263 }
253264 }, [threadId, slug, data.channel.id]);
254265 useEffect(() => {
255266 setThread(null);
267+ setThreadRoot(null);
256268 void loadThread();
257269 }, [loadThread]);
258270
281293 } else {
282294 if (event.type === "message.updated") serverCounted.current.add(message.id);
283295 setMessages((now) => mergeMessages(now, [keepMine(now, message)]));
296+ // The open thread's root, a session's card say, changes in place at its top too.
297+ if (message.id === threadId) setThreadRoot((now) => (now ? keepMine([now], message) : now));
284298 }
285299 // Whoever just spoke is no longer typing.
286300 setTyping((now) => {
295309 const gone = (m: ShownMessage) => (m.id === event.id ? { ...m, deleted_at: new Date().toISOString() } : m);
296310 setMessages((now) => now.map(gone));
297311 setThread((now) => now?.map(gone) ?? now);
312+ setThreadRoot((now) => (now ? gone(now) : now));
298313 } else if (event.type === "reaction.added" || event.type === "reaction.removed") {
299314 if (event.channel_id !== data.channel.id) return;
300315 setMessages((now) => applyReactionEvent(now, event, me?.id ?? null));
477492 setParams(next, { preventScrollReset: true, replace: !!threadId && !!id });
478493 };
479494
495+ // A thread's link, to copy and to cite; and "Write this up in Docs", which
496+ // asks an agent, in the thread and as the person, for a page about it.
497+ const [writeUp, setWriteUp] = useState<string | null>(null);
498+ const linkToThread = (root: string) => threadLink(window.location.origin, channelPath(slug, channel), root);
499+ const copyThreadLink = (root: string) => {
500+ const done = navigator.clipboard?.writeText(linkToThread(root));
501+ if (!done) return showToast(false, "Couldn't copy the link here.");
502+ done.then(
503+ () => showToast(true, "Link to the thread copied."),
504+ () => showToast(false, "Couldn't copy the link here."),
505+ );
506+ };
507+ const writers = useMemo(() => writeUpAgents(members.map((m) => m.member)), [members]);
508+ const askForWriteUp = async (body: string): Promise<string | null> => {
509+ const root = writeUp;
510+ if (!root) return null;
511+ // The thread open already: posted as any reply is, shown at once.
512+ if (root === threadId) {
513+ void post(body, root);
514+ return null;
515+ }
516+ const saved = await send<ChatMessage>({ intent: "post", channel_id: data.channel.id, body, thread_root: root });
517+ if (!saved.ok) return saved.error.message;
518+ openThread(root);
519+ return null;
520+ };
521+
480522 const updateChannel = useCallback(
481523 async (change: ChannelChange): Promise<Result<unknown>> => {
482524 const done = await send<{ channel: Channel }>({ intent: "update_channel", channel_id: data.channel.id, ...change });
519561 const placeholder = isDm
520562 ? `Message ${others.map(shownName).join(", ") || "yourself"}`
521563 : `Message #${name}. @ a teammate or an agent`;
522− const rootMessage = threadId ? messages.find((m) => m.id === threadId) ?? null : null;
564+ const rootMessage = threadId ? (messages.find((m) => m.id === threadId) ?? (threadRoot?.id === threadId ? threadRoot : null)) : null;
523565 const cardContext = useMemo(
524566 () => ({
525567 slug,
594636 onThread={() => openThread(row.message.id)}
595637 onRetry={() => retry(row.message)}
596638 onLongPress={() => setActions(row.message)}
639+ onCopyLink={() => copyThreadLink(row.message.id)}
640+ onWriteUp={joined && !archived ? () => setWriteUp(row.message.id) : undefined}
597641 threadOpen={row.message.id === threadId}
598642 />
599643 ),
642686 </div>
643687 </section>
644688 {threadId ? (
645− <SidePanel title="Thread" subtitle={isDm ? undefined : `#${name}`} onClose={() => openThread(null)}>
689+ <SidePanel
690+ title="Thread"
691+ subtitle={isDm ? undefined : `#${name}`}
692+ onClose={() => openThread(null)}
693+ menu={<ThreadMenu onCopyLink={() => copyThreadLink(threadId)} onWriteUp={joined && !archived ? () => setWriteUp(threadId) : undefined} />}
694+ >
646695 <ThreadPanel
647696 root={rootMessage}
648697 replies={thread}
683732 mine={actions != null && actions.author.kind === "user" && actions.author.id === me?.id}
684733 onClose={() => setActions(null)}
685734 onThread={(id) => openThread(id)}
735+ onCopyLink={(message) => copyThreadLink(message.thread_root ?? message.id)}
736+ onWriteUp={joined && !archived ? (message) => setWriteUp(message.thread_root ?? message.id) : undefined}
686737 onEdit={(message) => setEditing({ id: message.id, body: message.body })}
687738 onDelete={async (message) => {
688739 // Gone at once; back if the service refuses.
691742 if (!done.ok) setMessages((now) => now.map((m) => (m.id === message.id ? { ...m, deleted_at: null } : m)));
692743 }}
693744 />
745+ <WriteUpDialog
746+ open={writeUp != null}
747+ onOpenChange={(open) => !open && setWriteUp(null)}
748+ slug={slug}
749+ agents={writers}
750+ link={writeUp && typeof window !== "undefined" ? linkToThread(writeUp) : ""}
751+ onSend={askForWriteUp}
752+ />
694753 <BottomSheet open={editing != null} onOpenChange={(open) => !open && setEditing(null)} title="Edit message">
695754 {editing && (
696755 <form
10781137 mine,
10791138 onClose,
10801139 onThread,
1140+ onCopyLink,
1141+ onWriteUp,
10811142 onEdit,
10821143 onDelete,
10831144 }: {
10851146 mine: boolean;
10861147 onClose: () => void;
10871148 onThread: (id: string) => void;
1149+ onCopyLink: (message: ShownMessage) => void;
1150+ /** Absent where the person cannot post (not joined, archived). */
1151+ onWriteUp?: (message: ShownMessage) => void;
10881152 onEdit: (message: ShownMessage) => void;
10891153 onDelete: (message: ShownMessage) => void;
10901154 }) {
11101174 <SheetRow icon={<Copy />} onClick={act(() => void navigator.clipboard?.writeText(message.body || message.card?.title || ""))}>
11111175 Copy text
11121176 </SheetRow>
1177+ {!message.pending && (
1178+ <SheetRow icon={<Link2 />} onClick={act(() => onCopyLink(message))}>
1179+ Copy link to thread
1180+ </SheetRow>
1181+ )}
1182+ {!message.pending && onWriteUp && (
1183+ <SheetRow icon={<FileText />} onClick={act(() => onWriteUp(message))}>
1184+ Write this up in Docs
1185+ </SheetRow>
1186+ )}
11131187 {mine && message.kind === "text" && (
11141188 <SheetRow icon={<Pencil />} onClick={act(() => onEdit(message))}>
11151189 Edit
11411215 onThread,
11421216 onRetry,
11431217 onLongPress,
1218+ onCopyLink,
1219+ onWriteUp,
11441220 threadOpen,
11451221 inThread,
11461222 }: {
11471223 /** A phone: a long press opens the message's actions. */
11481224 onLongPress?: () => void;
1225+ /** The hover toolbar's "⋯": copy the link to its thread, write it up in Docs. */
1226+ onCopyLink?: () => void;
1227+ onWriteUp?: () => void;
11491228 message: ShownMessage;
11501229 head: boolean;
11511230 zone: string | undefined;
12481327 <MessageSquareText size={15} />
12491328 </button>
12501329 </Hint>
1330+ {onCopyLink && <MoreActions onCopyLink={onCopyLink} onWriteUp={onWriteUp} />}
12511331 </div>
12521332 )}
12531333 </article>
12541334 );
12551335 }
12561336
1337+/** A message's "⋯" in its hover toolbar. */
1338+function MoreActions({ onCopyLink, onWriteUp }: { onCopyLink: () => void; onWriteUp?: () => void }) {
1339+ return (
1340+ <DropdownMenu>
1341+ <Hint label="More actions">
1342+ <DropdownMenuTrigger
1343+ aria-label="More actions"
1344+ className="flex size-7 items-center justify-center rounded-md text-muted outline-none hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/50"
1345+ >
1346+ <Ellipsis size={15} />
1347+ </DropdownMenuTrigger>
1348+ </Hint>
1349+ <DropdownMenuContent align="end">
1350+ <ThreadMenuItems onCopyLink={onCopyLink} onWriteUp={onWriteUp} />
1351+ </DropdownMenuContent>
1352+ </DropdownMenu>
1353+ );
1354+}
1355+
1356+function ThreadMenuItems({ onCopyLink, onWriteUp }: { onCopyLink: () => void; onWriteUp?: () => void }) {
1357+ return (
1358+ <>
1359+ <DropdownMenuItem onSelect={onCopyLink}>
1360+ <Link2 />
1361+ Copy link to thread
1362+ </DropdownMenuItem>
1363+ {onWriteUp && (
1364+ <DropdownMenuItem onSelect={onWriteUp}>
1365+ <FileText />
1366+ Write this up in Docs
1367+ </DropdownMenuItem>
1368+ )}
1369+ </>
1370+ );
1371+}
1372+
1373+/** The thread panel's "⋯", in its header. */
1374+function ThreadMenu({ onCopyLink, onWriteUp }: { onCopyLink: () => void; onWriteUp?: () => void }) {
1375+ return (
1376+ <DropdownMenu>
1377+ <Hint label="Thread actions">
1378+ <DropdownMenuTrigger
1379+ aria-label="Thread actions"
1380+ className="flex size-10 items-center justify-center rounded-full text-muted outline-none hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/50 active:bg-raised lg:size-7 lg:rounded-md"
1381+ >
1382+ <Ellipsis size={16} />
1383+ </DropdownMenuTrigger>
1384+ </Hint>
1385+ <DropdownMenuContent align="end">
1386+ <ThreadMenuItems onCopyLink={onCopyLink} onWriteUp={onWriteUp} />
1387+ </DropdownMenuContent>
1388+ </DropdownMenu>
1389+ );
1390+}
1391+
12571392 /** "reviewer is typing…", for whoever is. */
12581393 /** What an agent working on a reply says it is doing: one phrase per agent, so each sounds like itself. */
12591394 const AGENT_DOING = ["is reading the thread", "is thinking it through", "is looking into it", "is reading the diff", "is checking the details", "is drafting a reply"];
12911426 * is a screen of its own, pushed over the conversation, as tall as what
12921427 * the keyboard leaves.
12931428 */
1294−function SidePanel({ title, subtitle, onClose, children }: { title: string; subtitle?: string; onClose: () => void; children: ReactNode }) {
1429+function SidePanel({ title, subtitle, onClose, menu, children }: { title: string; subtitle?: string; onClose: () => void; menu?: ReactNode; children: ReactNode }) {
12951430 const swipe = useSwipeBack(onClose);
12961431 return (
12971432 <aside
13051440 </button>
13061441 <h2 className="text-[0.9375rem] font-semibold">{title}</h2>
13071442 {subtitle && <span className="truncate text-sm text-faint">{subtitle}</span>}
1308− <button type="button" onClick={onClose} aria-label="Close" className="ml-auto hidden rounded-md p-1 text-faint hover:bg-raised hover:text-fg lg:block">
1309− <X size={16} />
1310− </button>
1443+ <div className="ml-auto flex shrink-0 items-center gap-1">
1444+ {menu}
1445+ <button type="button" onClick={onClose} aria-label="Close" className="hidden rounded-md p-1 text-faint hover:bg-raised hover:text-fg lg:block">
1446+ <X size={16} />
1447+ </button>
1448+ </div>
13111449 </div>
13121450 <div className="flex min-h-0 grow flex-col">{children}</div>
13131451 </aside>
13491487 {root ? (
13501488 <MessageRow message={root} head zone={zone} context={context} code={code} slug={slug} onRetry={() => onRetry(root)} inThread />
13511489 ) : (
1352− <p className="px-4 py-2 text-sm text-faint">The message this thread is under is further back.</p>
1490+ replies != null && <p className="px-4 py-2 text-sm text-faint">The message this thread is under is further back.</p>
13531491 )}
13541492 <div className="my-2 flex items-center gap-3 px-4 text-xs text-faint">
13551493 {replies == null ? "Loading replies…" : `${replies.filter((r) => !r.deleted_at).length} ${replies.length === 1 ? "reply" : "replies"}`}
+177−0
1+import { FileText, LoaderCircle } from "lucide-react";
2+import { useEffect, useState } from "react";
3+import { Link } from "react-router";
4+
5+import type { DocsSidebar, Result } from "@g1t/contracts";
6+
7+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
8+import { Field, FieldDescription, FieldError, FieldLabel } from "../ui/field";
9+import { Input } from "../ui/input";
10+import { SelectField } from "../ui/select";
11+import { ORCHESTRATOR, type WritableSpace, type WriteUpAgent, writableSpaces, writeUpMessage } from "../../lib/write-up";
12+
13+// "Write this up in Docs" (docs/WORKSPACE.md, "Docs"): which space, a
14+// title if you have one, and which agent writes it. Sending posts the ask
15+// in the thread, as you, where everyone sees it; the agent answers it as
16+// it answers any mention, and links the page it made.
17+
18+type Spaces = { state: "loading" } | { state: "ready"; spaces: WritableSpace[] } | { state: "failed"; message: string };
19+
20+export function WriteUpDialog({
21+ open,
22+ onOpenChange,
23+ slug,
24+ agents,
25+ link,
26+ onSend,
27+}: {
28+ open: boolean;
29+ onOpenChange: (open: boolean) => void;
30+ slug: string;
31+ /** Who can write it: @g1t, then the conversation's agents (lib/write-up.ts `writeUpAgents`). */
32+ agents: WriteUpAgent[];
33+ /** The thread's link, cited as the source. */
34+ link: string;
35+ /** Posts the ask in the thread; answers with what went wrong, or null. */
36+ onSend: (body: string) => Promise<string | null>;
37+}) {
38+ const [spaces, setSpaces] = useState<Spaces>({ state: "loading" });
39+ const [space, setSpace] = useState("");
40+ const [agent, setAgent] = useState(ORCHESTRATOR);
41+ const [title, setTitle] = useState("");
42+ const [busy, setBusy] = useState(false);
43+ const [error, setError] = useState<string | null>(null);
44+
45+ // The spaces, read each time it opens: one may have been made meanwhile.
46+ useEffect(() => {
47+ if (!open) return;
48+ let live = true;
49+ setSpaces({ state: "loading" });
50+ setError(null);
51+ setTitle("");
52+ setAgent(ORCHESTRATOR);
53+ (async () => {
54+ try {
55+ const response = await fetch(`/${slug}/-/docs/api`);
56+ const result = (await response.json()) as Result<DocsSidebar>;
57+ if (!live) return;
58+ if (!result.ok) return setSpaces({ state: "failed", message: result.error.message });
59+ const list = writableSpaces(result.value.spaces);
60+ setSpaces({ state: "ready", spaces: list });
61+ setSpace((now) => (list.some((s) => s.id === now) ? now : (list[0]?.id ?? "")));
62+ } catch {
63+ if (live) setSpaces({ state: "failed", message: "Couldn't load your Docs spaces. Try again in a moment." });
64+ }
65+ })();
66+ return () => {
67+ live = false;
68+ };
69+ }, [open, slug]);
70+
71+ const chosen = spaces.state === "ready" ? spaces.spaces.find((s) => s.id === space) ?? null : null;
72+ const writer = agents.find((a) => a.handle === agent) ?? agents[0] ?? { handle: ORCHESTRATOR, name: ORCHESTRATOR };
73+ const body = chosen ? writeUpMessage({ agent: writer.handle, space: chosen.name, title, link }) : null;
74+
75+ const submit = async () => {
76+ if (!body || busy) return;
77+ setBusy(true);
78+ setError(null);
79+ const failed = await onSend(body);
80+ setBusy(false);
81+ if (failed) return setError(failed);
82+ onOpenChange(false);
83+ };
84+
85+ return (
86+ <Dialog open={open} onOpenChange={onOpenChange}>
87+ <DialogContent className="max-w-md">
88+ <DialogHeader>
89+ <DialogTitle>Write this up in Docs</DialogTitle>
90+ <DialogDescription>An agent turns this thread into a page: what was decided, why, and what's next, with a link back here.</DialogDescription>
91+ </DialogHeader>
92+ <form
93+ className="grid gap-4"
94+ onSubmit={(event) => {
95+ event.preventDefault();
96+ void submit();
97+ }}
98+ >
99+ <Field>
100+ <FieldLabel htmlFor="write-up-space">Space</FieldLabel>
101+ {spaces.state === "loading" ? (
102+ <div className="flex h-9 items-center gap-2 rounded-md border border-line px-3 text-sm text-faint">
103+ <LoaderCircle size={14} className="animate-spin" aria-hidden="true" />
104+ Loading spaces…
105+ </div>
106+ ) : spaces.state === "failed" ? (
107+ <FieldError>{spaces.message}</FieldError>
108+ ) : spaces.spaces.length === 0 ? (
109+ <FieldDescription>
110+ There's no Docs space you can write in yet.{" "}
111+ <Link to={`/${slug}/-/docs/new`} className="font-medium text-accent hover:underline" onClick={() => onOpenChange(false)}>
112+ Create one
113+ </Link>
114+ </FieldDescription>
115+ ) : (
116+ <SelectField
117+ id="write-up-space"
118+ value={space}
119+ onValueChange={setSpace}
120+ options={spaces.spaces.map((s) => ({ value: s.id, label: s.name }))}
121+ />
122+ )}
123+ </Field>
124+ <Field>
125+ <FieldLabel htmlFor="write-up-title">
126+ Title <span className="font-normal text-faint">(optional)</span>
127+ </FieldLabel>
128+ <Input
129+ id="write-up-title"
130+ value={title}
131+ onChange={(event) => setTitle(event.target.value)}
132+ placeholder="Left to the agent"
133+ maxLength={120}
134+ autoComplete="off"
135+ />
136+ </Field>
137+ <Field>
138+ <FieldLabel htmlFor="write-up-agent">Written by</FieldLabel>
139+ <SelectField
140+ id="write-up-agent"
141+ value={writer.handle}
142+ onValueChange={setAgent}
143+ options={agents.map((a) => ({ value: a.handle, label: a.name === a.handle ? `@${a.handle}` : `${a.name} (@${a.handle})` }))}
144+ />
145+ </Field>
146+ {body && (
147+ <div className="rounded-lg border border-line bg-bg/60 p-3">
148+ <p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted">
149+ <FileText size={13} aria-hidden="true" />
150+ Posts in this thread, as you
151+ </p>
152+ <p className="text-[0.8125rem] leading-snug break-words text-fg-soft">{body}</p>
153+ </div>
154+ )}
155+ <FieldError>{error}</FieldError>
156+ <DialogFooter>
157+ <button
158+ type="button"
159+ onClick={() => onOpenChange(false)}
160+ className="inline-flex h-9 items-center rounded-md border border-line px-3 text-sm font-medium text-fg/90 transition-colors hover:border-line-strong hover:bg-surface"
161+ >
162+ Cancel
163+ </button>
164+ <button
165+ type="submit"
166+ disabled={!body || busy}
167+ className="inline-flex h-9 items-center justify-center gap-1.5 rounded-md bg-accent px-3.5 text-sm font-medium text-bg transition-colors hover:bg-accent-hover disabled:opacity-50"
168+ >
169+ {busy && <LoaderCircle size={14} className="animate-spin" aria-hidden="true" />}
170+ {busy ? "Asking…" : `Ask @${writer.handle}`}
171+ </button>
172+ </DialogFooter>
173+ </form>
174+ </DialogContent>
175+ </Dialog>
176+ );
177+}
+282−0
1+import { ArrowUpRight, CircleAlert, CircleCheck, LoaderCircle, X } from "lucide-react";
2+import { type FormEvent, useEffect, useId, useRef, useState } from "react";
3+
4+import type { CardAction, CardActionResult, FeedNotification, Result } from "@g1t/contracts";
5+
6+import { Hint } from "../ui/hint";
7+import { InputAddon, InputGroup } from "../ui/input";
8+import { cn } from "../../lib/cn";
9+import { actionMode, inputValue, moneyInitial } from "../../lib/card-actions";
10+import { currentSink, dismiss, settle, useWaitingCards } from "../../lib/notify-client";
11+import { cardActionRequest, notificationActions } from "../../lib/notify-store";
12+
13+// A chat card's actions on a notification about it (docs/WORKSPACE.md,
14+// "Cards"): a session at its cap (Approve more with the amount inline,
15+// Stop, Open), a draft issue (File issue, Discard). Pressing one sends the
16+// same `card_action` as the card in the conversation; the card there
17+// changes for everyone, and this notification is put away.
18+
19+const BASE =
20+ "inline-flex h-7 shrink-0 items-center justify-center gap-1 rounded-md px-2.5 text-xs font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:pointer-events-none disabled:opacity-50 max-sm:h-9 max-sm:px-3";
21+
22+const STYLES: Record<NonNullable<CardAction["style"]>, string> = {
23+ primary: "bg-accent text-bg hover:bg-accent-hover",
24+ danger: "border border-danger/40 text-danger hover:border-danger hover:bg-danger/10",
25+ default: "border border-line text-fg/85 hover:border-line-strong hover:bg-raised hover:text-fg",
26+};
27+
28+const buttonClass = (style: CardAction["style"], active = false) => cn(BASE, STYLES[style ?? "default"], active && "border-accent/60 bg-raised text-fg");
29+
30+type Said = { ok: boolean; message: string };
31+
32+export function NotificationCardActions({
33+ notification,
34+ onNavigate,
35+ className,
36+}: {
37+ notification: FeedNotification;
38+ /** After a link is followed: closes the panel it is in. */
39+ onNavigate?: () => void;
40+ className?: string;
41+}) {
42+ const actions = notificationActions(notification);
43+ const [busy, setBusy] = useState<string | null>(null);
44+ const [open, setOpen] = useState<string | null>(null);
45+ const [said, setSaid] = useState<Said | null>(null);
46+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
47+ useEffect(() => () => {
48+ if (timer.current) clearTimeout(timer.current);
49+ }, []);
50+ if (!actions.length) return null;
51+ const opened = actions.find((a) => a.id === open) ?? null;
52+
53+ const run = async (action: CardAction, input: string | null) => {
54+ const request = cardActionRequest(notification, action, input);
55+ if (!request || busy) return;
56+ setBusy(action.id);
57+ setSaid(null);
58+ let answer: Said;
59+ try {
60+ const response = await fetch(request.url, {
61+ method: "POST",
62+ headers: { "content-type": "application/json" },
63+ body: JSON.stringify(request.body),
64+ });
65+ const result = (await response.json()) as Result<CardActionResult>;
66+ answer = result.ok
67+ ? { ok: result.value.ok, message: result.value.message ?? (result.value.ok ? `${action.label}: done.` : `${action.label} didn't work.`) }
68+ : { ok: false, message: result.error.message };
69+ } catch {
70+ answer = { ok: false, message: `${action.label} didn't go through. Try again in a moment.` };
71+ }
72+ setBusy(null);
73+ setSaid(answer);
74+ if (answer.ok) {
75+ setOpen(null);
76+ // Long enough to read what happened, then it is put away.
77+ timer.current = setTimeout(() => settle(notification.id), 1_400);
78+ }
79+ };
80+
81+ const press = (action: CardAction) => {
82+ const mode = actionMode(action);
83+ if (mode === "link") {
84+ dismiss(notification.id);
85+ onNavigate?.();
86+ currentSink().open(action.href!);
87+ return;
88+ }
89+ if (mode === "input" || mode === "confirm") {
90+ setOpen((now) => (now === action.id ? null : action.id));
91+ return;
92+ }
93+ void run(action, null);
94+ };
95+
96+ return (
97+ <div className={cn("space-y-2", className)}>
98+ <div role="group" aria-label={`${notification.title}: actions`} className="flex flex-wrap items-center gap-1.5" aria-busy={busy ? true : undefined}>
99+ {actions.map((action) => {
100+ const mode = actionMode(action);
101+ const asks = mode === "input" || mode === "confirm";
102+ return (
103+ <button
104+ key={action.id}
105+ type="button"
106+ disabled={!!busy || !!said?.ok}
107+ aria-expanded={asks ? open === action.id : undefined}
108+ onClick={() => press(action)}
109+ className={buttonClass(action.style, asks && open === action.id)}
110+ >
111+ {busy === action.id && <LoaderCircle size={12} aria-hidden="true" className="-ml-0.5 animate-spin" />}
112+ {action.label}
113+ {mode === "link" && <ArrowUpRight size={12} aria-hidden="true" className="-mr-0.5 text-faint" />}
114+ </button>
115+ );
116+ })}
117+ </div>
118+ {opened && !said?.ok && (
119+ <Ask
120+ key={opened.id}
121+ action={opened}
122+ busy={busy === opened.id}
123+ onCancel={() => setOpen(null)}
124+ onSubmit={(value) => void run(opened, value)}
125+ />
126+ )}
127+ {said && (
128+ <p role={said.ok ? "status" : "alert"} className={cn("flex items-start gap-1.5 text-xs leading-snug", said.ok ? "text-success" : "text-danger")}>
129+ <span className="mt-px shrink-0" aria-hidden="true">
130+ {said.ok ? <CircleCheck size={13} /> : <CircleAlert size={13} />}
131+ </span>
132+ {said.message}
133+ </p>
134+ )}
135+ </div>
136+ );
137+}
138+
139+/** What an action needs first, right there: an amount, a line of text, or a yes. */
140+function Ask({ action, busy, onSubmit, onCancel }: { action: CardAction; busy: boolean; onSubmit: (value: string | null) => void; onCancel: () => void }) {
141+ const input = action.input ?? null;
142+ const money = input?.kind === "money";
143+ const [value, setValue] = useState(() => (money ? moneyInitial(input?.initial) : (input?.initial ?? "")));
144+ const [tried, setTried] = useState(false);
145+ const field = useRef<HTMLInputElement>(null);
146+ const id = useId();
147+ const ready = input ? inputValue(action, value) : null;
148+ const invalid = !!input && tried && !ready;
149+
150+ useEffect(() => {
151+ field.current?.focus();
152+ if (money) field.current?.select();
153+ }, [money]);
154+
155+ const submit = (event: FormEvent) => {
156+ event.preventDefault();
157+ if (busy) return;
158+ if (input && !ready) {
159+ setTried(true);
160+ field.current?.focus();
161+ return;
162+ }
163+ onSubmit(input ? ready : null);
164+ };
165+
166+ return (
167+ <form
168+ onSubmit={submit}
169+ onKeyDown={(event) => {
170+ if (event.key === "Escape") {
171+ event.stopPropagation();
172+ onCancel();
173+ }
174+ }}
175+ className="rounded-lg bg-bg/70 p-2 ring-1 ring-line"
176+ >
177+ {input ? (
178+ <label htmlFor={id} className="mb-1.5 block text-[0.6875rem] font-medium text-muted">
179+ {input.label || action.label}
180+ </label>
181+ ) : (
182+ <p className="mb-2 text-xs leading-snug text-fg-soft">{action.confirm}</p>
183+ )}
184+ <div className="flex items-center gap-1.5">
185+ {input &&
186+ (money ? (
187+ <InputGroup className={cn("h-7 grow max-sm:h-9", invalid && "border-danger/70")}>
188+ <InputAddon className="pr-0 font-mono text-muted">$</InputAddon>
189+ <input
190+ ref={field}
191+ id={id}
192+ value={value}
193+ onChange={(event) => {
194+ setValue(event.target.value);
195+ setTried(false);
196+ }}
197+ inputMode="decimal"
198+ autoComplete="off"
199+ data-1p-ignore
200+ placeholder={input.placeholder ?? "0.00"}
201+ aria-invalid={invalid || undefined}
202+ className="w-full min-w-0 bg-transparent pr-2 pl-1 font-mono text-xs text-fg tabular-nums outline-none placeholder:text-faint max-sm:text-base"
203+ />
204+ </InputGroup>
205+ ) : (
206+ <input
207+ ref={field}
208+ id={id}
209+ value={value}
210+ onChange={(event) => {
211+ setValue(event.target.value);
212+ setTried(false);
213+ }}
214+ autoComplete="off"
215+ data-1p-ignore
216+ placeholder={input.placeholder ?? undefined}
217+ aria-invalid={invalid || undefined}
218+ className="h-7 min-w-0 grow rounded-md border border-line bg-bg px-2 text-xs text-fg outline-none placeholder:text-faint focus-visible:border-accent-dim aria-invalid:border-danger/70 max-sm:h-9 max-sm:text-base"
219+ />
220+ ))}
221+ <div className={cn("flex shrink-0 gap-1.5", !input && "w-full justify-end")}>
222+ <button type="button" onClick={onCancel} className={buttonClass("default")}>
223+ Cancel
224+ </button>
225+ <button type="submit" disabled={busy} aria-busy={busy || undefined} className={buttonClass(action.style === "danger" ? "danger" : "primary")}>
226+ {busy && <LoaderCircle size={12} aria-hidden="true" className="-ml-0.5 animate-spin" />}
227+ {action.label}
228+ </button>
229+ </div>
230+ </div>
231+ {invalid && <p className="mt-1.5 text-[0.6875rem] text-danger">{money ? "Enter an amount in dollars, like 5.00." : "Write something first."}</p>}
232+ </form>
233+ );
234+}
235+
236+/**
237+ * The chat cards waiting on the person, at the top of the notifications
238+ * panel: each one's title and preview, a link to where it is, and its
239+ * actions. Gone once acted on here, or after a day.
240+ */
241+export function WaitingCards({ onNavigate }: { onNavigate?: () => void }) {
242+ const waiting = useWaitingCards();
243+ if (!waiting.length) return null;
244+ return (
245+ <section aria-labelledby="waiting-cards" className="mb-4">
246+ <h3 id="waiting-cards" className="mb-2 text-xs font-medium text-muted">
247+ Waiting on you in chat
248+ </h3>
249+ <ul className="space-y-2">
250+ {waiting.map((notification) => (
251+ <li key={notification.id} className="relative rounded-lg border border-line bg-surface p-3">
252+ <span aria-hidden="true" className="absolute inset-y-2 left-0 w-[3px] rounded-r bg-accent" />
253+ <div className="flex items-start gap-2">
254+ <button
255+ type="button"
256+ onClick={() => {
257+ onNavigate?.();
258+ currentSink().open(notification.href);
259+ }}
260+ className="min-w-0 grow rounded text-left outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
261+ >
262+ <span className="block truncate text-sm font-medium text-fg hover:underline">{notification.title}</span>
263+ {notification.body && <span className="mt-0.5 line-clamp-2 block text-xs text-muted">{notification.body}</span>}
264+ </button>
265+ <Hint label="Put away">
266+ <button
267+ type="button"
268+ aria-label="Put away"
269+ onClick={() => settle(notification.id)}
270+ className="flex size-6 shrink-0 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg"
271+ >
272+ <X size={14} />
273+ </button>
274+ </Hint>
275+ </div>
276+ <NotificationCardActions notification={notification} onNavigate={onNavigate} className="mt-2.5" />
277+ </li>
278+ ))}
279+ </ul>
280+ </section>
281+ );
282+}
+7−3
33
44 import type { FeedNotification, NotificationKind } from "@g1t/contracts";
55
6+import { NotificationCardActions } from "./card-actions";
67 import { MemberAvatar } from "../chat/marks";
78 import { Avatar } from "../ui";
89 import { closeOffer, currentSink, declinePush, dismiss, enablePush, useNotifyState } from "../../lib/notify-client";
9−import { TOAST_GUESS, TOAST_MS, canQuickReply, hiddenToFit, quickReplyRequest } from "../../lib/notify-store";
10+import { TOAST_GUESS, TOAST_MS, canQuickReply, hiddenToFit, notificationActions, quickReplyRequest } from "../../lib/notify-store";
1011
1112 const KIND: Record<NotificationKind, { label: string; icon: ReactNode }> = {
1213 dm: { label: "Direct message", icon: <MessageCircle /> },
109110 function ToastCard({ notification, paused }: { notification: FeedNotification; paused: boolean }) {
110111 const kind = KIND[notification.kind];
111112 const loud = LOUD.has(notification.kind);
113+ // A card's actions (Approve more, Stop, File issue…): more time to decide.
114+ const card = notificationActions(notification).length > 0;
112115 const open = () => {
113116 dismiss(notification.id);
114117 currentSink().open(notification.href);
143146 >
144147 <X size={14} />
145148 </button>
146− {canQuickReply(notification) && <QuickReply notification={notification} onSent={() => dismiss(notification.id)} />}
149+ {card && <NotificationCardActions notification={notification} className="px-3 pb-3 sm:pl-14" />}
150+ {!card && canQuickReply(notification) && <QuickReply notification={notification} onSent={() => dismiss(notification.id)} />}
147151 <span aria-hidden="true" className="absolute inset-x-0 bottom-0 h-px bg-line">
148152 <span
149153 className="g1t-toast-timer block h-full bg-accent/70"
150− style={{ animationDuration: `${TOAST_MS}ms`, animationPlayState: paused ? "paused" : "running" }}
154+ style={{ animationDuration: `${card ? TOAST_MS * 2 : TOAST_MS}ms`, animationPlayState: paused ? "paused" : "running" }}
151155 onAnimationEnd={() => dismiss(notification.id)}
152156 />
153157 </span>
+33−2
2323
2424 import {
2525 HEARTBEAT_MS,
26+ addRecent,
2627 addToast,
2728 attentionCount,
2829 badgesOf,
3132 offerPush,
3233 reconnectDelay,
3334 titleWith,
35+ waitingCards,
3436 type LiveBadges,
3537 type PushChoice,
3638 type Toast,
175177 people: Record<string, PresenceEntry>;
176178 /** Your own presence, status and Do Not Disturb, once the feed has said. */
177179 me: OwnPresence | null;
180+ /** The latest notifications, newest first: the panel's cards waiting on you come from these. */
181+ recent: FeedNotification[];
182+ /** Card notifications acted on (or put away) in this tab, by id. */
183+ settled: ReadonlySet<string>;
178184 };
179185
180−let state: NotifyState = { connected: false, counts: {}, inbox: null, toasts: [], preferences: null, vapidKey: null, offer: false, people: {}, me: null };
186+let state: NotifyState = {
187+ connected: false,
188+ counts: {},
189+ inbox: null,
190+ toasts: [],
191+ preferences: null,
192+ vapidKey: null,
193+ offer: false,
194+ people: {},
195+ me: null,
196+ recent: [],
197+ settled: new Set(),
198+};
181199 const listeners = new Set<() => void>();
182200 const SERVER_STATE = state;
183201
207225 return workspace ? (s.counts[workspace.toLowerCase()] ?? null) : null;
208226 }
209227
228+/** A card's notification was acted on, or put away: its toast goes, and the panel stops waiting on it. */
229+export function settle(id: string): void {
230+ if (state.settled.has(id)) return;
231+ set({ settled: new Set(state.settled).add(id), toasts: dismissToast(state.toasts, id) });
232+}
233+
234+/** The chat cards waiting on the person (docs/WORKSPACE.md, "Cards"), newest first, for the panel. */
235+export function useWaitingCards(workspace?: string | null): FeedNotification[] {
236+ const s = useNotifyState();
237+ return waitingCards(s.recent, s.settled, Date.now(), workspace);
238+}
239+
210240 /** People's presence by user id, with an index by username (lowercased). */
211241 let peopleIndex: { from: Record<string, PresenceEntry>; byName: Map<string, PresenceEntry> } = { from: {}, byName: new Map() };
212242
338368 function onEvent(event: FeedEvent): void {
339369 switch (event.type) {
340370 case "hello":
341− set({ preferences: event.preferences, vapidKey: event.vapid_public_key });
371+ set({ preferences: event.preferences, vapidKey: event.vapid_public_key, recent: addRecent(state.recent, event.notifications ?? []) });
342372 break;
343373 case "counts": {
344374 const { type: _, ...counts } = event;
346376 break;
347377 }
348378 case "notification":
379+ set({ recent: addRecent(state.recent, [event.notification]) });
349380 sink.deliver(event.notification, { toast: event.toast, focused: focused() });
350381 break;
351382 case "preferences":
+51−0
55
66 import {
77 MAX_TOASTS,
8+ RECENT_KEPT,
9+ WAITING_MS,
10+ addRecent,
811 addToast,
12+ cardActionRequest,
13+ notificationActions,
14+ waitingCards,
915 attentionCount,
1016 badgesOf,
1117 canQuickReply,
165171 assert.equal(hiddenToFit([100, 100], 276, 60), 0);
166172 assert.equal(hiddenToFit([100, 100], 275, 60), 1);
167173 });
174+
175+const capCard = {
176+ channel_id: "chn_9",
177+ message_id: "msg_card",
178+ actions: [
179+ { id: "approve", label: "Approve more", style: "primary" as const, input: { kind: "money" as const, label: "New cap" } },
180+ { id: "stop", label: "Stop", style: "danger" as const, confirm: "Stop this session?" },
181+ { id: "open", label: "Open", href: "/acme/-/agents/g1t/sessions/ses_1" },
182+ ],
183+};
184+
185+test("a card's notification offers its actions and presses them as the card does", () => {
186+ const n = note("approval:ses_1:2000000", { kind: "approval", channel_id: null, card: capCard });
187+ assert.deepEqual(notificationActions(n).map((a) => a.id), ["approve", "stop", "open"]);
188+ assert.deepEqual(notificationActions(note("m1")), []);
189+ assert.deepEqual(cardActionRequest(n, capCard.actions[0]!, "5.00"), {
190+ url: "/acme/-/chat/api",
191+ body: { intent: "card_action", channel_id: "chn_9", message_id: "msg_card", action_id: "approve", input: "5.00" },
192+ });
193+ // A link opens its page; an action the card does not offer is not sent.
194+ assert.equal(cardActionRequest(n, capCard.actions[2]!, null), null);
195+ assert.equal(cardActionRequest(n, { id: "merge", label: "Merge" }, null), null);
196+ assert.equal(cardActionRequest(note("m1"), capCard.actions[1]!, null), null);
197+});
198+
199+test("recent notifications are kept newest first, once each", () => {
200+ const a = note("a", { created_at: "2026-10-08T00:00:01Z" });
201+ const b = note("b", { created_at: "2026-10-08T00:00:02Z" });
202+ assert.deepEqual(addRecent([a], [b, a]).map((n) => n.id), ["b", "a"]);
203+ const many = Array.from({ length: RECENT_KEPT + 5 }, (_, i) => note(`n${i}`, { created_at: new Date(Date.UTC(2026, 9, 8, 0, 0, i)).toISOString() }));
204+ assert.equal(addRecent([], many).length, RECENT_KEPT);
205+});
206+
207+test("the panel waits on the newest notification per card, for a day, until it is acted on", () => {
208+ const now = Date.parse("2026-10-09T12:00:00Z");
209+ const older = note("approval:ses_1:1000000", { kind: "approval", card: capCard, created_at: "2026-10-09T10:00:00Z" });
210+ const newer = note("approval:ses_1:2000000", { kind: "approval", card: capCard, created_at: "2026-10-09T11:00:00Z" });
211+ const draft = note("msg_d", { kind: "agent_waiting", workspace: "side", card: { ...capCard, message_id: "msg_draft" }, created_at: "2026-10-09T11:30:00Z" });
212+ const stale = note("msg_s", { kind: "agent_waiting", card: { ...capCard, message_id: "msg_stale" }, created_at: new Date(now - WAITING_MS - 1).toISOString() });
213+ const recent = addRecent([], [older, newer, draft, stale, note("plain")]);
214+ assert.deepEqual(waitingCards(recent, new Set(), now).map((n) => n.id), ["msg_d", "approval:ses_1:2000000"]);
215+ assert.deepEqual(waitingCards(recent, new Set(), now, "Acme").map((n) => n.id), ["approval:ses_1:2000000"]);
216+ // Acting on the newest puts the card away; the older one about it does not come back.
217+ assert.deepEqual(waitingCards(recent, new Set(["approval:ses_1:2000000"]), now, "acme"), []);
218+});
+64−1
55 * reconnecting. Pure, so they are tested apart from the socket
66 * (lib/notify-client.ts) and the toasts (components/notifications/).
77 */
8−import type { ChatSidebarEntry, FeedCounts, FeedNotification } from "@g1t/contracts";
8+import type { CardAction, ChatSidebarEntry, FeedCounts, FeedNotification } from "@g1t/contracts";
99
1010 /** The most toasts on screen; a fourth pushes the oldest out. */
1111 export const MAX_TOASTS = 3;
9494 };
9595 }
9696
97+// ── Cards ─────────────────────────────────────────────────────────────────
98+
99+/** A card's actions on its notification: what the toast and the panel offer (docs/WORKSPACE.md, "Cards"). */
100+export function notificationActions(notification: FeedNotification): CardAction[] {
101+ const card = notification.card;
102+ if (!card || !notification.workspace || !card.channel_id || !card.message_id) return [];
103+ return card.actions.filter((a) => !!a.id && !!a.label);
104+}
105+
106+/** What pressing one posts: the same `card_action` the card itself sends, to the chat api route. */
107+export function cardActionRequest(
108+ notification: FeedNotification,
109+ action: CardAction,
110+ input: string | null,
111+): { url: string; body: { intent: "card_action"; channel_id: string; message_id: string; action_id: string; input: string | null } } | null {
112+ const card = notification.card;
113+ if (action.href || !card || !notificationActions(notification).some((a) => a.id === action.id)) return null;
114+ return {
115+ url: `/${notification.workspace}/-/chat/api`,
116+ body: { intent: "card_action", channel_id: card.channel_id, message_id: card.message_id, action_id: action.id, input },
117+ };
118+}
119+
120+/** How long a card's notification stays in the panel's "Waiting on you". */
121+export const WAITING_MS = 24 * 3600_000;
122+/** The most notifications the panel keeps to look through. */
123+export const RECENT_KEPT = 50;
124+
125+/** Notifications as the feed tells them, newest first, each once, at most `RECENT_KEPT`. */
126+export function addRecent(recent: FeedNotification[], incoming: FeedNotification[]): FeedNotification[] {
127+ const seen = new Set<string>();
128+ const out: FeedNotification[] = [];
129+ for (const n of [...incoming, ...recent]) {
130+ if (seen.has(n.id)) continue;
131+ seen.add(n.id);
132+ out.push(n);
133+ }
134+ return out.sort((a, b) => (a.created_at < b.created_at ? 1 : a.created_at > b.created_at ? -1 : 0)).slice(0, RECENT_KEPT);
135+}
136+
137+/**
138+ * The cards waiting on the person, for the panel: notifications with
139+ * actions from the last day, the newest one per card, unless it was acted
140+ * on or put away here.
141+ */
142+export function waitingCards(recent: FeedNotification[], settled: ReadonlySet<string>, now: number, workspace?: string | null): FeedNotification[] {
143+ const cards = new Set<string>();
144+ const out: FeedNotification[] = [];
145+ for (const n of recent) {
146+ if (!notificationActions(n).length) continue;
147+ if (workspace && n.workspace !== workspace.toLowerCase()) continue;
148+ // The newest about a card speaks for it: once that one is acted on, older ones are moot.
149+ const key = `${n.card!.channel_id}:${n.card!.message_id}`;
150+ if (cards.has(key)) continue;
151+ cards.add(key);
152+ if (settled.has(n.id)) continue;
153+ const at = Date.parse(n.created_at);
154+ if (Number.isFinite(at) && now - at > WAITING_MS) continue;
155+ out.push(n);
156+ }
157+ return out;
158+}
159+
97160 // ── Counts ────────────────────────────────────────────────────────────────
98161
99162 /** What the rail shows for a workspace. */
+56−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { cleanTitle, threadLink, writableSpaces, writeUpAgents, writeUpMessage } from "./write-up.ts";
5+
6+test("a thread's link is its conversation with ?thread=", () => {
7+ assert.equal(threadLink("https://g1t.sh/", "/acme/-/chat/launch", "msg_1"), "https://g1t.sh/acme/-/chat/launch?thread=msg_1");
8+ assert.equal(threadLink("https://g1t.sh", "/acme/-/chat/dm/chn_1", "a b"), "https://g1t.sh/acme/-/chat/dm/chn_1?thread=a%20b");
9+});
10+
11+test("only spaces the person can write in, not archived, the default first", () => {
12+ const spaces = writableSpaces([
13+ { id: "s1", name: "Design", viewer_role: "edit", archived_at: null },
14+ { id: "s2", name: "Board", viewer_role: "view", archived_at: null },
15+ { id: "s3", name: "Old", viewer_role: "manage", archived_at: "2026-01-01T00:00:00Z" },
16+ { id: "s4", name: "General", viewer_role: "manage", archived_at: null, is_default: true },
17+ { id: "s5", name: "Notes", viewer_role: "comment", archived_at: null },
18+ ]);
19+ assert.deepEqual(spaces, [
20+ { id: "s4", name: "General" },
21+ { id: "s1", name: "Design" },
22+ ]);
23+});
24+
25+test("@g1t is always offered first, then the conversation's agents, once each", () => {
26+ const agents = writeUpAgents([
27+ { kind: "user", name: "ana", display_name: "Ana" },
28+ { kind: "agent", name: "Scribe", display_name: "Scribe" },
29+ { kind: "agent", name: "g1t", display_name: "g1t" },
30+ { kind: "agent", name: "scribe", display_name: "Scribe" },
31+ { kind: "agent", name: "qa", display_name: " " },
32+ ]);
33+ assert.deepEqual(agents, [
34+ { handle: "g1t", name: "g1t" },
35+ { handle: "scribe", name: "Scribe" },
36+ { handle: "qa", name: "qa" },
37+ ]);
38+});
39+
40+test("a title is one line without double quotes", () => {
41+ assert.equal(cleanTitle(' The "launch"\n plan '), "The 'launch' plan");
42+ assert.equal(cleanTitle(null), "");
43+ assert.equal(cleanTitle("x".repeat(200)).length, 120);
44+});
45+
46+test("the ask names the agent, the space, the title if any, and the thread to cite", () => {
47+ const link = "https://g1t.sh/acme/-/chat/launch?thread=msg_1";
48+ assert.equal(
49+ writeUpMessage({ agent: "g1t", space: "Engineering", title: "Launch plan", link }),
50+ `@g1t write this thread up as a Docs page in Engineering titled "Launch plan": what was decided, why, and what's next. Link this thread as the source: ${link}`,
51+ );
52+ assert.equal(
53+ writeUpMessage({ agent: "@Scribe", space: " Product notes ", title: " ", link }),
54+ `@scribe write this thread up as a Docs page in Product notes: what was decided, why, and what's next. Link this thread as the source: ${link}`,
55+ );
56+});
+64−0
1+/**
2+ * "Write this up in Docs" from chat (docs/WORKSPACE.md, "Docs"): the
3+ * spaces a person can pick, the agents that can write it, and the message
4+ * that asks one to. Nothing is written behind anyone's back: the person
5+ * posts the ask in the thread, as themselves, and the agent answers it the
6+ * usual way (a session if it needs one, then `create_page`). Pure, so it
7+ * is tested on its own; components/chat/write-up.tsx draws the dialog.
8+ */
9+import type { DocRole, MemberProfile } from "@g1t/contracts";
10+
11+/** The orchestrator every workspace has: the default writer, and @-mentioning it brings it in. */
12+export const ORCHESTRATOR = "g1t";
13+
14+/** The longest title the ask carries. */
15+const TITLE_MAX = 120;
16+
17+/** A thread's link, to cite and to share: the conversation's path (lib/chat.ts `channelPath`) with `?thread=`. */
18+export function threadLink(origin: string, conversationPath: string, rootId: string): string {
19+ return `${origin.replace(/\/+$/, "")}${conversationPath}?thread=${encodeURIComponent(rootId)}`;
20+}
21+
22+export type WritableSpace = { id: string; name: string };
23+
24+/** The Docs spaces a person can write in, not archived, the workspace's default first. */
25+export function writableSpaces(
26+ spaces: readonly { id: string; name: string; viewer_role: DocRole; archived_at: string | null; is_default?: boolean }[],
27+): WritableSpace[] {
28+ return spaces
29+ .filter((s) => (s.viewer_role === "edit" || s.viewer_role === "manage") && !s.archived_at)
30+ .sort((a, b) => Number(!!b.is_default) - Number(!!a.is_default))
31+ .map((s) => ({ id: s.id, name: s.name }));
32+}
33+
34+export type WriteUpAgent = { handle: string; name: string };
35+
36+/** Who can write it: @g1t first (always there to ask), then the agents in this conversation. */
37+export function writeUpAgents(members: readonly Pick<MemberProfile, "kind" | "name" | "display_name">[]): WriteUpAgent[] {
38+ const out: WriteUpAgent[] = [{ handle: ORCHESTRATOR, name: ORCHESTRATOR }];
39+ const seen = new Set([ORCHESTRATOR]);
40+ for (const member of members) {
41+ const handle = member.name.toLowerCase();
42+ if (member.kind !== "agent" || !handle || seen.has(handle)) continue;
43+ seen.add(handle);
44+ out.push({ handle, name: member.display_name.trim() || handle });
45+ }
46+ return out;
47+}
48+
49+/** A title as the ask quotes it: one line, no double quotes, not too long. */
50+export function cleanTitle(title: string | null | undefined): string {
51+ const text = String(title ?? "")
52+ .replace(/\s+/g, " ")
53+ .replace(/"/g, "'")
54+ .trim();
55+ return text.length > TITLE_MAX ? text.slice(0, TITLE_MAX).trimEnd() : text;
56+}
57+
58+/** What the person posts in the thread to ask for the page. */
59+export function writeUpMessage(input: { agent: string; space: string; title?: string | null; link: string }): string {
60+ const title = cleanTitle(input.title);
61+ const handle = input.agent.replace(/^@/, "").toLowerCase() || ORCHESTRATOR;
62+ const space = input.space.replace(/\s+/g, " ").trim();
63+ return `@${handle} write this thread up as a Docs page in ${space}${title ? ` titled "${title}"` : ""}: what was decided, why, and what's next. Link this thread as the source: ${input.link}`;
64+}
+44−3
11 // g1t's service worker: browser notifications, nothing else. It caches
22 // nothing and answers no requests, so pages load exactly as without it.
33 // Pushes come from the notify service (services/notify), encrypted for
4−// this browser: { title, body, href, tag, kind, urgent }.
4+// this browser: { title, body, href, tag, kind, urgent, workspace, actions,
5+// card }. A notification about a chat card (a session at its cap, a draft
6+// issue) has buttons for the card's actions that need nothing typed: a
7+// link opens its page; the others are pressed here, as on the card, by
8+// posting `card_action` to the site, and the answer shows as a notification.
59
610 self.addEventListener("install", () => self.skipWaiting());
711 self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
1418 data = { title: "g1t", body: event.data ? event.data.text() : "" };
1519 }
1620 const title = data.title || "g1t";
21+ const safe = (href) => (typeof href === "string" && href.startsWith("/") && !href.startsWith("//") ? href : null);
22+ const actions = Array.isArray(data.actions)
23+ ? data.actions
24+ .filter((a) => a && typeof a.id === "string" && typeof a.label === "string")
25+ .slice(0, 2)
26+ .map((a) => ({ id: a.id, label: a.label, href: safe(a.href) }))
27+ : [];
28+ const card =
29+ data.card && typeof data.card.channel_id === "string" && typeof data.card.message_id === "string" && typeof data.workspace === "string"
30+ ? { workspace: data.workspace, channel_id: data.card.channel_id, message_id: data.card.message_id }
31+ : null;
1732 event.waitUntil(
1833 self.registration.showNotification(title, {
1934 body: data.body || "",
2237 // One notification per conversation: a newer message replaces the last.
2338 tag: data.tag || undefined,
2439 renotify: Boolean(data.tag),
25− data: { href: typeof data.href === "string" && data.href.startsWith("/") && !data.href.startsWith("//") ? data.href : "/" },
40+ actions: actions.map((a) => ({ action: a.id, title: a.label })),
41+ data: { href: safe(data.href) || "/", actions, card },
2642 }),
2743 );
2844 });
2945
46+/** Presses a card's action for the person, with their session, and says what happened. */
47+async function pressCard(card, action, title) {
48+ let message = `${action.label} didn't go through. Open g1t to try again.`;
49+ try {
50+ const response = await fetch(`/${encodeURIComponent(card.workspace)}/-/chat/api`, {
51+ method: "POST",
52+ credentials: "same-origin",
53+ headers: { "content-type": "application/json" },
54+ body: JSON.stringify({ intent: "card_action", channel_id: card.channel_id, message_id: card.message_id, action_id: action.id, input: null }),
55+ });
56+ const result = await response.json();
57+ if (result && result.ok) message = result.value.message || (result.value.ok ? `${action.label}: done.` : `${action.label} didn't work.`);
58+ else if (result && result.error && result.error.message) message = result.error.message;
59+ } catch {
60+ // Offline, or signed out: the message above says so.
61+ }
62+ await self.registration.showNotification(title || "g1t", { body: message, icon: "/icon-192.png", badge: "/badge-96.png", tag: `card:${card.message_id}` });
63+}
64+
3065 self.addEventListener("notificationclick", (event) => {
3166 event.notification.close();
32− const href = (event.notification.data && event.notification.data.href) || "/";
67+ const data = event.notification.data || {};
68+ const pressed = event.action && Array.isArray(data.actions) ? data.actions.find((a) => a.id === event.action) : null;
69+ if (pressed && !pressed.href && data.card) {
70+ event.waitUntil(pressCard(data.card, pressed, event.notification.title));
71+ return;
72+ }
73+ const href = (pressed && pressed.href) || data.href || "/";
3374 const target = new URL(href, self.location.origin).href;
3475 event.waitUntil(
3576 (async () => {
+32−2
532532 For self-hosting, the Durable Object, R2 and D1 sit behind the room,
533533 `FileStore` and SQL; nothing above them depends on Cloudflare.
534534
535−Not built yet: citations and staleness, "write this up" from a thread,
536−the documenter agent, repository docs as spaces, `doc.page.*` events and
535+"Write this up" from a thread is built as an ask, not a hidden job: **⋯ →
536+Write this up in Docs** (a message's menu, the long-press sheet, the thread
537+panel's header) picks a space the person can edit, an optional title and
538+the writer (@g1t, or an agent in the conversation), then posts, as the
539+person, in the thread: `@g1t write this thread up as a Docs page in <space>
540+titled "<title>": what was decided, why, and what's next. Link this thread
541+as the source: <thread link>`. The normal agent flow does the rest
542+(a session if needed, `create_page`). The page linking back is the agent's
543+doing; the thread link it cites is `<conversation path>?thread=<id>`, which
544+**Copy link to thread** also gives.
545+
546+Not built yet: citations and staleness, the documenter agent, repository docs as spaces, `doc.page.*` events and
537547 indexing pages in `services/context`.
538548
539549 ## Chat
10001010 that needs a value (an amount, a line of text) asks for it inline. Agents
10011011 never file, approve or stop anything on their own through a card.
10021012
1013+**From a notification.** A notification about a card carries
1014+`card: { channel_id, message_id, actions }` (`FeedNotification.card`), and
1015+pressing one of those actions sends the same `card_action` as the card.
1016+
1017+- Chat attaches it to any notification about a message whose card has an
1018+ owner and something to press, and an agent's card that asks someone to
1019+ act (a primary action: File issue, Approve more) notifies whoever asked
1020+ the agent as `agent_waiting`, if they are in the conversation.
1021+- The agents service sends `approval` (a session at its cap) with the
1022+ session card's place and actions.
1023+- The toast shows the actions (amounts inline, confirmations inline); the
1024+ inbox panel lists **Waiting on you in chat**: the newest notification per
1025+ card from the last day, until it is acted on in that tab.
1026+- A push has buttons only for actions with no input (two at most: Stop,
1027+ Open, File issue, Discard). The service worker posts `card_action` with
1028+ the person's session and shows the answer as a notification.
1029+- A thread's first page carries `root`, the message it is under, however
1030+ long the thread is, so a session's live card stays at the top of its
1031+ thread panel.
1032+
10031033 ## Live notifications
10041034
10051035 A DM has to reach someone wherever they are in g1t, not only inside Chat.
+6−0
294294 /** Pass as `before` to read further back; null at the beginning. */
295295 older: string | null;
296296 /**
297+ * For a thread's first page: the message the thread is under, however
298+ * many replies it has, so a thread panel always shows it (a session's
299+ * live card, say) at its top.
300+ */
301+ root?: ChatMessage | null;
302+ /**
297303 * Set when the page was read with `after`: pass it as `after` again for
298304 * the next messages, or null when this page reached the newest.
299305 */
+19−0
77 * events service about every inbox item, and the site forwards each tab's
88 * `/-/live` socket to it. Wire shapes are snake_case end to end.
99 */
10+import type { CardAction } from "./chat";
1011 import type { ServiceBinding } from "./clients";
1112 import type { User } from "./identity";
1213
4344 channel_id?: string | null;
4445 /** The thread it is in, for a reply: quick replies go there. */
4546 thread_root?: string | null;
47+ /**
48+ * The chat card it is about, when that card has something to press: a
49+ * session at its cap (Approve more, Stop, Open), a draft issue (File
50+ * issue, Discard). The toast and the notifications panel show these
51+ * actions, and pressing one goes to the site's `card_action`, exactly as
52+ * on the card itself (docs/WORKSPACE.md, "Cards").
53+ */
54+ card?: NotificationCard | null;
4655 created_at: string;
4756 };
4857
58+/** A card's place and its main actions, carried on a notification about it. */
59+export type NotificationCard = {
60+ /** The conversation the card is in. */
61+ channel_id: string;
62+ /** The message that is the card. */
63+ message_id: string;
64+ /** The card's actions as it offers them: ids, labels, inputs and links, the same as on the card. */
65+ actions: CardAction[];
66+};
67+
4968 /** How much a person hears of. Counts always move; this governs toasts and pushes. */
5069 export type NotifyLevel = "all" | "dms_mentions" | "none";
5170
+10−2
992992 /** Tells whoever asked, and the agent's maker, that a session waits for more budget. */
993993 async function notifyApproval(env: SessionEnv, row: SessionRow, agent: Row): Promise<void> {
994994 if (!env.NOTIFY) return;
995+ // The card's own buttons ride along, so it can be approved from the notification.
996+ const { root } = await speaker(env.DB, row);
997+ const href = `/${row.workspace}/-/agents/${agent.handle}/sessions/${row.id}`;
998+ const card = root.card_message_id
999+ ? { channel_id: root.channel_id, message_id: root.card_message_id, actions: sessionActions("needs_approval", row.cap_micros, row.charged_micros, href) }
1000+ : null;
9951001 const targets = new Set<string>();
9961002 if (row.asked_by_username) targets.add(row.asked_by_username);
9971003 if (agent.created_by) targets.add(agent.created_by);
10071013 workspace: row.workspace,
10081014 title: `${agent.display_name} needs more budget`,
10091015 body: `"${row.title}" reached its cap of ${dollars(row.cap_micros ?? 0)}.`,
1010− href: `/${row.workspace}/-/agents/${agent.handle}/sessions/${row.id}`,
1016+ href,
10111017 actor: { kind: "agent", id: agent.id, name: agent.display_name, avatar_seed: agent.avatar_seed || agent.handle },
1012− channel_id: null,
1018+ // While that conversation is open the card is there already: no toast.
1019+ channel_id: root.channel_id,
1020+ card,
10131021 created_at: iso(),
10141022 },
10151023 }),
+11−6
10301030 .all<MessageRow>();
10311031 const page = pageOf(rows.results, size);
10321032 let list = page.rows;
1033− if (root && page.older === null) {
1034− const first = await this.messageRow(channel.id, root);
1035− if (first) list = [...list, first];
1036− }
1037− return ok({ messages: await this.toMessages(slug, workspace, list, userKey(a.viewer!)), older: page.older });
1033+ // The message a thread is under: the oldest once the page reaches the
1034+ // start, and, on the first page, as `root` however long the thread is,
1035+ // so a session's card stays at the top of its thread.
1036+ const firstPage = before === "~";
1037+ const rootRow = root && (page.older === null || firstPage) ? await this.messageRow(channel.id, root) : null;
1038+ if (rootRow && page.older === null) list = [...list, rootRow];
1039+ const messages = await this.toMessages(slug, workspace, list, userKey(a.viewer!));
1040+ if (!rootRow || !firstPage) return ok({ messages, older: page.older });
1041+ const shown = messages.find((m) => m.id === rootRow.id) ?? (await this.toMessages(slug, workspace, [rootRow], userKey(a.viewer!)))[0] ?? null;
1042+ return ok({ messages, older: page.older, root: shown });
10381043 }
10391044
10401045 /**
11101115 );
11111116 // Notify: counts for everyone in the conversation, a notification for those it is for.
11121117 this.defer(
1113− notifyMessage(this.db, this.env.NOTIFY, (keys) => this.profiles(place.slug, workspace, keys), { slug: place.slug, channel, row, handles }).catch(
1118+ notifyMessage(this.db, this.env.NOTIFY, (keys) => this.profiles(place.slug, workspace, keys), { slug: place.slug, channel, row, handles, asked_by: chain.asked_by }).catch(
11141119 (error) => console.error("chat could not notify about", row.id, error),
11151120 ),
11161121 );
+53−1
33
44 import type { MemberProfile } from "@g1t/contracts";
55
6−import { conversationHref, countsAfterRead, messageDeliveries, preview, recipients, type Person } from "./notify.ts";
6+import { asksToAct, conversationHref, countsAfterRead, messageDeliveries, notificationCard, preview, recipients, type Person } from "./notify.ts";
77
88 const person = (id: string, username: string, muted = false): Person => ({ key: `user:${id}`, user_id: id, username, muted });
99 const ana = person("u1", "ana");
9494 assert.deepEqual(countsAfterRead(rows, "c", "user:u1", "ana", "m2"), { unread: 2, mentions: 1 });
9595 assert.deepEqual(countsAfterRead([], "c", "user:u1", "ana", "m9"), { unread: 0, mentions: 0 });
9696 });
97+
98+test("a card's actions ride on a notification only when it has an owner and something to press", () => {
99+ const draft = {
100+ kind: "draft_issue",
101+ title: "Fix the login",
102+ owner: "agents",
103+ actions: [
104+ { id: "file", label: "File issue", style: "primary" },
105+ { id: "discard", label: "Discard" },
106+ ],
107+ };
108+ const card = notificationCard("chn_1", "msg_1", draft)!;
109+ assert.deepEqual(card, { channel_id: "chn_1", message_id: "msg_1", actions: draft.actions });
110+ assert.ok(asksToAct(card));
111+ // Links only, no owner, or no actions: nothing to press from a toast.
112+ assert.equal(notificationCard("chn_1", "msg_1", { ...draft, actions: [{ id: "open", label: "Open", href: "/x" }] }), null);
113+ assert.equal(notificationCard("chn_1", "msg_1", { ...draft, owner: null }), null);
114+ assert.equal(notificationCard("chn_1", "msg_1", { kind: "pull", title: "t" }), null);
115+ assert.equal(notificationCard("chn_1", "msg_1", null), null);
116+ // A working session's card (Message, Stop, Open) is pressable but asks nobody.
117+ const working = notificationCard("chn_1", "msg_2", {
118+ owner: "agents",
119+ actions: [
120+ { id: "steer", label: "Message", input: { kind: "text", label: "Tell it" } },
121+ { id: "stop", label: "Stop", style: "danger" },
122+ { id: "open", label: "Open", href: "/acme/-/agents/g1t/sessions/s1" },
123+ ],
124+ });
125+ assert.equal(working?.actions.length, 3);
126+ assert.equal(asksToAct(working), false);
127+});
128+
129+test("an agent's card that asks someone to act notifies whoever asked, with its actions", () => {
130+ const g1t: MemberProfile = { kind: "agent", id: "a1", name: "g1t", display_username: null, display_name: "g1t", avatar: null, role: null };
131+ const card = notificationCard("chn_1", "msg_9", { owner: "agents", actions: [{ id: "file", label: "File issue", style: "primary" }, { id: "discard", label: "Discard" }] });
132+ const items = messageDeliveries({
133+ slug: "acme",
134+ channel: { id: "chn_1", kind: "channel", name: "design" },
135+ message: { id: "msg_9", author: "agent:a1", body: "", card_title: "Fix the login", thread_root: "msg_1", created_at: "2026-10-08T00:00:00Z" },
136+ author: g1t,
137+ card,
138+ recipients: recipients({ author: "agent:a1", channelKind: "channel", people: [ana, bo], mentioned: [], thread: new Set(["user:u2"]), waitingOn: "user:u1" }),
139+ });
140+ const [toAna, toBo] = items;
141+ assert.equal(toAna.notification?.kind, "agent_waiting");
142+ assert.equal(toAna.notification?.body, "Fix the login");
143+ assert.deepEqual(toAna.notification?.card, card);
144+ assert.equal(toBo.notification?.kind, "thread_reply");
145+ assert.equal(toBo.notification?.card?.message_id, "msg_9");
146+ // A mention still reads as a mention.
147+ assert.deepEqual(kinds(recipients({ author: "agent:a1", channelKind: "channel", people: [ana], mentioned: ["ana"], thread: null, waitingOn: "user:u1" })), { u1: "mention" });
148+});
+44−2
1111 * - The author is never told of their own message; their own count for
1212 * the conversation goes to nothing (what you wrote, you have read).
1313 * - Agents have no feed: only people are told.
14+ * - An agent's card that asks someone to act (a draft issue to file, a
15+ * session's Approve more: a primary action) notifies whoever asked the
16+ * agent, as waiting on them, if they are in the conversation.
17+ * - A notification about a card someone can act on carries the card's
18+ * place and actions (`card`), so its toast offers them.
1419 *
1520 * `recipients` is pure, so the rules are tested apart from the service.
1621 */
17−import type { FeedDelivery, FeedNotification, MemberProfile, NotificationKind } from "@g1t/contracts";
22+import type { CardAction, FeedDelivery, FeedNotification, MemberProfile, NotificationCard, NotificationKind } from "@g1t/contracts";
1823
1924 import { tally, type UnreadRow } from "./unread.ts";
2025
3237 mentioned: string[];
3338 /** For a reply: the people in its thread, by key; null for a top-level message. */
3439 thread: ReadonlySet<string> | null;
40+ /** Who an agent's card asks to act (`user:<id>`, whoever asked the agent), or null. */
41+ waitingOn?: string | null;
3542 }): Recipient[] {
3643 const named = new Set(input.mentioned.map((h) => h.toLowerCase()));
3744 const out: Recipient[] = [];
4148 let kind: NotificationKind | null = null;
4249 if (input.channelKind === "dm") kind = "dm";
4350 else if (mentioned) kind = "mention";
51+ else if (input.waitingOn === person.key) kind = "agent_waiting";
4452 else if (input.thread?.has(person.key) && !person.muted) kind = "thread_reply";
4553 out.push({ user_id: person.user_id, kind, mentioned, muted: person.muted });
4654 }
6068 return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
6169 }
6270
71+/** At most this many of a card's actions ride on a notification. */
72+const NOTIFIED_ACTIONS = 4;
73+
74+/**
75+ * A card's place and actions for a notification about it, from the card as
76+ * stored: only when it has an owner to answer and something to press that
77+ * is not just a link; null otherwise.
78+ */
79+export function notificationCard(channelId: string, messageId: string, card: unknown): NotificationCard | null {
80+ if (!card || typeof card !== "object") return null;
81+ const c = card as { owner?: unknown; actions?: unknown };
82+ if (!c.owner || !Array.isArray(c.actions)) return null;
83+ const actions = (c.actions as CardAction[]).filter((a) => a && typeof a.id === "string" && typeof a.label === "string").slice(0, NOTIFIED_ACTIONS);
84+ if (!actions.some((a) => !a.href)) return null;
85+ return { channel_id: channelId, message_id: messageId, actions };
86+}
87+
88+/** Whether a card asks someone to act: it has a primary action that runs (File issue, Approve more). */
89+export function asksToAct(card: NotificationCard | null): boolean {
90+ return !!card?.actions.some((a) => a.style === "primary" && !a.href);
91+}
92+
6393 /** Where a conversation, or a thread in it, is on the site. */
6494 export function conversationHref(slug: string, channel: { id: string; kind: "channel" | "dm"; name: string | null }, threadRoot: string | null): string {
6595 const base = channel.kind === "dm" || !channel.name ? `/${slug}/-/chat/dm/${channel.id}` : `/${slug}/-/chat/${channel.name}`;
73103 message: { id: string; author: string; body: string; card_title: string | null; thread_root: string | null; created_at: string };
74104 author: MemberProfile;
75105 recipients: Recipient[];
106+ /** The card's place and actions, when it has something to press. */
107+ card?: NotificationCard | null;
76108 }): FeedDelivery[] {
77109 const { slug, channel, message, author } = input;
78110 const where = channel.kind === "dm" ? "" : ` in #${channel.name}`;
97129 },
98130 channel_id: channel.id,
99131 thread_root: message.thread_root,
132+ ...(input.card ? { card: input.card } : {}),
100133 created_at: message.created_at,
101134 }
102135 : null;
150183 channel: { id: string; kind: "channel" | "dm"; name: string | null };
151184 row: { id: string; author: string; body: string; card: string | null; thread_root: string | null; created_at: string };
152185 handles: string[];
186+ /** Who the agent posting was asked by (a user id), for a card that waits on them. */
187+ asked_by?: string | null;
153188 },
154189 ): Promise<void> {
155190 if (!notify) return;
177212 const author = found.get(row.author);
178213 if (!author) return;
179214 let cardTitle: string | null = null;
215+ let card: NotificationCard | null = null;
180216 if (row.card) {
181217 try {
182− cardTitle = (JSON.parse(row.card) as { title?: string }).title ?? null;
218+ const parsed = JSON.parse(row.card) as { title?: string };
219+ cardTitle = parsed.title ?? null;
220+ card = notificationCard(channel.id, row.id, parsed);
183221 } catch {
184222 cardTitle = null;
185223 }
186224 }
225+ // An agent's card asking someone to act waits on whoever asked the agent.
226+ const waitingOn = row.author.startsWith("agent:") && input.asked_by && asksToAct(card) ? `user:${input.asked_by}` : null;
187227 await deliver(
188228 notify,
189229 messageDeliveries({
191231 channel,
192232 message: { id: row.id, author: row.author, body: row.body, card_title: cardTitle, thread_root: row.thread_root, created_at: row.created_at },
193233 author,
234+ card,
194235 recipients: recipients({
195236 author: row.author,
196237 channelKind: channel.kind,
197238 people,
198239 mentioned: input.handles,
199240 thread: thread ? new Set(thread.results.map((r) => r.author)) : null,
241+ waitingOn,
200242 }),
201243 }),
202244 );
+56−1
44 import type { FeedNotification } from "@g1t/contracts";
55
66 import { applyCounts, totals } from "./counts.ts";
7−import { STALE_MS, anyFocused, cleanNotification, decide, levelFor, mergePreferences, pushPayload, readPreferences, wants } from "./prefs.ts";
7+import { STALE_MS, anyFocused, cleanCard, cleanNotification, decide, levelFor, mergePreferences, pushActions, pushPayload, readPreferences, wants } from "./prefs.ts";
88
99 const NOW = 1_800_000_000_000;
1010 const dms = { level: "dms_mentions" as const, workspaces: {} };
135135 complete: true,
136136 });
137137 });
138+
139+const capped = {
140+ channel_id: "chn_1",
141+ message_id: "msg_card",
142+ actions: [
143+ { id: "approve", label: "Approve more", style: "primary", input: { kind: "money", label: "New cap", initial: "4.00" } },
144+ { id: "stop", label: "Stop", style: "danger", confirm: "Stop this session and everything under it?" },
145+ { id: "open", label: "Open", href: "/acme/-/agents/g1t/sessions/ses_1" },
146+ ],
147+};
148+
149+test("a notification's card is checked: its place, its actions, links kept on the site", () => {
150+ const card = cleanCard(capped)!;
151+ assert.equal(card.message_id, "msg_card");
152+ assert.deepEqual(
153+ card.actions.map((a) => [a.id, a.style, a.input?.kind ?? null, a.href ?? null]),
154+ [
155+ ["approve", "primary", "money", null],
156+ ["stop", "danger", null, null],
157+ ["open", "default", null, "/acme/-/agents/g1t/sessions/ses_1"],
158+ ],
159+ );
160+ assert.equal(card.actions[1].confirm, "Stop this session and everything under it?");
161+ assert.equal(cleanCard({ ...capped, actions: [{ id: "x", label: "X", href: "https://evil.example/" }] })!.actions[0].href, null);
162+ assert.equal(cleanCard({ ...capped, message_id: "" }), null);
163+ assert.equal(cleanCard({ ...capped, actions: [{ id: "", label: "" }] }), null);
164+ assert.equal(cleanCard("nope"), null);
165+ // Carried on the notification when it is one; left off otherwise.
166+ const n = cleanNotification({ id: "approval:ses_1:2000000", kind: "approval", workspace: "acme", title: "g1t needs more budget", card: capped })!;
167+ assert.equal(n.card?.actions.length, 3);
168+ assert.equal("card" in cleanNotification({ id: "n", kind: "dm", title: "t" })!, false);
169+});
170+
171+test("a push shows only the card's actions that need nothing typed or confirmed, two at most", () => {
172+ const card = cleanCard(capped)!;
173+ // Stop asks first in the app; a push can't, so it isn't offered there.
174+ assert.deepEqual(pushActions(card), [{ id: "open", label: "Open", href: "/acme/-/agents/g1t/sessions/ses_1" }]);
175+ assert.deepEqual(pushActions(null), []);
176+ const payload = pushPayload({
177+ id: "approval:ses_1:2000000",
178+ kind: "approval",
179+ workspace: "acme",
180+ title: "g1t needs more budget",
181+ body: "",
182+ href: "/acme/-/agents/g1t/sessions/ses_1",
183+ actor: { kind: "agent", id: "a1", name: "g1t" },
184+ channel_id: "chn_1",
185+ card,
186+ created_at: "2026-10-08T00:00:00Z",
187+ });
188+ assert.deepEqual(payload.card, { channel_id: "chn_1", message_id: "msg_card" });
189+ assert.equal(payload.workspace, "acme");
190+ // Its own notification: a later message in the conversation does not replace it.
191+ assert.equal(payload.tag, "approval:approval:ses_1:2000000");
192+});
+85−3
1010 * says it has focus over its socket, and a tab that has said nothing for
1111 * `STALE_MS` counts as gone (a laptop lid closed on it).
1212 */
13−import type { FeedNotification, NotificationKind, NotifyLevel, NotifyPreferences } from "@g1t/contracts";
13+import type { CardAction, FeedNotification, NotificationCard, NotificationKind, NotifyLevel, NotifyPreferences } from "@g1t/contracts";
1414
1515 // The same as NOTIFY_LEVELS and DEFAULT_NOTIFY_PREFERENCES in @g1t/contracts, kept here so
1616 // Node runs the tests on this file without the contracts package.
118118 const href = text(n.href, 2000);
119119 const a = (n.actor && typeof n.actor === "object" ? n.actor : {}) as Record<string, unknown>;
120120 const actorKind = a.kind === "user" || a.kind === "agent" ? a.kind : "system";
121+ const card = cleanCard(n.card);
121122 return {
122123 id,
123124 kind,
135136 },
136137 channel_id: text(n.channel_id, 100) || null,
137138 thread_root: text(n.thread_root, 100) || null,
139+ ...(card ? { card } : {}),
138140 created_at: text(n.created_at, 40) || new Date().toISOString(),
139141 };
140142 }
141143
144+/** The most of a card's actions a notification carries, and a push shows (browsers show two). */
145+const CARD_ACTIONS = 4;
146+const PUSH_ACTIONS = 2;
147+
148+/** A site path, or null: a notification never links somewhere else. */
149+function sitePath(value: unknown): string | null {
150+ const href = typeof value === "string" ? value.trim().slice(0, 2000) : "";
151+ return href.startsWith("/") && !href.startsWith("//") ? href : null;
152+}
153+
154+/**
155+ * The card a notification is about, checked: where it is and its actions
156+ * as the card offers them. Null when it is not one, or has nothing to press.
157+ */
158+export function cleanCard(value: unknown): NotificationCard | null {
159+ if (!value || typeof value !== "object") return null;
160+ const c = value as Record<string, unknown>;
161+ const text = (v: unknown, max: number) => (typeof v === "string" ? v.trim().slice(0, max) : "");
162+ const channel_id = text(c.channel_id, 100);
163+ const message_id = text(c.message_id, 100);
164+ if (!channel_id || !message_id || !Array.isArray(c.actions)) return null;
165+ const actions: CardAction[] = [];
166+ for (const raw of c.actions.slice(0, CARD_ACTIONS)) {
167+ if (!raw || typeof raw !== "object") continue;
168+ const a = raw as Record<string, unknown>;
169+ const id = text(a.id, 40);
170+ const label = text(a.label, 60);
171+ if (!id || !label) continue;
172+ const style = a.style === "primary" || a.style === "danger" ? a.style : "default";
173+ const input = a.input && typeof a.input === "object" ? (a.input as Record<string, unknown>) : null;
174+ actions.push({
175+ id,
176+ label,
177+ style,
178+ confirm: text(a.confirm, 200) || null,
179+ input:
180+ input && (input.kind === "money" || input.kind === "text")
181+ ? { kind: input.kind, label: text(input.label, 100), placeholder: text(input.placeholder, 100) || null, initial: text(input.initial, 40) || null }
182+ : null,
183+ href: sitePath(a.href),
184+ });
185+ }
186+ return actions.length ? { channel_id, message_id, actions } : null;
187+}
188+
189+/**
190+ * A push's buttons: a card's actions that need nothing typed (a push has no
191+ * field) and nothing confirmed (a push can't ask first, so Stop waits for
192+ * the app), links and all.
193+ */
194+export type PushAction = { id: string; label: string; href: string | null };
195+
196+export function pushActions(card: NotificationCard | null | undefined): PushAction[] {
197+ if (!card) return [];
198+ return card.actions
199+ .filter((a) => !a.input && !a.confirm && a.style !== "danger")
200+ .slice(0, PUSH_ACTIONS)
201+ .map((a) => ({ id: a.id, label: a.label, href: a.href ?? null }));
202+}
203+
142204 /**
143205 * What a push carries: little, under the 4 KB a push may hold. `tag` makes
144206 * the notifications of one conversation replace each other.
145207 */
146−export function pushPayload(n: FeedNotification): { title: string; body: string; href: string; tag: string; kind: NotificationKind; urgent: boolean } {
208+export type PushPayload = {
209+ title: string;
210+ body: string;
211+ href: string;
212+ tag: string;
213+ kind: NotificationKind;
214+ urgent: boolean;
215+ /** The workspace's slug, for a card action's request. */
216+ workspace: string;
217+ /** Buttons on the notification: a card's actions that need nothing typed. */
218+ actions: PushAction[];
219+ /** Where the card is, for those that run (public/sw.js posts `card_action`). */
220+ card: { channel_id: string; message_id: string } | null;
221+};
222+
223+export function pushPayload(n: FeedNotification): PushPayload {
224+ const actions = pushActions(n.card);
147225 return {
148226 title: n.title,
149227 body: n.body.slice(0, 240),
150228 href: n.href,
151− tag: n.channel_id ? `chat:${n.channel_id}` : `${n.kind}:${n.id}`,
229+ // A card's notification is its own, so a newer message does not replace it.
230+ tag: n.channel_id && !actions.length ? `chat:${n.channel_id}` : `${n.kind}:${n.id}`,
152231 kind: n.kind,
153232 urgent: DIRECT.has(n.kind),
233+ workspace: n.workspace,
234+ actions,
235+ card: actions.length && n.card ? { channel_id: n.card.channel_id, message_id: n.card.message_id } : null,
154236 };
155237 }