@checkstack/ai-frontend 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +75 -0
- package/package.json +32 -0
- package/src/components/AppliedCardView.tsx +36 -0
- package/src/components/ConfirmCardView.tsx +117 -0
- package/src/components/DiffView.tsx +84 -0
- package/src/components/SideBySideDiff.tsx +120 -0
- package/src/index.tsx +22 -0
- package/src/lib/chat-state.test.ts +213 -0
- package/src/lib/chat-state.ts +231 -0
- package/src/lib/line-diff.test.ts +87 -0
- package/src/lib/line-diff.ts +206 -0
- package/src/lib/mode-toggle.logic.test.ts +64 -0
- package/src/lib/mode-toggle.logic.ts +57 -0
- package/src/lib/model-options.logic.test.ts +55 -0
- package/src/lib/model-options.logic.ts +31 -0
- package/src/lib/new-chat.logic.test.ts +84 -0
- package/src/lib/new-chat.logic.ts +62 -0
- package/src/lib/stream-parser.test.ts +241 -0
- package/src/lib/stream-parser.ts +286 -0
- package/src/lib/use-chat-turn.ts +163 -0
- package/src/pages/ChatPage.tsx +661 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
PageLayout,
|
|
4
|
+
Card,
|
|
5
|
+
CardContent,
|
|
6
|
+
Button,
|
|
7
|
+
Textarea,
|
|
8
|
+
Select,
|
|
9
|
+
SelectTrigger,
|
|
10
|
+
SelectValue,
|
|
11
|
+
SelectContent,
|
|
12
|
+
SelectItem,
|
|
13
|
+
LoadingSpinner,
|
|
14
|
+
EmptyState,
|
|
15
|
+
MarkdownBlock,
|
|
16
|
+
ConfirmationModal,
|
|
17
|
+
Alert,
|
|
18
|
+
AlertIcon,
|
|
19
|
+
AlertContent,
|
|
20
|
+
AlertTitle,
|
|
21
|
+
AlertDescription,
|
|
22
|
+
usePerformance,
|
|
23
|
+
useInitOnceForKey,
|
|
24
|
+
} from "@checkstack/ui";
|
|
25
|
+
import { usePluginClient, useQueryClient } from "@checkstack/frontend-api";
|
|
26
|
+
import {
|
|
27
|
+
AiApi,
|
|
28
|
+
pluginMetadata,
|
|
29
|
+
type AiPermissionMode,
|
|
30
|
+
} from "@checkstack/ai-common";
|
|
31
|
+
import {
|
|
32
|
+
Sparkles,
|
|
33
|
+
Send,
|
|
34
|
+
Plus,
|
|
35
|
+
Trash2,
|
|
36
|
+
AlertCircle,
|
|
37
|
+
X,
|
|
38
|
+
Copy,
|
|
39
|
+
Check,
|
|
40
|
+
Loader2,
|
|
41
|
+
Wrench,
|
|
42
|
+
} from "lucide-react";
|
|
43
|
+
import { useChatTurn } from "../lib/use-chat-turn";
|
|
44
|
+
import { ConfirmCardView } from "../components/ConfirmCardView";
|
|
45
|
+
import { AppliedCardView } from "../components/AppliedCardView";
|
|
46
|
+
import { buildModelOptions } from "../lib/model-options.logic";
|
|
47
|
+
import { decideNewChatAction } from "../lib/new-chat.logic";
|
|
48
|
+
import {
|
|
49
|
+
PERMISSION_MODE_OPTIONS,
|
|
50
|
+
deriveModeToggleValue,
|
|
51
|
+
buildModeUpdate,
|
|
52
|
+
} from "../lib/mode-toggle.logic";
|
|
53
|
+
import type { ChatMessage, AssistantPart } from "../lib/chat-state";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A single tool-call status line, rendered at the point in the turn where the
|
|
57
|
+
* model invoked the tool: a running spinner, a done check, or an error. The spin
|
|
58
|
+
* is disabled on low-power devices (see performance rule) - it falls back to a
|
|
59
|
+
* static wrench so the line is still legible without an infinite animation.
|
|
60
|
+
*/
|
|
61
|
+
function ToolStatusLine({
|
|
62
|
+
part,
|
|
63
|
+
isLowPower,
|
|
64
|
+
}: {
|
|
65
|
+
part: Extract<AssistantPart, { kind: "tool" }>;
|
|
66
|
+
isLowPower: boolean;
|
|
67
|
+
}) {
|
|
68
|
+
const isError = part.status === "error";
|
|
69
|
+
return (
|
|
70
|
+
<div
|
|
71
|
+
className={`flex items-center gap-1.5 text-xs ${
|
|
72
|
+
isError ? "text-destructive" : "text-muted-foreground"
|
|
73
|
+
}`}
|
|
74
|
+
>
|
|
75
|
+
{part.status === "running" ? (
|
|
76
|
+
isLowPower ? (
|
|
77
|
+
<Wrench className="h-3 w-3 shrink-0" />
|
|
78
|
+
) : (
|
|
79
|
+
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
|
|
80
|
+
)
|
|
81
|
+
) : isError ? (
|
|
82
|
+
<AlertCircle className="h-3 w-3 shrink-0" />
|
|
83
|
+
) : (
|
|
84
|
+
<Check className="h-3 w-3 shrink-0" />
|
|
85
|
+
)}
|
|
86
|
+
<span className="font-medium">{part.toolName}</span>
|
|
87
|
+
{part.status === "running" ? <span>running...</span> : null}
|
|
88
|
+
{isError && part.errorText ? (
|
|
89
|
+
<span className="min-w-0 truncate" title={part.errorText}>
|
|
90
|
+
- {part.errorText}
|
|
91
|
+
</span>
|
|
92
|
+
) : null}
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One rendered message row: user bubble, or the assistant's ordered parts. */
|
|
98
|
+
function MessageRow({
|
|
99
|
+
message,
|
|
100
|
+
onDecision,
|
|
101
|
+
}: {
|
|
102
|
+
message: ChatMessage;
|
|
103
|
+
onDecision: (decision: {
|
|
104
|
+
token: string;
|
|
105
|
+
decision: "apply" | "decline";
|
|
106
|
+
}) => void;
|
|
107
|
+
}) {
|
|
108
|
+
const { isLowPower } = usePerformance();
|
|
109
|
+
if (message.role === "user") {
|
|
110
|
+
return (
|
|
111
|
+
<div className="flex justify-end">
|
|
112
|
+
{/* User text stays plain, preserving newlines. */}
|
|
113
|
+
<div className="max-w-[80%] whitespace-pre-wrap rounded-lg bg-primary px-3 py-2 text-sm text-primary-foreground">
|
|
114
|
+
{message.text}
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Show a trailing "Thinking..." while the turn is still streaming and we are
|
|
121
|
+
// waiting on the model's next output: before any part arrives, and in the gap
|
|
122
|
+
// AFTER a tool call completes (or a card is proposed) while the model decides
|
|
123
|
+
// what to do next. We do NOT show it while text is actively streaming (the
|
|
124
|
+
// text itself is the progress) or while a tool is still running (its own
|
|
125
|
+
// spinner shows that).
|
|
126
|
+
const lastPart = message.parts.at(-1);
|
|
127
|
+
const thinking =
|
|
128
|
+
message.streaming &&
|
|
129
|
+
(!lastPart ||
|
|
130
|
+
lastPart.kind === "confirm" ||
|
|
131
|
+
lastPart.kind === "applied" ||
|
|
132
|
+
(lastPart.kind === "tool" && lastPart.status !== "running"));
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div className="flex justify-start">
|
|
136
|
+
{/* Assistant parts render in order so text segments break between tool
|
|
137
|
+
calls and each tool call (and its result/error) shows in place. */}
|
|
138
|
+
<div className="max-w-[80%] space-y-2 rounded-lg bg-muted px-3 py-2 text-sm">
|
|
139
|
+
{message.parts.map((part, index) => {
|
|
140
|
+
if (part.kind === "text") {
|
|
141
|
+
// Assistant text renders markdown so **bold**, lists, and code
|
|
142
|
+
// render. Partial markdown mid-stream renders fine (text accumulates).
|
|
143
|
+
return part.text ? (
|
|
144
|
+
<MarkdownBlock key={`text-${index}`} size="sm">
|
|
145
|
+
{part.text}
|
|
146
|
+
</MarkdownBlock>
|
|
147
|
+
) : null;
|
|
148
|
+
}
|
|
149
|
+
if (part.kind === "confirm") {
|
|
150
|
+
return (
|
|
151
|
+
<ConfirmCardView
|
|
152
|
+
key={part.toolCallId || index}
|
|
153
|
+
card={part.card}
|
|
154
|
+
onDecision={onDecision}
|
|
155
|
+
/>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
if (part.kind === "applied") {
|
|
159
|
+
return (
|
|
160
|
+
<AppliedCardView
|
|
161
|
+
key={part.toolCallId || index}
|
|
162
|
+
card={part.card}
|
|
163
|
+
/>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return (
|
|
167
|
+
<ToolStatusLine
|
|
168
|
+
key={part.toolCallId || index}
|
|
169
|
+
part={part}
|
|
170
|
+
isLowPower={isLowPower}
|
|
171
|
+
/>
|
|
172
|
+
);
|
|
173
|
+
})}
|
|
174
|
+
{thinking ? (
|
|
175
|
+
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
176
|
+
{isLowPower ? null : <Loader2 className="h-3 w-3 shrink-0 animate-spin" />}
|
|
177
|
+
<span>Thinking...</span>
|
|
178
|
+
</div>
|
|
179
|
+
) : null}
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The in-app AI chat page (Phase 4). Streams a server-side agent loop;
|
|
187
|
+
* read tools auto-run, mutating/destructive tools surface a confirm card. The
|
|
188
|
+
* model picker defaults to the selected integration's `defaultModel` (§14.6).
|
|
189
|
+
*/
|
|
190
|
+
export function ChatPage() {
|
|
191
|
+
const ai = usePluginClient(AiApi);
|
|
192
|
+
const queryClient = useQueryClient();
|
|
193
|
+
const integrationsQuery = ai.listChatIntegrations.useQuery();
|
|
194
|
+
const conversationsQuery = ai.listConversations.useQuery();
|
|
195
|
+
|
|
196
|
+
const [conversationId, setConversationId] = useState<string | undefined>();
|
|
197
|
+
const [connectionId, setConnectionId] = useState<string | undefined>();
|
|
198
|
+
const [model, setModel] = useState<string | undefined>();
|
|
199
|
+
// The conversation's permission mode (Approve/Auto). Defaults to the safe
|
|
200
|
+
// `approve`; hydrated from the loaded conversation and persisted via
|
|
201
|
+
// updateConversation. Governs the mutate tool branch only (server-side).
|
|
202
|
+
const [permissionMode, setPermissionMode] =
|
|
203
|
+
useState<AiPermissionMode>("approve");
|
|
204
|
+
const [input, setInput] = useState("");
|
|
205
|
+
// Transient "Copied" feedback for the error banner's copy button.
|
|
206
|
+
const [copiedError, setCopiedError] = useState(false);
|
|
207
|
+
const bottomRef = useRef<HTMLDivElement>(null);
|
|
208
|
+
|
|
209
|
+
const {
|
|
210
|
+
messages,
|
|
211
|
+
setMessages,
|
|
212
|
+
streaming,
|
|
213
|
+
error,
|
|
214
|
+
send,
|
|
215
|
+
sendDecision,
|
|
216
|
+
clearError,
|
|
217
|
+
} = useChatTurn();
|
|
218
|
+
|
|
219
|
+
const integrations = useMemo(
|
|
220
|
+
() => integrationsQuery.data?.integrations ?? [],
|
|
221
|
+
[integrationsQuery.data],
|
|
222
|
+
);
|
|
223
|
+
const selectedIntegration = useMemo(
|
|
224
|
+
() => integrations.find((i) => i.connectionId === connectionId),
|
|
225
|
+
[integrations, connectionId],
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const createConversation = ai.createConversation.useMutation();
|
|
229
|
+
const updateConversation = ai.updateConversation.useMutation();
|
|
230
|
+
const archiveConversation = ai.archiveConversation.useMutation();
|
|
231
|
+
// The conversation queued for the delete (archive) confirmation modal.
|
|
232
|
+
const [pendingDelete, setPendingDelete] = useState<
|
|
233
|
+
{ id: string; title: string | null } | undefined
|
|
234
|
+
>();
|
|
235
|
+
const [loadId, setLoadId] = useState<string | undefined>();
|
|
236
|
+
const loadedConversation = ai.getConversation.useQuery(
|
|
237
|
+
{ id: loadId ?? "" },
|
|
238
|
+
{ enabled: Boolean(loadId), gcTime: 0 },
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
// Default the picker to the first integration + its defaultModel (§14.6).
|
|
242
|
+
useEffect(() => {
|
|
243
|
+
if (!connectionId && integrations.length > 0) {
|
|
244
|
+
setConnectionId(integrations[0].connectionId);
|
|
245
|
+
setModel(integrations[0].defaultModel);
|
|
246
|
+
}
|
|
247
|
+
}, [integrations, connectionId]);
|
|
248
|
+
|
|
249
|
+
// When the integration changes, default the model to its defaultModel.
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
if (selectedIntegration) setModel(selectedIntegration.defaultModel);
|
|
252
|
+
}, [selectedIntegration]);
|
|
253
|
+
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
256
|
+
}, [messages]);
|
|
257
|
+
|
|
258
|
+
const conversations = useMemo(
|
|
259
|
+
() => conversationsQuery.data?.conversations ?? [],
|
|
260
|
+
[conversationsQuery.data],
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const startNewConversation = async () => {
|
|
264
|
+
clearError();
|
|
265
|
+
// Dedupe: if the open conversation is already an empty untitled draft, reuse
|
|
266
|
+
// it instead of spawning another "Untitled chat" row.
|
|
267
|
+
const current = conversations.find((c) => c.id === conversationId);
|
|
268
|
+
const action = decideNewChatAction({
|
|
269
|
+
current: current
|
|
270
|
+
? { id: current.id, title: current.title }
|
|
271
|
+
: undefined,
|
|
272
|
+
messages: messages.map((m) => ({ text: m.text })),
|
|
273
|
+
});
|
|
274
|
+
if (action.kind === "reuse") {
|
|
275
|
+
setConversationId(action.conversationId);
|
|
276
|
+
setMessages([]);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const conv = await createConversation.mutateAsync({
|
|
280
|
+
integrationId: connectionId,
|
|
281
|
+
model,
|
|
282
|
+
permissionMode,
|
|
283
|
+
});
|
|
284
|
+
setConversationId(conv.id);
|
|
285
|
+
setMessages([]);
|
|
286
|
+
// The new conversation must appear in (and highlight within) the sidebar
|
|
287
|
+
// immediately. createConversation auto-invalidates this plugin's queries on
|
|
288
|
+
// success, so the list refetches; setting conversationId above makes the new
|
|
289
|
+
// row the active/highlighted one.
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const openConversation = (id: string) => {
|
|
293
|
+
clearError();
|
|
294
|
+
setLoadId(id);
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// Soft-delete (archive) the confirmed conversation: the row + transcript are
|
|
298
|
+
// retained server-side, the chat just disappears from the sidebar. If the
|
|
299
|
+
// archived chat was the open one, clear the view back to the empty state.
|
|
300
|
+
const confirmDelete = async () => {
|
|
301
|
+
const target = pendingDelete;
|
|
302
|
+
if (!target) return;
|
|
303
|
+
await archiveConversation.mutateAsync({ id: target.id });
|
|
304
|
+
setPendingDelete(undefined);
|
|
305
|
+
if (target.id === conversationId) {
|
|
306
|
+
setConversationId(undefined);
|
|
307
|
+
setLoadId(undefined);
|
|
308
|
+
setMessages([]);
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// When a conversation loads, hydrate the local message list from its
|
|
313
|
+
// persisted transcript (shared Postgres — readable on any pod). Seed ONCE per
|
|
314
|
+
// conversation id via useInitOnceForKey (frontend/query-invalidation Pillar 3):
|
|
315
|
+
// a naive `useEffect([data])` would re-seed on every background refetch (the
|
|
316
|
+
// whole-plugin invalidation after each turn, window refocus, etc.), rebuilding
|
|
317
|
+
// messages from the TEXT-ONLY transcript and wiping the just-streamed turn's
|
|
318
|
+
// confirm cards and tool-step parts (those live only in client state). Keying
|
|
319
|
+
// on the conversation id makes background refetches of the same chat no-ops.
|
|
320
|
+
useInitOnceForKey(
|
|
321
|
+
loadedConversation.data,
|
|
322
|
+
loadedConversation.data?.conversation.id,
|
|
323
|
+
(result) => {
|
|
324
|
+
setConversationId(result.conversation.id);
|
|
325
|
+
if (result.conversation.model) setModel(result.conversation.model);
|
|
326
|
+
if (result.conversation.integrationId)
|
|
327
|
+
setConnectionId(result.conversation.integrationId);
|
|
328
|
+
setPermissionMode(
|
|
329
|
+
deriveModeToggleValue({
|
|
330
|
+
conversationMode: result.conversation.permissionMode,
|
|
331
|
+
}),
|
|
332
|
+
);
|
|
333
|
+
setMessages(
|
|
334
|
+
result.messages
|
|
335
|
+
.filter((m) => m.role === "user" || m.role === "assistant")
|
|
336
|
+
.map((m): ChatMessage => {
|
|
337
|
+
const text =
|
|
338
|
+
typeof m.content.text === "string" ? m.content.text : "";
|
|
339
|
+
const role = m.role === "user" ? "user" : "assistant";
|
|
340
|
+
// The persisted transcript only stores rendered text (tool steps
|
|
341
|
+
// live in `modelMessages` for replay, not for display), so a
|
|
342
|
+
// reloaded assistant turn is a single text part.
|
|
343
|
+
return {
|
|
344
|
+
id: m.id,
|
|
345
|
+
role,
|
|
346
|
+
text,
|
|
347
|
+
parts:
|
|
348
|
+
role === "assistant" && text ? [{ kind: "text", text }] : [],
|
|
349
|
+
streaming: false,
|
|
350
|
+
};
|
|
351
|
+
}),
|
|
352
|
+
);
|
|
353
|
+
},
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
const onSend = async () => {
|
|
357
|
+
if (!input.trim() || !connectionId) return;
|
|
358
|
+
let convId = conversationId;
|
|
359
|
+
if (!convId) {
|
|
360
|
+
const conv = await createConversation.mutateAsync({
|
|
361
|
+
integrationId: connectionId,
|
|
362
|
+
model,
|
|
363
|
+
permissionMode,
|
|
364
|
+
});
|
|
365
|
+
convId = conv.id;
|
|
366
|
+
setConversationId(conv.id);
|
|
367
|
+
}
|
|
368
|
+
const text = input;
|
|
369
|
+
setInput("");
|
|
370
|
+
await send({ conversationId: convId, connectionId, model, text });
|
|
371
|
+
// The chat turn streams via a raw fetch (not an oRPC mutation), so it does
|
|
372
|
+
// not auto-invalidate this plugin's queries. After the turn completes the
|
|
373
|
+
// backend may have set an auto-title on a previously untitled conversation,
|
|
374
|
+
// so refresh this plugin's queries to pick it up. No polling loop. Whole-
|
|
375
|
+
// plugin invalidation is the documented default (frontend/query-invalidation
|
|
376
|
+
// Pillar 2); the getConversation refetch it triggers is harmless because the
|
|
377
|
+
// message hydration below seeds ONCE per conversation via useInitOnceForKey
|
|
378
|
+
// (Pillar 3) and so cannot clobber the just-streamed turn's confirm cards /
|
|
379
|
+
// tool-step parts (which live only in client state, never in the transcript).
|
|
380
|
+
void queryClient.invalidateQueries({
|
|
381
|
+
queryKey: [[pluginMetadata.pluginId]],
|
|
382
|
+
});
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
// After the operator applies/declines a confirm card, stream the model's
|
|
386
|
+
// acknowledgment so the conversation continues instead of dead-ending on
|
|
387
|
+
// "waiting for your confirmation". The apply itself already ran via applyTool
|
|
388
|
+
// (inside ConfirmCardView); this only drives the model's reaction.
|
|
389
|
+
const handleDecision = (decision: {
|
|
390
|
+
token: string;
|
|
391
|
+
decision: "apply" | "decline";
|
|
392
|
+
}) => {
|
|
393
|
+
if (!conversationId || !connectionId) return;
|
|
394
|
+
void sendDecision({
|
|
395
|
+
conversationId,
|
|
396
|
+
connectionId,
|
|
397
|
+
model,
|
|
398
|
+
token: decision.token,
|
|
399
|
+
decision: decision.decision,
|
|
400
|
+
});
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
// Change the permission mode: update local state immediately, and persist to
|
|
404
|
+
// the loaded conversation (owner-scoped) when one is open. A brand-new chat
|
|
405
|
+
// with no conversation yet just keeps the local choice, which is then sent on
|
|
406
|
+
// the create call (onSend / startNewConversation). updateConversation is an
|
|
407
|
+
// oRPC mutation so it auto-invalidates this plugin's queries on success.
|
|
408
|
+
const onModeChange = (next: AiPermissionMode) => {
|
|
409
|
+
setPermissionMode(next);
|
|
410
|
+
const update = buildModeUpdate({
|
|
411
|
+
conversationId,
|
|
412
|
+
currentMode: permissionMode,
|
|
413
|
+
nextMode: next,
|
|
414
|
+
});
|
|
415
|
+
if (update) void updateConversation.mutateAsync(update);
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
// Copy the FULL error text (the provider's HTTP body) to the clipboard so the
|
|
419
|
+
// operator can forward it, even though the banner only shows a one-line digest.
|
|
420
|
+
const copyError = async () => {
|
|
421
|
+
if (!error) return;
|
|
422
|
+
try {
|
|
423
|
+
await navigator.clipboard.writeText(error);
|
|
424
|
+
setCopiedError(true);
|
|
425
|
+
setTimeout(() => setCopiedError(false), 1500);
|
|
426
|
+
} catch {
|
|
427
|
+
// Clipboard API unavailable (non-secure context); silently no-op.
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
// The picker always offers the connection's defaultModel (first) followed by
|
|
432
|
+
// its allowlist, de-duplicated. With no allowlist this is just [defaultModel],
|
|
433
|
+
// so the picker stays a tidy Select rather than a free-text field.
|
|
434
|
+
const modelOptions = useMemo(
|
|
435
|
+
() =>
|
|
436
|
+
buildModelOptions({
|
|
437
|
+
defaultModel: selectedIntegration?.defaultModel,
|
|
438
|
+
availableModels: selectedIntegration?.availableModels,
|
|
439
|
+
}),
|
|
440
|
+
[selectedIntegration],
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
return (
|
|
444
|
+
<PageLayout
|
|
445
|
+
title="AI assistant"
|
|
446
|
+
subtitle="Chat with Checkstack's built-in assistant. It can read incidents, health checks, and anomalies, and propose automations for you to confirm."
|
|
447
|
+
icon={Sparkles}
|
|
448
|
+
>
|
|
449
|
+
<div className="grid grid-cols-1 md:grid-cols-[260px_1fr] gap-4 h-[calc(100vh-220px)]">
|
|
450
|
+
{/* Conversation sidebar */}
|
|
451
|
+
<Card className="overflow-hidden flex flex-col">
|
|
452
|
+
<CardContent className="p-2 flex flex-col gap-2 overflow-y-auto">
|
|
453
|
+
<Button
|
|
454
|
+
size="sm"
|
|
455
|
+
variant="outline"
|
|
456
|
+
className="w-full"
|
|
457
|
+
onClick={startNewConversation}
|
|
458
|
+
>
|
|
459
|
+
<Plus className="w-3.5 h-3.5 mr-1" /> New chat
|
|
460
|
+
</Button>
|
|
461
|
+
{conversations.map((c) => (
|
|
462
|
+
<div
|
|
463
|
+
key={c.id}
|
|
464
|
+
className={`group flex items-center gap-1 rounded px-2 py-1 hover:bg-muted ${
|
|
465
|
+
c.id === conversationId ? "bg-muted font-medium" : ""
|
|
466
|
+
}`}
|
|
467
|
+
>
|
|
468
|
+
<button
|
|
469
|
+
type="button"
|
|
470
|
+
onClick={() => openConversation(c.id)}
|
|
471
|
+
className="flex-1 text-left text-sm truncate"
|
|
472
|
+
>
|
|
473
|
+
{c.title ?? "Untitled chat"}
|
|
474
|
+
</button>
|
|
475
|
+
<button
|
|
476
|
+
type="button"
|
|
477
|
+
aria-label="Delete chat"
|
|
478
|
+
title="Delete"
|
|
479
|
+
onClick={() =>
|
|
480
|
+
setPendingDelete({ id: c.id, title: c.title })
|
|
481
|
+
}
|
|
482
|
+
className="shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100 focus:opacity-100"
|
|
483
|
+
>
|
|
484
|
+
<Trash2 className="w-3.5 h-3.5" />
|
|
485
|
+
</button>
|
|
486
|
+
</div>
|
|
487
|
+
))}
|
|
488
|
+
</CardContent>
|
|
489
|
+
</Card>
|
|
490
|
+
|
|
491
|
+
{/* Chat panel */}
|
|
492
|
+
<Card className="flex flex-col overflow-hidden">
|
|
493
|
+
<div className="flex items-center gap-2 border-b p-2">
|
|
494
|
+
<Select value={connectionId} onValueChange={setConnectionId}>
|
|
495
|
+
<SelectTrigger className="w-48">
|
|
496
|
+
<SelectValue placeholder="Integration" />
|
|
497
|
+
</SelectTrigger>
|
|
498
|
+
<SelectContent>
|
|
499
|
+
{integrations.map((i) => (
|
|
500
|
+
<SelectItem key={i.connectionId} value={i.connectionId}>
|
|
501
|
+
{i.name}
|
|
502
|
+
</SelectItem>
|
|
503
|
+
))}
|
|
504
|
+
</SelectContent>
|
|
505
|
+
</Select>
|
|
506
|
+
<Select value={model} onValueChange={setModel}>
|
|
507
|
+
<SelectTrigger className="w-48">
|
|
508
|
+
<SelectValue placeholder="Model" />
|
|
509
|
+
</SelectTrigger>
|
|
510
|
+
<SelectContent>
|
|
511
|
+
{modelOptions.map((m) => (
|
|
512
|
+
<SelectItem key={m} value={m}>
|
|
513
|
+
{m}
|
|
514
|
+
</SelectItem>
|
|
515
|
+
))}
|
|
516
|
+
</SelectContent>
|
|
517
|
+
</Select>
|
|
518
|
+
{/* Permission mode: Approve surfaces a confirm card for changes; Auto
|
|
519
|
+
applies non-destructive changes immediately. Destructive actions
|
|
520
|
+
always require confirmation regardless of this setting. */}
|
|
521
|
+
<Select
|
|
522
|
+
value={permissionMode}
|
|
523
|
+
onValueChange={(v) =>
|
|
524
|
+
onModeChange(deriveModeToggleValue({ conversationMode: v }))
|
|
525
|
+
}
|
|
526
|
+
>
|
|
527
|
+
<SelectTrigger
|
|
528
|
+
className="w-36"
|
|
529
|
+
title="Approve: confirm each change. Auto: apply non-destructive changes immediately. Destructive actions always need confirmation."
|
|
530
|
+
>
|
|
531
|
+
<SelectValue placeholder="Mode" />
|
|
532
|
+
</SelectTrigger>
|
|
533
|
+
<SelectContent>
|
|
534
|
+
{PERMISSION_MODE_OPTIONS.map((o) => (
|
|
535
|
+
<SelectItem key={o.value} value={o.value}>
|
|
536
|
+
{o.label}
|
|
537
|
+
</SelectItem>
|
|
538
|
+
))}
|
|
539
|
+
</SelectContent>
|
|
540
|
+
</Select>
|
|
541
|
+
</div>
|
|
542
|
+
|
|
543
|
+
<div className="flex-1 overflow-y-auto p-3 space-y-3">
|
|
544
|
+
{integrationsQuery.isLoading ? (
|
|
545
|
+
<LoadingSpinner />
|
|
546
|
+
) : integrations.length === 0 ? (
|
|
547
|
+
<EmptyState
|
|
548
|
+
icon={<Sparkles className="w-8 h-8" />}
|
|
549
|
+
title="No AI integration configured"
|
|
550
|
+
description="Add an OpenAI-compatible connection in Settings to start chatting."
|
|
551
|
+
/>
|
|
552
|
+
) : messages.length === 0 ? (
|
|
553
|
+
<EmptyState
|
|
554
|
+
icon={<Sparkles className="w-8 h-8" />}
|
|
555
|
+
title="Ask the assistant"
|
|
556
|
+
description="Try: 'Summarize the open incidents' or 'Draft an automation that pages on-call when the API health check fails.'"
|
|
557
|
+
/>
|
|
558
|
+
) : (
|
|
559
|
+
messages.map((m) => (
|
|
560
|
+
<MessageRow
|
|
561
|
+
key={m.id}
|
|
562
|
+
message={m}
|
|
563
|
+
onDecision={handleDecision}
|
|
564
|
+
/>
|
|
565
|
+
))
|
|
566
|
+
)}
|
|
567
|
+
<div ref={bottomRef} />
|
|
568
|
+
</div>
|
|
569
|
+
|
|
570
|
+
{/* Persistent error banner: a failed turn is never persisted
|
|
571
|
+
server-side (onFinish does not run on error), so the in-bubble
|
|
572
|
+
error would be wiped by the post-turn transcript refetch. This
|
|
573
|
+
banner reads from the durable hook `error` state instead, so the
|
|
574
|
+
operator can read and copy the provider's actual message (e.g. a
|
|
575
|
+
400 `invalid_prompt` body). Dismiss with the X or by sending again. */}
|
|
576
|
+
{error ? (
|
|
577
|
+
<div className="border-t p-2">
|
|
578
|
+
<Alert variant="error">
|
|
579
|
+
<AlertIcon>
|
|
580
|
+
<AlertCircle className="h-4 w-4" />
|
|
581
|
+
</AlertIcon>
|
|
582
|
+
{/* Single-line digest: the full provider body can be thousands
|
|
583
|
+
of chars (a JSON validation dump), so truncate it here and
|
|
584
|
+
expose the whole thing via Copy + the native hover tooltip. */}
|
|
585
|
+
<AlertContent className="min-w-0 flex-1">
|
|
586
|
+
<AlertTitle>The assistant could not respond</AlertTitle>
|
|
587
|
+
<AlertDescription>
|
|
588
|
+
<span className="block truncate" title={error}>
|
|
589
|
+
{error}
|
|
590
|
+
</span>
|
|
591
|
+
</AlertDescription>
|
|
592
|
+
</AlertContent>
|
|
593
|
+
<div className="ml-auto flex shrink-0 items-center gap-1">
|
|
594
|
+
<Button
|
|
595
|
+
size="sm"
|
|
596
|
+
variant="ghost"
|
|
597
|
+
className="h-7 gap-1 px-2 text-destructive hover:text-destructive"
|
|
598
|
+
onClick={() => void copyError()}
|
|
599
|
+
>
|
|
600
|
+
{copiedError ? (
|
|
601
|
+
<Check className="h-3.5 w-3.5" />
|
|
602
|
+
) : (
|
|
603
|
+
<Copy className="h-3.5 w-3.5" />
|
|
604
|
+
)}
|
|
605
|
+
{copiedError ? "Copied" : "Copy"}
|
|
606
|
+
</Button>
|
|
607
|
+
<button
|
|
608
|
+
type="button"
|
|
609
|
+
aria-label="Dismiss error"
|
|
610
|
+
onClick={clearError}
|
|
611
|
+
className="text-destructive/70 hover:text-destructive"
|
|
612
|
+
>
|
|
613
|
+
<X className="h-4 w-4" />
|
|
614
|
+
</button>
|
|
615
|
+
</div>
|
|
616
|
+
</Alert>
|
|
617
|
+
</div>
|
|
618
|
+
) : null}
|
|
619
|
+
|
|
620
|
+
<div className="border-t p-2 flex gap-2">
|
|
621
|
+
<Textarea
|
|
622
|
+
className="flex-1 resize-none"
|
|
623
|
+
rows={2}
|
|
624
|
+
placeholder="Message the assistant..."
|
|
625
|
+
value={input}
|
|
626
|
+
disabled={streaming || integrations.length === 0}
|
|
627
|
+
onChange={(e) => setInput(e.target.value)}
|
|
628
|
+
onKeyDown={(e) => {
|
|
629
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
630
|
+
e.preventDefault();
|
|
631
|
+
void onSend();
|
|
632
|
+
}
|
|
633
|
+
}}
|
|
634
|
+
/>
|
|
635
|
+
<Button
|
|
636
|
+
onClick={() => void onSend()}
|
|
637
|
+
disabled={streaming || !input.trim() || !connectionId}
|
|
638
|
+
>
|
|
639
|
+
<Send className="w-4 h-4" />
|
|
640
|
+
</Button>
|
|
641
|
+
</div>
|
|
642
|
+
</Card>
|
|
643
|
+
</div>
|
|
644
|
+
|
|
645
|
+
{/* Delete = soft archive: the row is retained server-side for abuse
|
|
646
|
+
introspection but disappears from the sidebar. Labeled "Delete". */}
|
|
647
|
+
<ConfirmationModal
|
|
648
|
+
isOpen={Boolean(pendingDelete)}
|
|
649
|
+
onClose={() => setPendingDelete(undefined)}
|
|
650
|
+
onConfirm={() => void confirmDelete()}
|
|
651
|
+
title="Delete chat"
|
|
652
|
+
message={`Delete "${
|
|
653
|
+
pendingDelete?.title ?? "Untitled chat"
|
|
654
|
+
}"? This removes it from your list.`}
|
|
655
|
+
confirmText="Delete"
|
|
656
|
+
variant="danger"
|
|
657
|
+
isLoading={archiveConversation.isPending}
|
|
658
|
+
/>
|
|
659
|
+
</PageLayout>
|
|
660
|
+
);
|
|
661
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "@checkstack/tsconfig/frontend.json",
|
|
3
|
+
"include": [
|
|
4
|
+
"src"
|
|
5
|
+
],
|
|
6
|
+
"references": [
|
|
7
|
+
{
|
|
8
|
+
"path": "../ai-common"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"path": "../common"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"path": "../frontend-api"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"path": "../integration-common"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"path": "../ui"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|