Skip to content
127 linesCodeBlameRaw
1/**
2 * One answer from @g1t, as it shows wherever g1t answers: the top bar's
3 * Ask (components/ask-composer.tsx) and its direct message in Chat
4 * (components/chat/channel.tsx). Thinking until its first words, the words
5 * as they stream, then the answer with who wrote it and what can be done
6 * with it: copy it, or have g1t answer again.
7 */
8import { Check, CircleAlert, Copy, CornerDownRight, RefreshCw, Settings2 } from "lucide-react";
9import { type ReactNode, useState } from "react";
10
11import type { AskPage, ChatMessage } from "@g1t/contracts";
12
13import { answerLabel, answering, askedFrom, cutOff } from "../../lib/g1t-chat";
14import { AskTrail } from "../ask-steps";
15import { Loader } from "../ai-elements/loader";
16import { MessageAction, MessageActions } from "../ai-elements/message";
17import { MessageResponse, type ResponseContext } from "../ai-elements/response";
18import { FolioUnfurls } from "../folios/unfurl";
19import { Alert } from "../ui/alert";
20import { Badge } from "../ui/badge";
21
22export function G1tAnswer({
23 message,
24 context,
25 onRetry,
26 steps,
27 actions = true,
28}: {
29 message: ChatMessage;
30 context: ResponseContext;
31 /** Answer the question again; only on the latest answer. */
32 onRetry?: () => void;
33 /** Drawn above its words instead of the steps it kept (g1t's tool calls). */
34 steps?: ReactNode;
35 /** Whether to show the row of actions under it. */
36 actions?: boolean;
37}) {
38 const answer = message.answer ?? null;
39 const live = answering(answer);
40 const [copied, setCopied] = useState(false);
41 const label = answerLabel(answer);
42 const copy = () => {
43 void navigator.clipboard?.writeText(message.body).then(() => {
44 setCopied(true);
45 setTimeout(() => setCopied(false), 1500);
46 });
47 };
48
49 // Ask is not set up: g1t's note says where an owner chooses a provider.
50 if (answer?.setup === "missing") {
51 return (
52 <Alert tone="info" className="flex items-start gap-2.5 px-3.5 py-3">
53 <Settings2 size={15} className="mt-1 shrink-0 text-info" />
54 <MessageResponse context={context}>{message.body}</MessageResponse>
55 </Alert>
56 );
57 }
58
59 return (
60 <div className="flex w-full min-w-0 flex-col gap-1.5">
61 {steps ?? <AskTrail events={answer?.steps} working={live} part="steps" />}
62 {answer?.state === "thinking" && !message.body ? (
63 <Loader />
64 ) : answer?.state === "error" ? (
65 <div className="flex items-start gap-2 text-sm text-muted">
66 <CircleAlert size={15} className="mt-0.5 shrink-0 text-danger" />
67 <MessageResponse context={context} className="text-sm text-muted">
68 {message.body}
69 </MessageResponse>
70 </div>
71 ) : (
72 <MessageResponse context={context} streaming={live}>
73 {message.body}
74 </MessageResponse>
75 )}
76 {!live && <AskTrail events={answer?.steps} working={false} part="proposals" slug={context.slug} />}
77 {!live && <AskTrail events={answer?.steps} working={false} part="sources" />}
78 {!live && message.body && <FolioUnfurls slug={context.slug} body={message.body} />}
79 {actions && !live && (
80 <MessageActions className="-ml-1.5">
81 {answer?.state !== "error" && (
82 <MessageAction label={copied ? "Copied" : "Copy answer"} onClick={copy}>
83 {copied ? <Check size={14} className="text-success" /> : <Copy size={14} />}
84 </MessageAction>
85 )}
86 {onRetry && (
87 <MessageAction label="Answer again" onClick={onRetry}>
88 <RefreshCw size={14} />
89 </MessageAction>
90 )}
91 {label && (
92 <Badge className="ml-1 px-1.5 py-0 font-normal">
93 <span className="max-w-64 truncate">{label}</span>
94 </Badge>
95 )}
96 {answer?.state === "stopped" && <span className="ml-1.5">Stopped</span>}
97 {cutOff(answer) && <span className="ml-1.5">Cut off at the length limit</span>}
98 </MessageActions>
99 )}
100 </div>
101 );
102}
103
104/** "New conversation": where g1t started reading afresh. */
105export function ConversationMark() {
106 return (
107 <div role="separator" aria-label="New conversation" className="relative my-1 flex items-center justify-center">
108 <span aria-hidden="true" className="absolute inset-x-0 top-1/2 h-px bg-line" />
109 <Badge className="relative bg-bg px-3 py-0.5 text-faint">New conversation</Badge>
110 </div>
111 );
112}
113
114/**
115 * Under a question to @g1t asked on a page: which page, so "this pull
116 * request" reads as the one it was. g1t was told the same.
117 */
118export function AskedFrom({ page }: { page: AskPage | null | undefined }) {
119 const from = askedFrom(page);
120 if (!from) return null;
121 return (
122 <a href={from.href} className="mt-0.5 inline-flex max-w-full items-center gap-1 text-xs text-faint hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">
123 <CornerDownRight size={12} aria-hidden="true" className="shrink-0" />
124 <span className="truncate">Asked on {from.label}</span>
125 </a>
126 );
127}