Skip to content
110 linesCodeBlameRaw
1/**
2 * @g1t's side of chat, as the site shows it: the direct message every
3 * member has with g1t, which the top bar's Ask (components/ask-composer)
4 * and Chat both show. Pure, so it is tested on its own (g1t-chat.test.ts).
5 */
6import type { AskPage, ChatAnswer, ChatLiveEvent, ChatMessage } from "@g1t/contracts";
7
8/** Whether a message is from @g1t itself. */
9export function fromG1t(message: Pick<ChatMessage, "author">): boolean {
10 return message.author.kind === "system" && message.author.id === "g1t";
11}
12
13/** Whether an answer is still being written. */
14export function answering(answer: ChatAnswer | null | undefined): boolean {
15 return answer?.state === "thinking" || answer?.state === "streaming";
16}
17
18/** Whether any answer in the conversation is still being written: Stop shows, Send waits. */
19export function anyAnswering(messages: readonly ChatMessage[]): boolean {
20 return messages.some((message) => !message.deleted_at && answering(message.answer));
21}
22
23/** Who wrote an answer: "Anthropic · claude-…", once the provider said. */
24export function answerLabel(answer: ChatAnswer | null | undefined): string | null {
25 if (!answer?.provider_label) return null;
26 return answer.model ? `${answer.provider_label} · ${answer.model}` : answer.provider_label;
27}
28
29/** Whether an answer was cut off at its length limit. */
30export function cutOff(answer: ChatAnswer | null | undefined): boolean {
31 return answer?.stop_reason === "max_tokens" || answer?.stop_reason === "length";
32}
33
34/**
35 * A conversation's top-level messages after one live event: new and
36 * changed ones by id, oldest first; an answer as it streams; deleted ones
37 * gone. Thread replies and other conversations' events change nothing.
38 */
39export function applyLive(list: readonly ChatMessage[], event: ChatLiveEvent, channelId: string): ChatMessage[] {
40 switch (event.type) {
41 case "message.created":
42 case "message.updated": {
43 const message = event.message;
44 if (message.channel_id !== channelId || message.thread_root) return list as ChatMessage[];
45 if (message.deleted_at) return list.filter((m) => m.id !== message.id);
46 const at = list.findIndex((m) => m.id === message.id);
47 if (at >= 0) return list.map((m) => (m.id === message.id ? { ...m, ...message } : m));
48 return [...list, message].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
49 }
50 case "message.streaming": {
51 if (event.channel_id !== channelId) return list as ChatMessage[];
52 return list.map((m) =>
53 // Never back over what a stored message already has: a late frame after the end changes nothing.
54 m.id === event.id && answering(m.answer) ? { ...m, body: event.body, answer: event.answer } : m,
55 );
56 }
57 case "message.deleted":
58 if (event.channel_id !== channelId) return list as ChatMessage[];
59 return list.filter((m) => m.id !== event.id);
60 default:
61 return list as ChatMessage[];
62 }
63}
64
65/**
66 * What a reader may ask first, from where they are: about the issue or
67 * pull request in front of them, the repository, or their work.
68 */
69export function suggestions(page: AskPage): string[] {
70 if (page.pull) return [`Summarize pull request #${page.pull}`, `What's left before #${page.pull} can merge?`, "What changed in this pull request, file by file?", "What should I check when reviewing this?"];
71 if (page.issue) return [`Summarize issue #${page.issue}`, `What's blocking #${page.issue}?`, "How would you approach this issue?", "Which code does this issue touch?"];
72 if (page.repo) return [`What is ${page.repo.name} for?`, "What shipped here this week?", "What's open and needs attention?", "Where do I start reading the code?"];
73 return ["What shipped today?", "What needs my attention?", "Summarize what my team is working on", "How do I set up a deployment?"];
74}
75
76/** A link g1t knows how to show as a chip: an issue or pull request, a commit, or another page of the site. */
77export type RefTarget =
78 | { kind: "issue"; repo: string; number: number; href: string }
79 | { kind: "commit"; repo: string; sha: string; href: string }
80 | { kind: "page"; href: string };
81
82/** The site's own hosts: a full link to one of them is shown as the page it leads to. */
83const SITE_HOSTS = new Set(["g1t.sh", "www.g1t.sh"]);
84
85/**
86 * Where a link leads on the site, if it does: `/acme/web/issues/12`,
87 * `/acme/web/pull/12`, `/acme/web/commit/<sha>`, or the same as a full
88 * `https://g1t.sh/…` link (or one to `origin`, the site the reader is on).
89 */
90export function refTarget(href: string, origin: string | null = null): RefTarget | null {
91 let path: string;
92 if (href.startsWith("/") && !href.startsWith("//")) path = href;
93 else {
94 let url: URL;
95 try {
96 url = new URL(href);
97 } catch {
98 return null;
99 }
100 if (url.protocol !== "https:" && url.protocol !== "http:") return null;
101 if (!SITE_HOSTS.has(url.host) && url.origin !== origin) return null;
102 path = `${url.pathname}${url.search}${url.hash}`;
103 }
104 const bare = path.split(/[?#]/)[0]!;
105 const issue = bare.match(/^\/([a-z0-9][a-z0-9-]*)\/([a-z0-9._-]+)\/(?:issues|pull)\/(\d+)\/?$/i);
106 if (issue) return { kind: "issue", repo: `${issue[1]!.toLowerCase()}/${issue[2]}`, number: Number(issue[3]), href: path };
107 const commit = bare.match(/^\/([a-z0-9][a-z0-9-]*)\/([a-z0-9._-]+)\/commit\/([0-9a-f]{7,40})\/?$/i);
108 if (commit) return { kind: "commit", repo: `${commit[1]!.toLowerCase()}/${commit[2]}`, sha: commit[3]!.toLowerCase(), href: path };
109 return { kind: "page", href: path };
110}