create-interview-cockpit 0.18.0 → 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.
@@ -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,
@@ -26,7 +30,9 @@ import {
26
30
  } from "../githubActionsLab";
27
31
  import type { GithubActionsLabWorkspace } from "../types";
28
32
  import * as api from "../api";
29
- import type { GhaStreamMessage } from "../api";
33
+ import type { GhaJobSnapshot, GhaStreamMessage } from "../api";
34
+ import GhaJobsPanel from "./GhaJobsPanel";
35
+ import GhaHistoryPanel from "./GhaHistoryPanel";
30
36
 
31
37
  // ─── Modal layout constants ──────────────────────────────────────────────
32
38
 
@@ -36,6 +42,7 @@ const DEFAULT_W = Math.min(1280, window.innerWidth - 48);
36
42
  const DEFAULT_H = Math.min(820, window.innerHeight - 48);
37
43
  const EDITOR_FONT =
38
44
  'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
45
+ type ResizeDir = "e" | "s" | "se" | "sw" | "w" | "ne" | "nw" | "n";
39
46
 
40
47
  const EVENTS = [
41
48
  "push",
@@ -135,6 +142,17 @@ export default function GithubActionsLabModal() {
135
142
  const [running, setRunning] = useState(false);
136
143
  const [consoleLines, setConsoleLines] = useState<ConsoleLine[]>([]);
137
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);
138
156
  const abortRef = useRef<AbortController | null>(null);
139
157
  const consoleEndRef = useRef<HTMLDivElement | null>(null);
140
158
 
@@ -162,6 +180,14 @@ export default function GithubActionsLabModal() {
162
180
  ox: number;
163
181
  oy: number;
164
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);
165
191
 
166
192
  const onTitleMouseDown = useCallback(
167
193
  (e: React.MouseEvent) => {
@@ -196,6 +222,64 @@ export default function GithubActionsLabModal() {
196
222
  [pos.x, pos.y, maximized],
197
223
  );
198
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
+
199
283
  // ── File operations ───────────────────────────────────────────────
200
284
  const fileOrder = useMemo(() => getGhaLabFileOrder(workspace), [workspace]);
201
285
  const grouped = useMemo(() => groupByFolder(fileOrder), [fileOrder]);
@@ -258,13 +342,51 @@ export default function GithubActionsLabModal() {
258
342
  if (!currentQuestion) return;
259
343
  setSaving(true);
260
344
  try {
261
- const payload = serializeGhaLabWorkspace({
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({
262
375
  ...workspace,
263
376
  label: labName || workspace.label,
264
377
  activeFile,
265
378
  defaultEvent: event,
266
379
  defaultWorkflow: workflow,
267
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;
268
390
  if (activeGhaId) {
269
391
  await overwriteContextFileContent(
270
392
  currentQuestion.id,
@@ -322,6 +444,11 @@ export default function GithubActionsLabModal() {
322
444
  async (command: string) => {
323
445
  setRunning(true);
324
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");
325
452
  appendConsole({ kind: "input", text: `$ ${command}\n` });
326
453
 
327
454
  try {
@@ -342,6 +469,16 @@ export default function GithubActionsLabModal() {
342
469
  (message: GhaStreamMessage) => {
343
470
  if (message.type === "output") {
344
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
+ });
345
482
  } else if (message.type === "error") {
346
483
  setRunError(message.error);
347
484
  appendConsole({
@@ -353,6 +490,8 @@ export default function GithubActionsLabModal() {
353
490
  kind: "info",
354
491
  text: `\n[done] exit=${message.exitCode} • ${message.durationMs}ms\n`,
355
492
  });
493
+ // Refresh History so the just-completed run shows up.
494
+ setHistoryNonce((n) => n + 1);
356
495
  }
357
496
  },
358
497
  );
@@ -425,14 +564,59 @@ export default function GithubActionsLabModal() {
425
564
  // ── Render ────────────────────────────────────────────────────────
426
565
  const containerStyle: React.CSSProperties = maximized
427
566
  ? { top: 0, left: 0, width: "100vw", height: "100vh" }
428
- : { top: pos.y, left: pos.x, width: size.w, height: size.h };
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
+ };
429
575
 
430
576
  return (
431
- <div className="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm">
577
+ <div className="fixed inset-0 z-40 bg-black/40">
432
578
  <div
433
579
  className="absolute flex flex-col rounded-2xl border border-slate-800 bg-slate-950 shadow-2xl overflow-hidden"
434
580
  style={containerStyle}
435
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
+
436
620
  {/* Title bar */}
437
621
  <div
438
622
  onMouseDown={onTitleMouseDown}
@@ -558,9 +742,25 @@ export default function GithubActionsLabModal() {
558
742
  </div>
559
743
 
560
744
  {/* Main body */}
561
- <div className="flex-1 min-h-0 grid grid-cols-[220px_1fr_1fr]">
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
+ >
562
755
  {/* File tree */}
563
- <div className="flex flex-col border-r border-slate-800 bg-slate-900/30 min-h-0">
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
+ >
564
764
  <div className="flex items-center justify-between px-2 py-1.5 border-b border-slate-800/60">
565
765
  <span className="text-[10px] font-semibold tracking-widest text-slate-500">
566
766
  FILES
@@ -626,9 +826,44 @@ export default function GithubActionsLabModal() {
626
826
  </div>
627
827
 
628
828
  {/* 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)"}
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>
632
867
  </div>
633
868
  <div className="flex-1 min-h-0">
634
869
  {activeFile && workspace.files[activeFile] !== undefined && (
@@ -658,12 +893,41 @@ export default function GithubActionsLabModal() {
658
893
  </div>
659
894
  </div>
660
895
 
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
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
+ ))}
667
931
  </div>
668
932
  <div className="flex items-center gap-1">
669
933
  {running && (
@@ -675,69 +939,107 @@ export default function GithubActionsLabModal() {
675
939
  <StopCircle className="w-3.5 h-3.5" />
676
940
  </button>
677
941
  )}
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>
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
+ )}
685
951
  </div>
686
952
  </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}
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} />
724
998
  </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"
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
+ }
739
1026
  />
740
- </form>
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
+ )}
741
1043
  </div>
742
1044
  </div>
743
1045
  </div>