create-interview-cockpit 0.4.0 → 0.6.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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/template/client/package-lock.json +753 -1
  3. package/template/client/package.json +4 -0
  4. package/template/client/src/App.tsx +20 -0
  5. package/template/client/src/api.ts +455 -3
  6. package/template/client/src/components/AiSettingsModal.tsx +855 -248
  7. package/template/client/src/components/AnnotationDialog.tsx +3 -9
  8. package/template/client/src/components/ChatMessage.tsx +132 -27
  9. package/template/client/src/components/ChatView.tsx +365 -123
  10. package/template/client/src/components/CodeContextPanel.tsx +714 -0
  11. package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
  12. package/template/client/src/components/CodeRunnerModal.tsx +3030 -0
  13. package/template/client/src/components/DocRefModal.tsx +551 -0
  14. package/template/client/src/components/FileAttachments.tsx +128 -12
  15. package/template/client/src/components/FilePickerModal.tsx +181 -0
  16. package/template/client/src/components/FileViewerModal.tsx +406 -28
  17. package/template/client/src/components/InfraLabModal.tsx +1706 -0
  18. package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
  19. package/template/client/src/components/MarkdownRenderer.tsx +219 -2
  20. package/template/client/src/components/NotesModal.tsx +977 -0
  21. package/template/client/src/components/PlotEmbed.tsx +173 -0
  22. package/template/client/src/components/Sidebar.tsx +397 -127
  23. package/template/client/src/components/TextAnnotator.tsx +8 -15
  24. package/template/client/src/components/VizCraftEmbed.tsx +412 -25
  25. package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
  26. package/template/client/src/infraLab.ts +124 -0
  27. package/template/client/src/reactLab.ts +477 -0
  28. package/template/client/src/store.ts +416 -2
  29. package/template/client/src/types.ts +41 -1
  30. package/template/client/tsconfig.tsbuildinfo +1 -1
  31. package/template/cockpit.json +1 -1
  32. package/template/package.json +1 -1
  33. package/template/server/src/google-drive.ts +144 -2
  34. package/template/server/src/index.ts +1890 -188
  35. package/template/server/src/infra-runner.ts +1104 -0
  36. package/template/server/src/storage.ts +274 -3
