create-interview-cockpit 0.23.0 → 0.23.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-interview-cockpit",
3
- "version": "0.23.0",
3
+ "version": "0.23.2",
4
4
  "description": "Scaffold a personal AI-powered interview prep cockpit",
5
5
  "type": "module",
6
6
  "bin": {
@@ -549,11 +549,13 @@ export async function streamGhaCommand(
549
549
  workspace: GithubActionsLabWorkspace;
550
550
  },
551
551
  onMessage: (message: GhaStreamMessage) => void,
552
+ options?: { signal?: AbortSignal },
552
553
  ): Promise<void> {
553
554
  const res = await fetch(`${BASE}/gha/run-stream`, {
554
555
  method: "POST",
555
556
  headers: { "Content-Type": "application/json" },
556
557
  body: JSON.stringify(input),
558
+ ...(options?.signal ? { signal: options.signal } : {}),
557
559
  });
558
560
  if (!res.ok || !res.body) {
559
561
  const err = await res.text().catch(() => "act run failed");
@@ -0,0 +1,281 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import { GitBranch, Info, Layers, Trash2, XCircle } from "lucide-react";
3
+ import {
4
+ evaluateConcurrencyFor,
5
+ type GhaConcurrencyContext,
6
+ type GhaConcurrencyRun,
7
+ type ParsedConcurrency,
8
+ } from "../ghaConcurrency";
9
+
10
+ // ─── Status pill ────────────────────────────────────────────────────────
11
+
12
+ function StatusPill({ status }: { status: GhaConcurrencyRun["status"] }) {
13
+ const cls =
14
+ status === "running"
15
+ ? "bg-amber-500/15 text-amber-200 border-amber-500/30"
16
+ : status === "pending"
17
+ ? "bg-slate-700/40 text-slate-300 border-slate-600/40"
18
+ : status === "completed"
19
+ ? "bg-emerald-500/15 text-emerald-200 border-emerald-500/30"
20
+ : "bg-red-500/10 text-red-300 border-red-500/30";
21
+ return (
22
+ <span
23
+ className={`inline-block rounded border px-1.5 py-0.5 text-[10px] uppercase tracking-wide ${cls}`}
24
+ >
25
+ {status}
26
+ </span>
27
+ );
28
+ }
29
+
30
+ // ─── Component ──────────────────────────────────────────────────────────
31
+
32
+ interface Props {
33
+ parsed: ParsedConcurrency | null;
34
+ workflowPath: string;
35
+ runs: GhaConcurrencyRun[];
36
+ context: GhaConcurrencyContext;
37
+ onContextChange: (next: GhaConcurrencyContext) => void;
38
+ onClearRuns: () => void;
39
+ }
40
+
41
+ /**
42
+ * Live viewer of how GitHub's concurrency rules are being applied to the
43
+ * real `act` runs triggered from the lab. The user edits the simulated
44
+ * `github.*` context here; that context is then used at Run-button time
45
+ * to compute (groupKey, cancelInProgress) for the new run.
46
+ */
47
+ export default function GhaConcurrencyPanel({
48
+ parsed,
49
+ workflowPath,
50
+ runs,
51
+ context,
52
+ onContextChange,
53
+ onClearRuns,
54
+ }: Props) {
55
+ // Preview the (groupKey, cancelInProgress) the NEXT triggered run would
56
+ // get with the current context — useful so the user can confirm their
57
+ // setup before clicking Run.
58
+ const preview = useMemo(
59
+ () => evaluateConcurrencyFor(parsed, context),
60
+ [parsed, context],
61
+ );
62
+
63
+ const groups = useMemo(() => {
64
+ const map = new Map<string, GhaConcurrencyRun[]>();
65
+ for (const r of runs) {
66
+ if (!map.has(r.groupKey)) map.set(r.groupKey, []);
67
+ map.get(r.groupKey)!.push(r);
68
+ }
69
+ return Array.from(map.entries()).map(([key, items]) => ({
70
+ key,
71
+ items: items.slice().sort((a, b) => a.seq - b.seq),
72
+ }));
73
+ }, [runs]);
74
+
75
+ return (
76
+ <div className="flex-1 min-h-0 overflow-auto p-3 text-xs text-slate-300 space-y-3">
77
+ {/* ── Explanation ──────────────────────────────────────────────── */}
78
+ <section className="rounded-xl border border-amber-500/20 bg-amber-500/5 p-3">
79
+ <div className="mb-1 flex items-center gap-2 text-sm font-semibold text-amber-200">
80
+ <GitBranch className="h-4 w-4" />
81
+ Concurrency applied to real runs
82
+ </div>
83
+ <p className="text-slate-400 leading-5">
84
+ Click <span className="text-amber-200">Run</span> in the toolbar to
85
+ trigger a real <span className="text-amber-200">act</span> invocation.
86
+ When you click Run again while one is still in flight the lab will
87
+ apply GitHub&apos;s rules: same group with{" "}
88
+ <span className="text-amber-200">cancel-in-progress: true</span>{" "}
89
+ cancels the active run, otherwise the new run is queued behind it.
90
+ Different group keys queue too (the console only shows one run at a
91
+ time), and would run in parallel on real GitHub.
92
+ </p>
93
+ </section>
94
+
95
+ {/* ── Parsed block ─────────────────────────────────────────────── */}
96
+ <section className="rounded-xl border border-slate-800 bg-slate-900/40 p-3">
97
+ <div className="mb-2 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-slate-200">
98
+ <Layers className="h-3.5 w-3.5 text-amber-300" />
99
+ Workflow concurrency block
100
+ <span className="ml-auto font-normal normal-case text-slate-500 truncate">
101
+ {workflowPath || <em>no workflow selected</em>}
102
+ </span>
103
+ </div>
104
+ {parsed ? (
105
+ <div className="grid grid-cols-[110px_1fr] gap-y-1 text-[11px] font-mono">
106
+ <span className="text-slate-500">group</span>
107
+ <span className="text-slate-200 break-all">{parsed.groupExpr}</span>
108
+ <span className="text-slate-500">cancel</span>
109
+ <span className="text-slate-200 break-all">
110
+ {parsed.cancelExpr}
111
+ </span>
112
+ <span className="text-slate-500">next → key</span>
113
+ <span className="text-amber-200 break-all">
114
+ {preview.groupKey || <em className="text-slate-600">empty</em>}
115
+ </span>
116
+ <span className="text-slate-500">next → cancel?</span>
117
+ <span
118
+ className={
119
+ preview.cancelInProgress ? "text-emerald-300" : "text-slate-400"
120
+ }
121
+ >
122
+ {preview.cancelInProgress ? "true" : "false"}
123
+ </span>
124
+ </div>
125
+ ) : (
126
+ <div className="text-slate-400 leading-5">
127
+ No <span className="text-amber-200">concurrency:</span> block in
128
+ this workflow. New runs queue serially in the lab; GitHub would run
129
+ them in parallel. Add a block to try it:
130
+ <pre className="mt-2 rounded-lg border border-slate-800 bg-slate-950/70 p-2 text-[11px] text-slate-200 overflow-auto">{`concurrency:
131
+ group: ci-\${{ github.event_name }}-\${{ github.head_ref || github.ref }}
132
+ cancel-in-progress: \${{ github.event_name == 'pull_request' }}`}</pre>
133
+ </div>
134
+ )}
135
+ </section>
136
+
137
+ {/* ── Simulated github.* context ───────────────────────────────── */}
138
+ <section className="rounded-xl border border-slate-800 bg-slate-900/40 p-3">
139
+ <div className="mb-2 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-slate-200">
140
+ <Info className="h-3.5 w-3.5 text-amber-300" />
141
+ Simulated github context (applied to the next run)
142
+ </div>
143
+ <p className="mb-2 text-slate-500 leading-5">
144
+ <span className="text-amber-200">github.event_name</span> comes from
145
+ the toolbar Event selector; the rest is edited here. These values are
146
+ used only to evaluate the concurrency expression — the actual act
147
+ invocation isn&apos;t affected.
148
+ </p>
149
+ <div className="grid grid-cols-2 gap-2">
150
+ {(
151
+ [
152
+ { key: "ref", label: "github.ref" },
153
+ { key: "head_ref", label: "github.head_ref" },
154
+ { key: "workflow", label: "github.workflow" },
155
+ ] as const
156
+ ).map((field) => (
157
+ <label key={field.key} className="block">
158
+ <span className="block text-[10px] uppercase tracking-wider text-slate-500">
159
+ {field.label}
160
+ </span>
161
+ <input
162
+ value={context[field.key]}
163
+ onChange={(e) =>
164
+ onContextChange({ ...context, [field.key]: e.target.value })
165
+ }
166
+ className="mt-0.5 w-full rounded border border-slate-800 bg-slate-950/60 px-2 py-1 font-mono text-[11px] text-slate-200 outline-none focus:border-amber-500/40"
167
+ />
168
+ </label>
169
+ ))}
170
+ <div>
171
+ <span className="block text-[10px] uppercase tracking-wider text-slate-500">
172
+ github.event_name
173
+ </span>
174
+ <div className="mt-0.5 w-full rounded border border-slate-800 bg-slate-950/30 px-2 py-1 font-mono text-[11px] text-slate-500">
175
+ {context.event_name}{" "}
176
+ <span className="text-slate-600">(from toolbar)</span>
177
+ </div>
178
+ </div>
179
+ </div>
180
+ </section>
181
+
182
+ {/* ── Run timeline ─────────────────────────────────────────────── */}
183
+ <section className="rounded-xl border border-slate-800 bg-slate-900/40 p-3">
184
+ <div className="mb-2 flex items-center justify-between gap-2">
185
+ <div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-slate-200">
186
+ <Layers className="h-3.5 w-3.5 text-amber-300" />
187
+ Runs grouped by concurrency key
188
+ </div>
189
+ {runs.length > 0 && (
190
+ <button
191
+ onClick={onClearRuns}
192
+ className="text-slate-500 hover:text-slate-200"
193
+ title="Clear concurrency history"
194
+ >
195
+ <Trash2 className="h-3 w-3" />
196
+ </button>
197
+ )}
198
+ </div>
199
+
200
+ {runs.length === 0 ? (
201
+ <p className="text-slate-500 leading-5">
202
+ No runs yet. Click <span className="text-amber-200">Run</span> in
203
+ the toolbar twice in a row to watch the rules kick in.
204
+ </p>
205
+ ) : (
206
+ <div className="space-y-3">
207
+ {groups.map((g) => (
208
+ <div
209
+ key={g.key || "__no_group__"}
210
+ className="rounded-lg border border-slate-800 bg-slate-950/40 p-2"
211
+ >
212
+ <div className="mb-1.5 flex items-center justify-between gap-2 text-[10px] font-mono text-slate-400">
213
+ <span className="text-amber-200 break-all">
214
+ {g.key || (
215
+ <em className="text-slate-600">(no concurrency block)</em>
216
+ )}
217
+ </span>
218
+ <span className="text-slate-600">
219
+ {g.items.length} run(s)
220
+ </span>
221
+ </div>
222
+ <ul className="space-y-1">
223
+ {g.items.map((r) => (
224
+ <RunRow key={r.id} run={r} />
225
+ ))}
226
+ </ul>
227
+ </div>
228
+ ))}
229
+ </div>
230
+ )}
231
+ </section>
232
+ </div>
233
+ );
234
+ }
235
+
236
+ function RunRow({ run }: { run: GhaConcurrencyRun }) {
237
+ // Re-render this single row every 250ms while running so the elapsed
238
+ // timer ticks. The rest of the panel stays still.
239
+ const [, force] = useState(0);
240
+ useEffect(() => {
241
+ if (run.status !== "running") return;
242
+ const id = window.setInterval(() => force((n) => n + 1), 250);
243
+ return () => window.clearInterval(id);
244
+ }, [run.status]);
245
+
246
+ const elapsed = (() => {
247
+ if (run.startedAt == null) return null;
248
+ const end = run.endedAt ?? Date.now();
249
+ return Math.max(0, end - run.startedAt);
250
+ })();
251
+
252
+ return (
253
+ <li className="rounded border border-slate-800/70 bg-slate-900/40 p-1.5">
254
+ <div className="flex items-center gap-2 text-[11px]">
255
+ <span className="font-mono text-slate-500">#{run.seq}</span>
256
+ <StatusPill status={run.status} />
257
+ <span className="font-mono text-slate-400 truncate">
258
+ {run.eventName} ·{" "}
259
+ {run.context.head_ref || run.context.ref || run.workflowPath}
260
+ </span>
261
+ <span className="ml-auto font-mono text-slate-500">
262
+ {elapsed != null ? `${(elapsed / 1000).toFixed(1)}s` : ""}
263
+ {run.exitCode != null && (
264
+ <span className="ml-1 text-slate-600">exit={run.exitCode}</span>
265
+ )}
266
+ </span>
267
+ {run.status === "cancelled" && (
268
+ <XCircle className="h-3 w-3 text-red-400" />
269
+ )}
270
+ </div>
271
+ <div className="mt-0.5 truncate font-mono text-[10px] text-slate-600">
272
+ {run.command}
273
+ </div>
274
+ {run.cancelReason && (
275
+ <div className="mt-1 text-[10px] text-red-300/80">
276
+ {run.cancelReason}
277
+ </div>
278
+ )}
279
+ </li>
280
+ );
281
+ }