create-interview-cockpit 0.17.3 → 0.19.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.
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import {
3
3
  AlertCircle,
4
4
  Check,
@@ -26,31 +26,488 @@ import {
26
26
  Trash2,
27
27
  X,
28
28
  } from "lucide-react";
29
+ import MonacoEditorLib from "@monaco-editor/react";
30
+ import type {
31
+ BeforeMount,
32
+ Monaco,
33
+ OnChange,
34
+ OnMount,
35
+ } from "@monaco-editor/react";
36
+ import { useStore } from "../store";
37
+ import {
38
+ cloneInfraLabWorkspace,
39
+ DEFAULT_INFRA_LAB,
40
+ getInfraLabFileOrder,
41
+ serializeInfraLabWorkspace,
42
+ } from "../infraLab";
43
+ import type { InfraLabWorkspace } from "../types";
44
+ import type {
45
+ InfraCommandStreamMessage,
46
+ InfraRunAction,
47
+ InfraRunDetails,
48
+ InfraRunListItem,
49
+ } from "../api";
50
+ import * as api from "../api";
51
+ import ReactMarkdown from "react-markdown";
52
+ import remarkGfm from "remark-gfm";
29
53
 
30
54
  const MIN_W = 900;
31
55
  const MIN_H = 560;
32
56
  const DEFAULT_W = Math.min(1280, window.innerWidth - 48);
33
57
  const DEFAULT_H = Math.min(820, window.innerHeight - 48);
34
58
  type ResizeDir = "e" | "s" | "se" | "sw" | "w" | "ne" | "nw" | "n" | null;
35
- import type { InfraCommandStreamMessage } from "../api";
36
59
 
37
60
  interface ConsoleLine {
38
61
  id: string;
39
62
  kind: "stdout" | "stderr" | "info" | "input";
40
63
  text: string;
41
64
  }
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";
65
+
66
+ const EDITOR_FONT =
67
+ 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
68
+ const DOCKER_DEMO_URL = "http://localhost:4288";
69
+ const INFRA_CONSOLE_HELP = `Supported commands:
70
+ terraform init | validate | plan | apply -auto-approve | destroy -auto-approve | output | state | version
71
+ docker version | docker info | docker ps -a | docker images | docker inspect <name>
72
+ docker build -t <tag> . | docker run --rm <image> | docker exec <container> <cmd>
73
+ docker compose up -d --build | docker compose ps | docker compose logs | docker compose down [-v]
74
+ docker network ls | docker volume ls | docker logs <container> | docker stop/rm <container>
75
+ curl -s http://localhost:4288/api/ping | pwd | ls | cat <workspace-file>
76
+
77
+ Notes:
78
+ - Run one command at a time; shell pipes/operators are disabled in this lab console.
79
+ - Use detached compose runs (docker compose up -d ...) so the console does not stay attached forever.
80
+ - Docker prune commands require --force or -f so they do not hang on an interactive prompt.
81
+ - The command runs from the saved infra workspace session directory.`;
82
+
83
+ let infraMonacoConfigured = false;
84
+
85
+ interface InfraFileTreeNode {
86
+ name: string;
87
+ path: string;
88
+ folders: InfraFileTreeNode[];
89
+ files: string[];
90
+ }
91
+
92
+ function baseName(filePath: string): string {
93
+ return filePath.split("/").pop() || filePath;
94
+ }
95
+
96
+ function buildInfraFileTree(paths: string[]): InfraFileTreeNode {
97
+ const root: InfraFileTreeNode = {
98
+ name: "",
99
+ path: "",
100
+ folders: [],
101
+ files: [],
102
+ };
103
+
104
+ for (const filePath of paths) {
105
+ const parts = filePath.split("/").filter(Boolean);
106
+ let node = root;
107
+
108
+ for (let index = 0; index < parts.length - 1; index += 1) {
109
+ const name = parts[index];
110
+ const folderPath = parts.slice(0, index + 1).join("/");
111
+ let child = node.folders.find((entry) => entry.path === folderPath);
112
+
113
+ if (!child) {
114
+ child = {
115
+ name,
116
+ path: folderPath,
117
+ folders: [],
118
+ files: [],
119
+ };
120
+ node.folders.push(child);
121
+ }
122
+
123
+ node = child;
124
+ }
125
+
126
+ node.files.push(filePath);
127
+ }
128
+
129
+ return root;
130
+ }
131
+
132
+ function getInfraEditorLanguage(fileName: string): string {
133
+ const lower = fileName.toLowerCase();
134
+
135
+ if (lower.endsWith(".tf") || lower.endsWith(".tfvars")) return "terraform";
136
+ if (lower.endsWith(".ts") || lower.endsWith(".tsx")) return "typescript";
137
+ if (
138
+ lower.endsWith(".js") ||
139
+ lower.endsWith(".jsx") ||
140
+ lower.endsWith(".mjs")
141
+ ) {
142
+ return "javascript";
143
+ }
144
+ if (lower.endsWith(".json")) return "json";
145
+ if (lower.endsWith(".md") || lower.endsWith(".markdown")) return "markdown";
146
+ if (baseName(fileName).toLowerCase() === "dockerfile") return "dockerfile";
147
+ if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "yaml";
148
+ return "plaintext";
149
+ }
150
+
151
+ function hasTerraformFiles(files: Record<string, string>): boolean {
152
+ return Object.keys(files).some((fileName) => {
153
+ const lower = fileName.toLowerCase();
154
+ return lower.endsWith(".tf") || lower.endsWith(".tfvars");
155
+ });
156
+ }
157
+
158
+ function hasDockerComposeFile(files: Record<string, string>): boolean {
159
+ return Object.keys(files).some((fileName) => {
160
+ const lower = baseName(fileName).toLowerCase();
161
+ return (
162
+ lower === "compose.yaml" ||
163
+ lower === "compose.yml" ||
164
+ lower === "docker-compose.yaml" ||
165
+ lower === "docker-compose.yml"
166
+ );
167
+ });
168
+ }
169
+
170
+ function hasDockerfile(files: Record<string, string>): boolean {
171
+ return Object.keys(files).some(
172
+ (fileName) => baseName(fileName).toLowerCase() === "dockerfile",
173
+ );
174
+ }
175
+
176
+ function isDockerFileWorkspace(workspace: InfraLabWorkspace): boolean {
177
+ return (
178
+ workspace.provider === "docker" &&
179
+ (hasDockerComposeFile(workspace.files) || hasDockerfile(workspace.files))
180
+ );
181
+ }
182
+
183
+ function configureInfraMonaco(monaco: Monaco) {
184
+ const hasTerraform = monaco.languages
185
+ .getLanguages()
186
+ .some((language: { id: string }) => language.id === "terraform");
187
+
188
+ if (!hasTerraform) {
189
+ monaco.languages.register({
190
+ id: "terraform",
191
+ extensions: [".tf", ".tfvars"],
192
+ aliases: ["Terraform", "tf", "tfvars"],
193
+ });
194
+ monaco.languages.setLanguageConfiguration("terraform", {
195
+ comments: {
196
+ lineComment: "#",
197
+ blockComment: ["/*", "*/"],
198
+ },
199
+ brackets: [
200
+ ["{", "}"],
201
+ ["[", "]"],
202
+ ["(", ")"],
203
+ ],
204
+ autoClosingPairs: [
205
+ { open: "{", close: "}" },
206
+ { open: "[", close: "]" },
207
+ { open: "(", close: ")" },
208
+ { open: '"', close: '"' },
209
+ ],
210
+ surroundingPairs: [
211
+ { open: "{", close: "}" },
212
+ { open: "[", close: "]" },
213
+ { open: "(", close: ")" },
214
+ { open: '"', close: '"' },
215
+ ],
216
+ });
217
+ monaco.languages.setMonarchTokensProvider("terraform", {
218
+ keywords: [
219
+ "terraform",
220
+ "provider",
221
+ "resource",
222
+ "data",
223
+ "variable",
224
+ "output",
225
+ "locals",
226
+ "module",
227
+ "required_providers",
228
+ "required_version",
229
+ "source",
230
+ "version",
231
+ "for",
232
+ "in",
233
+ "if",
234
+ ],
235
+ constants: ["true", "false", "null"],
236
+ tokenizer: {
237
+ root: [
238
+ [/#.*$/, "comment"],
239
+ [/\/\/.*$/, "comment"],
240
+ [/\/\*/, "comment", "@comment"],
241
+ [/"/, "string", "@string"],
242
+ [/[{}()[\]]/, "@brackets"],
243
+ [/[a-zA-Z_][\w-]*(?=\s*{)/, "type.identifier"],
244
+ [/[a-zA-Z_][\w-]*(?=\s*=)/, "attribute.name"],
245
+ [
246
+ /[a-zA-Z_][\w-]*/,
247
+ {
248
+ cases: {
249
+ "@keywords": "keyword",
250
+ "@constants": "constant",
251
+ "@default": "identifier",
252
+ },
253
+ },
254
+ ],
255
+ [/\d+(\.\d+)?/, "number"],
256
+ [/[=.:,]/, "delimiter"],
257
+ ],
258
+ string: [
259
+ [/[^\\"]+/, "string"],
260
+ [/\\./, "string.escape"],
261
+ [/"/, "string", "@pop"],
262
+ ],
263
+ comment: [
264
+ [/[^/*]+/, "comment"],
265
+ [/\*\//, "comment", "@pop"],
266
+ [/[/*]/, "comment"],
267
+ ],
268
+ },
269
+ });
270
+ }
271
+
272
+ if (!infraMonacoConfigured) {
273
+ // These app files are edited as an in-browser lab, not inside a real
274
+ // node_modules-backed project. Keep Monaco useful for syntax checks while
275
+ // preventing generated NestJS/Express/Redis imports from looking broken.
276
+ const tsLang = monaco.languages.typescript as any;
277
+ if (tsLang?.typescriptDefaults && tsLang?.javascriptDefaults) {
278
+ const compilerOptions = {
279
+ target: tsLang.ScriptTarget.ESNext,
280
+ module: tsLang.ModuleKind.ESNext,
281
+ moduleResolution:
282
+ tsLang.ModuleResolutionKind.Bundler ??
283
+ tsLang.ModuleResolutionKind.NodeJs,
284
+ allowJs: true,
285
+ allowNonTsExtensions: true,
286
+ allowSyntheticDefaultImports: true,
287
+ esModuleInterop: true,
288
+ experimentalDecorators: true,
289
+ emitDecoratorMetadata: true,
290
+ resolveJsonModule: true,
291
+ skipLibCheck: true,
292
+ strict: false,
293
+ };
294
+ const diagnosticsOptions = {
295
+ noSemanticValidation: false,
296
+ noSyntaxValidation: false,
297
+ diagnosticCodesToIgnore: [2307, 2688, 2792, 7016],
298
+ };
299
+
300
+ tsLang.typescriptDefaults.setCompilerOptions(compilerOptions);
301
+ tsLang.javascriptDefaults.setCompilerOptions(compilerOptions);
302
+ tsLang.typescriptDefaults.setDiagnosticsOptions(diagnosticsOptions);
303
+ tsLang.javascriptDefaults.setDiagnosticsOptions(diagnosticsOptions);
304
+
305
+ const labAmbientTypes = `
306
+ declare var process: {
307
+ env: Record<string, string | undefined>;
308
+ argv: string[];
309
+ cwd(): string;
310
+ };
311
+
312
+ declare var Buffer: {
313
+ from(value: string, encoding?: string): { toString(encoding?: string): string };
314
+ };
315
+
316
+ declare module 'reflect-metadata' {}
317
+
318
+ declare module '@nestjs/common' {
319
+ export function Body(...args: any[]): ParameterDecorator;
320
+ export function Controller(...args: any[]): ClassDecorator;
321
+ export function Get(...args: any[]): MethodDecorator;
322
+ export function Headers(...args: any[]): ParameterDecorator;
323
+ export function Module(...args: any[]): ClassDecorator;
324
+ export function Param(...args: any[]): ParameterDecorator;
325
+ export function Post(...args: any[]): MethodDecorator;
326
+ export function Query(...args: any[]): ParameterDecorator;
327
+ export function Req(...args: any[]): ParameterDecorator;
328
+ export function Res(...args: any[]): ParameterDecorator;
329
+ }
330
+
331
+ declare module '@nestjs/core' {
332
+ export const NestFactory: {
333
+ create(module: any, options?: any): Promise<any>;
334
+ };
335
+ }
336
+
337
+ declare module '@nestjs/platform-express' {}
338
+
339
+ declare module 'cookie-parser' {
340
+ function cookieParser(...args: any[]): any;
341
+ export default cookieParser;
342
+ }
343
+
344
+ declare module 'ioredis' {
345
+ export default class Redis {
346
+ constructor(...args: any[]);
347
+ get(key: string): Promise<string | null>;
348
+ setex(key: string, seconds: number, value: string): Promise<any>;
349
+ del(key: string): Promise<any>;
350
+ expire(key: string, seconds: number): Promise<any>;
351
+ }
352
+ }
353
+
354
+ declare module 'express' {
355
+ export type Request = any;
356
+ export type Response = any;
357
+ const express: any;
358
+ export default express;
359
+ }
360
+
361
+ declare module 'crypto' {
362
+ const crypto: any;
363
+ export default crypto;
364
+ export function randomUUID(): string;
365
+ export function randomBytes(size: number): { toString(encoding?: string): string };
366
+ export function createHash(algorithm: string): { update(value: string): any; digest(encoding?: string): string };
367
+ }
368
+ `;
369
+
370
+ tsLang.typescriptDefaults.addExtraLib(
371
+ labAmbientTypes,
372
+ "file:///infra-lab/types/node-and-nest.d.ts",
373
+ );
374
+ tsLang.javascriptDefaults.addExtraLib(
375
+ labAmbientTypes,
376
+ "file:///infra-lab/types/node-and-nest.d.ts",
377
+ );
378
+ }
379
+
380
+ monaco.editor.defineTheme("infra-lab-dark", {
381
+ base: "vs-dark",
382
+ inherit: true,
383
+ rules: [
384
+ { token: "comment", foreground: "64748b", fontStyle: "italic" },
385
+ { token: "keyword", foreground: "22d3ee", fontStyle: "bold" },
386
+ { token: "type.identifier", foreground: "a78bfa" },
387
+ { token: "attribute.name", foreground: "93c5fd" },
388
+ { token: "string", foreground: "86efac" },
389
+ { token: "number", foreground: "fbbf24" },
390
+ { token: "constant", foreground: "f472b6" },
391
+ ],
392
+ colors: {
393
+ "editor.background": "#020617",
394
+ "editor.foreground": "#dbeafe",
395
+ "editorLineNumber.foreground": "#475569",
396
+ "editorLineNumber.activeForeground": "#67e8f9",
397
+ "editorCursor.foreground": "#22d3ee",
398
+ "editor.lineHighlightBackground": "#0f172a",
399
+ "editor.selectionBackground": "#155e75AA",
400
+ "editor.inactiveSelectionBackground": "#164e6333",
401
+ "editorIndentGuide.background1": "#1e293b",
402
+ "editorIndentGuide.activeBackground1": "#0e7490",
403
+ "editorWidget.background": "#0f172a",
404
+ "editorWidget.border": "#334155",
405
+ "minimap.background": "#020617",
406
+ },
407
+ });
408
+ infraMonacoConfigured = true;
409
+ }
410
+ }
411
+
412
+ function InfraMonacoEditor({
413
+ value,
414
+ fileName,
415
+ onChange,
416
+ onSave,
417
+ onPlan,
418
+ }: {
419
+ value: string;
420
+ fileName: string;
421
+ onChange: (value: string) => void;
422
+ onSave: () => void;
423
+ onPlan: () => void;
424
+ }) {
425
+ const language = getInfraEditorLanguage(fileName);
426
+ const modelPath = `file:///infra-lab/${fileName}`;
427
+ const onChangeRef = useRef(onChange);
428
+ const onSaveRef = useRef(onSave);
429
+ const onPlanRef = useRef(onPlan);
430
+
431
+ useEffect(() => {
432
+ onChangeRef.current = onChange;
433
+ onSaveRef.current = onSave;
434
+ onPlanRef.current = onPlan;
435
+ }, [onChange, onSave, onPlan]);
436
+
437
+ const handleBeforeMount = useCallback<BeforeMount>((monaco) => {
438
+ configureInfraMonaco(monaco);
439
+ }, []);
440
+
441
+ const handleMount = useCallback<OnMount>((editor, monaco) => {
442
+ editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
443
+ onSaveRef.current();
444
+ });
445
+ editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => {
446
+ onPlanRef.current();
447
+ });
448
+ }, []);
449
+
450
+ const handleEditorChange = useCallback<OnChange>((next) => {
451
+ onChangeRef.current(next ?? "");
452
+ }, []);
453
+
454
+ const editorOptions = useMemo(
455
+ () => ({
456
+ fontFamily: EDITOR_FONT,
457
+ fontSize: 13,
458
+ lineHeight: 22,
459
+ minimap: {
460
+ enabled: true,
461
+ renderCharacters: false,
462
+ scale: 0.8,
463
+ },
464
+ wordWrap: "off" as const,
465
+ automaticLayout: true,
466
+ smoothScrolling: true,
467
+ cursorSmoothCaretAnimation: "on" as const,
468
+ scrollBeyondLastLine: false,
469
+ renderWhitespace: "selection" as const,
470
+ bracketPairColorization: { enabled: true },
471
+ guides: {
472
+ indentation: true,
473
+ bracketPairs: true,
474
+ },
475
+ padding: { top: 14, bottom: 14 },
476
+ lineNumbersMinChars: 4,
477
+ glyphMargin: true,
478
+ folding: true,
479
+ foldingHighlight: true,
480
+ links: true,
481
+ formatOnPaste: true,
482
+ formatOnType: true,
483
+ suggest: { preview: true },
484
+ scrollbar: {
485
+ vertical: "auto" as const,
486
+ horizontal: "auto" as const,
487
+ useShadows: true,
488
+ },
489
+ stickyScroll: { enabled: true },
490
+ }),
491
+ [],
492
+ );
493
+
494
+ return (
495
+ <div className="h-full w-full overflow-hidden rounded-xl border border-slate-800 bg-slate-950 shadow-inner select-text">
496
+ <MonacoEditorLib
497
+ height="100%"
498
+ width="100%"
499
+ language={language}
500
+ path={modelPath}
501
+ theme="infra-lab-dark"
502
+ value={value}
503
+ beforeMount={handleBeforeMount}
504
+ onMount={handleMount}
505
+ onChange={handleEditorChange}
506
+ options={editorOptions}
507
+ />
508
+ </div>
509
+ );
510
+ }
54
511
 
55
512
  function updateInfraFile(
56
513
  workspace: InfraLabWorkspace,
@@ -241,6 +698,15 @@ export default function InfraLabModal() {
241
698
 
242
699
  const fileOrder = getInfraLabFileOrder(workspace);
243
700
  const currentFile = workspace.files[activeFile] ?? "";
701
+ const dockerFileWorkspace = isDockerFileWorkspace(workspace);
702
+ const terraformWorkspace = hasTerraformFiles(workspace.files);
703
+ const dockerComposeWorkspace = hasDockerComposeFile(workspace.files);
704
+ const labTitle = dockerFileWorkspace ? "Docker Lab" : "Infrastructure Lab";
705
+ const primaryEditorActionLabel = dockerFileWorkspace
706
+ ? dockerComposeWorkspace
707
+ ? "docker compose up -d --build"
708
+ : "docker build -t interview-cockpit/docker-deep-dive-api:local ."
709
+ : "terraform plan";
244
710
 
245
711
  const persistWorkspace = useCallback(
246
712
  async (forceNew: boolean) => {
@@ -396,6 +862,19 @@ export default function InfraLabModal() {
396
862
  const inspectorJson = selectedRun?.planJson ?? selectedRun?.validationJson;
397
863
  const logOutput =
398
864
  selectedRun?.logs || runError || "Run output will appear here.";
865
+ const hasRunOutput = Boolean(selectedRun || runError);
866
+ const profileLabel =
867
+ workspace.executionMode === "docker"
868
+ ? "Local Docker"
869
+ : workspace.executionMode === "localstack"
870
+ ? "AWS LocalStack"
871
+ : "Plan Only";
872
+ const modeDescription =
873
+ workspace.executionMode === "docker"
874
+ ? "Local Docker container target"
875
+ : workspace.executionMode === "localstack"
876
+ ? "Local emulator target"
877
+ : "Speculative plan target";
399
878
 
400
879
  // ── Practice console ──────────────────────────────────────────
401
880
  const [bottomTab, setBottomTab] = useState<"output" | "console" | "chat">(
@@ -404,6 +883,7 @@ export default function InfraLabModal() {
404
883
  const [consoleLines, setConsoleLines] = useState<ConsoleLine[]>([]);
405
884
  const [cmdInput, setCmdInput] = useState("");
406
885
  const [consoleRunning, setConsoleRunning] = useState(false);
886
+ const [lastConsoleCommand, setLastConsoleCommand] = useState("");
407
887
  const consoleOutputRef = useRef<HTMLDivElement>(null);
408
888
  const cmdAbortRef = useRef<{ aborted: boolean }>({ aborted: false });
409
889
 
@@ -421,53 +901,80 @@ export default function InfraLabModal() {
421
901
  }
422
902
  }, [consoleLines, bottomTab]);
423
903
 
904
+ const runConsoleCommand = useCallback(
905
+ async (rawCommand: string) => {
906
+ const cmd = rawCommand.trim();
907
+ if (!cmd || consoleRunning) return;
908
+ setLastConsoleCommand(cmd);
909
+ if (rawCommand === cmdInput) setCmdInput("");
910
+
911
+ if (cmd === "clear") {
912
+ setConsoleLines([]);
913
+ return;
914
+ }
915
+
916
+ if (cmd === "help" || cmd === "?") {
917
+ appendLine("info", `${INFRA_CONSOLE_HELP}\n`);
918
+ setBottomTab("console");
919
+ return;
920
+ }
921
+
922
+ setBottomTab("console");
923
+ setConsoleRunning(true);
924
+ const abort = { aborted: false };
925
+ cmdAbortRef.current = abort;
926
+ // Don't echo here — the server sends back an info line "$ <command>"
927
+ // as the very first message, which serves as the canonical prompt echo.
928
+ try {
929
+ await api.streamInfraCommand(
930
+ {
931
+ questionId: currentQuestion?.id,
932
+ fileId: activeInfraId ?? undefined,
933
+ label: labName,
934
+ command: cmd,
935
+ workspace,
936
+ },
937
+ (msg: InfraCommandStreamMessage) => {
938
+ if (abort.aborted) return;
939
+ if (msg.type === "output") appendLine(msg.kind, msg.text);
940
+ else if (msg.type === "error") appendLine("stderr", msg.error);
941
+ else if (msg.type === "complete") {
942
+ setSelectedRun(msg.run);
943
+ setSelectedRunId(msg.run.id);
944
+ if (activeInfraId) void refreshRunHistory(activeInfraId);
945
+ if (msg.run.workspaceSnapshot) {
946
+ // Merge any server-generated files (e.g. .terraform.lock.hcl) back
947
+ // into the workspace so they appear in the file tree.
948
+ setWorkspace((prev) => ({
949
+ ...prev,
950
+ files: { ...prev.files, ...msg.run.workspaceSnapshot!.files },
951
+ }));
952
+ }
953
+ }
954
+ },
955
+ );
956
+ } catch (err: unknown) {
957
+ if (!abort.aborted)
958
+ appendLine("stderr", (err as Error)?.message ?? "Command failed");
959
+ } finally {
960
+ if (!abort.aborted) setConsoleRunning(false);
961
+ }
962
+ },
963
+ [
964
+ consoleRunning,
965
+ currentQuestion,
966
+ activeInfraId,
967
+ labName,
968
+ workspace,
969
+ appendLine,
970
+ cmdInput,
971
+ refreshRunHistory,
972
+ ],
973
+ );
974
+
424
975
  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
- ]);
976
+ await runConsoleCommand(cmdInput);
977
+ }, [cmdInput, runConsoleCommand]);
471
978
 
472
979
  const handleStop = useCallback(() => {
473
980
  cmdAbortRef.current.aborted = true;
@@ -583,16 +1090,36 @@ export default function InfraLabModal() {
583
1090
  }
584
1091
  }, [chatInput, chatLoading, chatMessages, workspace.files, currentQuestion]);
585
1092
 
1093
+ const [runOutputCopied, setRunOutputCopied] = useState(false);
586
1094
  const [consoleCopied, setConsoleCopied] = useState(false);
1095
+ const [consoleCommandCopied, setConsoleCommandCopied] = useState(false);
1096
+
1097
+ const handleCopyRunOutput = useCallback(() => {
1098
+ if (!hasRunOutput) return;
1099
+ void navigator.clipboard.writeText(logOutput).then(() => {
1100
+ setRunOutputCopied(true);
1101
+ setTimeout(() => setRunOutputCopied(false), 1800);
1102
+ });
1103
+ }, [hasRunOutput, logOutput]);
587
1104
 
588
1105
  const handleCopyConsole = useCallback(() => {
589
- const text = consoleLines.map((l) => l.text).join("\n");
1106
+ if (consoleLines.length === 0) return;
1107
+ const text = consoleLines.map((l) => l.text).join("");
590
1108
  void navigator.clipboard.writeText(text).then(() => {
591
1109
  setConsoleCopied(true);
592
1110
  setTimeout(() => setConsoleCopied(false), 1800);
593
1111
  });
594
1112
  }, [consoleLines]);
595
1113
 
1114
+ const handleCopyConsoleCommand = useCallback(() => {
1115
+ const command = cmdInput.trim() || lastConsoleCommand;
1116
+ if (!command) return;
1117
+ void navigator.clipboard.writeText(command).then(() => {
1118
+ setConsoleCommandCopied(true);
1119
+ setTimeout(() => setConsoleCommandCopied(false), 1800);
1120
+ });
1121
+ }, [cmdInput, lastConsoleCommand]);
1122
+
596
1123
  const [chatCopiedId, setChatCopiedId] = useState<string | null>(null);
597
1124
 
598
1125
  const handleCopyMessage = useCallback((id: string, content: string) => {
@@ -602,6 +1129,37 @@ export default function InfraLabModal() {
602
1129
  });
603
1130
  }, []);
604
1131
 
1132
+ // ── Docker demo panel ─────────────────────────────────────────
1133
+ const [dockerDemoLoading, setDockerDemoLoading] = useState(false);
1134
+ const [dockerDemoResponse, setDockerDemoResponse] = useState(
1135
+ "Start the Compose stack, then hit the API from here.",
1136
+ );
1137
+
1138
+ const hitDockerDemo = useCallback(
1139
+ async (path: string, init?: RequestInit) => {
1140
+ setDockerDemoLoading(true);
1141
+ try {
1142
+ const response = await fetch(`${DOCKER_DEMO_URL}${path}`, init);
1143
+ const contentType = response.headers.get("content-type") ?? "";
1144
+ const body = contentType.includes("application/json")
1145
+ ? await response.json()
1146
+ : await response.text();
1147
+ setDockerDemoResponse(
1148
+ typeof body === "string" ? body : JSON.stringify(body, null, 2),
1149
+ );
1150
+ } catch (error) {
1151
+ setDockerDemoResponse(
1152
+ `Could not reach ${DOCKER_DEMO_URL}. Run docker compose up -d --build first.\n\n${
1153
+ (error as Error)?.message ?? String(error)
1154
+ }`,
1155
+ );
1156
+ } finally {
1157
+ setDockerDemoLoading(false);
1158
+ }
1159
+ },
1160
+ [],
1161
+ );
1162
+
605
1163
  // ── Panel collapse ─────────────────────────────────────────────
606
1164
  const [leftCollapsed, setLeftCollapsed] = useState(false);
607
1165
  const [rightCollapsed, setRightCollapsed] = useState(false);
@@ -671,50 +1229,86 @@ export default function InfraLabModal() {
671
1229
  [workspace.files, activeFile],
672
1230
  );
673
1231
 
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
- })();
1232
+ const fileOrderKey = fileOrder.join("\0");
1233
+ const fileTree = useMemo(() => buildInfraFileTree(fileOrder), [fileOrderKey]);
710
1234
 
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
- });
1235
+ const renderFileNode = (fileName: string, depth: number) => {
1236
+ const isActive = fileName === activeFile;
1237
+ const canDelete = Object.keys(workspace.files).length > 1;
1238
+
1239
+ return (
1240
+ <div
1241
+ key={`file:${fileName}`}
1242
+ style={{ paddingLeft: `${depth * 12}px` }}
1243
+ className="group relative"
1244
+ >
1245
+ <button
1246
+ onClick={() => {
1247
+ setActiveFile(fileName);
1248
+ setWorkspace((current) => ({
1249
+ ...current,
1250
+ activeFile: fileName,
1251
+ }));
1252
+ }}
1253
+ className={`w-full rounded-lg border px-2 py-1.5 pr-6 text-left text-xs transition-colors ${
1254
+ isActive
1255
+ ? "border-cyan-500/60 bg-cyan-500/10 text-cyan-200 shadow-[0_0_0_1px_rgba(34,211,238,0.08)]"
1256
+ : "border-slate-800 bg-slate-900 text-slate-400 hover:border-slate-700 hover:text-slate-200"
1257
+ }`}
1258
+ title={fileName}
1259
+ >
1260
+ {baseName(fileName)}
1261
+ </button>
1262
+ {canDelete && (
1263
+ <button
1264
+ onClick={() => handleDeleteFile(fileName)}
1265
+ 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"
1266
+ title={`Delete ${fileName}`}
1267
+ >
1268
+ <Trash2 className="w-3 h-3" />
1269
+ </button>
1270
+ )}
1271
+ </div>
1272
+ );
1273
+ };
1274
+
1275
+ const renderFolderNode = (folder: InfraFileTreeNode, depth: number) => {
1276
+ const isCollapsed = collapsedFolders.has(folder.path);
1277
+
1278
+ return (
1279
+ <div key={`folder:${folder.path}`} className="space-y-0.5">
1280
+ <button
1281
+ onClick={() =>
1282
+ setCollapsedFolders((prev) => {
1283
+ const next = new Set(prev);
1284
+ if (next.has(folder.path)) next.delete(folder.path);
1285
+ else next.add(folder.path);
1286
+ return next;
1287
+ })
1288
+ }
1289
+ style={{ paddingLeft: `${depth * 12 + 2}px` }}
1290
+ className="w-full flex items-center gap-1 py-1 text-xs text-slate-500 hover:text-slate-300 transition-colors"
1291
+ title={folder.path}
1292
+ >
1293
+ {isCollapsed ? (
1294
+ <ChevronRight className="w-3 h-3 shrink-0" />
1295
+ ) : (
1296
+ <ChevronDown className="w-3 h-3 shrink-0" />
1297
+ )}
1298
+ <Folder className="w-3 h-3 shrink-0" />
1299
+ <span className="truncate">{folder.name}/</span>
1300
+ </button>
1301
+ {!isCollapsed && (
1302
+ <div className="space-y-0.5">
1303
+ {folder.files.map((fileName) =>
1304
+ renderFileNode(fileName, depth + 1),
1305
+ )}
1306
+ {folder.folders.map((child) => renderFolderNode(child, depth + 1))}
1307
+ </div>
1308
+ )}
1309
+ </div>
1310
+ );
1311
+ };
718
1312
 
719
1313
  const windowStyle: React.CSSProperties = maximized
720
1314
  ? {
@@ -785,7 +1379,7 @@ export default function InfraLabModal() {
785
1379
  >
786
1380
  <GripVertical className="w-3.5 h-3.5 text-slate-600 shrink-0" />
787
1381
  <span className="text-[11px] uppercase tracking-[0.2em] text-cyan-400/80 shrink-0">
788
- Infrastructure Lab
1382
+ {labTitle}
789
1383
  </span>
790
1384
  {/* Lab name */}
791
1385
  <input
@@ -800,15 +1394,24 @@ export default function InfraLabModal() {
800
1394
  onMouseDown={(e) => e.stopPropagation()}
801
1395
  value={workspace.executionMode}
802
1396
  onChange={(event) =>
803
- setWorkspace((current) => ({
804
- ...current,
805
- executionMode:
806
- event.target.value === "plan-only" ? "plan-only" : "localstack",
807
- }))
1397
+ setWorkspace((current) => {
1398
+ const executionMode =
1399
+ event.target.value === "docker"
1400
+ ? "docker"
1401
+ : event.target.value === "plan-only"
1402
+ ? "plan-only"
1403
+ : "localstack";
1404
+ return {
1405
+ ...current,
1406
+ provider: executionMode === "docker" ? "docker" : "aws",
1407
+ executionMode,
1408
+ };
1409
+ })
808
1410
  }
809
1411
  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
1412
  >
811
1413
  <option value="localstack">AWS LocalStack Profile</option>
1414
+ <option value="docker">Local Docker Profile</option>
812
1415
  <option value="plan-only">Plan-Only Profile</option>
813
1416
  </select>
814
1417
  {/* Actions */}
@@ -822,30 +1425,81 @@ export default function InfraLabModal() {
822
1425
  Saved
823
1426
  </span>
824
1427
  )}
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>
1428
+ {terraformWorkspace && (
1429
+ <>
1430
+ <button
1431
+ onClick={() => void runInfra("validate")}
1432
+ disabled={!!runningAction || !currentQuestion}
1433
+ 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"
1434
+ >
1435
+ {runningAction === "validate" ? (
1436
+ <Loader2 className="w-3 h-3 animate-spin" />
1437
+ ) : (
1438
+ <Play className="w-3 h-3" />
1439
+ )}
1440
+ Validate
1441
+ </button>
1442
+ <button
1443
+ onClick={() => void runInfra("plan")}
1444
+ disabled={!!runningAction || !currentQuestion}
1445
+ 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"
1446
+ >
1447
+ {runningAction === "plan" ? (
1448
+ <Loader2 className="w-3 h-3 animate-spin" />
1449
+ ) : (
1450
+ <Play className="w-3 h-3" />
1451
+ )}
1452
+ Plan
1453
+ </button>
1454
+ </>
1455
+ )}
1456
+ {dockerFileWorkspace && (
1457
+ <>
1458
+ {hasDockerfile(workspace.files) && (
1459
+ <button
1460
+ onClick={() =>
1461
+ void runConsoleCommand(
1462
+ "docker build -t interview-cockpit/docker-deep-dive-api:local .",
1463
+ )
1464
+ }
1465
+ disabled={consoleRunning || !currentQuestion}
1466
+ className="inline-flex items-center gap-1.5 rounded border border-sky-500/30 bg-sky-500/10 px-2.5 py-1 text-xs font-medium text-sky-100 hover:bg-sky-500/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
1467
+ >
1468
+ {consoleRunning ? (
1469
+ <Loader2 className="w-3 h-3 animate-spin" />
1470
+ ) : (
1471
+ <Play className="w-3 h-3" />
1472
+ )}
1473
+ Build
1474
+ </button>
1475
+ )}
1476
+ {dockerComposeWorkspace && (
1477
+ <button
1478
+ onClick={() =>
1479
+ void runConsoleCommand("docker compose up -d --build")
1480
+ }
1481
+ disabled={consoleRunning || !currentQuestion}
1482
+ 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"
1483
+ >
1484
+ {consoleRunning ? (
1485
+ <Loader2 className="w-3 h-3 animate-spin" />
1486
+ ) : (
1487
+ <Play className="w-3 h-3" />
1488
+ )}
1489
+ Compose Up
1490
+ </button>
1491
+ )}
1492
+ {dockerComposeWorkspace && (
1493
+ <button
1494
+ onClick={() => void runConsoleCommand("docker compose down")}
1495
+ disabled={consoleRunning || !currentQuestion}
1496
+ className="inline-flex items-center gap-1.5 rounded border border-slate-700 bg-slate-800 px-2.5 py-1 text-xs font-medium text-slate-200 hover:border-slate-600 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
1497
+ >
1498
+ Down
1499
+ </button>
1500
+ )}
1501
+ </>
1502
+ )}
849
1503
  <button
850
1504
  onClick={() => void persistWorkspace(false)}
851
1505
  disabled={saving || !currentQuestion}
@@ -973,69 +1627,8 @@ export default function InfraLabModal() {
973
1627
 
974
1628
  {/* File tree */}
975
1629
  <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
- })}
1630
+ {fileTree.files.map((fileName) => renderFileNode(fileName, 0))}
1631
+ {fileTree.folders.map((folder) => renderFolderNode(folder, 0))}
1039
1632
  </div>
1040
1633
  </div>
1041
1634
  </aside>
@@ -1059,16 +1652,15 @@ export default function InfraLabModal() {
1059
1652
  {activeFile}
1060
1653
  </p>
1061
1654
  <p className="text-xs text-slate-500">
1062
- Edit the Terraform workspace and run validate or plan against
1063
- the saved artifact.
1655
+ {dockerFileWorkspace
1656
+ ? `Edit Dockerfile, Compose, and app files, then press Cmd/Ctrl+Enter to run ${primaryEditorActionLabel}.`
1657
+ : "Edit Terraform and app files, then run Terraform, Docker, or workspace commands through the console."}
1064
1658
  </p>
1065
1659
  </div>
1066
1660
  </div>
1067
1661
  <div className="flex items-center gap-1.5 shrink-0">
1068
1662
  <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"}
1663
+ {profileLabel}
1072
1664
  </span>
1073
1665
  <button
1074
1666
  onClick={() => setRightCollapsed((v) => !v)}
@@ -1087,35 +1679,23 @@ export default function InfraLabModal() {
1087
1679
  </button>
1088
1680
  </div>
1089
1681
  </div>
1090
- <div className="flex-1 min-h-0 p-4 bg-slate-950">
1091
- <textarea
1682
+ <div className="flex-1 min-h-0 bg-slate-950 p-3 select-text">
1683
+ <InfraMonacoEditor
1092
1684
  value={currentFile}
1093
- onChange={(event) =>
1685
+ fileName={activeFile}
1686
+ onChange={(value) =>
1094
1687
  setWorkspace((current) =>
1095
- updateInfraFile(current, activeFile, event.target.value),
1688
+ updateInfraFile(current, activeFile, value),
1096
1689
  )
1097
1690
  }
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
- });
1691
+ onSave={() => void persistWorkspace(false)}
1692
+ onPlan={() => {
1693
+ if (dockerFileWorkspace) {
1694
+ void runConsoleCommand(primaryEditorActionLabel);
1695
+ } else {
1696
+ void runInfra("plan");
1115
1697
  }
1116
1698
  }}
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
1699
  />
1120
1700
  </div>
1121
1701
  <div className="h-72 border-t border-slate-800 bg-slate-950/80 flex flex-col">
@@ -1157,38 +1737,74 @@ export default function InfraLabModal() {
1157
1737
  Chat
1158
1738
  </button>
1159
1739
  <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">
1740
+ {bottomTab === "output" && (
1741
+ <div className="mr-2 flex items-center gap-1.5">
1173
1742
  <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"
1743
+ onClick={handleCopyRunOutput}
1744
+ disabled={!hasRunOutput}
1745
+ className="inline-flex items-center gap-1 rounded border border-slate-700 px-2 py-1 text-[10px] font-medium text-slate-400 hover:border-cyan-500/40 hover:bg-cyan-500/10 hover:text-cyan-200 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
1746
+ title="Copy run output"
1177
1747
  >
1178
- {consoleCopied ? (
1748
+ {runOutputCopied ? (
1179
1749
  <ClipboardCheck className="w-3 h-3 text-emerald-400" />
1180
1750
  ) : (
1181
1751
  <Clipboard className="w-3 h-3" />
1182
1752
  )}
1753
+ {runOutputCopied ? "Copied" : "Copy log"}
1183
1754
  </button>
1184
- <button
1185
- onClick={() => setConsoleLines([])}
1186
- className="text-[10px] text-slate-600 hover:text-slate-400 transition-colors"
1187
- >
1188
- clear
1189
- </button>
1755
+ {selectedRun && (
1756
+ <span
1757
+ className={`rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide ${
1758
+ selectedRun.status === "completed"
1759
+ ? "bg-emerald-500/15 text-emerald-300"
1760
+ : "bg-red-500/15 text-red-300"
1761
+ }`}
1762
+ >
1763
+ {selectedRun.status}
1764
+ </span>
1765
+ )}
1190
1766
  </div>
1191
1767
  )}
1768
+ {bottomTab === "console" &&
1769
+ (consoleLines.length > 0 ||
1770
+ !!cmdInput.trim() ||
1771
+ !!lastConsoleCommand) && (
1772
+ <div className="flex items-center gap-1.5 mr-2">
1773
+ <button
1774
+ onClick={handleCopyConsole}
1775
+ disabled={consoleLines.length === 0}
1776
+ className="inline-flex items-center gap-1 rounded border border-slate-700 px-2 py-1 text-[10px] font-medium text-slate-400 hover:border-emerald-500/40 hover:bg-emerald-500/10 hover:text-emerald-200 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
1777
+ title="Copy output"
1778
+ >
1779
+ {consoleCopied ? (
1780
+ <ClipboardCheck className="w-3 h-3 text-emerald-400" />
1781
+ ) : (
1782
+ <Clipboard className="w-3 h-3" />
1783
+ )}
1784
+ {consoleCopied ? "Copied" : "Copy output"}
1785
+ </button>
1786
+ <button
1787
+ onClick={handleCopyConsoleCommand}
1788
+ disabled={!cmdInput.trim() && !lastConsoleCommand}
1789
+ className="inline-flex items-center gap-1 rounded border border-slate-700 px-2 py-1 text-[10px] font-medium text-slate-400 hover:border-cyan-500/40 hover:bg-cyan-500/10 hover:text-cyan-200 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
1790
+ title="Copy current command, or the last command if the prompt is empty"
1791
+ >
1792
+ {consoleCommandCopied ? (
1793
+ <ClipboardCheck className="w-3 h-3 text-emerald-400" />
1794
+ ) : (
1795
+ <Clipboard className="w-3 h-3" />
1796
+ )}
1797
+ {consoleCommandCopied ? "Copied cmd" : "Copy cmd"}
1798
+ </button>
1799
+ <button
1800
+ onClick={() => setConsoleLines([])}
1801
+ disabled={consoleLines.length === 0}
1802
+ className="text-[10px] text-slate-600 hover:text-slate-400 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
1803
+ >
1804
+ clear
1805
+ </button>
1806
+ </div>
1807
+ )}
1192
1808
  {bottomTab === "chat" && chatMessages.length > 0 && (
1193
1809
  <button
1194
1810
  onClick={() => setChatMessages([])}
@@ -1215,8 +1831,14 @@ export default function InfraLabModal() {
1215
1831
  >
1216
1832
  {consoleLines.length === 0 && (
1217
1833
  <p className="text-slate-600">
1218
- Type a terraform command and press Enter. e.g.{" "}
1219
- <span className="text-slate-500">terraform validate</span>
1834
+ Type a Terraform, Docker, or local workspace command and
1835
+ press Enter. Try{" "}
1836
+ <span className="text-slate-500">help</span>,{" "}
1837
+ <span className="text-slate-500">
1838
+ docker context show
1839
+ </span>
1840
+ , or{" "}
1841
+ <span className="text-slate-500">docker network ls</span>.
1220
1842
  </p>
1221
1843
  )}
1222
1844
  {consoleLines.map((line) => (
@@ -1259,7 +1881,7 @@ export default function InfraLabModal() {
1259
1881
  void handleRunCommand();
1260
1882
  }
1261
1883
  }}
1262
- placeholder="terraform version"
1884
+ placeholder="terraform version, docker network ls, or help"
1263
1885
  disabled={consoleRunning}
1264
1886
  className="flex-1 bg-transparent font-mono text-xs text-slate-200 placeholder-slate-600 outline-none disabled:opacity-50"
1265
1887
  autoComplete="off"
@@ -1300,7 +1922,9 @@ export default function InfraLabModal() {
1300
1922
  <p className="text-xs text-slate-600 pt-1">
1301
1923
  Ask anything about your workspace — e.g.{" "}
1302
1924
  <span className="text-slate-500">
1303
- "What does this provider block do?"
1925
+ {dockerFileWorkspace
1926
+ ? '"Why can the api reach Redis at redis:6379?"'
1927
+ : '"What does this provider block do?"'}
1304
1928
  </span>
1305
1929
  </p>
1306
1930
  )}
@@ -1447,7 +2071,11 @@ export default function InfraLabModal() {
1447
2071
  void handleChatSend();
1448
2072
  }
1449
2073
  }}
1450
- placeholder="Ask about your Terraform code…"
2074
+ placeholder={
2075
+ dockerFileWorkspace
2076
+ ? "Ask about Docker images, containers, Compose, ports, networks, or volumes…"
2077
+ : "Ask about your Terraform code…"
2078
+ }
1451
2079
  disabled={chatLoading}
1452
2080
  className="flex-1 bg-transparent text-xs text-slate-200 placeholder-slate-600 outline-none resize-none disabled:opacity-50 max-h-20"
1453
2081
  />
@@ -1478,6 +2106,110 @@ export default function InfraLabModal() {
1478
2106
  className="p-4 overflow-y-auto h-full"
1479
2107
  style={{ display: rightCollapsed ? "none" : undefined }}
1480
2108
  >
2109
+ {dockerFileWorkspace && (
2110
+ <div className="mb-4 rounded-xl border border-cyan-500/20 bg-cyan-500/10 p-4">
2111
+ <div className="flex items-start justify-between gap-3">
2112
+ <div>
2113
+ <p className="text-sm font-semibold text-cyan-100">
2114
+ Docker Deep Dive
2115
+ </p>
2116
+ <p className="mt-1 text-xs leading-5 text-cyan-100/70">
2117
+ Build an image, run multiple containers, hit the exposed
2118
+ API, inspect logs, compare env vars, and reset volumes.
2119
+ </p>
2120
+ </div>
2121
+ <span className="rounded-full border border-cyan-400/30 px-2 py-1 text-[10px] uppercase tracking-wide text-cyan-200">
2122
+ port 4288
2123
+ </span>
2124
+ </div>
2125
+
2126
+ <div className="mt-3 grid grid-cols-2 gap-2">
2127
+ {[
2128
+ ["Up", "docker compose up -d --build"],
2129
+ ["PS", "docker compose ps"],
2130
+ ["Logs", "docker logs docker-deep-dive-api"],
2131
+ [
2132
+ "Exec",
2133
+ 'docker compose exec -T api node -e "console.log(process.env.REDIS_URL)"',
2134
+ ],
2135
+ ["Images", "docker images"],
2136
+ ["Down", "docker compose down"],
2137
+ ].map(([label, command]) => (
2138
+ <button
2139
+ key={command}
2140
+ onClick={() => void runConsoleCommand(command)}
2141
+ disabled={consoleRunning}
2142
+ className="rounded-lg border border-cyan-500/20 bg-slate-950/70 px-2 py-1.5 text-left text-[11px] font-medium text-cyan-100 hover:border-cyan-400/40 hover:bg-cyan-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
2143
+ title={command}
2144
+ >
2145
+ {label}
2146
+ </button>
2147
+ ))}
2148
+ </div>
2149
+
2150
+ <div className="mt-4 rounded-xl border border-slate-800 bg-slate-950/80 p-3">
2151
+ <div className="flex items-center justify-between gap-2">
2152
+ <p className="text-xs font-semibold text-slate-200">
2153
+ Demo API
2154
+ </p>
2155
+ <button
2156
+ onClick={() =>
2157
+ window.open(DOCKER_DEMO_URL, "_blank", "noopener")
2158
+ }
2159
+ className="text-[10px] text-cyan-300 hover:text-cyan-200 transition-colors"
2160
+ >
2161
+ open page
2162
+ </button>
2163
+ </div>
2164
+ <div className="mt-2 flex flex-wrap gap-1.5">
2165
+ <button
2166
+ onClick={() => void hitDockerDemo("/api/ping")}
2167
+ disabled={dockerDemoLoading}
2168
+ className="rounded-full border border-slate-700 px-2 py-1 text-[10px] text-slate-300 hover:border-cyan-500/40 hover:text-cyan-200 disabled:opacity-40"
2169
+ >
2170
+ Ping
2171
+ </button>
2172
+ <button
2173
+ onClick={() =>
2174
+ void hitDockerDemo("/api/redis/increment/panel")
2175
+ }
2176
+ disabled={dockerDemoLoading}
2177
+ className="rounded-full border border-slate-700 px-2 py-1 text-[10px] text-slate-300 hover:border-cyan-500/40 hover:text-cyan-200 disabled:opacity-40"
2178
+ >
2179
+ Redis ++
2180
+ </button>
2181
+ <button
2182
+ onClick={() =>
2183
+ void hitDockerDemo("/api/cache/panel", {
2184
+ method: "POST",
2185
+ headers: { "content-type": "application/json" },
2186
+ body: JSON.stringify({
2187
+ value: `panel saved ${new Date().toISOString()}`,
2188
+ }),
2189
+ })
2190
+ }
2191
+ disabled={dockerDemoLoading}
2192
+ className="rounded-full border border-slate-700 px-2 py-1 text-[10px] text-slate-300 hover:border-cyan-500/40 hover:text-cyan-200 disabled:opacity-40"
2193
+ >
2194
+ Set cache
2195
+ </button>
2196
+ <button
2197
+ onClick={() => void hitDockerDemo("/api/cache/panel")}
2198
+ disabled={dockerDemoLoading}
2199
+ className="rounded-full border border-slate-700 px-2 py-1 text-[10px] text-slate-300 hover:border-cyan-500/40 hover:text-cyan-200 disabled:opacity-40"
2200
+ >
2201
+ Read cache
2202
+ </button>
2203
+ </div>
2204
+ <pre className="mt-3 max-h-52 overflow-auto whitespace-pre-wrap rounded-lg bg-slate-900 px-3 py-2 font-mono text-[11px] leading-5 text-slate-300">
2205
+ {dockerDemoLoading
2206
+ ? "Calling demo API…"
2207
+ : dockerDemoResponse}
2208
+ </pre>
2209
+ </div>
2210
+ </div>
2211
+ )}
2212
+
1481
2213
  <div className="rounded-xl border border-slate-800 bg-slate-900/80 p-4">
1482
2214
  <p className="text-sm font-semibold text-slate-100">
1483
2215
  Execution Profile
@@ -1485,15 +2217,15 @@ export default function InfraLabModal() {
1485
2217
  <dl className="mt-3 space-y-3 text-sm">
1486
2218
  <div>
1487
2219
  <dt className="text-slate-500">Provider</dt>
1488
- <dd className="mt-1 text-slate-200">AWS</dd>
2220
+ <dd className="mt-1 text-slate-200">
2221
+ {workspace.provider === "docker"
2222
+ ? "Docker + local containers"
2223
+ : "AWS"}
2224
+ </dd>
1489
2225
  </div>
1490
2226
  <div>
1491
2227
  <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>
2228
+ <dd className="mt-1 text-slate-200">{modeDescription}</dd>
1497
2229
  </div>
1498
2230
  <div>
1499
2231
  <dt className="text-slate-500">Files</dt>
@@ -1525,7 +2257,7 @@ export default function InfraLabModal() {
1525
2257
  </div>
1526
2258
  {!selectedRun && (
1527
2259
  <p className="mt-3 text-sm text-slate-500">
1528
- Run validate or plan to inspect persisted artifacts here.
2260
+ Run a command to inspect persisted artifacts here.
1529
2261
  </p>
1530
2262
  )}
1531
2263
  {selectedRun && (
@@ -1663,8 +2395,7 @@ export default function InfraLabModal() {
1663
2395
  <div className="mt-3 space-y-2">
1664
2396
  {runHistory.length === 0 && (
1665
2397
  <p className="text-sm text-slate-500">
1666
- Saved runs will appear here after the first validate or
1667
- plan.
2398
+ Saved runs will appear here after the first lab command.
1668
2399
  </p>
1669
2400
  )}
1670
2401
  {runHistory.map((run) => (