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.
@@ -0,0 +1,1048 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import {
3
+ ChevronDown,
4
+ ChevronRight,
5
+ FilePlus,
6
+ Folder,
7
+ Loader2,
8
+ Maximize2,
9
+ Minimize2,
10
+ PanelLeftClose,
11
+ PanelLeftOpen,
12
+ PanelRightClose,
13
+ PanelRightOpen,
14
+ Play,
15
+ Save,
16
+ StopCircle,
17
+ Terminal,
18
+ Trash2,
19
+ X,
20
+ } from "lucide-react";
21
+ import MonacoEditorLib from "@monaco-editor/react";
22
+ import type { BeforeMount, Monaco, OnChange } from "@monaco-editor/react";
23
+ import { useStore } from "../store";
24
+ import {
25
+ cloneGhaLabWorkspace,
26
+ DEFAULT_GHA_LAB,
27
+ getGhaLabFileOrder,
28
+ listWorkflowPaths,
29
+ serializeGhaLabWorkspace,
30
+ } from "../githubActionsLab";
31
+ import type { GithubActionsLabWorkspace } from "../types";
32
+ import * as api from "../api";
33
+ import type { GhaJobSnapshot, GhaStreamMessage } from "../api";
34
+ import GhaJobsPanel from "./GhaJobsPanel";
35
+ import GhaHistoryPanel from "./GhaHistoryPanel";
36
+
37
+ // ─── Modal layout constants ──────────────────────────────────────────────
38
+
39
+ const MIN_W = 900;
40
+ const MIN_H = 560;
41
+ const DEFAULT_W = Math.min(1280, window.innerWidth - 48);
42
+ const DEFAULT_H = Math.min(820, window.innerHeight - 48);
43
+ const EDITOR_FONT =
44
+ 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
45
+ type ResizeDir = "e" | "s" | "se" | "sw" | "w" | "ne" | "nw" | "n";
46
+
47
+ const EVENTS = [
48
+ "push",
49
+ "pull_request",
50
+ "workflow_dispatch",
51
+ "release",
52
+ "schedule",
53
+ ] as const;
54
+
55
+ // ─── Helpers ─────────────────────────────────────────────────────────────
56
+
57
+ function baseName(filePath: string): string {
58
+ return filePath.split("/").pop() || filePath;
59
+ }
60
+
61
+ function getEditorLanguage(filePath: string): string {
62
+ const lower = filePath.toLowerCase();
63
+ if (lower.endsWith(".yml") || lower.endsWith(".yaml")) return "yaml";
64
+ if (lower.endsWith(".json")) return "json";
65
+ if (lower.endsWith(".md") || lower.endsWith(".markdown")) return "markdown";
66
+ if (lower.endsWith(".js") || lower.endsWith(".mjs")) return "javascript";
67
+ if (lower.endsWith(".ts") || lower.endsWith(".tsx")) return "typescript";
68
+ if (lower.endsWith(".sh") || lower.endsWith(".bash")) return "shell";
69
+ if (baseName(lower) === "dockerfile") return "dockerfile";
70
+ return "plaintext";
71
+ }
72
+
73
+ // Tiny grouped-by-folder list to keep the modal lean.
74
+ function groupByFolder(paths: string[]): { folder: string; files: string[] }[] {
75
+ const map = new Map<string, string[]>();
76
+ for (const p of paths) {
77
+ const idx = p.lastIndexOf("/");
78
+ const folder = idx === -1 ? "" : p.slice(0, idx);
79
+ if (!map.has(folder)) map.set(folder, []);
80
+ map.get(folder)!.push(p);
81
+ }
82
+ return Array.from(map.entries())
83
+ .sort(([a], [b]) => a.localeCompare(b))
84
+ .map(([folder, files]) => ({ folder, files: files.sort() }));
85
+ }
86
+
87
+ // ─── Component ───────────────────────────────────────────────────────────
88
+
89
+ interface ConsoleLine {
90
+ id: string;
91
+ kind: "stdout" | "stderr" | "info" | "input";
92
+ text: string;
93
+ }
94
+
95
+ export default function GithubActionsLabModal() {
96
+ const {
97
+ closeGhaLab,
98
+ currentQuestion,
99
+ runnerInitialGha,
100
+ runnerInitialGhaFileId,
101
+ saveCodeSnippetToQuestion,
102
+ overwriteContextFileContent,
103
+ } = useStore();
104
+
105
+ // ── Workspace state ────────────────────────────────────────────────
106
+ const [workspace, setWorkspace] = useState<GithubActionsLabWorkspace>(() =>
107
+ cloneGhaLabWorkspace(runnerInitialGha ?? DEFAULT_GHA_LAB),
108
+ );
109
+ const [labName, setLabName] = useState(
110
+ runnerInitialGha?.label ?? DEFAULT_GHA_LAB.label,
111
+ );
112
+ const [activeFile, setActiveFile] = useState(
113
+ runnerInitialGha?.activeFile ?? DEFAULT_GHA_LAB.activeFile,
114
+ );
115
+ const [activeGhaId, setActiveGhaId] = useState<string | null>(
116
+ runnerInitialGhaFileId ?? null,
117
+ );
118
+
119
+ useEffect(() => {
120
+ const next = cloneGhaLabWorkspace(runnerInitialGha ?? DEFAULT_GHA_LAB);
121
+ setWorkspace(next);
122
+ setLabName(next.label);
123
+ setActiveFile(next.activeFile);
124
+ setActiveGhaId(runnerInitialGhaFileId ?? null);
125
+ }, [runnerInitialGha, runnerInitialGhaFileId]);
126
+
127
+ // ── Save state ─────────────────────────────────────────────────────
128
+ const [saving, setSaving] = useState(false);
129
+ const [saved, setSaved] = useState(false);
130
+
131
+ // ── Run state ──────────────────────────────────────────────────────
132
+ const workflowPaths = useMemo(
133
+ () => listWorkflowPaths(workspace),
134
+ [workspace],
135
+ );
136
+ const [event, setEvent] = useState<string>(workspace.defaultEvent ?? "push");
137
+ const [workflow, setWorkflow] = useState<string>(
138
+ workspace.defaultWorkflow ?? workflowPaths[0] ?? ".github/workflows/ci.yml",
139
+ );
140
+ const [jobFilter, setJobFilter] = useState<string>("");
141
+ const [dryRun, setDryRun] = useState(false);
142
+ const [running, setRunning] = useState(false);
143
+ const [consoleLines, setConsoleLines] = useState<ConsoleLine[]>([]);
144
+ const [runError, setRunError] = useState<string | null>(null);
145
+ // Live job snapshots reported by the server during the active run.
146
+ // Reset every time the user kicks off a new run.
147
+ const [liveJobs, setLiveJobs] = useState<GhaJobSnapshot[]>([]);
148
+ // "console" | "jobs" | "history" — controls the right pane tab.
149
+ const [rightTab, setRightTab] = useState<"console" | "jobs" | "history">(
150
+ "console",
151
+ );
152
+ // Bumped each time a run completes so the History tab refetches.
153
+ const [historyNonce, setHistoryNonce] = useState(0);
154
+ const [leftCollapsed, setLeftCollapsed] = useState(false);
155
+ const [rightCollapsed, setRightCollapsed] = useState(false);
156
+ const abortRef = useRef<AbortController | null>(null);
157
+ const consoleEndRef = useRef<HTMLDivElement | null>(null);
158
+
159
+ useEffect(() => {
160
+ // Keep workflow selection valid when files change
161
+ if (!workflowPaths.includes(workflow)) {
162
+ setWorkflow(workflowPaths[0] ?? "");
163
+ }
164
+ }, [workflowPaths, workflow]);
165
+
166
+ useEffect(() => {
167
+ consoleEndRef.current?.scrollIntoView({ behavior: "smooth" });
168
+ }, [consoleLines.length]);
169
+
170
+ // ── Drag / resize / maximize ───────────────────────────────────────
171
+ const [pos, setPos] = useState(() => ({
172
+ x: Math.max(0, (window.innerWidth - DEFAULT_W) / 2),
173
+ y: Math.max(0, (window.innerHeight - DEFAULT_H) / 2),
174
+ }));
175
+ const [size, setSize] = useState({ w: DEFAULT_W, h: DEFAULT_H });
176
+ const [maximized, setMaximized] = useState(false);
177
+ const dragStart = useRef<{
178
+ mx: number;
179
+ my: number;
180
+ ox: number;
181
+ oy: number;
182
+ } | null>(null);
183
+ const resizeStart = useRef<{
184
+ mx: number;
185
+ my: number;
186
+ ox: number;
187
+ oy: number;
188
+ ow: number;
189
+ oh: number;
190
+ } | null>(null);
191
+
192
+ const onTitleMouseDown = useCallback(
193
+ (e: React.MouseEvent) => {
194
+ if (maximized) return;
195
+ dragStart.current = {
196
+ mx: e.clientX,
197
+ my: e.clientY,
198
+ ox: pos.x,
199
+ oy: pos.y,
200
+ };
201
+ const onMove = (ev: MouseEvent) => {
202
+ if (!dragStart.current) return;
203
+ setPos({
204
+ x: Math.max(
205
+ 0,
206
+ dragStart.current.ox + (ev.clientX - dragStart.current.mx),
207
+ ),
208
+ y: Math.max(
209
+ 0,
210
+ dragStart.current.oy + (ev.clientY - dragStart.current.my),
211
+ ),
212
+ });
213
+ };
214
+ const onUp = () => {
215
+ dragStart.current = null;
216
+ window.removeEventListener("mousemove", onMove);
217
+ window.removeEventListener("mouseup", onUp);
218
+ };
219
+ window.addEventListener("mousemove", onMove);
220
+ window.addEventListener("mouseup", onUp);
221
+ },
222
+ [pos.x, pos.y, maximized],
223
+ );
224
+
225
+ const startResize = useCallback(
226
+ (dir: ResizeDir) => (e: React.MouseEvent) => {
227
+ if (maximized) return;
228
+ e.preventDefault();
229
+ e.stopPropagation();
230
+ resizeStart.current = {
231
+ mx: e.clientX,
232
+ my: e.clientY,
233
+ ox: pos.x,
234
+ oy: pos.y,
235
+ ow: size.w,
236
+ oh: size.h,
237
+ };
238
+
239
+ const originalCursor = document.body.style.cursor;
240
+ const originalUserSelect = document.body.style.userSelect;
241
+ document.body.style.cursor = `${dir}-resize`;
242
+ document.body.style.userSelect = "none";
243
+
244
+ const onMove = (ev: MouseEvent) => {
245
+ const resize = resizeStart.current;
246
+ if (!resize) return;
247
+ const dx = ev.clientX - resize.mx;
248
+ const dy = ev.clientY - resize.my;
249
+ let w = resize.ow;
250
+ let h = resize.oh;
251
+ let x = resize.ox;
252
+ let y = resize.oy;
253
+
254
+ if (dir.includes("e")) w = Math.max(MIN_W, resize.ow + dx);
255
+ if (dir.includes("s")) h = Math.max(MIN_H, resize.oh + dy);
256
+ if (dir.includes("w")) {
257
+ w = Math.max(MIN_W, resize.ow - dx);
258
+ x = Math.min(resize.ox + resize.ow - MIN_W, resize.ox + dx);
259
+ }
260
+ if (dir.includes("n")) {
261
+ h = Math.max(MIN_H, resize.oh - dy);
262
+ y = Math.min(resize.oy + resize.oh - MIN_H, resize.oy + dy);
263
+ }
264
+
265
+ setSize({ w, h });
266
+ setPos({ x: Math.max(0, x), y: Math.max(0, y) });
267
+ };
268
+
269
+ const onUp = () => {
270
+ resizeStart.current = null;
271
+ document.body.style.cursor = originalCursor;
272
+ document.body.style.userSelect = originalUserSelect;
273
+ window.removeEventListener("mousemove", onMove);
274
+ window.removeEventListener("mouseup", onUp);
275
+ };
276
+
277
+ window.addEventListener("mousemove", onMove);
278
+ window.addEventListener("mouseup", onUp);
279
+ },
280
+ [maximized, pos.x, pos.y, size.w, size.h],
281
+ );
282
+
283
+ // ── File operations ───────────────────────────────────────────────
284
+ const fileOrder = useMemo(() => getGhaLabFileOrder(workspace), [workspace]);
285
+ const grouped = useMemo(() => groupByFolder(fileOrder), [fileOrder]);
286
+ const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(
287
+ () => new Set(),
288
+ );
289
+ const toggleFolder = (folder: string) => {
290
+ setCollapsedFolders((prev) => {
291
+ const next = new Set(prev);
292
+ if (next.has(folder)) next.delete(folder);
293
+ else next.add(folder);
294
+ return next;
295
+ });
296
+ };
297
+
298
+ const updateFile = (fileName: string, content: string) => {
299
+ setWorkspace((prev) => ({
300
+ ...prev,
301
+ activeFile: fileName,
302
+ files: { ...prev.files, [fileName]: content },
303
+ }));
304
+ };
305
+
306
+ const addFile = () => {
307
+ const name = window.prompt(
308
+ "New file path (e.g. .github/workflows/release.yml)",
309
+ ".github/workflows/new.yml",
310
+ );
311
+ if (!name) return;
312
+ const trimmed = name.trim();
313
+ if (!trimmed || workspace.files[trimmed]) return;
314
+ setWorkspace((prev) => ({
315
+ ...prev,
316
+ activeFile: trimmed,
317
+ files: { ...prev.files, [trimmed]: "" },
318
+ }));
319
+ setActiveFile(trimmed);
320
+ };
321
+
322
+ const deleteFile = (fileName: string) => {
323
+ if (!window.confirm(`Delete ${fileName}?`)) return;
324
+ setWorkspace((prev) => {
325
+ const { [fileName]: _, ...rest } = prev.files;
326
+ const nextActive =
327
+ prev.activeFile === fileName
328
+ ? (Object.keys(rest)[0] ?? "")
329
+ : prev.activeFile;
330
+ return { ...prev, activeFile: nextActive, files: rest };
331
+ });
332
+ if (activeFile === fileName) {
333
+ const remaining = Object.keys(workspace.files).filter(
334
+ (f) => f !== fileName,
335
+ );
336
+ setActiveFile(remaining[0] ?? "");
337
+ }
338
+ };
339
+
340
+ // ── Save lab as context file ──────────────────────────────────────
341
+ const handleSave = useCallback(async () => {
342
+ if (!currentQuestion) return;
343
+ setSaving(true);
344
+ try {
345
+ // When the user opted in, embed a compact summary of the most recent
346
+ // runs into the saved JSON so the chat LLM has real execution data
347
+ // to reason about (job statuses, durations, exit codes).
348
+ let recentRunsExtras: Record<string, unknown> = {};
349
+ if (workspace.includeRunHistoryInContext) {
350
+ try {
351
+ const runs = await api.listGhaRuns({
352
+ questionId: currentQuestion.id,
353
+ ...(activeGhaId ? { fileId: activeGhaId } : {}),
354
+ limit: 5,
355
+ });
356
+ recentRunsExtras = {
357
+ recentRuns: runs.map((r) => ({
358
+ command: r.command,
359
+ status: r.status,
360
+ exitCode: r.exitCode,
361
+ startedAt: r.startedAt,
362
+ durationMs: r.durationMs,
363
+ jobs: (r.jobs ?? []).map((j) => ({
364
+ name: j.name,
365
+ status: j.status,
366
+ durationMs: j.durationMs,
367
+ })),
368
+ })),
369
+ };
370
+ } catch {
371
+ // Non-fatal — just skip the appendix if the list call fails.
372
+ }
373
+ }
374
+ const serialized = serializeGhaLabWorkspace({
375
+ ...workspace,
376
+ label: labName || workspace.label,
377
+ activeFile,
378
+ defaultEvent: event,
379
+ defaultWorkflow: workflow,
380
+ });
381
+ // Splice the extras into the serialised JSON without breaking the
382
+ // existing schema parsers tolerate.
383
+ const payload = Object.keys(recentRunsExtras).length
384
+ ? JSON.stringify(
385
+ { ...JSON.parse(serialized), ...recentRunsExtras },
386
+ null,
387
+ 2,
388
+ )
389
+ : serialized;
390
+ if (activeGhaId) {
391
+ await overwriteContextFileContent(
392
+ currentQuestion.id,
393
+ activeGhaId,
394
+ payload,
395
+ );
396
+ } else {
397
+ const cf = await saveCodeSnippetToQuestion(
398
+ currentQuestion.id,
399
+ payload,
400
+ "json",
401
+ labName || workspace.label,
402
+ "github-actions",
403
+ );
404
+ setActiveGhaId(cf.id);
405
+ }
406
+ setSaved(true);
407
+ window.setTimeout(() => setSaved(false), 1500);
408
+ } finally {
409
+ setSaving(false);
410
+ }
411
+ }, [
412
+ currentQuestion,
413
+ workspace,
414
+ labName,
415
+ activeFile,
416
+ event,
417
+ workflow,
418
+ activeGhaId,
419
+ overwriteContextFileContent,
420
+ saveCodeSnippetToQuestion,
421
+ ]);
422
+
423
+ // ── Run act ──────────────────────────────────────────────────────
424
+ const buildCommand = useCallback(() => {
425
+ const parts = ["act", event];
426
+ if (workflow) {
427
+ parts.push("-W", workflow);
428
+ }
429
+ if (jobFilter.trim()) {
430
+ parts.push("-j", jobFilter.trim());
431
+ }
432
+ if (dryRun) parts.push("-n");
433
+ return parts.join(" ");
434
+ }, [event, workflow, jobFilter, dryRun]);
435
+
436
+ const appendConsole = useCallback((line: Omit<ConsoleLine, "id">) => {
437
+ setConsoleLines((prev) => [
438
+ ...prev,
439
+ { ...line, id: `${Date.now()}-${Math.random().toString(36).slice(2)}` },
440
+ ]);
441
+ }, []);
442
+
443
+ const runCommand = useCallback(
444
+ async (command: string) => {
445
+ setRunning(true);
446
+ setRunError(null);
447
+ // Reset the DAG so the user always sees a fresh "pending → running →
448
+ // success/failed" lifecycle for this invocation. Switch tabs to Jobs
449
+ // so the visualisation is immediately visible.
450
+ setLiveJobs([]);
451
+ setRightTab("jobs");
452
+ appendConsole({ kind: "input", text: `$ ${command}\n` });
453
+
454
+ try {
455
+ await api.streamGhaCommand(
456
+ {
457
+ command,
458
+ workspace: {
459
+ ...workspace,
460
+ activeFile,
461
+ label: labName || workspace.label,
462
+ defaultEvent: event,
463
+ defaultWorkflow: workflow,
464
+ },
465
+ ...(activeGhaId ? { fileId: activeGhaId } : {}),
466
+ ...(currentQuestion ? { questionId: currentQuestion.id } : {}),
467
+ label: labName || workspace.label,
468
+ },
469
+ (message: GhaStreamMessage) => {
470
+ if (message.type === "output") {
471
+ appendConsole({ kind: message.kind, text: message.text });
472
+ } else if (message.type === "job") {
473
+ // Merge by job name so repeated updates (running → success)
474
+ // replace the prior entry rather than appending duplicates.
475
+ setLiveJobs((prev) => {
476
+ const idx = prev.findIndex((j) => j.name === message.job.name);
477
+ if (idx === -1) return [...prev, message.job];
478
+ const next = prev.slice();
479
+ next[idx] = message.job;
480
+ return next;
481
+ });
482
+ } else if (message.type === "error") {
483
+ setRunError(message.error);
484
+ appendConsole({
485
+ kind: "stderr",
486
+ text: `\n[error] ${message.error}\n`,
487
+ });
488
+ } else if (message.type === "complete") {
489
+ appendConsole({
490
+ kind: "info",
491
+ text: `\n[done] exit=${message.exitCode} • ${message.durationMs}ms\n`,
492
+ });
493
+ // Refresh History so the just-completed run shows up.
494
+ setHistoryNonce((n) => n + 1);
495
+ }
496
+ },
497
+ );
498
+ } catch (err: any) {
499
+ const msg = err?.message || "Failed to start run";
500
+ setRunError(msg);
501
+ appendConsole({ kind: "stderr", text: `\n[error] ${msg}\n` });
502
+ } finally {
503
+ setRunning(false);
504
+ }
505
+ },
506
+ [
507
+ workspace,
508
+ activeFile,
509
+ labName,
510
+ event,
511
+ workflow,
512
+ activeGhaId,
513
+ currentQuestion,
514
+ appendConsole,
515
+ ],
516
+ );
517
+
518
+ const handleRun = () => runCommand(buildCommand());
519
+ const handleListJobs = () =>
520
+ runCommand(workflow ? `act -W ${workflow} -l` : "act -l");
521
+
522
+ const clearConsole = () => setConsoleLines([]);
523
+
524
+ // ── Console input ─────────────────────────────────────────────────
525
+ const [consoleInput, setConsoleInput] = useState("");
526
+ const handleConsoleSubmit = (e: React.FormEvent) => {
527
+ e.preventDefault();
528
+ const cmd = consoleInput.trim();
529
+ if (!cmd || running) return;
530
+ setConsoleInput("");
531
+ runCommand(cmd);
532
+ };
533
+
534
+ // ── Monaco config ─────────────────────────────────────────────────
535
+ const handleBeforeMount = useCallback<BeforeMount>((monaco: Monaco) => {
536
+ monaco.editor.defineTheme("gha-lab-dark", {
537
+ base: "vs-dark",
538
+ inherit: true,
539
+ rules: [
540
+ { token: "comment", foreground: "64748b", fontStyle: "italic" },
541
+ { token: "keyword", foreground: "f59e0b", fontStyle: "bold" },
542
+ { token: "string", foreground: "86efac" },
543
+ { token: "type", foreground: "a78bfa" },
544
+ ],
545
+ colors: {
546
+ "editor.background": "#0b0f1a",
547
+ "editor.foreground": "#e2e8f0",
548
+ "editorLineNumber.foreground": "#475569",
549
+ "editorLineNumber.activeForeground": "#fbbf24",
550
+ "editorCursor.foreground": "#fbbf24",
551
+ "editor.lineHighlightBackground": "#111827",
552
+ "editor.selectionBackground": "#92400eAA",
553
+ },
554
+ });
555
+ }, []);
556
+
557
+ const handleEditorChange = useCallback<OnChange>(
558
+ (next) => {
559
+ updateFile(activeFile, next ?? "");
560
+ },
561
+ [activeFile],
562
+ );
563
+
564
+ // ── Render ────────────────────────────────────────────────────────
565
+ const containerStyle: React.CSSProperties = maximized
566
+ ? { top: 0, left: 0, width: "100vw", height: "100vh" }
567
+ : {
568
+ top: pos.y,
569
+ left: pos.x,
570
+ width: size.w,
571
+ height: size.h,
572
+ minWidth: MIN_W,
573
+ minHeight: MIN_H,
574
+ };
575
+
576
+ return (
577
+ <div className="fixed inset-0 z-40 bg-black/40">
578
+ <div
579
+ className="absolute flex flex-col rounded-2xl border border-slate-800 bg-slate-950 shadow-2xl overflow-hidden"
580
+ style={containerStyle}
581
+ >
582
+ {/* Resize handles */}
583
+ {!maximized && (
584
+ <>
585
+ <div
586
+ className="absolute inset-x-0 top-0 h-1 cursor-n-resize z-20"
587
+ onMouseDown={startResize("n")}
588
+ />
589
+ <div
590
+ className="absolute inset-x-0 bottom-0 h-1 cursor-s-resize z-20"
591
+ onMouseDown={startResize("s")}
592
+ />
593
+ <div
594
+ className="absolute inset-y-0 left-0 w-1 cursor-w-resize z-20"
595
+ onMouseDown={startResize("w")}
596
+ />
597
+ <div
598
+ className="absolute inset-y-0 right-0 w-1 cursor-e-resize z-20"
599
+ onMouseDown={startResize("e")}
600
+ />
601
+ <div
602
+ className="absolute top-0 left-0 w-3 h-3 cursor-nw-resize z-30"
603
+ onMouseDown={startResize("nw")}
604
+ />
605
+ <div
606
+ className="absolute top-0 right-0 w-3 h-3 cursor-ne-resize z-30"
607
+ onMouseDown={startResize("ne")}
608
+ />
609
+ <div
610
+ className="absolute bottom-0 left-0 w-3 h-3 cursor-sw-resize z-30"
611
+ onMouseDown={startResize("sw")}
612
+ />
613
+ <div
614
+ className="absolute bottom-0 right-0 w-3 h-3 cursor-se-resize z-30"
615
+ onMouseDown={startResize("se")}
616
+ />
617
+ </>
618
+ )}
619
+
620
+ {/* Title bar */}
621
+ <div
622
+ onMouseDown={onTitleMouseDown}
623
+ className="flex items-center gap-2 border-b border-slate-800 bg-slate-900/80 px-4 py-2 cursor-move select-none"
624
+ >
625
+ <div className="flex items-center gap-2">
626
+ <span className="inline-block h-2 w-2 rounded-full bg-amber-400" />
627
+ <span className="text-xs font-semibold tracking-widest text-amber-300">
628
+ GITHUB ACTIONS LAB
629
+ </span>
630
+ </div>
631
+ <input
632
+ value={labName}
633
+ onChange={(e) => setLabName(e.target.value)}
634
+ className="ml-2 flex-1 bg-transparent text-sm font-medium text-slate-100 outline-none border-b border-transparent focus:border-amber-500/50"
635
+ />
636
+ <button
637
+ onClick={() => setMaximized((v) => !v)}
638
+ className="p-1.5 rounded text-slate-400 hover:text-amber-300 hover:bg-slate-800/60"
639
+ title={maximized ? "Restore" : "Maximize"}
640
+ >
641
+ {maximized ? (
642
+ <Minimize2 className="w-4 h-4" />
643
+ ) : (
644
+ <Maximize2 className="w-4 h-4" />
645
+ )}
646
+ </button>
647
+ <button
648
+ onClick={closeGhaLab}
649
+ className="p-1.5 rounded text-slate-400 hover:text-red-300 hover:bg-slate-800/60"
650
+ title="Close"
651
+ >
652
+ <X className="w-4 h-4" />
653
+ </button>
654
+ </div>
655
+
656
+ {/* Toolbar */}
657
+ <div className="flex flex-wrap items-center gap-2 border-b border-slate-800 bg-slate-900/40 px-3 py-2 text-xs">
658
+ <label className="text-slate-400">Event</label>
659
+ <select
660
+ value={event}
661
+ onChange={(e) => setEvent(e.target.value)}
662
+ className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-200"
663
+ >
664
+ {EVENTS.map((ev) => (
665
+ <option key={ev} value={ev}>
666
+ {ev}
667
+ </option>
668
+ ))}
669
+ </select>
670
+
671
+ <label className="ml-2 text-slate-400">Workflow</label>
672
+ <select
673
+ value={workflow}
674
+ onChange={(e) => setWorkflow(e.target.value)}
675
+ className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-200"
676
+ >
677
+ {workflowPaths.length === 0 && (
678
+ <option value="">No workflows</option>
679
+ )}
680
+ {workflowPaths.map((wf) => (
681
+ <option key={wf} value={wf}>
682
+ {wf}
683
+ </option>
684
+ ))}
685
+ </select>
686
+
687
+ <label className="ml-2 text-slate-400">Job</label>
688
+ <input
689
+ value={jobFilter}
690
+ onChange={(e) => setJobFilter(e.target.value)}
691
+ placeholder="(all)"
692
+ className="w-24 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-200 placeholder:text-slate-600"
693
+ />
694
+
695
+ <label className="ml-2 inline-flex items-center gap-1 text-slate-400">
696
+ <input
697
+ type="checkbox"
698
+ checked={dryRun}
699
+ onChange={(e) => setDryRun(e.target.checked)}
700
+ className="accent-amber-400"
701
+ />
702
+ Dry run
703
+ </label>
704
+
705
+ <div className="ml-auto flex items-center gap-2">
706
+ <button
707
+ onClick={handleListJobs}
708
+ disabled={running}
709
+ className="flex items-center gap-1 rounded border border-slate-700 px-2 py-1 text-slate-300 hover:border-amber-500/40 hover:text-amber-200 disabled:opacity-50"
710
+ title="List jobs in the selected workflow"
711
+ >
712
+ List jobs
713
+ </button>
714
+ <button
715
+ onClick={handleRun}
716
+ disabled={running || workflowPaths.length === 0}
717
+ className="flex items-center gap-1 rounded bg-amber-500/20 border border-amber-500/40 px-3 py-1 text-amber-200 hover:bg-amber-500/30 disabled:opacity-50"
718
+ >
719
+ {running ? (
720
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
721
+ ) : (
722
+ <Play className="w-3.5 h-3.5" />
723
+ )}
724
+ Run
725
+ </button>
726
+ {currentQuestion && (
727
+ <button
728
+ onClick={handleSave}
729
+ disabled={saving}
730
+ className="flex items-center gap-1 rounded border border-slate-700 px-2 py-1 text-slate-300 hover:border-amber-500/40 hover:text-amber-200 disabled:opacity-50"
731
+ title={activeGhaId ? "Update lab snapshot" : "Save as lab"}
732
+ >
733
+ {saving ? (
734
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
735
+ ) : (
736
+ <Save className="w-3.5 h-3.5" />
737
+ )}
738
+ {saved ? "Saved" : activeGhaId ? "Update" : "Save"}
739
+ </button>
740
+ )}
741
+ </div>
742
+ </div>
743
+
744
+ {/* Main body */}
745
+ <div
746
+ className="flex-1 min-h-0 grid"
747
+ style={{
748
+ gridTemplateColumns: [
749
+ leftCollapsed ? "0px" : "220px",
750
+ "minmax(0,1fr)",
751
+ rightCollapsed ? "0px" : "minmax(320px,1fr)",
752
+ ].join(" "),
753
+ }}
754
+ >
755
+ {/* File tree */}
756
+ <div
757
+ className="flex flex-col border-r border-slate-800 bg-slate-900/30 min-h-0 overflow-hidden"
758
+ style={{
759
+ gridColumn: 1,
760
+ visibility: leftCollapsed ? "hidden" : undefined,
761
+ borderRightWidth: leftCollapsed ? 0 : undefined,
762
+ }}
763
+ >
764
+ <div className="flex items-center justify-between px-2 py-1.5 border-b border-slate-800/60">
765
+ <span className="text-[10px] font-semibold tracking-widest text-slate-500">
766
+ FILES
767
+ </span>
768
+ <button
769
+ onClick={addFile}
770
+ className="p-1 rounded text-slate-400 hover:text-amber-300 hover:bg-slate-800/60"
771
+ title="Add file"
772
+ >
773
+ <FilePlus className="w-3.5 h-3.5" />
774
+ </button>
775
+ </div>
776
+ <div className="flex-1 overflow-auto p-1 text-xs">
777
+ {grouped.map(({ folder, files }) => {
778
+ const collapsed = collapsedFolders.has(folder);
779
+ return (
780
+ <div key={folder || "root"} className="mb-1">
781
+ {folder && (
782
+ <button
783
+ onClick={() => toggleFolder(folder)}
784
+ className="flex items-center gap-1 w-full px-1 py-0.5 text-slate-400 hover:text-slate-200"
785
+ >
786
+ {collapsed ? (
787
+ <ChevronRight className="w-3 h-3" />
788
+ ) : (
789
+ <ChevronDown className="w-3 h-3" />
790
+ )}
791
+ <Folder className="w-3 h-3" />
792
+ <span className="truncate">{folder}/</span>
793
+ </button>
794
+ )}
795
+ {!collapsed &&
796
+ files.map((filePath) => (
797
+ <div
798
+ key={filePath}
799
+ className={`group flex items-center gap-1 pl-${folder ? 5 : 1} pr-1 py-0.5 rounded cursor-pointer ${
800
+ activeFile === filePath
801
+ ? "bg-amber-500/15 text-amber-200"
802
+ : "text-slate-300 hover:bg-slate-800/40"
803
+ }`}
804
+ onClick={() => setActiveFile(filePath)}
805
+ style={{ paddingLeft: folder ? 20 : 6 }}
806
+ >
807
+ <span className="truncate flex-1">
808
+ {baseName(filePath)}
809
+ </span>
810
+ <button
811
+ onClick={(e) => {
812
+ e.stopPropagation();
813
+ deleteFile(filePath);
814
+ }}
815
+ className="opacity-0 group-hover:opacity-100 p-0.5 text-slate-500 hover:text-red-400"
816
+ title="Delete"
817
+ >
818
+ <Trash2 className="w-3 h-3" />
819
+ </button>
820
+ </div>
821
+ ))}
822
+ </div>
823
+ );
824
+ })}
825
+ </div>
826
+ </div>
827
+
828
+ {/* Editor */}
829
+ <div
830
+ className="min-h-0 flex flex-col border-r border-slate-800"
831
+ style={{ gridColumn: 2 }}
832
+ >
833
+ <div className="flex items-center justify-between gap-2 px-2 py-1.5 border-b border-slate-800/60 text-[11px] text-slate-400">
834
+ <div className="flex items-center gap-1 min-w-0">
835
+ <button
836
+ onClick={() => setLeftCollapsed((v) => !v)}
837
+ className="p-1 rounded text-slate-500 hover:text-amber-300 hover:bg-slate-800/60 shrink-0"
838
+ title={
839
+ leftCollapsed ? "Show files panel" : "Hide files panel"
840
+ }
841
+ >
842
+ {leftCollapsed ? (
843
+ <PanelLeftOpen className="w-3.5 h-3.5" />
844
+ ) : (
845
+ <PanelLeftClose className="w-3.5 h-3.5" />
846
+ )}
847
+ </button>
848
+ <span className="truncate">
849
+ {activeFile || "(no file selected)"}
850
+ </span>
851
+ </div>
852
+ <button
853
+ onClick={() => setRightCollapsed((v) => !v)}
854
+ className="p-1 rounded text-slate-500 hover:text-amber-300 hover:bg-slate-800/60 shrink-0"
855
+ title={
856
+ rightCollapsed
857
+ ? "Show console/jobs panel"
858
+ : "Hide console/jobs panel"
859
+ }
860
+ >
861
+ {rightCollapsed ? (
862
+ <PanelRightOpen className="w-3.5 h-3.5" />
863
+ ) : (
864
+ <PanelRightClose className="w-3.5 h-3.5" />
865
+ )}
866
+ </button>
867
+ </div>
868
+ <div className="flex-1 min-h-0">
869
+ {activeFile && workspace.files[activeFile] !== undefined && (
870
+ <MonacoEditorLib
871
+ key={activeFile}
872
+ height="100%"
873
+ width="100%"
874
+ language={getEditorLanguage(activeFile)}
875
+ theme="gha-lab-dark"
876
+ path={`file:///gha-lab/${activeFile}`}
877
+ value={workspace.files[activeFile]}
878
+ beforeMount={handleBeforeMount}
879
+ onChange={handleEditorChange}
880
+ options={{
881
+ fontFamily: EDITOR_FONT,
882
+ fontSize: 13,
883
+ lineHeight: 22,
884
+ minimap: { enabled: false },
885
+ automaticLayout: true,
886
+ scrollBeyondLastLine: false,
887
+ wordWrap: "off",
888
+ tabSize: 2,
889
+ insertSpaces: true,
890
+ }}
891
+ />
892
+ )}
893
+ </div>
894
+ </div>
895
+
896
+ {/* Right pane: tabbed Console / Jobs / History */}
897
+ <div
898
+ className="min-h-0 flex flex-col bg-slate-950 overflow-hidden"
899
+ style={{
900
+ gridColumn: 3,
901
+ visibility: rightCollapsed ? "hidden" : undefined,
902
+ }}
903
+ >
904
+ <div className="flex items-center justify-between border-b border-slate-800/60 px-2 py-1">
905
+ <div className="flex items-center gap-0.5 text-[11px]">
906
+ {(
907
+ [
908
+ { id: "console", label: "Console" },
909
+ { id: "jobs", label: "Jobs" },
910
+ { id: "history", label: "History" },
911
+ ] as const
912
+ ).map((t) => (
913
+ <button
914
+ key={t.id}
915
+ onClick={() => setRightTab(t.id)}
916
+ className={`px-2 py-1 rounded ${
917
+ rightTab === t.id
918
+ ? "bg-slate-800/70 text-amber-200"
919
+ : "text-slate-400 hover:text-slate-200 hover:bg-slate-800/40"
920
+ }`}
921
+ >
922
+ {t.id === "console" && (
923
+ <Terminal className="inline w-3 h-3 mr-1 -mt-px" />
924
+ )}
925
+ {t.label}
926
+ {t.id === "jobs" && running && (
927
+ <span className="ml-1 inline-block w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
928
+ )}
929
+ </button>
930
+ ))}
931
+ </div>
932
+ <div className="flex items-center gap-1">
933
+ {running && (
934
+ <button
935
+ onClick={() => abortRef.current?.abort()}
936
+ className="p-1 rounded text-amber-300 hover:bg-slate-800/60"
937
+ title="Stop (close & restart to fully cancel)"
938
+ >
939
+ <StopCircle className="w-3.5 h-3.5" />
940
+ </button>
941
+ )}
942
+ {rightTab === "console" && (
943
+ <button
944
+ onClick={clearConsole}
945
+ className="p-1 rounded text-slate-500 hover:text-slate-200 hover:bg-slate-800/60"
946
+ title="Clear console"
947
+ >
948
+ <Trash2 className="w-3.5 h-3.5" />
949
+ </button>
950
+ )}
951
+ </div>
952
+ </div>
953
+
954
+ {rightTab === "console" && (
955
+ <>
956
+ <div className="flex-1 min-h-0 overflow-auto px-3 py-2 text-[11px] font-mono whitespace-pre-wrap break-words">
957
+ {consoleLines.length === 0 ? (
958
+ <div className="text-slate-600">
959
+ Click <span className="text-amber-300">Run</span> to
960
+ execute the selected workflow with{" "}
961
+ <span className="text-amber-300">act</span>. Output will
962
+ stream here just like the Actions tab on GitHub.
963
+ {"\n\n"}
964
+ Tips:
965
+ {"\n"} • First run pulls a ~500 MB runner image. Be
966
+ patient.
967
+ {"\n"} • Use{" "}
968
+ <span className="text-amber-300">Dry run</span> to plan a
969
+ workflow without Docker.
970
+ {"\n"} • Switch to the{" "}
971
+ <span className="text-amber-300">Jobs</span> tab to see a
972
+ live DAG of running, succeeded, and failed jobs.
973
+ </div>
974
+ ) : (
975
+ consoleLines.map((line) => (
976
+ <div
977
+ key={line.id}
978
+ className={
979
+ line.kind === "stderr"
980
+ ? "text-red-300"
981
+ : line.kind === "info"
982
+ ? "text-slate-400"
983
+ : line.kind === "input"
984
+ ? "text-amber-200"
985
+ : "text-slate-200"
986
+ }
987
+ >
988
+ {line.text}
989
+ </div>
990
+ ))
991
+ )}
992
+ {runError && (
993
+ <div className="mt-2 rounded border border-red-500/30 bg-red-500/10 px-2 py-1 text-red-200">
994
+ {runError}
995
+ </div>
996
+ )}
997
+ <div ref={consoleEndRef} />
998
+ </div>
999
+ <form
1000
+ onSubmit={handleConsoleSubmit}
1001
+ className="flex items-center gap-2 border-t border-slate-800/60 px-3 py-1.5"
1002
+ >
1003
+ <span className="text-amber-400 text-xs font-mono">$</span>
1004
+ <input
1005
+ value={consoleInput}
1006
+ onChange={(e) => setConsoleInput(e.target.value)}
1007
+ disabled={running}
1008
+ placeholder="act -l | act push -j greet | act -n"
1009
+ className="flex-1 bg-transparent text-xs font-mono text-slate-200 placeholder:text-slate-600 outline-none disabled:opacity-50"
1010
+ />
1011
+ </form>
1012
+ </>
1013
+ )}
1014
+
1015
+ {rightTab === "jobs" && (
1016
+ <GhaJobsPanel
1017
+ workflowYaml={workflow ? workspace.files[workflow] : undefined}
1018
+ jobs={liveJobs}
1019
+ caption={
1020
+ running
1021
+ ? "Live — updating as act emits job lines"
1022
+ : liveJobs.length
1023
+ ? "Last run"
1024
+ : "Pre-run plan (parsed from workflow YAML)"
1025
+ }
1026
+ />
1027
+ )}
1028
+
1029
+ {rightTab === "history" && (
1030
+ <GhaHistoryPanel
1031
+ {...(currentQuestion ? { questionId: currentQuestion.id } : {})}
1032
+ {...(activeGhaId ? { fileId: activeGhaId } : {})}
1033
+ includeInContext={!!workspace.includeRunHistoryInContext}
1034
+ onToggleIncludeInContext={(next) =>
1035
+ setWorkspace((prev) => ({
1036
+ ...prev,
1037
+ includeRunHistoryInContext: next,
1038
+ }))
1039
+ }
1040
+ refreshNonce={historyNonce}
1041
+ />
1042
+ )}
1043
+ </div>
1044
+ </div>
1045
+ </div>
1046
+ </div>
1047
+ );
1048
+ }