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
22 files+1280−390/22 viewed
| 183 | 183 | channel, and answers in it. Keep one request to one thread, and the agent | |
| 184 | 184 | always has the context it needs. | |
| 185 | 185 | ||
| 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 | + | ||
| 186 | 200 | ## Mentions | |
| 187 | 201 | ||
| 188 | 202 | Type `@` and a name to mention a person by username or an agent by handle: | |
| ⋯ | |||
| 256 | 270 | issue, a stopped session reads **Stopped**. | |
| 257 | 271 | ||
| 258 | 272 | 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**. | |
| 260 | 283 | ||
| 261 | 284 | | Card | Its buttons | | |
| 262 | 285 | | --- | --- | | |
| ⋯ | |||
| 459 | 482 | ||
| 460 | 483 | | | | | |
| 461 | 484 | | --- | --- | | |
| 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**. | | |
| 463 | 486 | | **Sound** | A soft chime with each pop-up, in this browser, if you turn it on. | | |
| 464 | 487 | | **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. | | |
| 466 | 489 | ||
| 467 | 490 | ### Choose what you hear about | |
| 468 | 491 | ||
| 198 | 198 | thread. The page is yours: you're its owner, and it links back to where it | |
| 199 | 199 | came from. | |
| 200 | 200 | ||
| 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 | + | ||
| 201 | 212 | ### Accept or reject | |
| 202 | 213 | ||
| 203 | 214 | Each suggestion says which agent made it, who it was for, and why. |
| 125 | 125 | setToasts(toasts.filter((t) => t.id !== id)); | |
| 126 | 126 | } | |
| 127 | 127 | ||
| 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) { | |
| 129 | 130 | const id = ++toastSeq; | |
| 130 | 131 | // Three at most; a fourth pushes the oldest out. | |
| 131 | 132 | setToasts([...toasts.slice(-2), { id, ok, message }]); |
| 3 | 3 | ArchiveRestore, | |
| 4 | 4 | ChevronLeft, | |
| 5 | 5 | Copy, | |
| 6 | + | Ellipsis, | |
| 7 | + | FileText, | |
| 8 | + | Link2, | |
| 6 | 9 | Pencil, | |
| 7 | 10 | Trash2, | |
| 8 | 11 | BellOff, | |
| ⋯ | |||
| 31 | 34 | } from "@g1t/contracts"; | |
| 32 | 35 | ||
| 33 | 36 | import { useChatData, useChatSend, useChatSidebar } from "./actions"; | |
| 34 | − | import { CardBox, CardToasts } from "./card"; | |
| 37 | + | import { CardBox, CardToasts, showToast } from "./card"; | |
| 35 | 38 | import { Composer } from "./composer"; | |
| 36 | 39 | import { type LiveState, useChatLive } from "./live"; | |
| 37 | 40 | import { AgentPill, MemberAvatar } from "./marks"; | |
| ⋯ | |||
| 41 | 44 | import { localTime } from "../../lib/time-zone"; | |
| 42 | 45 | import { BottomSheet, SheetRow, useBack, useSwipeBack } from "../mobile"; | |
| 43 | 46 | 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"; | |
| 44 | 50 | import { | |
| 45 | 51 | AlertDialog, | |
| 46 | 52 | AlertDialogAction, | |
| ⋯ | |||
| 112 | 118 | const [older, setOlder] = useState<string | null>(data.older); | |
| 113 | 119 | const [loadingOlder, setLoadingOlder] = useState(false); | |
| 114 | 120 | 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); | |
| 115 | 124 | const [typing, setTyping] = useState<Map<string, { member: MemberProfile; until: number }>>(new Map()); | |
| 116 | 125 | const [joined, setJoined] = useState(data.joined); | |
| 117 | 126 | const [members, setMembers] = useState<ChannelMember[]>(data.members); | |
| ⋯ | |||
| 244 | 253 | if (!threadId) return setThread(null); | |
| 245 | 254 | try { | |
| 246 | 255 | 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 }>; | |
| 248 | 257 | // The page reaches back to the message the thread is under: shown above it, not among the replies. | |
| 249 | 258 | 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); | |
| 250 | 261 | } catch { | |
| 251 | 262 | setThread([]); | |
| 252 | 263 | } | |
| 253 | 264 | }, [threadId, slug, data.channel.id]); | |
| 254 | 265 | useEffect(() => { | |
| 255 | 266 | setThread(null); | |
| 267 | + | setThreadRoot(null); | |
| 256 | 268 | void loadThread(); | |
| 257 | 269 | }, [loadThread]); | |
| 258 | 270 | ||
| ⋯ | |||
| 281 | 293 | } else { | |
| 282 | 294 | if (event.type === "message.updated") serverCounted.current.add(message.id); | |
| 283 | 295 | 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)); | |
| 284 | 298 | } | |
| 285 | 299 | // Whoever just spoke is no longer typing. | |
| 286 | 300 | setTyping((now) => { | |
| ⋯ | |||
| 295 | 309 | const gone = (m: ShownMessage) => (m.id === event.id ? { ...m, deleted_at: new Date().toISOString() } : m); | |
| 296 | 310 | setMessages((now) => now.map(gone)); | |
| 297 | 311 | setThread((now) => now?.map(gone) ?? now); | |
| 312 | + | setThreadRoot((now) => (now ? gone(now) : now)); | |
| 298 | 313 | } else if (event.type === "reaction.added" || event.type === "reaction.removed") { | |
| 299 | 314 | if (event.channel_id !== data.channel.id) return; | |
| 300 | 315 | setMessages((now) => applyReactionEvent(now, event, me?.id ?? null)); | |
| ⋯ | |||
| 477 | 492 | setParams(next, { preventScrollReset: true, replace: !!threadId && !!id }); | |
| 478 | 493 | }; | |
| 479 | 494 | ||
| 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 | + | ||
| 480 | 522 | const updateChannel = useCallback( | |
| 481 | 523 | async (change: ChannelChange): Promise<Result<unknown>> => { | |
| 482 | 524 | const done = await send<{ channel: Channel }>({ intent: "update_channel", channel_id: data.channel.id, ...change }); | |
| ⋯ | |||
| 519 | 561 | const placeholder = isDm | |
| 520 | 562 | ? `Message ${others.map(shownName).join(", ") || "yourself"}` | |
| 521 | 563 | : `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; | |
| 523 | 565 | const cardContext = useMemo( | |
| 524 | 566 | () => ({ | |
| 525 | 567 | slug, | |
| ⋯ | |||
| 594 | 636 | onThread={() => openThread(row.message.id)} | |
| 595 | 637 | onRetry={() => retry(row.message)} | |
| 596 | 638 | onLongPress={() => setActions(row.message)} | |
| 639 | + | onCopyLink={() => copyThreadLink(row.message.id)} | |
| 640 | + | onWriteUp={joined && !archived ? () => setWriteUp(row.message.id) : undefined} | |
| 597 | 641 | threadOpen={row.message.id === threadId} | |
| 598 | 642 | /> | |
| 599 | 643 | ), | |
| ⋯ | |||
| 642 | 686 | </div> | |
| 643 | 687 | </section> | |
| 644 | 688 | {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 | + | > | |
| 646 | 695 | <ThreadPanel | |
| 647 | 696 | root={rootMessage} | |
| 648 | 697 | replies={thread} | |
| ⋯ | |||
| 683 | 732 | mine={actions != null && actions.author.kind === "user" && actions.author.id === me?.id} | |
| 684 | 733 | onClose={() => setActions(null)} | |
| 685 | 734 | 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} | |
| 686 | 737 | onEdit={(message) => setEditing({ id: message.id, body: message.body })} | |
| 687 | 738 | onDelete={async (message) => { | |
| 688 | 739 | // Gone at once; back if the service refuses. | |
| ⋯ | |||
| 691 | 742 | if (!done.ok) setMessages((now) => now.map((m) => (m.id === message.id ? { ...m, deleted_at: null } : m))); | |
| 692 | 743 | }} | |
| 693 | 744 | /> | |
| 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 | + | /> | |
| 694 | 753 | <BottomSheet open={editing != null} onOpenChange={(open) => !open && setEditing(null)} title="Edit message"> | |
| 695 | 754 | {editing && ( | |
| 696 | 755 | <form | |
| ⋯ | |||
| 1078 | 1137 | mine, | |
| 1079 | 1138 | onClose, | |
| 1080 | 1139 | onThread, | |
| 1140 | + | onCopyLink, | |
| 1141 | + | onWriteUp, | |
| 1081 | 1142 | onEdit, | |
| 1082 | 1143 | onDelete, | |
| 1083 | 1144 | }: { | |
| ⋯ | |||
| 1085 | 1146 | mine: boolean; | |
| 1086 | 1147 | onClose: () => void; | |
| 1087 | 1148 | onThread: (id: string) => void; | |
| 1149 | + | onCopyLink: (message: ShownMessage) => void; | |
| 1150 | + | /** Absent where the person cannot post (not joined, archived). */ | |
| 1151 | + | onWriteUp?: (message: ShownMessage) => void; | |
| 1088 | 1152 | onEdit: (message: ShownMessage) => void; | |
| 1089 | 1153 | onDelete: (message: ShownMessage) => void; | |
| 1090 | 1154 | }) { | |
| ⋯ | |||
| 1110 | 1174 | <SheetRow icon={<Copy />} onClick={act(() => void navigator.clipboard?.writeText(message.body || message.card?.title || ""))}> | |
| 1111 | 1175 | Copy text | |
| 1112 | 1176 | </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 | + | )} | |
| 1113 | 1187 | {mine && message.kind === "text" && ( | |
| 1114 | 1188 | <SheetRow icon={<Pencil />} onClick={act(() => onEdit(message))}> | |
| 1115 | 1189 | Edit | |
| ⋯ | |||
| 1141 | 1215 | onThread, | |
| 1142 | 1216 | onRetry, | |
| 1143 | 1217 | onLongPress, | |
| 1218 | + | onCopyLink, | |
| 1219 | + | onWriteUp, | |
| 1144 | 1220 | threadOpen, | |
| 1145 | 1221 | inThread, | |
| 1146 | 1222 | }: { | |
| 1147 | 1223 | /** A phone: a long press opens the message's actions. */ | |
| 1148 | 1224 | onLongPress?: () => void; | |
| 1225 | + | /** The hover toolbar's "⋯": copy the link to its thread, write it up in Docs. */ | |
| 1226 | + | onCopyLink?: () => void; | |
| 1227 | + | onWriteUp?: () => void; | |
| 1149 | 1228 | message: ShownMessage; | |
| 1150 | 1229 | head: boolean; | |
| 1151 | 1230 | zone: string | undefined; | |
| ⋯ | |||
| 1248 | 1327 | <MessageSquareText size={15} /> | |
| 1249 | 1328 | </button> | |
| 1250 | 1329 | </Hint> | |
| 1330 | + | {onCopyLink && <MoreActions onCopyLink={onCopyLink} onWriteUp={onWriteUp} />} | |
| 1251 | 1331 | </div> | |
| 1252 | 1332 | )} | |
| 1253 | 1333 | </article> | |
| 1254 | 1334 | ); | |
| 1255 | 1335 | } | |
| 1256 | 1336 | ||
| 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 | + | ||
| 1257 | 1392 | /** "reviewer is typing…", for whoever is. */ | |
| 1258 | 1393 | /** What an agent working on a reply says it is doing: one phrase per agent, so each sounds like itself. */ | |
| 1259 | 1394 | 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"]; | |
| ⋯ | |||
| 1291 | 1426 | * is a screen of its own, pushed over the conversation, as tall as what | |
| 1292 | 1427 | * the keyboard leaves. | |
| 1293 | 1428 | */ | |
| 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 }) { | |
| 1295 | 1430 | const swipe = useSwipeBack(onClose); | |
| 1296 | 1431 | return ( | |
| 1297 | 1432 | <aside | |
| ⋯ | |||
| 1305 | 1440 | </button> | |
| 1306 | 1441 | <h2 className="text-[0.9375rem] font-semibold">{title}</h2> | |
| 1307 | 1442 | {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> | |
| 1311 | 1449 | </div> | |
| 1312 | 1450 | <div className="flex min-h-0 grow flex-col">{children}</div> | |
| 1313 | 1451 | </aside> | |
| ⋯ | |||
| 1349 | 1487 | {root ? ( | |
| 1350 | 1488 | <MessageRow message={root} head zone={zone} context={context} code={code} slug={slug} onRetry={() => onRetry(root)} inThread /> | |
| 1351 | 1489 | ) : ( | |
| 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> | |
| 1353 | 1491 | )} | |
| 1354 | 1492 | <div className="my-2 flex items-center gap-3 px-4 text-xs text-faint"> | |
| 1355 | 1493 | {replies == null ? "Loading replies…" : `${replies.filter((r) => !r.deleted_at).length} ${replies.length === 1 ? "reply" : "replies"}`} | |
| 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 | + | } |
| 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 | + | } |
| 3 | 3 | ||
| 4 | 4 | import type { FeedNotification, NotificationKind } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | + | import { NotificationCardActions } from "./card-actions"; | |
| 6 | 7 | import { MemberAvatar } from "../chat/marks"; | |
| 7 | 8 | import { Avatar } from "../ui"; | |
| 8 | 9 | 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"; | |
| 10 | 11 | ||
| 11 | 12 | const KIND: Record<NotificationKind, { label: string; icon: ReactNode }> = { | |
| 12 | 13 | dm: { label: "Direct message", icon: <MessageCircle /> }, | |
| ⋯ | |||
| 109 | 110 | function ToastCard({ notification, paused }: { notification: FeedNotification; paused: boolean }) { | |
| 110 | 111 | const kind = KIND[notification.kind]; | |
| 111 | 112 | 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; | |
| 112 | 115 | const open = () => { | |
| 113 | 116 | dismiss(notification.id); | |
| 114 | 117 | currentSink().open(notification.href); | |
| ⋯ | |||
| 143 | 146 | > | |
| 144 | 147 | <X size={14} /> | |
| 145 | 148 | </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)} />} | |
| 147 | 151 | <span aria-hidden="true" className="absolute inset-x-0 bottom-0 h-px bg-line"> | |
| 148 | 152 | <span | |
| 149 | 153 | 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" }} | |
| 151 | 155 | onAnimationEnd={() => dismiss(notification.id)} | |
| 152 | 156 | /> | |
| 153 | 157 | </span> | |
| 23 | 23 | ||
| 24 | 24 | import { | |
| 25 | 25 | HEARTBEAT_MS, | |
| 26 | + | addRecent, | |
| 26 | 27 | addToast, | |
| 27 | 28 | attentionCount, | |
| 28 | 29 | badgesOf, | |
| ⋯ | |||
| 31 | 32 | offerPush, | |
| 32 | 33 | reconnectDelay, | |
| 33 | 34 | titleWith, | |
| 35 | + | waitingCards, | |
| 34 | 36 | type LiveBadges, | |
| 35 | 37 | type PushChoice, | |
| 36 | 38 | type Toast, | |
| ⋯ | |||
| 175 | 177 | people: Record<string, PresenceEntry>; | |
| 176 | 178 | /** Your own presence, status and Do Not Disturb, once the feed has said. */ | |
| 177 | 179 | 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>; | |
| 178 | 184 | }; | |
| 179 | 185 | ||
| 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 | + | }; | |
| 181 | 199 | const listeners = new Set<() => void>(); | |
| 182 | 200 | const SERVER_STATE = state; | |
| 183 | 201 | ||
| ⋯ | |||
| 207 | 225 | return workspace ? (s.counts[workspace.toLowerCase()] ?? null) : null; | |
| 208 | 226 | } | |
| 209 | 227 | ||
| 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 | + | ||
| 210 | 240 | /** People's presence by user id, with an index by username (lowercased). */ | |
| 211 | 241 | let peopleIndex: { from: Record<string, PresenceEntry>; byName: Map<string, PresenceEntry> } = { from: {}, byName: new Map() }; | |
| 212 | 242 | ||
| ⋯ | |||
| 338 | 368 | function onEvent(event: FeedEvent): void { | |
| 339 | 369 | switch (event.type) { | |
| 340 | 370 | 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 ?? []) }); | |
| 342 | 372 | break; | |
| 343 | 373 | case "counts": { | |
| 344 | 374 | const { type: _, ...counts } = event; | |
| ⋯ | |||
| 346 | 376 | break; | |
| 347 | 377 | } | |
| 348 | 378 | case "notification": | |
| 379 | + | set({ recent: addRecent(state.recent, [event.notification]) }); | |
| 349 | 380 | sink.deliver(event.notification, { toast: event.toast, focused: focused() }); | |
| 350 | 381 | break; | |
| 351 | 382 | case "preferences": | |
| 5 | 5 | ||
| 6 | 6 | import { | |
| 7 | 7 | MAX_TOASTS, | |
| 8 | + | RECENT_KEPT, | |
| 9 | + | WAITING_MS, | |
| 10 | + | addRecent, | |
| 8 | 11 | addToast, | |
| 12 | + | cardActionRequest, | |
| 13 | + | notificationActions, | |
| 14 | + | waitingCards, | |
| 9 | 15 | attentionCount, | |
| 10 | 16 | badgesOf, | |
| 11 | 17 | canQuickReply, | |
| ⋯ | |||
| 165 | 171 | assert.equal(hiddenToFit([100, 100], 276, 60), 0); | |
| 166 | 172 | assert.equal(hiddenToFit([100, 100], 275, 60), 1); | |
| 167 | 173 | }); | |
| 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 | + | }); | |
| 5 | 5 | * reconnecting. Pure, so they are tested apart from the socket | |
| 6 | 6 | * (lib/notify-client.ts) and the toasts (components/notifications/). | |
| 7 | 7 | */ | |
| 8 | − | import type { ChatSidebarEntry, FeedCounts, FeedNotification } from "@g1t/contracts"; | |
| 8 | + | import type { CardAction, ChatSidebarEntry, FeedCounts, FeedNotification } from "@g1t/contracts"; | |
| 9 | 9 | ||
| 10 | 10 | /** The most toasts on screen; a fourth pushes the oldest out. */ | |
| 11 | 11 | export const MAX_TOASTS = 3; | |
| ⋯ | |||
| 94 | 94 | }; | |
| 95 | 95 | } | |
| 96 | 96 | ||
| 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 | + | ||
| 97 | 160 | // ── Counts ──────────────────────────────────────────────────────────────── | |
| 98 | 161 | ||
| 99 | 162 | /** What the rail shows for a workspace. */ | |
| 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 | + | }); |
| 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 | + | } |
| 1 | 1 | // g1t's service worker: browser notifications, nothing else. It caches | |
| 2 | 2 | // nothing and answers no requests, so pages load exactly as without it. | |
| 3 | 3 | // 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. | |
| 5 | 9 | ||
| 6 | 10 | self.addEventListener("install", () => self.skipWaiting()); | |
| 7 | 11 | self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim())); | |
| ⋯ | |||
| 14 | 18 | data = { title: "g1t", body: event.data ? event.data.text() : "" }; | |
| 15 | 19 | } | |
| 16 | 20 | 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; | |
| 17 | 32 | event.waitUntil( | |
| 18 | 33 | self.registration.showNotification(title, { | |
| 19 | 34 | body: data.body || "", | |
| ⋯ | |||
| 22 | 37 | // One notification per conversation: a newer message replaces the last. | |
| 23 | 38 | tag: data.tag || undefined, | |
| 24 | 39 | 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 }, | |
| 26 | 42 | }), | |
| 27 | 43 | ); | |
| 28 | 44 | }); | |
| 29 | 45 | ||
| 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 | + | ||
| 30 | 65 | self.addEventListener("notificationclick", (event) => { | |
| 31 | 66 | 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 || "/"; | |
| 33 | 74 | const target = new URL(href, self.location.origin).href; | |
| 34 | 75 | event.waitUntil( | |
| 35 | 76 | (async () => { | |
| 532 | 532 | For self-hosting, the Durable Object, R2 and D1 sit behind the room, | |
| 533 | 533 | `FileStore` and SQL; nothing above them depends on Cloudflare. | |
| 534 | 534 | ||
| 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 | |
| 537 | 547 | indexing pages in `services/context`. | |
| 538 | 548 | ||
| 539 | 549 | ## Chat | |
| ⋯ | |||
| 1000 | 1010 | that needs a value (an amount, a line of text) asks for it inline. Agents | |
| 1001 | 1011 | never file, approve or stop anything on their own through a card. | |
| 1002 | 1012 | ||
| 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 | + | ||
| 1003 | 1033 | ## Live notifications | |
| 1004 | 1034 | ||
| 1005 | 1035 | A DM has to reach someone wherever they are in g1t, not only inside Chat. | |
| 294 | 294 | /** Pass as `before` to read further back; null at the beginning. */ | |
| 295 | 295 | older: string | null; | |
| 296 | 296 | /** | |
| 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 | + | /** | |
| 297 | 303 | * Set when the page was read with `after`: pass it as `after` again for | |
| 298 | 304 | * the next messages, or null when this page reached the newest. | |
| 299 | 305 | */ |
| 7 | 7 | * events service about every inbox item, and the site forwards each tab's | |
| 8 | 8 | * `/-/live` socket to it. Wire shapes are snake_case end to end. | |
| 9 | 9 | */ | |
| 10 | + | import type { CardAction } from "./chat"; | |
| 10 | 11 | import type { ServiceBinding } from "./clients"; | |
| 11 | 12 | import type { User } from "./identity"; | |
| 12 | 13 | ||
| ⋯ | |||
| 43 | 44 | channel_id?: string | null; | |
| 44 | 45 | /** The thread it is in, for a reply: quick replies go there. */ | |
| 45 | 46 | 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; | |
| 46 | 55 | created_at: string; | |
| 47 | 56 | }; | |
| 48 | 57 | ||
| 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 | + | ||
| 49 | 68 | /** How much a person hears of. Counts always move; this governs toasts and pushes. */ | |
| 50 | 69 | export type NotifyLevel = "all" | "dms_mentions" | "none"; | |
| 51 | 70 | ||
| 992 | 992 | /** Tells whoever asked, and the agent's maker, that a session waits for more budget. */ | |
| 993 | 993 | async function notifyApproval(env: SessionEnv, row: SessionRow, agent: Row): Promise<void> { | |
| 994 | 994 | 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; | |
| 995 | 1001 | const targets = new Set<string>(); | |
| 996 | 1002 | if (row.asked_by_username) targets.add(row.asked_by_username); | |
| 997 | 1003 | if (agent.created_by) targets.add(agent.created_by); | |
| ⋯ | |||
| 1007 | 1013 | workspace: row.workspace, | |
| 1008 | 1014 | title: `${agent.display_name} needs more budget`, | |
| 1009 | 1015 | body: `"${row.title}" reached its cap of ${dollars(row.cap_micros ?? 0)}.`, | |
| 1010 | − | href: `/${row.workspace}/-/agents/${agent.handle}/sessions/${row.id}`, | |
| 1016 | + | href, | |
| 1011 | 1017 | 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, | |
| 1013 | 1021 | created_at: iso(), | |
| 1014 | 1022 | }, | |
| 1015 | 1023 | }), | |
| 1030 | 1030 | .all<MessageRow>(); | |
| 1031 | 1031 | const page = pageOf(rows.results, size); | |
| 1032 | 1032 | 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 }); | |
| 1038 | 1043 | } | |
| 1039 | 1044 | ||
| 1040 | 1045 | /** | |
| ⋯ | |||
| 1110 | 1115 | ); | |
| 1111 | 1116 | // Notify: counts for everyone in the conversation, a notification for those it is for. | |
| 1112 | 1117 | 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( | |
| 1114 | 1119 | (error) => console.error("chat could not notify about", row.id, error), | |
| 1115 | 1120 | ), | |
| 1116 | 1121 | ); | |
| 3 | 3 | ||
| 4 | 4 | import type { MemberProfile } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 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"; | |
| 7 | 7 | ||
| 8 | 8 | const person = (id: string, username: string, muted = false): Person => ({ key: `user:${id}`, user_id: id, username, muted }); | |
| 9 | 9 | const ana = person("u1", "ana"); | |
| ⋯ | |||
| 94 | 94 | assert.deepEqual(countsAfterRead(rows, "c", "user:u1", "ana", "m2"), { unread: 2, mentions: 1 }); | |
| 95 | 95 | assert.deepEqual(countsAfterRead([], "c", "user:u1", "ana", "m9"), { unread: 0, mentions: 0 }); | |
| 96 | 96 | }); | |
| 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 | + | }); | |
| 11 | 11 | * - The author is never told of their own message; their own count for | |
| 12 | 12 | * the conversation goes to nothing (what you wrote, you have read). | |
| 13 | 13 | * - 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. | |
| 14 | 19 | * | |
| 15 | 20 | * `recipients` is pure, so the rules are tested apart from the service. | |
| 16 | 21 | */ | |
| 17 | − | import type { FeedDelivery, FeedNotification, MemberProfile, NotificationKind } from "@g1t/contracts"; | |
| 22 | + | import type { CardAction, FeedDelivery, FeedNotification, MemberProfile, NotificationCard, NotificationKind } from "@g1t/contracts"; | |
| 18 | 23 | ||
| 19 | 24 | import { tally, type UnreadRow } from "./unread.ts"; | |
| 20 | 25 | ||
| ⋯ | |||
| 32 | 37 | mentioned: string[]; | |
| 33 | 38 | /** For a reply: the people in its thread, by key; null for a top-level message. */ | |
| 34 | 39 | 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; | |
| 35 | 42 | }): Recipient[] { | |
| 36 | 43 | const named = new Set(input.mentioned.map((h) => h.toLowerCase())); | |
| 37 | 44 | const out: Recipient[] = []; | |
| ⋯ | |||
| 41 | 48 | let kind: NotificationKind | null = null; | |
| 42 | 49 | if (input.channelKind === "dm") kind = "dm"; | |
| 43 | 50 | else if (mentioned) kind = "mention"; | |
| 51 | + | else if (input.waitingOn === person.key) kind = "agent_waiting"; | |
| 44 | 52 | else if (input.thread?.has(person.key) && !person.muted) kind = "thread_reply"; | |
| 45 | 53 | out.push({ user_id: person.user_id, kind, mentioned, muted: person.muted }); | |
| 46 | 54 | } | |
| ⋯ | |||
| 60 | 68 | return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text; | |
| 61 | 69 | } | |
| 62 | 70 | ||
| 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 | + | ||
| 63 | 93 | /** Where a conversation, or a thread in it, is on the site. */ | |
| 64 | 94 | export function conversationHref(slug: string, channel: { id: string; kind: "channel" | "dm"; name: string | null }, threadRoot: string | null): string { | |
| 65 | 95 | const base = channel.kind === "dm" || !channel.name ? `/${slug}/-/chat/dm/${channel.id}` : `/${slug}/-/chat/${channel.name}`; | |
| ⋯ | |||
| 73 | 103 | message: { id: string; author: string; body: string; card_title: string | null; thread_root: string | null; created_at: string }; | |
| 74 | 104 | author: MemberProfile; | |
| 75 | 105 | recipients: Recipient[]; | |
| 106 | + | /** The card's place and actions, when it has something to press. */ | |
| 107 | + | card?: NotificationCard | null; | |
| 76 | 108 | }): FeedDelivery[] { | |
| 77 | 109 | const { slug, channel, message, author } = input; | |
| 78 | 110 | const where = channel.kind === "dm" ? "" : ` in #${channel.name}`; | |
| ⋯ | |||
| 97 | 129 | }, | |
| 98 | 130 | channel_id: channel.id, | |
| 99 | 131 | thread_root: message.thread_root, | |
| 132 | + | ...(input.card ? { card: input.card } : {}), | |
| 100 | 133 | created_at: message.created_at, | |
| 101 | 134 | } | |
| 102 | 135 | : null; | |
| ⋯ | |||
| 150 | 183 | channel: { id: string; kind: "channel" | "dm"; name: string | null }; | |
| 151 | 184 | row: { id: string; author: string; body: string; card: string | null; thread_root: string | null; created_at: string }; | |
| 152 | 185 | 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; | |
| 153 | 188 | }, | |
| 154 | 189 | ): Promise<void> { | |
| 155 | 190 | if (!notify) return; | |
| ⋯ | |||
| 177 | 212 | const author = found.get(row.author); | |
| 178 | 213 | if (!author) return; | |
| 179 | 214 | let cardTitle: string | null = null; | |
| 215 | + | let card: NotificationCard | null = null; | |
| 180 | 216 | if (row.card) { | |
| 181 | 217 | 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); | |
| 183 | 221 | } catch { | |
| 184 | 222 | cardTitle = null; | |
| 185 | 223 | } | |
| 186 | 224 | } | |
| 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; | |
| 187 | 227 | await deliver( | |
| 188 | 228 | notify, | |
| 189 | 229 | messageDeliveries({ | |
| ⋯ | |||
| 191 | 231 | channel, | |
| 192 | 232 | message: { id: row.id, author: row.author, body: row.body, card_title: cardTitle, thread_root: row.thread_root, created_at: row.created_at }, | |
| 193 | 233 | author, | |
| 234 | + | card, | |
| 194 | 235 | recipients: recipients({ | |
| 195 | 236 | author: row.author, | |
| 196 | 237 | channelKind: channel.kind, | |
| 197 | 238 | people, | |
| 198 | 239 | mentioned: input.handles, | |
| 199 | 240 | thread: thread ? new Set(thread.results.map((r) => r.author)) : null, | |
| 241 | + | waitingOn, | |
| 200 | 242 | }), | |
| 201 | 243 | }), | |
| 202 | 244 | ); | |
| 4 | 4 | import type { FeedNotification } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | 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"; | |
| 8 | 8 | ||
| 9 | 9 | const NOW = 1_800_000_000_000; | |
| 10 | 10 | const dms = { level: "dms_mentions" as const, workspaces: {} }; | |
| ⋯ | |||
| 135 | 135 | complete: true, | |
| 136 | 136 | }); | |
| 137 | 137 | }); | |
| 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 | + | }); | |
| 10 | 10 | * says it has focus over its socket, and a tab that has said nothing for | |
| 11 | 11 | * `STALE_MS` counts as gone (a laptop lid closed on it). | |
| 12 | 12 | */ | |
| 13 | − | import type { FeedNotification, NotificationKind, NotifyLevel, NotifyPreferences } from "@g1t/contracts"; | |
| 13 | + | import type { CardAction, FeedNotification, NotificationCard, NotificationKind, NotifyLevel, NotifyPreferences } from "@g1t/contracts"; | |
| 14 | 14 | ||
| 15 | 15 | // The same as NOTIFY_LEVELS and DEFAULT_NOTIFY_PREFERENCES in @g1t/contracts, kept here so | |
| 16 | 16 | // Node runs the tests on this file without the contracts package. | |
| ⋯ | |||
| 118 | 118 | const href = text(n.href, 2000); | |
| 119 | 119 | const a = (n.actor && typeof n.actor === "object" ? n.actor : {}) as Record<string, unknown>; | |
| 120 | 120 | const actorKind = a.kind === "user" || a.kind === "agent" ? a.kind : "system"; | |
| 121 | + | const card = cleanCard(n.card); | |
| 121 | 122 | return { | |
| 122 | 123 | id, | |
| 123 | 124 | kind, | |
| ⋯ | |||
| 135 | 136 | }, | |
| 136 | 137 | channel_id: text(n.channel_id, 100) || null, | |
| 137 | 138 | thread_root: text(n.thread_root, 100) || null, | |
| 139 | + | ...(card ? { card } : {}), | |
| 138 | 140 | created_at: text(n.created_at, 40) || new Date().toISOString(), | |
| 139 | 141 | }; | |
| 140 | 142 | } | |
| 141 | 143 | ||
| 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 | + | ||
| 142 | 204 | /** | |
| 143 | 205 | * What a push carries: little, under the 4 KB a push may hold. `tag` makes | |
| 144 | 206 | * the notifications of one conversation replace each other. | |
| 145 | 207 | */ | |
| 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); | |
| 147 | 225 | return { | |
| 148 | 226 | title: n.title, | |
| 149 | 227 | body: n.body.slice(0, 240), | |
| 150 | 228 | 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}`, | |
| 152 | 231 | kind: n.kind, | |
| 153 | 232 | 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, | |
| 154 | 236 | }; | |
| 155 | 237 | } | |