@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.
@@ -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
+ }