@@ -0,0 +1,1706 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import {
3
+ AlertCircle,
4
+ Check,
5
+ ChevronDown,
6
+ ChevronLeft,
7
+ ChevronRight,
8
+ Clipboard,
9
+ ClipboardCheck,
10
+ FilePlus,
11
+ Folder,
12
+ GripVertical,
13
+ Loader2,
14
+ Maximize2,
15
+ MessageSquare,
16
+ Minimize2,
17
+ PanelLeftClose,
18
+ PanelLeftOpen,
19
+ PanelRightClose,
20
+ PanelRightOpen,
21
+ Play,
22
+ Save,
23
+ Send,
24
+ StopCircle,
25
+ Terminal,
26
+ Trash2,
27
+ X,
28
+ } from "lucide-react";
29
+
30
+ const MIN_W = 900;
31
+ const MIN_H = 560;
32
+ const DEFAULT_W = Math.min(1280, window.innerWidth - 48);
33
+ const DEFAULT_H = Math.min(820, window.innerHeight - 48);
34
+ type ResizeDir = "e" | "s" | "se" | "sw" | "w" | "ne" | "nw" | "n" | null;
35
+ import type { InfraCommandStreamMessage } from "../api";
36
+
37
+ interface ConsoleLine {
38
+ id: string;
39
+ kind: "stdout" | "stderr" | "info" | "input";
40
+ text: string;
41
+ }
42
+ import { useStore } from "../store";
43
+ import {
44
+ cloneInfraLabWorkspace,
45
+ DEFAULT_INFRA_LAB,
46
+ getInfraLabFileOrder,
47
+ serializeInfraLabWorkspace,
48
+ } from "../infraLab";
49
+ import type { InfraLabWorkspace } from "../types";
50
+ import type { InfraRunAction, InfraRunDetails, InfraRunListItem } from "../api";
51
+ import * as api from "../api";
52
+ import ReactMarkdown from "react-markdown";
53
+ import remarkGfm from "remark-gfm";
54
+
55
+ function updateInfraFile(
56
+ workspace: InfraLabWorkspace,
57
+ fileName: string,
58
+ content: string,
59
+ ): InfraLabWorkspace {
60
+ return {
61
+ ...workspace,
62
+ activeFile: fileName,
63
+ files: {
64
+ ...workspace.files,
65
+ [fileName]: content,
66
+ },
67
+ };
68
+ }
69
+
70
+ export default function InfraLabModal() {
71
+ const {
72
+ closeInfraLab,
73
+ currentQuestion,
74
+ runnerInitialInfra,
75
+ runnerInitialInfraFileId,
76
+ saveCodeSnippetToQuestion,
77
+ overwriteContextFileContent,
78
+ } = useStore();
79
+
80
+ const [workspace, setWorkspace] = useState<InfraLabWorkspace>(() =>
81
+ cloneInfraLabWorkspace(runnerInitialInfra ?? DEFAULT_INFRA_LAB),
82
+ );
83
+ const [labName, setLabName] = useState(
84
+ runnerInitialInfra?.label ?? DEFAULT_INFRA_LAB.label,
85
+ );
86
+ const [activeFile, setActiveFile] = useState(
87
+ runnerInitialInfra?.activeFile ?? DEFAULT_INFRA_LAB.activeFile,
88
+ );
89
+ const [activeInfraId, setActiveInfraId] = useState<string | null>(
90
+ runnerInitialInfraFileId ?? null,
91
+ );
92
+ const [saving, setSaving] = useState(false);
93
+ const [saved, setSaved] = useState(false);
94
+ const [runningAction, setRunningAction] = useState<InfraRunAction | null>(
95
+ null,
96
+ );
97
+ const [runHistory, setRunHistory] = useState<InfraRunListItem[]>([]);
98
+ const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
99
+ const [selectedRun, setSelectedRun] = useState<InfraRunDetails | null>(null);
100
+ const [loadingRuns, setLoadingRuns] = useState(false);
101
+ const [runError, setRunError] = useState<string | null>(null);
102
+ const [inspectorTab, setInspectorTab] = useState<"summary" | "json">(
103
+ "summary",
104
+ );
105
+
106
+ useEffect(() => {
107
+ const nextWorkspace = cloneInfraLabWorkspace(
108
+ runnerInitialInfra ?? DEFAULT_INFRA_LAB,
109
+ );
110
+ setWorkspace(nextWorkspace);
111
+ setLabName(nextWorkspace.label);
112
+ setActiveFile(nextWorkspace.activeFile);
113
+ setActiveInfraId(runnerInitialInfraFileId ?? null);
114
+ }, [runnerInitialInfra, runnerInitialInfraFileId]);
115
+
116
+ // ── Drag / resize ─────────────────────────────────────────────
117
+ const [pos, setPos] = useState(() => ({
118
+ x: Math.max(0, (window.innerWidth - DEFAULT_W) / 2),
119
+ y: Math.max(0, (window.innerHeight - DEFAULT_H) / 2),
120
+ }));
121
+ const [size, setSize] = useState({ w: DEFAULT_W, h: DEFAULT_H });
122
+ const [maximized, setMaximized] = useState(false);
123
+
124
+ const dragStart = useRef<{
125
+ mx: number;
126
+ my: number;
127
+ ox: number;
128
+ oy: number;
129
+ } | null>(null);
130
+ const resizeDir = useRef<ResizeDir>(null);
131
+ const resizeStart = useRef<{
132
+ mx: number;
133
+ my: number;
134
+ ox: number;
135
+ oy: number;
136
+ ow: number;
137
+ oh: number;
138
+ } | null>(null);
139
+ const savedPos = useRef(pos);
140
+ const savedSize = useRef(size);
141
+
142
+ const onTitleMouseDown = useCallback(
143
+ (e: React.MouseEvent) => {
144
+ if (maximized) return;
145
+ e.preventDefault();
146
+ dragStart.current = {
147
+ mx: e.clientX,
148
+ my: e.clientY,
149
+ ox: pos.x,
150
+ oy: pos.y,
151
+ };
152
+ },
153
+ [maximized, pos],
154
+ );
155
+
156
+ const startResize = useCallback(
157
+ (dir: ResizeDir) => (e: React.MouseEvent) => {
158
+ if (maximized) return;
159
+ e.preventDefault();
160
+ e.stopPropagation();
161
+ resizeDir.current = dir;
162
+ resizeStart.current = {
163
+ mx: e.clientX,
164
+ my: e.clientY,
165
+ ox: pos.x,
166
+ oy: pos.y,
167
+ ow: size.w,
168
+ oh: size.h,
169
+ };
170
+ },
171
+ [maximized, pos, size],
172
+ );
173
+
174
+ const toggleMax = useCallback(() => {
175
+ if (!maximized) {
176
+ savedPos.current = pos;
177
+ savedSize.current = size;
178
+ setMaximized(true);
179
+ } else {
180
+ setPos(savedPos.current);
181
+ setSize(savedSize.current);
182
+ setMaximized(false);
183
+ }
184
+ }, [maximized, pos, size]);
185
+
186
+ useEffect(() => {
187
+ const onMove = (e: MouseEvent) => {
188
+ const drag = dragStart.current;
189
+ const resize = resizeStart.current;
190
+ const dir = resizeDir.current;
191
+ if (drag) {
192
+ setPos({
193
+ x: Math.max(0, drag.ox + e.clientX - drag.mx),
194
+ y: Math.max(0, drag.oy + e.clientY - drag.my),
195
+ });
196
+ }
197
+ if (resize && dir) {
198
+ const dx = e.clientX - resize.mx;
199
+ const dy = e.clientY - resize.my;
200
+ setSize((prev) => {
201
+ let w = prev.w,
202
+ h = prev.h;
203
+ if (dir.includes("e")) w = Math.max(MIN_W, resize.ow + dx);
204
+ if (dir.includes("s")) h = Math.max(MIN_H, resize.oh + dy);
205
+ if (dir.includes("w")) w = Math.max(MIN_W, resize.ow - dx);
206
+ if (dir.includes("n")) h = Math.max(MIN_H, resize.oh - dy);
207
+ return { w, h };
208
+ });
209
+ if (dir.includes("w"))
210
+ setPos((p) => ({
211
+ ...p,
212
+ x: Math.min(resize.ox + resize.ow - MIN_W, resize.ox + dx),
213
+ }));
214
+ if (dir.includes("n"))
215
+ setPos((p) => ({
216
+ ...p,
217
+ y: Math.min(resize.oy + resize.oh - MIN_H, resize.oy + dy),
218
+ }));
219
+ }
220
+ };
221
+ const onUp = () => {
222
+ dragStart.current = null;
223
+ resizeStart.current = null;
224
+ resizeDir.current = null;
225
+ };
226
+ document.addEventListener("mousemove", onMove);
227
+ document.addEventListener("mouseup", onUp);
228
+ return () => {
229
+ document.removeEventListener("mousemove", onMove);
230
+ document.removeEventListener("mouseup", onUp);
231
+ };
232
+ }, []);
233
+
234
+ useEffect(() => {
235
+ const onKeyDown = (event: KeyboardEvent) => {
236
+ if (event.key === "Escape") closeInfraLab();
237
+ };
238
+ document.addEventListener("keydown", onKeyDown);
239
+ return () => document.removeEventListener("keydown", onKeyDown);
240
+ }, [closeInfraLab]);
241
+
242
+ const fileOrder = getInfraLabFileOrder(workspace);
243
+ const currentFile = workspace.files[activeFile] ?? "";
244
+
245
+ const persistWorkspace = useCallback(
246
+ async (forceNew: boolean) => {
247
+ if (!currentQuestion) return;
248
+
249
+ setSaving(true);
250
+ try {
251
+ const nextWorkspace = cloneInfraLabWorkspace({
252
+ ...workspace,
253
+ label: labName.trim() || DEFAULT_INFRA_LAB.label,
254
+ activeFile,
255
+ });
256
+ const payload = serializeInfraLabWorkspace(nextWorkspace);
257
+ let nextFileId = activeInfraId;
258
+
259
+ if (!forceNew && activeInfraId) {
260
+ await overwriteContextFileContent(
261
+ currentQuestion.id,
262
+ activeInfraId,
263
+ payload,
264
+ );
265
+ } else {
266
+ const cf = await saveCodeSnippetToQuestion(
267
+ currentQuestion.id,
268
+ payload,
269
+ "infra",
270
+ nextWorkspace.label,
271
+ "infra",
272
+ );
273
+ nextFileId = cf.id;
274
+ setActiveInfraId(cf.id);
275
+ }
276
+
277
+ setWorkspace(nextWorkspace);
278
+ setLabName(nextWorkspace.label);
279
+ setSaved(true);
280
+ setTimeout(() => setSaved(false), 1800);
281
+ return {
282
+ fileId: nextFileId ?? null,
283
+ workspace: nextWorkspace,
284
+ };
285
+ } finally {
286
+ setSaving(false);
287
+ }
288
+ },
289
+ [
290
+ activeFile,
291
+ activeInfraId,
292
+ currentQuestion,
293
+ labName,
294
+ overwriteContextFileContent,
295
+ saveCodeSnippetToQuestion,
296
+ workspace,
297
+ ],
298
+ );
299
+
300
+ const loadRunDetails = useCallback(async (runId: string) => {
301
+ const run = await api.fetchInfraRun(runId);
302
+ setSelectedRun(run);
303
+ setSelectedRunId(run.id);
304
+ }, []);
305
+
306
+ const refreshRunHistory = useCallback(
307
+ async (fileId: string) => {
308
+ setLoadingRuns(true);
309
+ try {
310
+ const history = await api.fetchInfraRuns(fileId);
311
+ setRunHistory(history);
312
+ if (history.length > 0) {
313
+ const preferred = history.find((item) => item.id === selectedRunId);
314
+ await loadRunDetails(preferred?.id ?? history[0].id);
315
+ } else {
316
+ setSelectedRun(null);
317
+ setSelectedRunId(null);
318
+ }
319
+ } finally {
320
+ setLoadingRuns(false);
321
+ }
322
+ },
323
+ [loadRunDetails, selectedRunId],
324
+ );
325
+
326
+ useEffect(() => {
327
+ if (!activeInfraId) {
328
+ setRunHistory([]);
329
+ setSelectedRunId(null);
330
+ setSelectedRun(null);
331
+ return;
332
+ }
333
+
334
+ let cancelled = false;
335
+ setLoadingRuns(true);
336
+ api
337
+ .fetchInfraRuns(activeInfraId)
338
+ .then(async (history) => {
339
+ if (cancelled) return;
340
+ setRunHistory(history);
341
+ if (history[0]) {
342
+ const run = await api.fetchInfraRun(history[0].id);
343
+ if (!cancelled) {
344
+ setSelectedRun(run);
345
+ setSelectedRunId(run.id);
346
+ }
347
+ } else {
348
+ setSelectedRun(null);
349
+ setSelectedRunId(null);
350
+ }
351
+ })
352
+ .catch(() => {
353
+ if (!cancelled) {
354
+ setRunHistory([]);
355
+ setSelectedRun(null);
356
+ setSelectedRunId(null);
357
+ }
358
+ })
359
+ .finally(() => {
360
+ if (!cancelled) setLoadingRuns(false);
361
+ });
362
+
363
+ return () => {
364
+ cancelled = true;
365
+ };
366
+ }, [activeInfraId]);
367
+
368
+ const runInfra = async (action: InfraRunAction) => {
369
+ if (!currentQuestion) return;
370
+
371
+ setRunError(null);
372
+ setRunningAction(action);
373
+ try {
374
+ const persisted = await persistWorkspace(false);
375
+ if (!persisted?.fileId) {
376
+ throw new Error("Save the infra lab before running it.");
377
+ }
378
+
379
+ const run = await api.runInfraAction({
380
+ questionId: currentQuestion.id,
381
+ fileId: persisted.fileId,
382
+ label: persisted.workspace.label,
383
+ action,
384
+ workspace: persisted.workspace,
385
+ });
386
+ setSelectedRun(run);
387
+ setSelectedRunId(run.id);
388
+ await refreshRunHistory(persisted.fileId);
389
+ } catch (error: any) {
390
+ setRunError(String(error?.message ?? error ?? "Infra run failed"));
391
+ } finally {
392
+ setRunningAction(null);
393
+ }
394
+ };
395
+
396
+ const inspectorJson = selectedRun?.planJson ?? selectedRun?.validationJson;
397
+ const logOutput =
398
+ selectedRun?.logs || runError || "Run output will appear here.";
399
+
400
+ // ── Practice console ──────────────────────────────────────────
401
+ const [bottomTab, setBottomTab] = useState<"output" | "console" | "chat">(
402
+ "output",
403
+ );
404
+ const [consoleLines, setConsoleLines] = useState<ConsoleLine[]>([]);
405
+ const [cmdInput, setCmdInput] = useState("");
406
+ const [consoleRunning, setConsoleRunning] = useState(false);
407
+ const consoleOutputRef = useRef<HTMLDivElement>(null);
408
+ const cmdAbortRef = useRef<{ aborted: boolean }>({ aborted: false });
409
+
410
+ const appendLine = useCallback((kind: ConsoleLine["kind"], text: string) => {
411
+ setConsoleLines((prev) => [
412
+ ...prev,
413
+ { id: crypto.randomUUID(), kind, text },
414
+ ]);
415
+ }, []);
416
+
417
+ useEffect(() => {
418
+ if (bottomTab === "console" && consoleOutputRef.current) {
419
+ consoleOutputRef.current.scrollTop =
420
+ consoleOutputRef.current.scrollHeight;
421
+ }
422
+ }, [consoleLines, bottomTab]);
423
+
424
+ const handleRunCommand = useCallback(async () => {
425
+ const cmd = cmdInput.trim();
426
+ if (!cmd || consoleRunning) return;
427
+ setCmdInput("");
428
+ setConsoleRunning(true);
429
+ const abort = { aborted: false };
430
+ cmdAbortRef.current = abort;
431
+ // Don't echo here — the server sends back an info line "$ terraform ..."
432
+ // as the very first message, which serves as the canonical prompt echo.
433
+ try {
434
+ await api.streamInfraCommand(
435
+ {
436
+ questionId: currentQuestion?.id,
437
+ fileId: activeInfraId ?? undefined,
438
+ label: labName,
439
+ command: cmd,
440
+ workspace,
441
+ },
442
+ (msg: InfraCommandStreamMessage) => {
443
+ if (abort.aborted) return;
444
+ if (msg.type === "output") appendLine(msg.kind, msg.text);
445
+ else if (msg.type === "error") appendLine("stderr", msg.error);
446
+ else if (msg.type === "complete" && msg.run.workspaceSnapshot) {
447
+ // Merge any server-generated files (e.g. .terraform.lock.hcl) back
448
+ // into the workspace so they appear in the file tree.
449
+ setWorkspace((prev) => ({
450
+ ...prev,
451
+ files: { ...prev.files, ...msg.run.workspaceSnapshot!.files },
452
+ }));
453
+ }
454
+ },
455
+ );
456
+ } catch (err: unknown) {
457
+ if (!abort.aborted)
458
+ appendLine("stderr", (err as Error)?.message ?? "Command failed");
459
+ } finally {
460
+ if (!abort.aborted) setConsoleRunning(false);
461
+ }
462
+ }, [
463
+ cmdInput,
464
+ consoleRunning,
465
+ currentQuestion,
466
+ activeInfraId,
467
+ labName,
468
+ workspace,
469
+ appendLine,
470
+ ]);
471
+
472
+ const handleStop = useCallback(() => {
473
+ cmdAbortRef.current.aborted = true;
474
+ setConsoleRunning(false);
475
+ appendLine("info", "^C");
476
+ }, [appendLine]);
477
+
478
+ // ── AI chat ──────────────────────────────────────────────
479
+ interface ChatMessage {
480
+ id: string;
481
+ role: "user" | "assistant";
482
+ content: string;
483
+ }
484
+
485
+ // Key scoped to the saved artifact, or falls back to question ID
486
+ const chatStorageKey = `infra-chat:${activeInfraId ?? `q:${currentQuestion?.id ?? "_"}`}`;
487
+ // Ref always holds the latest key so save-effect never closes over a stale key
488
+ const chatStorageKeyRef = useRef(chatStorageKey);
489
+ chatStorageKeyRef.current = chatStorageKey;
490
+
491
+ const [chatMessages, setChatMessages] = useState<ChatMessage[]>(() => {
492
+ try {
493
+ const stored = localStorage.getItem(chatStorageKey);
494
+ return stored ? (JSON.parse(stored) as ChatMessage[]) : [];
495
+ } catch {
496
+ return [];
497
+ }
498
+ });
499
+ const [chatInput, setChatInput] = useState("");
500
+ const [chatLoading, setChatLoading] = useState(false);
501
+ const chatScrollRef = useRef<HTMLDivElement>(null);
502
+ const chatInputRef = useRef<HTMLTextAreaElement>(null);
503
+ const chatAbortRef = useRef<{ aborted: boolean }>({ aborted: false });
504
+
505
+ // When the user switches workspace/artifact, reload that workspace's chat history
506
+ useEffect(() => {
507
+ try {
508
+ const stored = localStorage.getItem(chatStorageKey);
509
+ setChatMessages(stored ? (JSON.parse(stored) as ChatMessage[]) : []);
510
+ } catch {
511
+ setChatMessages([]);
512
+ }
513
+ }, [chatStorageKey]);
514
+
515
+ // Persist messages to localStorage every time they change
516
+ useEffect(() => {
517
+ if (chatMessages.length === 0) {
518
+ localStorage.removeItem(chatStorageKeyRef.current);
519
+ return;
520
+ }
521
+ localStorage.setItem(
522
+ chatStorageKeyRef.current,
523
+ JSON.stringify(chatMessages),
524
+ );
525
+ }, [chatMessages]);
526
+
527
+ useEffect(() => {
528
+ if (bottomTab === "chat" && chatScrollRef.current) {
529
+ chatScrollRef.current.scrollTop = chatScrollRef.current.scrollHeight;
530
+ }
531
+ }, [chatMessages, chatLoading, bottomTab]);
532
+
533
+ const handleChatSend = useCallback(async () => {
534
+ const text = chatInput.trim();
535
+ if (!text || chatLoading) return;
536
+ setChatInput("");
537
+ const userMsg: ChatMessage = {
538
+ id: crypto.randomUUID(),
539
+ role: "user",
540
+ content: text,
541
+ };
542
+ setChatMessages((prev) => [...prev, userMsg]);
543
+ setChatLoading(true);
544
+ const abort = { aborted: false };
545
+ chatAbortRef.current = abort;
546
+ const assistantId = crypto.randomUUID();
547
+ setChatMessages((prev) => [
548
+ ...prev,
549
+ { id: assistantId, role: "assistant", content: "" },
550
+ ]);
551
+ try {
552
+ const history = [...chatMessages, userMsg].map((m) => ({
553
+ role: m.role,
554
+ content: m.content,
555
+ }));
556
+ await api.streamInfraAsk(
557
+ {
558
+ messages: history,
559
+ workspace: workspace.files,
560
+ questionId: currentQuestion?.id,
561
+ },
562
+ (delta) => {
563
+ if (abort.aborted) return;
564
+ setChatMessages((prev) =>
565
+ prev.map((m) =>
566
+ m.id === assistantId ? { ...m, content: m.content + delta } : m,
567
+ ),
568
+ );
569
+ },
570
+ );
571
+ } catch (err: unknown) {
572
+ if (!abort.aborted) {
573
+ setChatMessages((prev) =>
574
+ prev.map((m) =>
575
+ m.id === assistantId
576
+ ? { ...m, content: (err as Error)?.message ?? "Request failed" }
577
+ : m,
578
+ ),
579
+ );
580
+ }
581
+ } finally {
582
+ if (!abort.aborted) setChatLoading(false);
583
+ }
584
+ }, [chatInput, chatLoading, chatMessages, workspace.files, currentQuestion]);
585
+
586
+ const [consoleCopied, setConsoleCopied] = useState(false);
587
+
588
+ const handleCopyConsole = useCallback(() => {
589
+ const text = consoleLines.map((l) => l.text).join("\n");
590
+ void navigator.clipboard.writeText(text).then(() => {
591
+ setConsoleCopied(true);
592
+ setTimeout(() => setConsoleCopied(false), 1800);
593
+ });
594
+ }, [consoleLines]);
595
+
596
+ const [chatCopiedId, setChatCopiedId] = useState<string | null>(null);
597
+
598
+ const handleCopyMessage = useCallback((id: string, content: string) => {
599
+ void navigator.clipboard.writeText(content).then(() => {
600
+ setChatCopiedId(id);
601
+ setTimeout(() => setChatCopiedId(null), 1800);
602
+ });
603
+ }, []);
604
+
605
+ // ── Panel collapse ─────────────────────────────────────────────
606
+ const [leftCollapsed, setLeftCollapsed] = useState(false);
607
+ const [rightCollapsed, setRightCollapsed] = useState(false);
608
+
609
+ // ── File management ───────────────────────────────────────────
610
+ const [newFileName, setNewFileName] = useState("");
611
+ const [showNewFileInput, setShowNewFileInput] = useState(false);
612
+ const [fileError, setFileError] = useState<string | null>(null);
613
+ const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(
614
+ new Set(),
615
+ );
616
+ const newFileInputRef = useRef<HTMLInputElement>(null);
617
+
618
+ useEffect(() => {
619
+ if (showNewFileInput)
620
+ setTimeout(() => newFileInputRef.current?.focus(), 30);
621
+ }, [showNewFileInput]);
622
+
623
+ const handleAddFile = useCallback(() => {
624
+ const name = newFileName.trim().replace(/\\/g, "/");
625
+ if (!name) {
626
+ setFileError("Name is required");
627
+ return;
628
+ }
629
+ if (name.includes("..") || name.startsWith("/")) {
630
+ setFileError("Invalid path");
631
+ return;
632
+ }
633
+ if (!/^[a-zA-Z0-9._/\-]+$/.test(name)) {
634
+ setFileError("Only letters, numbers, dots, _, -, / allowed");
635
+ return;
636
+ }
637
+ if (name in workspace.files) {
638
+ setFileError("File already exists");
639
+ return;
640
+ }
641
+ setWorkspace((w) => ({
642
+ ...w,
643
+ files: { ...w.files, [name]: "" },
644
+ activeFile: name,
645
+ }));
646
+ setActiveFile(name);
647
+ setNewFileName("");
648
+ setShowNewFileInput(false);
649
+ setFileError(null);
650
+ }, [newFileName, workspace.files]);
651
+
652
+ const handleDeleteFile = useCallback(
653
+ (fileName: string) => {
654
+ if (Object.keys(workspace.files).length <= 1) return;
655
+ if (!window.confirm(`Delete "${fileName}"?`)) return;
656
+ setWorkspace((w) => {
657
+ const next = { ...w.files };
658
+ delete next[fileName];
659
+ const remaining = Object.keys(next);
660
+ const nextActive =
661
+ w.activeFile === fileName ? (remaining[0] ?? "") : w.activeFile;
662
+ return { ...w, files: next, activeFile: nextActive };
663
+ });
664
+ if (activeFile === fileName) {
665
+ const remaining = Object.keys(workspace.files).filter(
666
+ (f) => f !== fileName,
667
+ );
668
+ setActiveFile(remaining[0] ?? "");
669
+ }
670
+ },
671
+ [workspace.files, activeFile],
672
+ );
673
+
674
+ // Build a visual tree from flat file paths (supports subfolders via /)
675
+ type TreeEntry =
676
+ | { kind: "folder"; path: string; label: string; depth: number }
677
+ | { kind: "file"; path: string; label: string; depth: number };
678
+
679
+ const treeEntries = (() => {
680
+ const seenFolders = new Set<string>();
681
+ const entries: TreeEntry[] = [];
682
+ const sorted = [...fileOrder].sort((a, b) => {
683
+ const ad = a.split("/").length;
684
+ const bd = b.split("/").length;
685
+ return ad !== bd ? ad - bd : a.localeCompare(b);
686
+ });
687
+ for (const filePath of sorted) {
688
+ const parts = filePath.split("/");
689
+ for (let i = 0; i < parts.length - 1; i++) {
690
+ const folderPath = parts.slice(0, i + 1).join("/");
691
+ if (!seenFolders.has(folderPath)) {
692
+ seenFolders.add(folderPath);
693
+ entries.push({
694
+ kind: "folder",
695
+ path: folderPath,
696
+ label: parts[i],
697
+ depth: i,
698
+ });
699
+ }
700
+ }
701
+ entries.push({
702
+ kind: "file",
703
+ path: filePath,
704
+ label: parts[parts.length - 1],
705
+ depth: parts.length - 1,
706
+ });
707
+ }
708
+ return entries;
709
+ })();
710
+
711
+ const visibleEntries = treeEntries.filter((entry) => {
712
+ const parts = entry.path.split("/");
713
+ for (let i = 1; i < parts.length; i++) {
714
+ if (collapsedFolders.has(parts.slice(0, i).join("/"))) return false;
715
+ }
716
+ return true;
717
+ });
718
+
719
+ const windowStyle: React.CSSProperties = maximized
720
+ ? {
721
+ position: "fixed",
722
+ inset: 0,
723
+ width: "100vw",
724
+ height: "100vh",
725
+ borderRadius: 0,
726
+ }
727
+ : {
728
+ position: "fixed",
729
+ left: pos.x,
730
+ top: pos.y,
731
+ width: size.w,
732
+ height: size.h,
733
+ minWidth: MIN_W,
734
+ minHeight: MIN_H,
735
+ };
736
+
737
+ return (
738
+ <div
739
+ className="z-50 flex flex-col bg-slate-900 border border-slate-700 rounded-xl shadow-2xl overflow-hidden select-none"
740
+ style={windowStyle}
741
+ >
742
+ {/* ── Resize handles ── */}
743
+ {!maximized && (
744
+ <>
745
+ <div
746
+ className="absolute top-0 left-0 right-0 h-1 cursor-n-resize z-10"
747
+ onMouseDown={startResize("n")}
748
+ />
749
+ <div
750
+ className="absolute bottom-0 left-0 right-0 h-1 cursor-s-resize z-10"
751
+ onMouseDown={startResize("s")}
752
+ />
753
+ <div
754
+ className="absolute top-0 left-0 bottom-0 w-1 cursor-w-resize z-10"
755
+ onMouseDown={startResize("w")}
756
+ />
757
+ <div
758
+ className="absolute top-0 right-0 bottom-0 w-1 cursor-e-resize z-10"
759
+ onMouseDown={startResize("e")}
760
+ />
761
+ <div
762
+ className="absolute top-0 left-0 w-3 h-3 cursor-nw-resize z-20"
763
+ onMouseDown={startResize("nw")}
764
+ />
765
+ <div
766
+ className="absolute top-0 right-0 w-3 h-3 cursor-ne-resize z-20"
767
+ onMouseDown={startResize("ne")}
768
+ />
769
+ <div
770
+ className="absolute bottom-0 left-0 w-3 h-3 cursor-sw-resize z-20"
771
+ onMouseDown={startResize("sw")}
772
+ />
773
+ <div
774
+ className="absolute bottom-0 right-0 w-3 h-3 cursor-se-resize z-20"
775
+ onMouseDown={startResize("se")}
776
+ />
777
+ </>
778
+ )}
779
+
780
+ {/* ── Title / drag bar ── */}
781
+ <div
782
+ className="flex items-center gap-2 px-3 py-2 bg-slate-800 border-b border-slate-700 shrink-0"
783
+ onMouseDown={onTitleMouseDown}
784
+ style={{ cursor: maximized ? "default" : "grab" }}
785
+ >
786
+ <GripVertical className="w-3.5 h-3.5 text-slate-600 shrink-0" />
787
+ <span className="text-[11px] uppercase tracking-[0.2em] text-cyan-400/80 shrink-0">
788
+ Infrastructure Lab
789
+ </span>
790
+ {/* Lab name */}
791
+ <input
792
+ value={labName}
793
+ onChange={(event) => setLabName(event.target.value)}
794
+ placeholder="Lab name"
795
+ onMouseDown={(e) => e.stopPropagation()}
796
+ className="flex-1 min-w-0 bg-slate-900 border border-slate-700 rounded px-2 py-1 text-sm text-slate-100 focus:outline-none focus:border-cyan-500"
797
+ />
798
+ {/* Profile select */}
799
+ <select
800
+ onMouseDown={(e) => e.stopPropagation()}
801
+ value={workspace.executionMode}
802
+ onChange={(event) =>
803
+ setWorkspace((current) => ({
804
+ ...current,
805
+ executionMode:
806
+ event.target.value === "plan-only" ? "plan-only" : "localstack",
807
+ }))
808
+ }
809
+ className="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-slate-200 focus:outline-none focus:border-cyan-500 shrink-0"
810
+ >
811
+ <option value="localstack">AWS LocalStack Profile</option>
812
+ <option value="plan-only">Plan-Only Profile</option>
813
+ </select>
814
+ {/* Actions */}
815
+ <div
816
+ className="flex items-center gap-1 shrink-0"
817
+ onMouseDown={(e) => e.stopPropagation()}
818
+ >
819
+ {saved && (
820
+ <span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-xs text-emerald-300">
821
+ <Check className="w-3 h-3" />
822
+ Saved
823
+ </span>
824
+ )}
825
+ <button
826
+ onClick={() => void runInfra("validate")}
827
+ disabled={!!runningAction || !currentQuestion}
828
+ className="inline-flex items-center gap-1.5 rounded border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-1 text-xs font-medium text-emerald-200 hover:bg-emerald-500/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
829
+ >
830
+ {runningAction === "validate" ? (
831
+ <Loader2 className="w-3 h-3 animate-spin" />
832
+ ) : (
833
+ <Play className="w-3 h-3" />
834
+ )}
835
+ Validate
836
+ </button>
837
+ <button
838
+ onClick={() => void runInfra("plan")}
839
+ disabled={!!runningAction || !currentQuestion}
840
+ className="inline-flex items-center gap-1.5 rounded border border-cyan-500/30 bg-cyan-500/10 px-2.5 py-1 text-xs font-medium text-cyan-100 hover:bg-cyan-500/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
841
+ >
842
+ {runningAction === "plan" ? (
843
+ <Loader2 className="w-3 h-3 animate-spin" />
844
+ ) : (
845
+ <Play className="w-3 h-3" />
846
+ )}
847
+ Plan
848
+ </button>
849
+ <button
850
+ onClick={() => void persistWorkspace(false)}
851
+ disabled={saving || !currentQuestion}
852
+ className="inline-flex items-center gap-1.5 rounded bg-cyan-500 px-2.5 py-1 text-xs font-medium text-slate-950 hover:bg-cyan-400 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
853
+ >
854
+ {saving ? (
855
+ <Loader2 className="w-3 h-3 animate-spin" />
856
+ ) : (
857
+ <Save className="w-3 h-3" />
858
+ )}
859
+ {activeInfraId ? "Save" : "Save Lab"}
860
+ </button>
861
+ <button
862
+ onClick={() => void persistWorkspace(true)}
863
+ disabled={saving || !currentQuestion}
864
+ className="rounded border border-slate-700 px-2.5 py-1 text-xs text-slate-200 hover:border-slate-600 hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
865
+ >
866
+ Save As
867
+ </button>
868
+ <button
869
+ onClick={toggleMax}
870
+ className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors"
871
+ title={maximized ? "Restore" : "Maximise"}
872
+ >
873
+ {maximized ? (
874
+ <Minimize2 className="w-3.5 h-3.5" />
875
+ ) : (
876
+ <Maximize2 className="w-3.5 h-3.5" />
877
+ )}
878
+ </button>
879
+ <button
880
+ onClick={closeInfraLab}
881
+ className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-red-400 transition-colors"
882
+ title="Close (Esc)"
883
+ >
884
+ <X className="w-3.5 h-3.5" />
885
+ </button>
886
+ </div>
887
+ </div>
888
+
889
+ <div
890
+ className="grid flex-1 min-h-0"
891
+ style={{
892
+ gridTemplateColumns: [
893
+ leftCollapsed ? "0px" : "220px",
894
+ "minmax(0,1fr)",
895
+ rightCollapsed ? "0px" : "360px",
896
+ ].join(" "),
897
+ }}
898
+ >
899
+ <aside
900
+ className="border-r border-slate-800 bg-slate-950/60 min-h-0 overflow-hidden flex flex-col"
901
+ style={{
902
+ width: leftCollapsed ? 0 : undefined,
903
+ padding: leftCollapsed ? 0 : undefined,
904
+ }}
905
+ >
906
+ <div
907
+ className="flex-1 overflow-y-auto p-3 flex flex-col gap-2"
908
+ style={{ display: leftCollapsed ? "none" : undefined }}
909
+ >
910
+ {/* Header */}
911
+ <div className="flex items-center justify-between">
912
+ <p className="text-xs font-semibold text-slate-200">
913
+ Workspace Files
914
+ </p>
915
+ <button
916
+ onClick={() => {
917
+ setShowNewFileInput((v) => !v);
918
+ setFileError(null);
919
+ setNewFileName("");
920
+ }}
921
+ className="p-1 rounded text-slate-500 hover:text-cyan-400 hover:bg-slate-800 transition-colors"
922
+ title="New file"
923
+ >
924
+ <FilePlus className="w-3.5 h-3.5" />
925
+ </button>
926
+ </div>
927
+
928
+ {/* New file input */}
929
+ {showNewFileInput && (
930
+ <div className="space-y-1">
931
+ <input
932
+ ref={newFileInputRef}
933
+ value={newFileName}
934
+ onChange={(e) => {
935
+ setNewFileName(e.target.value);
936
+ setFileError(null);
937
+ }}
938
+ onKeyDown={(e) => {
939
+ if (e.key === "Enter") handleAddFile();
940
+ if (e.key === "Escape") {
941
+ setShowNewFileInput(false);
942
+ setNewFileName("");
943
+ setFileError(null);
944
+ }
945
+ }}
946
+ placeholder="e.g. modules/vpc.tf"
947
+ className="w-full rounded border border-slate-700 bg-slate-800 px-2 py-1.5 text-xs text-slate-200 placeholder-slate-600 focus:outline-none focus:border-cyan-500"
948
+ />
949
+ {fileError && (
950
+ <p className="text-[10px] text-red-400">{fileError}</p>
951
+ )}
952
+ <div className="flex gap-1">
953
+ <button
954
+ onClick={handleAddFile}
955
+ disabled={!newFileName.trim()}
956
+ className="px-2 py-1 text-[10px] rounded bg-cyan-700 hover:bg-cyan-600 disabled:opacity-40 text-white transition-colors"
957
+ >
958
+ Create
959
+ </button>
960
+ <button
961
+ onClick={() => {
962
+ setShowNewFileInput(false);
963
+ setNewFileName("");
964
+ setFileError(null);
965
+ }}
966
+ className="px-2 py-1 text-[10px] rounded text-slate-400 hover:text-slate-200 hover:bg-slate-800 transition-colors"
967
+ >
968
+ Cancel
969
+ </button>
970
+ </div>
971
+ </div>
972
+ )}
973
+
974
+ {/* File tree */}
975
+ <div className="space-y-0.5">
976
+ {visibleEntries.map((entry) => {
977
+ if (entry.kind === "folder") {
978
+ const isCollapsed = collapsedFolders.has(entry.path);
979
+ return (
980
+ <button
981
+ key={`folder:${entry.path}`}
982
+ onClick={() =>
983
+ setCollapsedFolders((prev) => {
984
+ const next = new Set(prev);
985
+ if (next.has(entry.path)) next.delete(entry.path);
986
+ else next.add(entry.path);
987
+ return next;
988
+ })
989
+ }
990
+ style={{ paddingLeft: `${entry.depth * 12 + 2}px` }}
991
+ className="w-full flex items-center gap-1 py-1 text-xs text-slate-500 hover:text-slate-300 transition-colors"
992
+ >
993
+ {isCollapsed ? (
994
+ <ChevronRight className="w-3 h-3 shrink-0" />
995
+ ) : (
996
+ <ChevronDown className="w-3 h-3 shrink-0" />
997
+ )}
998
+ <Folder className="w-3 h-3 shrink-0" />
999
+ <span className="truncate">{entry.label}/</span>
1000
+ </button>
1001
+ );
1002
+ }
1003
+ const isActive = entry.path === activeFile;
1004
+ const canDelete = Object.keys(workspace.files).length > 1;
1005
+ return (
1006
+ <div
1007
+ key={`file:${entry.path}`}
1008
+ style={{ paddingLeft: `${entry.depth * 12}px` }}
1009
+ className="group relative"
1010
+ >
1011
+ <button
1012
+ onClick={() => {
1013
+ setActiveFile(entry.path);
1014
+ setWorkspace((current) => ({
1015
+ ...current,
1016
+ activeFile: entry.path,
1017
+ }));
1018
+ }}
1019
+ className={`w-full rounded-lg border px-2 py-1.5 pr-6 text-left text-xs transition-colors ${
1020
+ isActive
1021
+ ? "border-cyan-500/60 bg-cyan-500/10 text-cyan-200"
1022
+ : "border-slate-800 bg-slate-900 text-slate-400 hover:border-slate-700 hover:text-slate-200"
1023
+ }`}
1024
+ >
1025
+ {entry.label}
1026
+ </button>
1027
+ {canDelete && (
1028
+ <button
1029
+ onClick={() => handleDeleteFile(entry.path)}
1030
+ className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 rounded text-slate-700 opacity-0 group-hover:opacity-100 hover:text-red-400 hover:bg-red-500/10 transition-colors"
1031
+ title={`Delete ${entry.path}`}
1032
+ >
1033
+ <Trash2 className="w-3 h-3" />
1034
+ </button>
1035
+ )}
1036
+ </div>
1037
+ );
1038
+ })}
1039
+ </div>
1040
+ </div>
1041
+ </aside>
1042
+
1043
+ <section className="min-h-0 flex flex-col">
1044
+ <div className="flex items-center justify-between border-b border-slate-800 px-2 py-3">
1045
+ <div className="flex items-center gap-1.5 min-w-0">
1046
+ <button
1047
+ onClick={() => setLeftCollapsed((v) => !v)}
1048
+ className="p-1 rounded text-slate-600 hover:text-slate-300 hover:bg-slate-800 transition-colors shrink-0"
1049
+ title={leftCollapsed ? "Show files panel" : "Hide files panel"}
1050
+ >
1051
+ {leftCollapsed ? (
1052
+ <PanelLeftOpen className="w-3.5 h-3.5" />
1053
+ ) : (
1054
+ <PanelLeftClose className="w-3.5 h-3.5" />
1055
+ )}
1056
+ </button>
1057
+ <div>
1058
+ <p className="text-sm font-medium text-slate-100">
1059
+ {activeFile}
1060
+ </p>
1061
+ <p className="text-xs text-slate-500">
1062
+ Edit the Terraform workspace and run validate or plan against
1063
+ the saved artifact.
1064
+ </p>
1065
+ </div>
1066
+ </div>
1067
+ <div className="flex items-center gap-1.5 shrink-0">
1068
+ <span className="rounded-full border border-slate-700 px-2 py-1 text-[11px] uppercase tracking-wide text-slate-400">
1069
+ {workspace.executionMode === "localstack"
1070
+ ? "AWS LocalStack"
1071
+ : "Plan Only"}
1072
+ </span>
1073
+ <button
1074
+ onClick={() => setRightCollapsed((v) => !v)}
1075
+ className="p-1 rounded text-slate-600 hover:text-slate-300 hover:bg-slate-800 transition-colors"
1076
+ title={
1077
+ rightCollapsed
1078
+ ? "Show inspector panel"
1079
+ : "Hide inspector panel"
1080
+ }
1081
+ >
1082
+ {rightCollapsed ? (
1083
+ <PanelRightOpen className="w-3.5 h-3.5" />
1084
+ ) : (
1085
+ <PanelRightClose className="w-3.5 h-3.5" />
1086
+ )}
1087
+ </button>
1088
+ </div>
1089
+ </div>
1090
+ <div className="flex-1 min-h-0 p-4 bg-slate-950">
1091
+ <textarea
1092
+ value={currentFile}
1093
+ onChange={(event) =>
1094
+ setWorkspace((current) =>
1095
+ updateInfraFile(current, activeFile, event.target.value),
1096
+ )
1097
+ }
1098
+ onKeyDown={(event) => {
1099
+ if (event.key === "Tab") {
1100
+ event.preventDefault();
1101
+ const el = event.currentTarget;
1102
+ const start = el.selectionStart;
1103
+ const end = el.selectionEnd;
1104
+ const indent = " ";
1105
+ const next =
1106
+ el.value.slice(0, start) + indent + el.value.slice(end);
1107
+ setWorkspace((current) =>
1108
+ updateInfraFile(current, activeFile, next),
1109
+ );
1110
+ // restore cursor after React re-render
1111
+ requestAnimationFrame(() => {
1112
+ el.selectionStart = start + indent.length;
1113
+ el.selectionEnd = start + indent.length;
1114
+ });
1115
+ }
1116
+ }}
1117
+ spellCheck={false}
1118
+ className="h-full w-full resize-none rounded-xl border border-slate-800 bg-slate-950 px-4 py-3 font-mono text-sm leading-6 text-slate-200 focus:outline-none focus:border-cyan-500"
1119
+ />
1120
+ </div>
1121
+ <div className="h-72 border-t border-slate-800 bg-slate-950/80 flex flex-col">
1122
+ {/* Tab bar */}
1123
+ <div className="flex items-center border-b border-slate-800 px-1 shrink-0">
1124
+ <button
1125
+ onClick={() => setBottomTab("output")}
1126
+ className={`flex items-center gap-1.5 px-3 py-2 text-xs font-medium border-b-2 transition-colors ${
1127
+ bottomTab === "output"
1128
+ ? "border-cyan-500 text-cyan-300"
1129
+ : "border-transparent text-slate-500 hover:text-slate-300"
1130
+ }`}
1131
+ >
1132
+ Run Output
1133
+ </button>
1134
+ <button
1135
+ onClick={() => setBottomTab("console")}
1136
+ className={`flex items-center gap-1.5 px-3 py-2 text-xs font-medium border-b-2 transition-colors ${
1137
+ bottomTab === "console"
1138
+ ? "border-emerald-500 text-emerald-300"
1139
+ : "border-transparent text-slate-500 hover:text-slate-300"
1140
+ }`}
1141
+ >
1142
+ <Terminal className="w-3 h-3" />
1143
+ Console
1144
+ </button>
1145
+ <button
1146
+ onClick={() => {
1147
+ setBottomTab("chat");
1148
+ setTimeout(() => chatInputRef.current?.focus(), 30);
1149
+ }}
1150
+ className={`flex items-center gap-1.5 px-3 py-2 text-xs font-medium border-b-2 transition-colors ${
1151
+ bottomTab === "chat"
1152
+ ? "border-violet-500 text-violet-300"
1153
+ : "border-transparent text-slate-500 hover:text-slate-300"
1154
+ }`}
1155
+ >
1156
+ <MessageSquare className="w-3 h-3" />
1157
+ Chat
1158
+ </button>
1159
+ <div className="flex-1" />
1160
+ {bottomTab === "output" && selectedRun && (
1161
+ <span
1162
+ className={`mr-2 rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide ${
1163
+ selectedRun.status === "completed"
1164
+ ? "bg-emerald-500/15 text-emerald-300"
1165
+ : "bg-red-500/15 text-red-300"
1166
+ }`}
1167
+ >
1168
+ {selectedRun.status}
1169
+ </span>
1170
+ )}
1171
+ {bottomTab === "console" && consoleLines.length > 0 && (
1172
+ <div className="flex items-center gap-1 mr-2">
1173
+ <button
1174
+ onClick={handleCopyConsole}
1175
+ className="text-[10px] text-slate-600 hover:text-slate-400 transition-colors flex items-center gap-1"
1176
+ title="Copy output"
1177
+ >
1178
+ {consoleCopied ? (
1179
+ <ClipboardCheck className="w-3 h-3 text-emerald-400" />
1180
+ ) : (
1181
+ <Clipboard className="w-3 h-3" />
1182
+ )}
1183
+ </button>
1184
+ <button
1185
+ onClick={() => setConsoleLines([])}
1186
+ className="text-[10px] text-slate-600 hover:text-slate-400 transition-colors"
1187
+ >
1188
+ clear
1189
+ </button>
1190
+ </div>
1191
+ )}
1192
+ {bottomTab === "chat" && chatMessages.length > 0 && (
1193
+ <button
1194
+ onClick={() => setChatMessages([])}
1195
+ className="mr-2 text-[10px] text-slate-600 hover:text-slate-400 transition-colors"
1196
+ >
1197
+ clear
1198
+ </button>
1199
+ )}
1200
+ </div>
1201
+
1202
+ {/* Run Output panel */}
1203
+ {bottomTab === "output" && (
1204
+ <pre className="flex-1 overflow-auto px-4 py-3 font-mono text-xs leading-5 text-slate-300 whitespace-pre-wrap">
1205
+ {logOutput}
1206
+ </pre>
1207
+ )}
1208
+
1209
+ {/* Console panel */}
1210
+ {bottomTab === "console" && (
1211
+ <>
1212
+ <div
1213
+ ref={consoleOutputRef}
1214
+ className="flex-1 overflow-y-auto px-3 py-2 font-mono text-xs leading-5"
1215
+ >
1216
+ {consoleLines.length === 0 && (
1217
+ <p className="text-slate-600">
1218
+ Type a terraform command and press Enter. e.g.{" "}
1219
+ <span className="text-slate-500">terraform validate</span>
1220
+ </p>
1221
+ )}
1222
+ {consoleLines.map((line) => (
1223
+ <div
1224
+ key={line.id}
1225
+ className={
1226
+ line.kind === "input"
1227
+ ? "text-cyan-400"
1228
+ : line.kind === "stderr"
1229
+ ? "text-red-400"
1230
+ : line.kind === "info"
1231
+ ? "text-slate-500"
1232
+ : "text-slate-200"
1233
+ }
1234
+ style={{
1235
+ whiteSpace: "pre-wrap",
1236
+ wordBreak: "break-all",
1237
+ }}
1238
+ >
1239
+ {line.text}
1240
+ </div>
1241
+ ))}
1242
+ {consoleRunning && (
1243
+ <div className="flex items-center gap-1.5 text-slate-500 mt-1">
1244
+ <Loader2 className="w-3 h-3 animate-spin" /> running…
1245
+ </div>
1246
+ )}
1247
+ </div>
1248
+ <div className="flex items-center gap-1.5 px-3 py-2 border-t border-slate-800 bg-slate-900/60 shrink-0">
1249
+ <span className="text-emerald-400 font-mono text-xs select-none shrink-0">
1250
+ $
1251
+ </span>
1252
+ <input
1253
+ type="text"
1254
+ value={cmdInput}
1255
+ onChange={(e) => setCmdInput(e.target.value)}
1256
+ onKeyDown={(e) => {
1257
+ if (e.key === "Enter" && !e.shiftKey) {
1258
+ e.preventDefault();
1259
+ void handleRunCommand();
1260
+ }
1261
+ }}
1262
+ placeholder="terraform version"
1263
+ disabled={consoleRunning}
1264
+ className="flex-1 bg-transparent font-mono text-xs text-slate-200 placeholder-slate-600 outline-none disabled:opacity-50"
1265
+ autoComplete="off"
1266
+ spellCheck={false}
1267
+ />
1268
+ {consoleRunning ? (
1269
+ <button
1270
+ type="button"
1271
+ onClick={handleStop}
1272
+ className="p-1 rounded text-slate-500 hover:text-red-400 hover:bg-red-500/10 transition-colors shrink-0"
1273
+ title="Stop"
1274
+ >
1275
+ <StopCircle className="w-3.5 h-3.5" />
1276
+ </button>
1277
+ ) : (
1278
+ <button
1279
+ type="button"
1280
+ onClick={() => void handleRunCommand()}
1281
+ disabled={!cmdInput.trim()}
1282
+ className="p-1 rounded text-slate-600 hover:text-emerald-400 hover:bg-emerald-500/10 disabled:opacity-40 transition-colors shrink-0"
1283
+ title="Run (Enter)"
1284
+ >
1285
+ <Play className="w-3.5 h-3.5" />
1286
+ </button>
1287
+ )}
1288
+ </div>
1289
+ </>
1290
+ )}
1291
+
1292
+ {/* Chat panel */}
1293
+ {bottomTab === "chat" && (
1294
+ <>
1295
+ <div
1296
+ ref={chatScrollRef}
1297
+ className="flex-1 overflow-y-auto px-3 py-2 space-y-3"
1298
+ >
1299
+ {chatMessages.length === 0 && (
1300
+ <p className="text-xs text-slate-600 pt-1">
1301
+ Ask anything about your workspace — e.g.{" "}
1302
+ <span className="text-slate-500">
1303
+ "What does this provider block do?"
1304
+ </span>
1305
+ </p>
1306
+ )}
1307
+ {chatMessages.map((msg) => (
1308
+ <div
1309
+ key={msg.id}
1310
+ className={`flex flex-col gap-0.5 ${
1311
+ msg.role === "user" ? "items-end" : "items-start"
1312
+ }`}
1313
+ >
1314
+ <div
1315
+ className={`max-w-[85%] rounded-xl px-3 py-2 text-xs leading-5 ${
1316
+ msg.role === "user"
1317
+ ? "bg-violet-600/30 text-violet-100 whitespace-pre-wrap"
1318
+ : "bg-slate-800 text-slate-200 prose prose-invert prose-xs max-w-none"
1319
+ }`}
1320
+ >
1321
+ {msg.role === "user" ? (
1322
+ msg.content
1323
+ ) : msg.content ? (
1324
+ <ReactMarkdown
1325
+ remarkPlugins={[remarkGfm]}
1326
+ components={{
1327
+ code({ className, children, ...props }) {
1328
+ const isBlock =
1329
+ className?.startsWith("language-");
1330
+ return isBlock ? (
1331
+ <pre className="bg-slate-900/80 rounded p-2 overflow-x-auto my-1">
1332
+ <code
1333
+ className={`${className ?? ""} text-[11px]`}
1334
+ {...props}
1335
+ >
1336
+ {children}
1337
+ </code>
1338
+ </pre>
1339
+ ) : (
1340
+ <code
1341
+ className="bg-slate-900/60 px-1 rounded text-violet-300 text-[11px]"
1342
+ {...props}
1343
+ >
1344
+ {children}
1345
+ </code>
1346
+ );
1347
+ },
1348
+ table({ children }) {
1349
+ return (
1350
+ <table className="text-[11px] border-collapse w-full my-1">
1351
+ {children}
1352
+ </table>
1353
+ );
1354
+ },
1355
+ th({ children }) {
1356
+ return (
1357
+ <th className="border border-slate-700 px-2 py-1 text-left text-slate-300">
1358
+ {children}
1359
+ </th>
1360
+ );
1361
+ },
1362
+ td({ children }) {
1363
+ return (
1364
+ <td className="border border-slate-700 px-2 py-1">
1365
+ {children}
1366
+ </td>
1367
+ );
1368
+ },
1369
+ p({ children }) {
1370
+ return (
1371
+ <p className="mb-1 last:mb-0">{children}</p>
1372
+ );
1373
+ },
1374
+ ul({ children }) {
1375
+ return (
1376
+ <ul className="list-disc list-inside mb-1 space-y-0.5">
1377
+ {children}
1378
+ </ul>
1379
+ );
1380
+ },
1381
+ ol({ children }) {
1382
+ return (
1383
+ <ol className="list-decimal list-inside mb-1 space-y-0.5">
1384
+ {children}
1385
+ </ol>
1386
+ );
1387
+ },
1388
+ h2({ children }) {
1389
+ return (
1390
+ <h2 className="text-xs font-semibold text-slate-200 mt-2 mb-0.5">
1391
+ {children}
1392
+ </h2>
1393
+ );
1394
+ },
1395
+ h3({ children }) {
1396
+ return (
1397
+ <h3 className="text-xs font-semibold text-slate-300 mt-1.5 mb-0.5">
1398
+ {children}
1399
+ </h3>
1400
+ );
1401
+ },
1402
+ hr() {
1403
+ return <hr className="border-slate-700 my-2" />;
1404
+ },
1405
+ }}
1406
+ >
1407
+ {msg.content}
1408
+ </ReactMarkdown>
1409
+ ) : (
1410
+ <span className="flex items-center gap-1.5 text-slate-500">
1411
+ <Loader2 className="w-3 h-3 animate-spin" />{" "}
1412
+ thinking…
1413
+ </span>
1414
+ )}
1415
+ </div>
1416
+ {msg.role === "assistant" && msg.content && (
1417
+ <button
1418
+ onClick={() => handleCopyMessage(msg.id, msg.content)}
1419
+ className="flex items-center gap-1 text-[10px] text-slate-700 hover:text-slate-400 transition-colors px-1"
1420
+ title="Copy response"
1421
+ >
1422
+ {chatCopiedId === msg.id ? (
1423
+ <>
1424
+ <ClipboardCheck className="w-3 h-3 text-emerald-400" />
1425
+ <span className="text-emerald-400">Copied</span>
1426
+ </>
1427
+ ) : (
1428
+ <>
1429
+ <Clipboard className="w-3 h-3" />
1430
+ <span>Copy</span>
1431
+ </>
1432
+ )}
1433
+ </button>
1434
+ )}
1435
+ </div>
1436
+ ))}
1437
+ </div>
1438
+ <div className="flex items-end gap-1.5 px-3 py-2 border-t border-slate-800 bg-slate-900/60 shrink-0">
1439
+ <textarea
1440
+ ref={chatInputRef}
1441
+ rows={1}
1442
+ value={chatInput}
1443
+ onChange={(e) => setChatInput(e.target.value)}
1444
+ onKeyDown={(e) => {
1445
+ if (e.key === "Enter" && !e.shiftKey) {
1446
+ e.preventDefault();
1447
+ void handleChatSend();
1448
+ }
1449
+ }}
1450
+ placeholder="Ask about your Terraform code…"
1451
+ disabled={chatLoading}
1452
+ className="flex-1 bg-transparent text-xs text-slate-200 placeholder-slate-600 outline-none resize-none disabled:opacity-50 max-h-20"
1453
+ />
1454
+ <button
1455
+ type="button"
1456
+ onClick={() => void handleChatSend()}
1457
+ disabled={chatLoading || !chatInput.trim()}
1458
+ className="p-1 rounded text-slate-600 hover:text-violet-400 hover:bg-violet-500/10 disabled:opacity-40 transition-colors shrink-0"
1459
+ title="Send (Enter)"
1460
+ >
1461
+ {chatLoading ? (
1462
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
1463
+ ) : (
1464
+ <Send className="w-3.5 h-3.5" />
1465
+ )}
1466
+ </button>
1467
+ </div>
1468
+ </>
1469
+ )}
1470
+ </div>
1471
+ </section>
1472
+
1473
+ <aside
1474
+ className="border-l border-slate-800 bg-slate-950/40 min-h-0 overflow-hidden"
1475
+ style={{ width: rightCollapsed ? 0 : undefined }}
1476
+ >
1477
+ <div
1478
+ className="p-4 overflow-y-auto h-full"
1479
+ style={{ display: rightCollapsed ? "none" : undefined }}
1480
+ >
1481
+ <div className="rounded-xl border border-slate-800 bg-slate-900/80 p-4">
1482
+ <p className="text-sm font-semibold text-slate-100">
1483
+ Execution Profile
1484
+ </p>
1485
+ <dl className="mt-3 space-y-3 text-sm">
1486
+ <div>
1487
+ <dt className="text-slate-500">Provider</dt>
1488
+ <dd className="mt-1 text-slate-200">AWS</dd>
1489
+ </div>
1490
+ <div>
1491
+ <dt className="text-slate-500">Mode</dt>
1492
+ <dd className="mt-1 text-slate-200">
1493
+ {workspace.executionMode === "localstack"
1494
+ ? "Local emulator target"
1495
+ : "Speculative plan target"}
1496
+ </dd>
1497
+ </div>
1498
+ <div>
1499
+ <dt className="text-slate-500">Files</dt>
1500
+ <dd className="mt-1 text-slate-200">
1501
+ {fileOrder.length} tracked files
1502
+ </dd>
1503
+ </div>
1504
+ <div>
1505
+ <dt className="text-slate-500">Saved Artifact</dt>
1506
+ <dd className="mt-1 text-slate-200">
1507
+ {activeInfraId
1508
+ ? "Question-scoped infra lab"
1509
+ : "Unsaved draft"}
1510
+ </dd>
1511
+ </div>
1512
+ </dl>
1513
+ </div>
1514
+
1515
+ <div className="mt-4 rounded-xl border border-slate-800 bg-slate-900/80 p-4">
1516
+ <div className="flex items-center justify-between gap-3">
1517
+ <p className="text-sm font-semibold text-slate-100">
1518
+ Selected Run
1519
+ </p>
1520
+ {selectedRun && (
1521
+ <span className="text-[11px] text-slate-500">
1522
+ {Math.round(selectedRun.durationMs / 100) / 10}s
1523
+ </span>
1524
+ )}
1525
+ </div>
1526
+ {!selectedRun && (
1527
+ <p className="mt-3 text-sm text-slate-500">
1528
+ Run validate or plan to inspect persisted artifacts here.
1529
+ </p>
1530
+ )}
1531
+ {selectedRun && (
1532
+ <div className="mt-3 space-y-4 text-sm">
1533
+ <div className="flex items-center gap-2 text-slate-300">
1534
+ <span className="rounded-full border border-slate-700 px-2 py-1 text-[11px] uppercase tracking-wide text-slate-400">
1535
+ {selectedRun.action}
1536
+ </span>
1537
+ <span
1538
+ className={`rounded-full px-2 py-1 text-[11px] uppercase tracking-wide ${
1539
+ selectedRun.status === "completed"
1540
+ ? "bg-emerald-500/15 text-emerald-300"
1541
+ : "bg-red-500/15 text-red-300"
1542
+ }`}
1543
+ >
1544
+ {selectedRun.status}
1545
+ </span>
1546
+ </div>
1547
+
1548
+ {selectedRun.planSummary && (
1549
+ <div className="grid grid-cols-2 gap-2 text-xs">
1550
+ <div className="rounded-lg border border-slate-800 bg-slate-950 px-3 py-2">
1551
+ <p className="text-slate-500">Create</p>
1552
+ <p className="mt-1 text-lg text-emerald-300">
1553
+ {selectedRun.planSummary.add}
1554
+ </p>
1555
+ </div>
1556
+ <div className="rounded-lg border border-slate-800 bg-slate-950 px-3 py-2">
1557
+ <p className="text-slate-500">Change</p>
1558
+ <p className="mt-1 text-lg text-amber-300">
1559
+ {selectedRun.planSummary.change}
1560
+ </p>
1561
+ </div>
1562
+ <div className="rounded-lg border border-slate-800 bg-slate-950 px-3 py-2">
1563
+ <p className="text-slate-500">Destroy</p>
1564
+ <p className="mt-1 text-lg text-red-300">
1565
+ {selectedRun.planSummary.destroy}
1566
+ </p>
1567
+ </div>
1568
+ <div className="rounded-lg border border-slate-800 bg-slate-950 px-3 py-2">
1569
+ <p className="text-slate-500">Replace</p>
1570
+ <p className="mt-1 text-lg text-cyan-200">
1571
+ {selectedRun.planSummary.replace}
1572
+ </p>
1573
+ </div>
1574
+ </div>
1575
+ )}
1576
+
1577
+ {selectedRun.error && (
1578
+ <div className="rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-red-200">
1579
+ <div className="flex items-start gap-2">
1580
+ <AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
1581
+ <p>{selectedRun.error}</p>
1582
+ </div>
1583
+ </div>
1584
+ )}
1585
+
1586
+ <div>
1587
+ <div className="flex items-center gap-2 rounded-lg bg-slate-950 p-1">
1588
+ <button
1589
+ onClick={() => setInspectorTab("summary")}
1590
+ className={`flex-1 rounded-md px-2 py-1.5 text-xs transition-colors ${
1591
+ inspectorTab === "summary"
1592
+ ? "bg-slate-800 text-slate-100"
1593
+ : "text-slate-500 hover:text-slate-300"
1594
+ }`}
1595
+ >
1596
+ Summary
1597
+ </button>
1598
+ <button
1599
+ onClick={() => setInspectorTab("json")}
1600
+ className={`flex-1 rounded-md px-2 py-1.5 text-xs transition-colors ${
1601
+ inspectorTab === "json"
1602
+ ? "bg-slate-800 text-slate-100"
1603
+ : "text-slate-500 hover:text-slate-300"
1604
+ }`}
1605
+ >
1606
+ JSON
1607
+ </button>
1608
+ </div>
1609
+ {inspectorTab === "summary" ? (
1610
+ <div className="mt-3 space-y-2">
1611
+ {selectedRun.diagnostics.length > 0 ? (
1612
+ selectedRun.diagnostics.map((diagnostic, index) => (
1613
+ <div
1614
+ key={`${diagnostic.summary}-${index}`}
1615
+ className="rounded-lg border border-slate-800 bg-slate-950 px-3 py-2"
1616
+ >
1617
+ <p className="text-xs uppercase tracking-wide text-slate-500">
1618
+ {diagnostic.severity}
1619
+ </p>
1620
+ <p className="mt-1 text-sm text-slate-200">
1621
+ {diagnostic.summary}
1622
+ </p>
1623
+ {diagnostic.filename && (
1624
+ <p className="mt-1 text-xs text-slate-500">
1625
+ {diagnostic.filename}
1626
+ {diagnostic.line ? `:${diagnostic.line}` : ""}
1627
+ </p>
1628
+ )}
1629
+ {diagnostic.detail && (
1630
+ <p className="mt-2 text-xs text-slate-400 whitespace-pre-wrap">
1631
+ {diagnostic.detail}
1632
+ </p>
1633
+ )}
1634
+ </div>
1635
+ ))
1636
+ ) : (
1637
+ <div className="rounded-lg border border-slate-800 bg-slate-950 px-3 py-2 text-sm text-slate-400">
1638
+ No diagnostics were emitted for this run.
1639
+ </div>
1640
+ )}
1641
+ </div>
1642
+ ) : (
1643
+ <pre className="mt-3 max-h-72 overflow-auto rounded-lg border border-slate-800 bg-slate-950 px-3 py-2 font-mono text-[11px] leading-5 text-slate-300 whitespace-pre-wrap">
1644
+ {inspectorJson
1645
+ ? JSON.stringify(inspectorJson, null, 2)
1646
+ : "No JSON artifact is available for this run."}
1647
+ </pre>
1648
+ )}
1649
+ </div>
1650
+ </div>
1651
+ )}
1652
+ </div>
1653
+
1654
+ <div className="mt-4 rounded-xl border border-slate-800 bg-slate-900/80 p-4">
1655
+ <div className="flex items-center justify-between gap-2">
1656
+ <p className="text-sm font-semibold text-slate-100">
1657
+ Run History
1658
+ </p>
1659
+ {loadingRuns && (
1660
+ <Loader2 className="w-4 h-4 animate-spin text-slate-500" />
1661
+ )}
1662
+ </div>
1663
+ <div className="mt-3 space-y-2">
1664
+ {runHistory.length === 0 && (
1665
+ <p className="text-sm text-slate-500">
1666
+ Saved runs will appear here after the first validate or
1667
+ plan.
1668
+ </p>
1669
+ )}
1670
+ {runHistory.map((run) => (
1671
+ <button
1672
+ key={run.id}
1673
+ onClick={() => void loadRunDetails(run.id)}
1674
+ className={`w-full rounded-lg border px-3 py-2 text-left transition-colors ${
1675
+ selectedRunId === run.id
1676
+ ? "border-cyan-500/50 bg-cyan-500/10"
1677
+ : "border-slate-800 bg-slate-950 hover:border-slate-700"
1678
+ }`}
1679
+ >
1680
+ <div className="flex items-center justify-between gap-2">
1681
+ <span className="text-sm text-slate-200">
1682
+ {run.action}
1683
+ </span>
1684
+ <span
1685
+ className={`rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide ${
1686
+ run.status === "completed"
1687
+ ? "bg-emerald-500/15 text-emerald-300"
1688
+ : "bg-red-500/15 text-red-300"
1689
+ }`}
1690
+ >
1691
+ {run.status}
1692
+ </span>
1693
+ </div>
1694
+ <p className="mt-1 text-[11px] text-slate-500">
1695
+ {new Date(run.startedAt).toLocaleString()}
1696
+ </p>
1697
+ </button>
1698
+ ))}
1699
+ </div>
1700
+ </div>
1701
+ </div>
1702
+ </aside>
1703
+ </div>
1704
+ </div>
1705
+ );
1706
+ }