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.
- package/package.json +1 -1
- package/template/client/package-lock.json +753 -1
- package/template/client/package.json +4 -0
- package/template/client/src/App.tsx +20 -0
- package/template/client/src/api.ts +455 -3
- package/template/client/src/components/AiSettingsModal.tsx +855 -248
- package/template/client/src/components/AnnotationDialog.tsx +3 -9
- package/template/client/src/components/ChatMessage.tsx +132 -27
- package/template/client/src/components/ChatView.tsx +365 -123
- package/template/client/src/components/CodeContextPanel.tsx +714 -0
- package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
- package/template/client/src/components/CodeRunnerModal.tsx +3030 -0
- package/template/client/src/components/DocRefModal.tsx +551 -0
- package/template/client/src/components/FileAttachments.tsx +128 -12
- package/template/client/src/components/FilePickerModal.tsx +181 -0
- package/template/client/src/components/FileViewerModal.tsx +406 -28
- package/template/client/src/components/InfraLabModal.tsx +1706 -0
- package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
- package/template/client/src/components/MarkdownRenderer.tsx +219 -2
- package/template/client/src/components/NotesModal.tsx +977 -0
- package/template/client/src/components/PlotEmbed.tsx +173 -0
- package/template/client/src/components/Sidebar.tsx +397 -127
- package/template/client/src/components/TextAnnotator.tsx +8 -15
- package/template/client/src/components/VizCraftEmbed.tsx +412 -25
- package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
- package/template/client/src/infraLab.ts +124 -0
- package/template/client/src/reactLab.ts +477 -0
- package/template/client/src/store.ts +416 -2
- package/template/client/src/types.ts +41 -1
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/package.json +1 -1
- package/template/server/src/google-drive.ts +144 -2
- package/template/server/src/index.ts +1890 -188
- package/template/server/src/infra-runner.ts +1104 -0
- 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
|
+
}
|