create-interview-cockpit 0.18.0 → 0.20.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.
@@ -7,6 +7,10 @@ import {
7
7
  Loader2,
8
8
  Maximize2,
9
9
  Minimize2,
10
+ PanelLeftClose,
11
+ PanelLeftOpen,
12
+ PanelRightClose,
13
+ PanelRightOpen,
10
14
  Play,
11
15
  Save,
12
16
  StopCircle,
@@ -15,7 +19,12 @@ import {
15
19
  X,
16
20
  } from "lucide-react";
17
21
  import MonacoEditorLib from "@monaco-editor/react";
18
- import type { BeforeMount, Monaco, OnChange } from "@monaco-editor/react";
22
+ import type {
23
+ BeforeMount,
24
+ Monaco,
25
+ OnChange,
26
+ OnMount,
27
+ } from "@monaco-editor/react";
19
28
  import { useStore } from "../store";
20
29
  import {
21
30
  cloneGhaLabWorkspace,
@@ -26,7 +35,9 @@ import {
26
35
  } from "../githubActionsLab";
27
36
  import type { GithubActionsLabWorkspace } from "../types";
28
37
  import * as api from "../api";
29
- import type { GhaStreamMessage } from "../api";
38
+ import type { GhaJobSnapshot, GhaStreamMessage } from "../api";
39
+ import GhaJobsPanel from "./GhaJobsPanel";
40
+ import GhaHistoryPanel from "./GhaHistoryPanel";
30
41
 
31
42
  // ─── Modal layout constants ──────────────────────────────────────────────
32
43
 
@@ -36,6 +47,10 @@ const DEFAULT_W = Math.min(1280, window.innerWidth - 48);
36
47
  const DEFAULT_H = Math.min(820, window.innerHeight - 48);
37
48
  const EDITOR_FONT =
38
49
  'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
50
+ const GHA_LAB_MODEL_ROOT = "file:///gha-lab/";
51
+ type ResizeDir = "e" | "s" | "se" | "sw" | "w" | "ne" | "nw" | "n";
52
+
53
+ let ghaLabMonacoTypeLibsInjected = false;
39
54
 
40
55
  const EVENTS = [
41
56
  "push",
@@ -54,6 +69,8 @@ function baseName(filePath: string): string {
54
69
  function getEditorLanguage(filePath: string): string {
55
70
  const lower = filePath.toLowerCase();
56
71
  if (lower.endsWith(".yml") || lower.endsWith(".yaml")) return "yaml";
72
+ if (lower.endsWith(".html")) return "html";
73
+ if (lower.endsWith(".css")) return "css";
57
74
  if (lower.endsWith(".json")) return "json";
58
75
  if (lower.endsWith(".md") || lower.endsWith(".markdown")) return "markdown";
59
76
  if (lower.endsWith(".js") || lower.endsWith(".mjs")) return "javascript";
@@ -63,6 +80,13 @@ function getEditorLanguage(filePath: string): string {
63
80
  return "plaintext";
64
81
  }
65
82
 
83
+ function getGhaLabModelPath(filePath: string): string {
84
+ return `${GHA_LAB_MODEL_ROOT}${filePath
85
+ .split("/")
86
+ .map(encodeURIComponent)
87
+ .join("/")}`;
88
+ }
89
+
66
90
  // Tiny grouped-by-folder list to keep the modal lean.
67
91
  function groupByFolder(paths: string[]): { folder: string; files: string[] }[] {
68
92
  const map = new Map<string, string[]>();
@@ -135,8 +159,21 @@ export default function GithubActionsLabModal() {
135
159
  const [running, setRunning] = useState(false);
136
160
  const [consoleLines, setConsoleLines] = useState<ConsoleLine[]>([]);
137
161
  const [runError, setRunError] = useState<string | null>(null);
162
+ // Live job snapshots reported by the server during the active run.
163
+ // Reset every time the user kicks off a new run.
164
+ const [liveJobs, setLiveJobs] = useState<GhaJobSnapshot[]>([]);
165
+ // "console" | "jobs" | "history" — controls the right pane tab.
166
+ const [rightTab, setRightTab] = useState<"console" | "jobs" | "history">(
167
+ "console",
168
+ );
169
+ // Bumped each time a run completes so the History tab refetches.
170
+ const [historyNonce, setHistoryNonce] = useState(0);
171
+ const [leftCollapsed, setLeftCollapsed] = useState(false);
172
+ const [rightCollapsed, setRightCollapsed] = useState(false);
138
173
  const abortRef = useRef<AbortController | null>(null);
139
174
  const consoleEndRef = useRef<HTMLDivElement | null>(null);
175
+ const monacoRef = useRef<Monaco | null>(null);
176
+ const monacoModelUrisRef = useRef<Set<string>>(new Set());
140
177
 
141
178
  useEffect(() => {
142
179
  // Keep workflow selection valid when files change
@@ -162,6 +199,14 @@ export default function GithubActionsLabModal() {
162
199
  ox: number;
163
200
  oy: number;
164
201
  } | null>(null);
202
+ const resizeStart = useRef<{
203
+ mx: number;
204
+ my: number;
205
+ ox: number;
206
+ oy: number;
207
+ ow: number;
208
+ oh: number;
209
+ } | null>(null);
165
210
 
166
211
  const onTitleMouseDown = useCallback(
167
212
  (e: React.MouseEvent) => {
@@ -196,6 +241,64 @@ export default function GithubActionsLabModal() {
196
241
  [pos.x, pos.y, maximized],
197
242
  );
198
243
 
244
+ const startResize = useCallback(
245
+ (dir: ResizeDir) => (e: React.MouseEvent) => {
246
+ if (maximized) return;
247
+ e.preventDefault();
248
+ e.stopPropagation();
249
+ resizeStart.current = {
250
+ mx: e.clientX,
251
+ my: e.clientY,
252
+ ox: pos.x,
253
+ oy: pos.y,
254
+ ow: size.w,
255
+ oh: size.h,
256
+ };
257
+
258
+ const originalCursor = document.body.style.cursor;
259
+ const originalUserSelect = document.body.style.userSelect;
260
+ document.body.style.cursor = `${dir}-resize`;
261
+ document.body.style.userSelect = "none";
262
+
263
+ const onMove = (ev: MouseEvent) => {
264
+ const resize = resizeStart.current;
265
+ if (!resize) return;
266
+ const dx = ev.clientX - resize.mx;
267
+ const dy = ev.clientY - resize.my;
268
+ let w = resize.ow;
269
+ let h = resize.oh;
270
+ let x = resize.ox;
271
+ let y = resize.oy;
272
+
273
+ if (dir.includes("e")) w = Math.max(MIN_W, resize.ow + dx);
274
+ if (dir.includes("s")) h = Math.max(MIN_H, resize.oh + dy);
275
+ if (dir.includes("w")) {
276
+ w = Math.max(MIN_W, resize.ow - dx);
277
+ x = Math.min(resize.ox + resize.ow - MIN_W, resize.ox + dx);
278
+ }
279
+ if (dir.includes("n")) {
280
+ h = Math.max(MIN_H, resize.oh - dy);
281
+ y = Math.min(resize.oy + resize.oh - MIN_H, resize.oy + dy);
282
+ }
283
+
284
+ setSize({ w, h });
285
+ setPos({ x: Math.max(0, x), y: Math.max(0, y) });
286
+ };
287
+
288
+ const onUp = () => {
289
+ resizeStart.current = null;
290
+ document.body.style.cursor = originalCursor;
291
+ document.body.style.userSelect = originalUserSelect;
292
+ window.removeEventListener("mousemove", onMove);
293
+ window.removeEventListener("mouseup", onUp);
294
+ };
295
+
296
+ window.addEventListener("mousemove", onMove);
297
+ window.addEventListener("mouseup", onUp);
298
+ },
299
+ [maximized, pos.x, pos.y, size.w, size.h],
300
+ );
301
+
199
302
  // ── File operations ───────────────────────────────────────────────
200
303
  const fileOrder = useMemo(() => getGhaLabFileOrder(workspace), [workspace]);
201
304
  const grouped = useMemo(() => groupByFolder(fileOrder), [fileOrder]);
@@ -258,13 +361,51 @@ export default function GithubActionsLabModal() {
258
361
  if (!currentQuestion) return;
259
362
  setSaving(true);
260
363
  try {
261
- const payload = serializeGhaLabWorkspace({
364
+ // When the user opted in, embed a compact summary of the most recent
365
+ // runs into the saved JSON so the chat LLM has real execution data
366
+ // to reason about (job statuses, durations, exit codes).
367
+ let recentRunsExtras: Record<string, unknown> = {};
368
+ if (workspace.includeRunHistoryInContext) {
369
+ try {
370
+ const runs = await api.listGhaRuns({
371
+ questionId: currentQuestion.id,
372
+ ...(activeGhaId ? { fileId: activeGhaId } : {}),
373
+ limit: 5,
374
+ });
375
+ recentRunsExtras = {
376
+ recentRuns: runs.map((r) => ({
377
+ command: r.command,
378
+ status: r.status,
379
+ exitCode: r.exitCode,
380
+ startedAt: r.startedAt,
381
+ durationMs: r.durationMs,
382
+ jobs: (r.jobs ?? []).map((j) => ({
383
+ name: j.name,
384
+ status: j.status,
385
+ durationMs: j.durationMs,
386
+ })),
387
+ })),
388
+ };
389
+ } catch {
390
+ // Non-fatal — just skip the appendix if the list call fails.
391
+ }
392
+ }
393
+ const serialized = serializeGhaLabWorkspace({
262
394
  ...workspace,
263
395
  label: labName || workspace.label,
264
396
  activeFile,
265
397
  defaultEvent: event,
266
398
  defaultWorkflow: workflow,
267
399
  });
400
+ // Splice the extras into the serialised JSON without breaking the
401
+ // existing schema parsers tolerate.
402
+ const payload = Object.keys(recentRunsExtras).length
403
+ ? JSON.stringify(
404
+ { ...JSON.parse(serialized), ...recentRunsExtras },
405
+ null,
406
+ 2,
407
+ )
408
+ : serialized;
268
409
  if (activeGhaId) {
269
410
  await overwriteContextFileContent(
270
411
  currentQuestion.id,
@@ -322,6 +463,11 @@ export default function GithubActionsLabModal() {
322
463
  async (command: string) => {
323
464
  setRunning(true);
324
465
  setRunError(null);
466
+ // Reset the DAG so the user always sees a fresh "pending → running →
467
+ // success/failed" lifecycle for this invocation. Switch tabs to Jobs
468
+ // so the visualisation is immediately visible.
469
+ setLiveJobs([]);
470
+ setRightTab("jobs");
325
471
  appendConsole({ kind: "input", text: `$ ${command}\n` });
326
472
 
327
473
  try {
@@ -342,6 +488,16 @@ export default function GithubActionsLabModal() {
342
488
  (message: GhaStreamMessage) => {
343
489
  if (message.type === "output") {
344
490
  appendConsole({ kind: message.kind, text: message.text });
491
+ } else if (message.type === "job") {
492
+ // Merge by job name so repeated updates (running → success)
493
+ // replace the prior entry rather than appending duplicates.
494
+ setLiveJobs((prev) => {
495
+ const idx = prev.findIndex((j) => j.name === message.job.name);
496
+ if (idx === -1) return [...prev, message.job];
497
+ const next = prev.slice();
498
+ next[idx] = message.job;
499
+ return next;
500
+ });
345
501
  } else if (message.type === "error") {
346
502
  setRunError(message.error);
347
503
  appendConsole({
@@ -353,6 +509,8 @@ export default function GithubActionsLabModal() {
353
509
  kind: "info",
354
510
  text: `\n[done] exit=${message.exitCode} • ${message.durationMs}ms\n`,
355
511
  });
512
+ // Refresh History so the just-completed run shows up.
513
+ setHistoryNonce((n) => n + 1);
356
514
  }
357
515
  },
358
516
  );
@@ -382,6 +540,50 @@ export default function GithubActionsLabModal() {
382
540
 
383
541
  const clearConsole = () => setConsoleLines([]);
384
542
 
543
+ const syncMonacoWorkspaceModels = useCallback(
544
+ (monaco: Monaco, files: Record<string, string>) => {
545
+ const nextUris = new Set<string>();
546
+
547
+ for (const [filePath, content] of Object.entries(files)) {
548
+ const uri = monaco.Uri.parse(getGhaLabModelPath(filePath));
549
+ const uriText = uri.toString();
550
+ nextUris.add(uriText);
551
+
552
+ const existing = monaco.editor.getModel(uri);
553
+ if (existing) {
554
+ if (existing.getValue() !== content) existing.setValue(content);
555
+ } else {
556
+ monaco.editor.createModel(content, getEditorLanguage(filePath), uri);
557
+ }
558
+ }
559
+
560
+ for (const uriText of monacoModelUrisRef.current) {
561
+ if (nextUris.has(uriText)) continue;
562
+ monaco.editor.getModel(monaco.Uri.parse(uriText))?.dispose();
563
+ }
564
+
565
+ monacoModelUrisRef.current = nextUris;
566
+ },
567
+ [],
568
+ );
569
+
570
+ useEffect(() => {
571
+ const monaco = monacoRef.current;
572
+ if (!monaco) return;
573
+ syncMonacoWorkspaceModels(monaco, workspace.files);
574
+ }, [syncMonacoWorkspaceModels, workspace.files]);
575
+
576
+ useEffect(() => {
577
+ return () => {
578
+ const monaco = monacoRef.current;
579
+ if (!monaco) return;
580
+ for (const uriText of monacoModelUrisRef.current) {
581
+ monaco.editor.getModel(monaco.Uri.parse(uriText))?.dispose();
582
+ }
583
+ monacoModelUrisRef.current.clear();
584
+ };
585
+ }, []);
586
+
385
587
  // ── Console input ─────────────────────────────────────────────────
386
588
  const [consoleInput, setConsoleInput] = useState("");
387
589
  const handleConsoleSubmit = (e: React.FormEvent) => {
@@ -394,6 +596,139 @@ export default function GithubActionsLabModal() {
394
596
 
395
597
  // ── Monaco config ─────────────────────────────────────────────────
396
598
  const handleBeforeMount = useCallback<BeforeMount>((monaco: Monaco) => {
599
+ monacoRef.current = monaco;
600
+
601
+ // Monaco does not read the template's tsconfig.json or package.json, so
602
+ // configure its in-browser TypeScript worker like a Vite React TS project.
603
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
604
+ const tsLang = monaco.languages.typescript as any;
605
+ const compilerOptions = {
606
+ target: tsLang.ScriptTarget.ESNext,
607
+ module: tsLang.ModuleKind.ESNext,
608
+ moduleResolution:
609
+ tsLang.ModuleResolutionKind.Bundler ??
610
+ tsLang.ModuleResolutionKind.NodeJs,
611
+ jsx: tsLang.JsxEmit.ReactJSX ?? tsLang.JsxEmit.Preserve,
612
+ jsxImportSource: "react",
613
+ allowJs: true,
614
+ strict: true,
615
+ esModuleInterop: true,
616
+ allowSyntheticDefaultImports: true,
617
+ allowNonTsExtensions: true,
618
+ resolveJsonModule: true,
619
+ };
620
+ tsLang.typescriptDefaults.setCompilerOptions(compilerOptions);
621
+ tsLang.javascriptDefaults.setCompilerOptions(compilerOptions);
622
+ tsLang.typescriptDefaults.setDiagnosticsOptions({
623
+ noSemanticValidation: false,
624
+ noSyntaxValidation: false,
625
+ diagnosticCodesToIgnore: [2688],
626
+ });
627
+ tsLang.javascriptDefaults.setDiagnosticsOptions({
628
+ noSemanticValidation: false,
629
+ noSyntaxValidation: false,
630
+ diagnosticCodesToIgnore: [2688],
631
+ });
632
+ tsLang.typescriptDefaults.setEagerModelSync(true);
633
+ tsLang.javascriptDefaults.setEagerModelSync(true);
634
+
635
+ if (!ghaLabMonacoTypeLibsInjected) {
636
+ const reactViteTypes = `
637
+ declare module "react" {
638
+ export type ReactNode = any;
639
+ export type ReactElement = any;
640
+ export type CSSProperties = Record<string, string | number>;
641
+ export interface FC<P = {}> {
642
+ (props: P): ReactElement | null;
643
+ }
644
+ export const StrictMode: FC<{ children?: ReactNode }>;
645
+ const React: {
646
+ createElement: (...args: any[]) => ReactElement;
647
+ Fragment: any;
648
+ StrictMode: FC<{ children?: ReactNode }>;
649
+ };
650
+ export default React;
651
+ }
652
+
653
+ declare module "react/jsx-runtime" {
654
+ export namespace JSX {
655
+ interface Element {}
656
+ interface IntrinsicElements {
657
+ [elemName: string]: any;
658
+ }
659
+ }
660
+ export const Fragment: any;
661
+ export function jsx(type: any, props: any, key?: any): any;
662
+ export function jsxs(type: any, props: any, key?: any): any;
663
+ export function jsxDEV(
664
+ type: any,
665
+ props: any,
666
+ key: any,
667
+ isStaticChildren: boolean,
668
+ source: any,
669
+ self: any,
670
+ ): any;
671
+ }
672
+
673
+ declare namespace JSX {
674
+ interface Element {}
675
+ interface IntrinsicElements {
676
+ [elemName: string]: any;
677
+ }
678
+ }
679
+
680
+ declare module "react-dom/client" {
681
+ export function createRoot(container: Element | DocumentFragment): {
682
+ render(children: any): void;
683
+ unmount(): void;
684
+ };
685
+ }
686
+
687
+ declare module "vite" {
688
+ export function defineConfig(config: any): any;
689
+ }
690
+
691
+ declare module "@vitejs/plugin-react" {
692
+ export default function react(options?: any): any;
693
+ }
694
+
695
+ declare module "*.css" {
696
+ const classes: Record<string, string>;
697
+ export default classes;
698
+ }
699
+
700
+ interface ImportMetaEnv {
701
+ readonly BASE_URL: string;
702
+ readonly DEV: boolean;
703
+ readonly MODE: string;
704
+ readonly PROD: boolean;
705
+ readonly SSR: boolean;
706
+ readonly [key: string]: any;
707
+ }
708
+
709
+ interface ImportMeta {
710
+ readonly env: ImportMetaEnv;
711
+ }
712
+ `;
713
+ tsLang.typescriptDefaults.addExtraLib(
714
+ reactViteTypes,
715
+ "file:///__gha-lab-types/react-vite.d.ts",
716
+ );
717
+ tsLang.javascriptDefaults.addExtraLib(
718
+ reactViteTypes,
719
+ "file:///__gha-lab-types/react-vite.d.ts",
720
+ );
721
+ tsLang.typescriptDefaults.addExtraLib(
722
+ reactViteTypes,
723
+ "file:///node_modules/vite/client.d.ts",
724
+ );
725
+ tsLang.javascriptDefaults.addExtraLib(
726
+ reactViteTypes,
727
+ "file:///node_modules/vite/client.d.ts",
728
+ );
729
+ ghaLabMonacoTypeLibsInjected = true;
730
+ }
731
+
397
732
  monaco.editor.defineTheme("gha-lab-dark", {
398
733
  base: "vs-dark",
399
734
  inherit: true,
@@ -415,6 +750,14 @@ export default function GithubActionsLabModal() {
415
750
  });
416
751
  }, []);
417
752
 
753
+ const handleMount = useCallback<OnMount>(
754
+ (_editor, monaco) => {
755
+ monacoRef.current = monaco;
756
+ syncMonacoWorkspaceModels(monaco, workspace.files);
757
+ },
758
+ [syncMonacoWorkspaceModels, workspace.files],
759
+ );
760
+
418
761
  const handleEditorChange = useCallback<OnChange>(
419
762
  (next) => {
420
763
  updateFile(activeFile, next ?? "");
@@ -425,14 +768,59 @@ export default function GithubActionsLabModal() {
425
768
  // ── Render ────────────────────────────────────────────────────────
426
769
  const containerStyle: React.CSSProperties = maximized
427
770
  ? { top: 0, left: 0, width: "100vw", height: "100vh" }
428
- : { top: pos.y, left: pos.x, width: size.w, height: size.h };
771
+ : {
772
+ top: pos.y,
773
+ left: pos.x,
774
+ width: size.w,
775
+ height: size.h,
776
+ minWidth: MIN_W,
777
+ minHeight: MIN_H,
778
+ };
429
779
 
430
780
  return (
431
- <div className="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm">
781
+ <div className="fixed inset-0 z-40 bg-black/40">
432
782
  <div
433
783
  className="absolute flex flex-col rounded-2xl border border-slate-800 bg-slate-950 shadow-2xl overflow-hidden"
434
784
  style={containerStyle}
435
785
  >
786
+ {/* Resize handles */}
787
+ {!maximized && (
788
+ <>
789
+ <div
790
+ className="absolute inset-x-0 top-0 h-1 cursor-n-resize z-20"
791
+ onMouseDown={startResize("n")}
792
+ />
793
+ <div
794
+ className="absolute inset-x-0 bottom-0 h-1 cursor-s-resize z-20"
795
+ onMouseDown={startResize("s")}
796
+ />
797
+ <div
798
+ className="absolute inset-y-0 left-0 w-1 cursor-w-resize z-20"
799
+ onMouseDown={startResize("w")}
800
+ />
801
+ <div
802
+ className="absolute inset-y-0 right-0 w-1 cursor-e-resize z-20"
803
+ onMouseDown={startResize("e")}
804
+ />
805
+ <div
806
+ className="absolute top-0 left-0 w-3 h-3 cursor-nw-resize z-30"
807
+ onMouseDown={startResize("nw")}
808
+ />
809
+ <div
810
+ className="absolute top-0 right-0 w-3 h-3 cursor-ne-resize z-30"
811
+ onMouseDown={startResize("ne")}
812
+ />
813
+ <div
814
+ className="absolute bottom-0 left-0 w-3 h-3 cursor-sw-resize z-30"
815
+ onMouseDown={startResize("sw")}
816
+ />
817
+ <div
818
+ className="absolute bottom-0 right-0 w-3 h-3 cursor-se-resize z-30"
819
+ onMouseDown={startResize("se")}
820
+ />
821
+ </>
822
+ )}
823
+
436
824
  {/* Title bar */}
437
825
  <div
438
826
  onMouseDown={onTitleMouseDown}
@@ -558,9 +946,25 @@ export default function GithubActionsLabModal() {
558
946
  </div>
559
947
 
560
948
  {/* Main body */}
561
- <div className="flex-1 min-h-0 grid grid-cols-[220px_1fr_1fr]">
949
+ <div
950
+ className="flex-1 min-h-0 grid"
951
+ style={{
952
+ gridTemplateColumns: [
953
+ leftCollapsed ? "0px" : "220px",
954
+ "minmax(0,1fr)",
955
+ rightCollapsed ? "0px" : "minmax(320px,1fr)",
956
+ ].join(" "),
957
+ }}
958
+ >
562
959
  {/* File tree */}
563
- <div className="flex flex-col border-r border-slate-800 bg-slate-900/30 min-h-0">
960
+ <div
961
+ className="flex flex-col border-r border-slate-800 bg-slate-900/30 min-h-0 overflow-hidden"
962
+ style={{
963
+ gridColumn: 1,
964
+ visibility: leftCollapsed ? "hidden" : undefined,
965
+ borderRightWidth: leftCollapsed ? 0 : undefined,
966
+ }}
967
+ >
564
968
  <div className="flex items-center justify-between px-2 py-1.5 border-b border-slate-800/60">
565
969
  <span className="text-[10px] font-semibold tracking-widest text-slate-500">
566
970
  FILES
@@ -626,9 +1030,44 @@ export default function GithubActionsLabModal() {
626
1030
  </div>
627
1031
 
628
1032
  {/* Editor */}
629
- <div className="min-h-0 flex flex-col border-r border-slate-800">
630
- <div className="px-3 py-1.5 border-b border-slate-800/60 text-[11px] text-slate-400 truncate">
631
- {activeFile || "(no file selected)"}
1033
+ <div
1034
+ className="min-h-0 flex flex-col border-r border-slate-800"
1035
+ style={{ gridColumn: 2 }}
1036
+ >
1037
+ <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">
1038
+ <div className="flex items-center gap-1 min-w-0">
1039
+ <button
1040
+ onClick={() => setLeftCollapsed((v) => !v)}
1041
+ className="p-1 rounded text-slate-500 hover:text-amber-300 hover:bg-slate-800/60 shrink-0"
1042
+ title={
1043
+ leftCollapsed ? "Show files panel" : "Hide files panel"
1044
+ }
1045
+ >
1046
+ {leftCollapsed ? (
1047
+ <PanelLeftOpen className="w-3.5 h-3.5" />
1048
+ ) : (
1049
+ <PanelLeftClose className="w-3.5 h-3.5" />
1050
+ )}
1051
+ </button>
1052
+ <span className="truncate">
1053
+ {activeFile || "(no file selected)"}
1054
+ </span>
1055
+ </div>
1056
+ <button
1057
+ onClick={() => setRightCollapsed((v) => !v)}
1058
+ className="p-1 rounded text-slate-500 hover:text-amber-300 hover:bg-slate-800/60 shrink-0"
1059
+ title={
1060
+ rightCollapsed
1061
+ ? "Show console/jobs panel"
1062
+ : "Hide console/jobs panel"
1063
+ }
1064
+ >
1065
+ {rightCollapsed ? (
1066
+ <PanelRightOpen className="w-3.5 h-3.5" />
1067
+ ) : (
1068
+ <PanelRightClose className="w-3.5 h-3.5" />
1069
+ )}
1070
+ </button>
632
1071
  </div>
633
1072
  <div className="flex-1 min-h-0">
634
1073
  {activeFile && workspace.files[activeFile] !== undefined && (
@@ -638,9 +1077,10 @@ export default function GithubActionsLabModal() {
638
1077
  width="100%"
639
1078
  language={getEditorLanguage(activeFile)}
640
1079
  theme="gha-lab-dark"
641
- path={`file:///gha-lab/${activeFile}`}
1080
+ path={getGhaLabModelPath(activeFile)}
642
1081
  value={workspace.files[activeFile]}
643
1082
  beforeMount={handleBeforeMount}
1083
+ onMount={handleMount}
644
1084
  onChange={handleEditorChange}
645
1085
  options={{
646
1086
  fontFamily: EDITOR_FONT,
@@ -658,12 +1098,41 @@ export default function GithubActionsLabModal() {
658
1098
  </div>
659
1099
  </div>
660
1100
 
661
- {/* Console */}
662
- <div className="min-h-0 flex flex-col bg-slate-950">
663
- <div className="flex items-center justify-between border-b border-slate-800/60 px-3 py-1.5">
664
- <div className="flex items-center gap-1 text-[11px] text-slate-400">
665
- <Terminal className="w-3 h-3" />
666
- Console
1101
+ {/* Right pane: tabbed Console / Jobs / History */}
1102
+ <div
1103
+ className="min-h-0 flex flex-col bg-slate-950 overflow-hidden"
1104
+ style={{
1105
+ gridColumn: 3,
1106
+ visibility: rightCollapsed ? "hidden" : undefined,
1107
+ }}
1108
+ >
1109
+ <div className="flex items-center justify-between border-b border-slate-800/60 px-2 py-1">
1110
+ <div className="flex items-center gap-0.5 text-[11px]">
1111
+ {(
1112
+ [
1113
+ { id: "console", label: "Console" },
1114
+ { id: "jobs", label: "Jobs" },
1115
+ { id: "history", label: "History" },
1116
+ ] as const
1117
+ ).map((t) => (
1118
+ <button
1119
+ key={t.id}
1120
+ onClick={() => setRightTab(t.id)}
1121
+ className={`px-2 py-1 rounded ${
1122
+ rightTab === t.id
1123
+ ? "bg-slate-800/70 text-amber-200"
1124
+ : "text-slate-400 hover:text-slate-200 hover:bg-slate-800/40"
1125
+ }`}
1126
+ >
1127
+ {t.id === "console" && (
1128
+ <Terminal className="inline w-3 h-3 mr-1 -mt-px" />
1129
+ )}
1130
+ {t.label}
1131
+ {t.id === "jobs" && running && (
1132
+ <span className="ml-1 inline-block w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
1133
+ )}
1134
+ </button>
1135
+ ))}
667
1136
  </div>
668
1137
  <div className="flex items-center gap-1">
669
1138
  {running && (
@@ -675,69 +1144,107 @@ export default function GithubActionsLabModal() {
675
1144
  <StopCircle className="w-3.5 h-3.5" />
676
1145
  </button>
677
1146
  )}
678
- <button
679
- onClick={clearConsole}
680
- className="p-1 rounded text-slate-500 hover:text-slate-200 hover:bg-slate-800/60"
681
- title="Clear console"
682
- >
683
- <Trash2 className="w-3.5 h-3.5" />
684
- </button>
1147
+ {rightTab === "console" && (
1148
+ <button
1149
+ onClick={clearConsole}
1150
+ className="p-1 rounded text-slate-500 hover:text-slate-200 hover:bg-slate-800/60"
1151
+ title="Clear console"
1152
+ >
1153
+ <Trash2 className="w-3.5 h-3.5" />
1154
+ </button>
1155
+ )}
685
1156
  </div>
686
1157
  </div>
687
- <div className="flex-1 min-h-0 overflow-auto px-3 py-2 text-[11px] font-mono whitespace-pre-wrap break-words">
688
- {consoleLines.length === 0 ? (
689
- <div className="text-slate-600">
690
- Click <span className="text-amber-300">Run</span> to execute
691
- the selected workflow with{" "}
692
- <span className="text-amber-300">act</span>. Output will
693
- stream here just like the Actions tab on GitHub.
694
- {"\n\n"}
695
- Tips:
696
- {"\n"} First run pulls a ~500 MB runner image. Be patient.
697
- {"\n"} • Use <span className="text-amber-300">Dry run</span>{" "}
698
- to plan a workflow without Docker.
699
- {"\n"} • You can also type{" "}
700
- <span className="text-amber-300">act -l</span> in the console
701
- below.
702
- </div>
703
- ) : (
704
- consoleLines.map((line) => (
705
- <div
706
- key={line.id}
707
- className={
708
- line.kind === "stderr"
709
- ? "text-red-300"
710
- : line.kind === "info"
711
- ? "text-slate-400"
712
- : line.kind === "input"
713
- ? "text-amber-200"
714
- : "text-slate-200"
715
- }
716
- >
717
- {line.text}
718
- </div>
719
- ))
720
- )}
721
- {runError && (
722
- <div className="mt-2 rounded border border-red-500/30 bg-red-500/10 px-2 py-1 text-red-200">
723
- {runError}
1158
+
1159
+ {rightTab === "console" && (
1160
+ <>
1161
+ <div className="flex-1 min-h-0 overflow-auto px-3 py-2 text-[11px] font-mono whitespace-pre-wrap break-words">
1162
+ {consoleLines.length === 0 ? (
1163
+ <div className="text-slate-600">
1164
+ Click <span className="text-amber-300">Run</span> to
1165
+ execute the selected workflow with{" "}
1166
+ <span className="text-amber-300">act</span>. Output will
1167
+ stream here just like the Actions tab on GitHub.
1168
+ {"\n\n"}
1169
+ Tips:
1170
+ {"\n"} • First run pulls a ~500 MB runner image. Be
1171
+ patient.
1172
+ {"\n"} • Use{" "}
1173
+ <span className="text-amber-300">Dry run</span> to plan a
1174
+ workflow without Docker.
1175
+ {"\n"} Switch to the{" "}
1176
+ <span className="text-amber-300">Jobs</span> tab to see a
1177
+ live DAG of running, succeeded, and failed jobs.
1178
+ </div>
1179
+ ) : (
1180
+ consoleLines.map((line) => (
1181
+ <div
1182
+ key={line.id}
1183
+ className={
1184
+ line.kind === "stderr"
1185
+ ? "text-red-300"
1186
+ : line.kind === "info"
1187
+ ? "text-slate-400"
1188
+ : line.kind === "input"
1189
+ ? "text-amber-200"
1190
+ : "text-slate-200"
1191
+ }
1192
+ >
1193
+ {line.text}
1194
+ </div>
1195
+ ))
1196
+ )}
1197
+ {runError && (
1198
+ <div className="mt-2 rounded border border-red-500/30 bg-red-500/10 px-2 py-1 text-red-200">
1199
+ {runError}
1200
+ </div>
1201
+ )}
1202
+ <div ref={consoleEndRef} />
724
1203
  </div>
725
- )}
726
- <div ref={consoleEndRef} />
727
- </div>
728
- <form
729
- onSubmit={handleConsoleSubmit}
730
- className="flex items-center gap-2 border-t border-slate-800/60 px-3 py-1.5"
731
- >
732
- <span className="text-amber-400 text-xs font-mono">$</span>
733
- <input
734
- value={consoleInput}
735
- onChange={(e) => setConsoleInput(e.target.value)}
736
- disabled={running}
737
- placeholder="act -l | act push -j greet | act -n"
738
- className="flex-1 bg-transparent text-xs font-mono text-slate-200 placeholder:text-slate-600 outline-none disabled:opacity-50"
1204
+ <form
1205
+ onSubmit={handleConsoleSubmit}
1206
+ className="flex items-center gap-2 border-t border-slate-800/60 px-3 py-1.5"
1207
+ >
1208
+ <span className="text-amber-400 text-xs font-mono">$</span>
1209
+ <input
1210
+ value={consoleInput}
1211
+ onChange={(e) => setConsoleInput(e.target.value)}
1212
+ disabled={running}
1213
+ placeholder="act -l | act push -j greet | act -n"
1214
+ className="flex-1 bg-transparent text-xs font-mono text-slate-200 placeholder:text-slate-600 outline-none disabled:opacity-50"
1215
+ />
1216
+ </form>
1217
+ </>
1218
+ )}
1219
+
1220
+ {rightTab === "jobs" && (
1221
+ <GhaJobsPanel
1222
+ workflowYaml={workflow ? workspace.files[workflow] : undefined}
1223
+ jobs={liveJobs}
1224
+ caption={
1225
+ running
1226
+ ? "Live — updating as act emits job lines"
1227
+ : liveJobs.length
1228
+ ? "Last run"
1229
+ : "Pre-run plan (parsed from workflow YAML)"
1230
+ }
739
1231
  />
740
- </form>
1232
+ )}
1233
+
1234
+ {rightTab === "history" && (
1235
+ <GhaHistoryPanel
1236
+ {...(currentQuestion ? { questionId: currentQuestion.id } : {})}
1237
+ {...(activeGhaId ? { fileId: activeGhaId } : {})}
1238
+ includeInContext={!!workspace.includeRunHistoryInContext}
1239
+ onToggleIncludeInContext={(next) =>
1240
+ setWorkspace((prev) => ({
1241
+ ...prev,
1242
+ includeRunHistoryInContext: next,
1243
+ }))
1244
+ }
1245
+ refreshNonce={historyNonce}
1246
+ />
1247
+ )}
741
1248
  </div>
742
1249
  </div>
743
1250
  </div>