create-interview-cockpit 0.23.0 → 0.23.1

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.
@@ -5,6 +5,8 @@ import {
5
5
  Copy,
6
6
  FilePlus,
7
7
  Folder,
8
+ GitBranch,
9
+ KeyRound,
8
10
  ListChecks,
9
11
  Loader2,
10
12
  Maximize2,
@@ -37,10 +39,19 @@ import {
37
39
  serializeGhaLabWorkspace,
38
40
  } from "../githubActionsLab";
39
41
  import type { GithubActionsLabWorkspace } from "../types";
42
+ import type { GithubActionsLabEnvironmentEntry } from "../types";
40
43
  import * as api from "../api";
41
44
  import type { GhaJobSnapshot, GhaStreamMessage } from "../api";
42
45
  import GhaJobsPanel from "./GhaJobsPanel";
43
46
  import GhaHistoryPanel from "./GhaHistoryPanel";
47
+ import GhaConcurrencyPanel from "./GhaConcurrencyPanel";
48
+ import {
49
+ defaultContextForEvent,
50
+ evaluateConcurrencyFor,
51
+ parseConcurrencyBlock,
52
+ type GhaConcurrencyContext,
53
+ type GhaConcurrencyRun,
54
+ } from "../ghaConcurrency";
44
55
 
45
56
  // ─── Modal layout constants ──────────────────────────────────────────────
46
57
 
@@ -226,6 +237,44 @@ interface ConsoleLine {
226
237
  text: string;
227
238
  }
228
239
 
240
+ type GhaEnvironmentKind = "variables" | "secrets" | "env";
241
+
242
+ const GHA_ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
243
+
244
+ const GHA_ENVIRONMENT_SECTIONS: Array<{
245
+ kind: GhaEnvironmentKind;
246
+ title: string;
247
+ addLabel: string;
248
+ namePlaceholder: string;
249
+ valuePlaceholder: string;
250
+ help: string;
251
+ }> = [
252
+ {
253
+ kind: "variables",
254
+ title: "Repository variables",
255
+ addLabel: "Add variable",
256
+ namePlaceholder: "API_URL",
257
+ valuePlaceholder: "https://example.test",
258
+ help: "Available in workflows as `${{ vars.NAME }}` through act's --var-file.",
259
+ },
260
+ {
261
+ kind: "secrets",
262
+ title: "Repository secrets",
263
+ addLabel: "Add secret",
264
+ namePlaceholder: "NPM_TOKEN",
265
+ valuePlaceholder: "secret value",
266
+ help: "Available in workflows as `${{ secrets.NAME }}` through act's --secret-file.",
267
+ },
268
+ {
269
+ kind: "env",
270
+ title: "Runner environment",
271
+ addLabel: "Add env",
272
+ namePlaceholder: "NODE_ENV",
273
+ valuePlaceholder: "test",
274
+ help: "Available to shell steps as environment variables, e.g. `$NAME`, through act's --env-file.",
275
+ },
276
+ ];
277
+
229
278
  export default function GithubActionsLabModal() {
230
279
  const {
231
280
  closeGhaLab,
@@ -279,15 +328,55 @@ export default function GithubActionsLabModal() {
279
328
  // Live job snapshots reported by the server during the active run.
280
329
  // Reset every time the user kicks off a new run.
281
330
  const [liveJobs, setLiveJobs] = useState<GhaJobSnapshot[]>([]);
282
- // "console" | "jobs" | "history" — controls the right pane tab.
283
- const [rightTab, setRightTab] = useState<"console" | "jobs" | "history">(
284
- "console",
331
+ // "console" | "jobs" | "env" | "concurrency" | "history" — controls the right pane tab.
332
+ const [rightTab, setRightTab] = useState<
333
+ "console" | "jobs" | "env" | "concurrency" | "history"
334
+ >("console");
335
+ const environmentEntryCount = useMemo(
336
+ () =>
337
+ GHA_ENVIRONMENT_SECTIONS.reduce((total, section) => {
338
+ return (
339
+ total +
340
+ (workspace.environment?.[section.kind] ?? []).filter(
341
+ (entry) => entry.enabled !== false && entry.name.trim(),
342
+ ).length
343
+ );
344
+ }, 0),
345
+ [workspace.environment],
285
346
  );
286
347
  // Bumped each time a run completes so the History tab refetches.
287
348
  const [historyNonce, setHistoryNonce] = useState(0);
288
349
  const [leftCollapsed, setLeftCollapsed] = useState(false);
289
350
  const [rightCollapsed, setRightCollapsed] = useState(false);
290
351
  const abortRef = useRef<AbortController | null>(null);
352
+ // ── Concurrency engine state ───────────────────────────────────────
353
+ // The concurrency tab is no longer a simulator — these records track
354
+ // real `act` invocations so we can apply GitHub's queue/cancel rules
355
+ // when the user clicks Run a second time before the first finishes.
356
+ const [concurrencyRuns, setConcurrencyRuns] = useState<GhaConcurrencyRun[]>(
357
+ [],
358
+ );
359
+ const [concurrencyContext, setConcurrencyContext] =
360
+ useState<GhaConcurrencyContext>(() =>
361
+ defaultContextForEvent(
362
+ workspace.defaultEvent ?? "push",
363
+ workspace.defaultWorkflow ?? ".github/workflows/ci.yml",
364
+ "main",
365
+ 42,
366
+ "feature/login",
367
+ ),
368
+ );
369
+ // Mirrors of state used inside async callbacks to dodge stale closures
370
+ // when the runner finishes and needs to drain the next pending run.
371
+ const concurrencyRunsRef = useRef<GhaConcurrencyRun[]>([]);
372
+ const activeRunIdRef = useRef<string | null>(null);
373
+ const runSeqRef = useRef(0);
374
+ const runConcurrencyRunRef = useRef<
375
+ ((run: GhaConcurrencyRun) => Promise<void>) | null
376
+ >(null);
377
+ useEffect(() => {
378
+ concurrencyRunsRef.current = concurrencyRuns;
379
+ }, [concurrencyRuns]);
291
380
  const consoleEndRef = useRef<HTMLDivElement | null>(null);
292
381
  const monacoRef = useRef<Monaco | null>(null);
293
382
  const monacoModelUrisRef = useRef<Set<string>>(new Set());
@@ -874,6 +963,48 @@ export default function GithubActionsLabModal() {
874
963
  setBulkMenuOpen(false);
875
964
  };
876
965
 
966
+ // ── GitHub Actions environment inputs ─────────────────────────────
967
+ const getEnvironmentRows = (kind: GhaEnvironmentKind) =>
968
+ workspace.environment?.[kind] ?? [];
969
+
970
+ const setEnvironmentRows = (
971
+ kind: GhaEnvironmentKind,
972
+ rows: GithubActionsLabEnvironmentEntry[],
973
+ ) => {
974
+ setWorkspace((prev) => ({
975
+ ...prev,
976
+ environment: {
977
+ ...(prev.environment ?? {}),
978
+ [kind]: rows,
979
+ },
980
+ }));
981
+ };
982
+
983
+ const addEnvironmentEntry = (kind: GhaEnvironmentKind) => {
984
+ const rows = getEnvironmentRows(kind);
985
+ setEnvironmentRows(kind, [...rows, { name: "", value: "", enabled: true }]);
986
+ setRightTab("env");
987
+ };
988
+
989
+ const updateEnvironmentEntry = (
990
+ kind: GhaEnvironmentKind,
991
+ index: number,
992
+ patch: Partial<GithubActionsLabEnvironmentEntry>,
993
+ ) => {
994
+ const rows = getEnvironmentRows(kind).slice();
995
+ const current = rows[index];
996
+ if (!current) return;
997
+ rows[index] = { ...current, ...patch };
998
+ setEnvironmentRows(kind, rows);
999
+ };
1000
+
1001
+ const removeEnvironmentEntry = (kind: GhaEnvironmentKind, index: number) => {
1002
+ setEnvironmentRows(
1003
+ kind,
1004
+ getEnvironmentRows(kind).filter((_, i) => i !== index),
1005
+ );
1006
+ };
1007
+
877
1008
  // ── Save lab as context file ──────────────────────────────────────
878
1009
  const handleSave = useCallback(async () => {
879
1010
  if (!currentQuestion) return;
@@ -978,7 +1109,10 @@ export default function GithubActionsLabModal() {
978
1109
  }, []);
979
1110
 
980
1111
  const runCommand = useCallback(
981
- async (command: string) => {
1112
+ async (run: GhaConcurrencyRun) => {
1113
+ const controller = new AbortController();
1114
+ abortRef.current = controller;
1115
+ activeRunIdRef.current = run.id;
982
1116
  setRunning(true);
983
1117
  setRunError(null);
984
1118
  // Reset the DAG so the user always sees a fresh "pending → running →
@@ -986,12 +1120,25 @@ export default function GithubActionsLabModal() {
986
1120
  // so the visualisation is immediately visible.
987
1121
  setLiveJobs([]);
988
1122
  setRightTab("jobs");
989
- appendConsole({ kind: "input", text: `$ ${command}\n` });
1123
+ appendConsole({ kind: "input", text: `$ ${run.command}\n` });
1124
+
1125
+ // Flip this run's record to running so the Concurrency panel ticks.
1126
+ setConcurrencyRuns((rs) =>
1127
+ rs.map((r) =>
1128
+ r.id === run.id
1129
+ ? { ...r, status: "running", startedAt: Date.now() }
1130
+ : r,
1131
+ ),
1132
+ );
1133
+
1134
+ let exitCode: number | undefined;
1135
+ let didError = false;
1136
+ let errorMsg = "";
990
1137
 
991
1138
  try {
992
1139
  await api.streamGhaCommand(
993
1140
  {
994
- command,
1141
+ command: run.command,
995
1142
  workspace: {
996
1143
  ...workspace,
997
1144
  activeFile,
@@ -1023,6 +1170,7 @@ export default function GithubActionsLabModal() {
1023
1170
  text: `\n[error] ${message.error}\n`,
1024
1171
  });
1025
1172
  } else if (message.type === "complete") {
1173
+ exitCode = message.exitCode;
1026
1174
  appendConsole({
1027
1175
  kind: "info",
1028
1176
  text: `\n[done] exit=${message.exitCode} • ${message.durationMs}ms\n`,
@@ -1031,13 +1179,54 @@ export default function GithubActionsLabModal() {
1031
1179
  setHistoryNonce((n) => n + 1);
1032
1180
  }
1033
1181
  },
1182
+ { signal: controller.signal },
1034
1183
  );
1035
1184
  } catch (err: any) {
1036
- const msg = err?.message || "Failed to start run";
1037
- setRunError(msg);
1038
- appendConsole({ kind: "stderr", text: `\n[error] ${msg}\n` });
1185
+ if (controller.signal.aborted) {
1186
+ // The run was cancelled (either by the user or by a newer run
1187
+ // that supersedes it). Its record was already marked cancelled
1188
+ // — do not surface the AbortError as a normal failure.
1189
+ appendConsole({
1190
+ kind: "info",
1191
+ text: `\n[cancelled] run #${run.seq}\n`,
1192
+ });
1193
+ } else {
1194
+ didError = true;
1195
+ errorMsg = err?.message || "Failed to start run";
1196
+ setRunError(errorMsg);
1197
+ appendConsole({ kind: "stderr", text: `\n[error] ${errorMsg}\n` });
1198
+ }
1039
1199
  } finally {
1200
+ abortRef.current = null;
1201
+ activeRunIdRef.current = null;
1040
1202
  setRunning(false);
1203
+
1204
+ // Finalise this run's record. If the enqueue path already marked
1205
+ // it cancelled (preempted by a newer run) keep that status.
1206
+ setConcurrencyRuns((rs) =>
1207
+ rs.map((r) => {
1208
+ if (r.id !== run.id) return r;
1209
+ if (r.status === "cancelled") return r;
1210
+ return {
1211
+ ...r,
1212
+ status: didError ? "cancelled" : "completed",
1213
+ endedAt: Date.now(),
1214
+ ...(exitCode !== undefined ? { exitCode } : {}),
1215
+ ...(didError ? { cancelReason: errorMsg } : {}),
1216
+ };
1217
+ }),
1218
+ );
1219
+
1220
+ // Drain the queue: pick the oldest pending and start it. We use
1221
+ // the ref to dodge the stale snapshot inside this async closure.
1222
+ const pending = concurrencyRunsRef.current.find(
1223
+ (r) => r.status === "pending",
1224
+ );
1225
+ if (pending) {
1226
+ window.setTimeout(() => {
1227
+ runConcurrencyRunRef.current?.(pending);
1228
+ }, 0);
1229
+ }
1041
1230
  }
1042
1231
  },
1043
1232
  [
@@ -1051,10 +1240,133 @@ export default function GithubActionsLabModal() {
1051
1240
  appendConsole,
1052
1241
  ],
1053
1242
  );
1243
+ useEffect(() => {
1244
+ runConcurrencyRunRef.current = runCommand;
1245
+ }, [runCommand]);
1246
+
1247
+ /**
1248
+ * Enqueue a new run. Evaluates the active workflow's concurrency block
1249
+ * against the current github.* context to decide whether to cancel the
1250
+ * in-flight run, drop older pendings in the same group, or just queue.
1251
+ */
1252
+ const enqueueRun = useCallback(
1253
+ (command: string) => {
1254
+ const parsed = parseConcurrencyBlock(
1255
+ workflow ? workspace.files[workflow] : undefined,
1256
+ );
1257
+ const ctx: GhaConcurrencyContext = {
1258
+ ...concurrencyContext,
1259
+ event_name: event,
1260
+ };
1261
+ const { groupKey, cancelInProgress } = evaluateConcurrencyFor(
1262
+ parsed,
1263
+ ctx,
1264
+ );
1265
+
1266
+ runSeqRef.current += 1;
1267
+ const seq = runSeqRef.current;
1268
+ const newRun: GhaConcurrencyRun = {
1269
+ id: `${seq}-${Math.random().toString(36).slice(2, 8)}`,
1270
+ seq,
1271
+ command,
1272
+ eventName: event,
1273
+ workflowPath: workflow,
1274
+ groupKey,
1275
+ cancelInProgress,
1276
+ context: ctx,
1277
+ status: "pending",
1278
+ };
1054
1279
 
1055
- const handleRun = () => runCommand(buildCommand());
1280
+ // We need to know whether anything was actively running BEFORE we
1281
+ // mutate state — if so, the drain in runCommand's finally will
1282
+ // pick up the new pending; otherwise we kick it ourselves.
1283
+ const wasIdle = !activeRunIdRef.current;
1284
+ let preemptedActive = false;
1285
+
1286
+ setConcurrencyRuns((prev) => {
1287
+ const updated = [...prev];
1288
+
1289
+ // Rule 1: same group + new.cancelInProgress=true cancels the
1290
+ // currently running sibling. Empty groupKey means "no concurrency
1291
+ // block" and never coalesces with anything.
1292
+ if (groupKey) {
1293
+ const activeIdx = updated.findIndex((r) => r.status === "running");
1294
+ if (activeIdx >= 0) {
1295
+ const active = updated[activeIdx];
1296
+ if (active.groupKey === groupKey && newRun.cancelInProgress) {
1297
+ updated[activeIdx] = {
1298
+ ...active,
1299
+ status: "cancelled",
1300
+ endedAt: Date.now(),
1301
+ cancelReason: `superseded by run #${seq}`,
1302
+ };
1303
+ preemptedActive = true;
1304
+ }
1305
+ }
1306
+
1307
+ // Rule 2: GitHub keeps at most ONE pending per group. Any older
1308
+ // pending in the same group gets cancelled by the newcomer.
1309
+ for (let i = 0; i < updated.length; i += 1) {
1310
+ const r = updated[i];
1311
+ if (r.status === "pending" && r.groupKey === groupKey) {
1312
+ updated[i] = {
1313
+ ...r,
1314
+ status: "cancelled",
1315
+ endedAt: Date.now(),
1316
+ cancelReason: `superseded by pending run #${seq}`,
1317
+ };
1318
+ }
1319
+ }
1320
+ }
1321
+
1322
+ updated.push(newRun);
1323
+ return updated;
1324
+ });
1325
+
1326
+ // Abort the in-flight fetch AFTER the state mutation so runCommand's
1327
+ // catch path sees the record already marked cancelled and won't
1328
+ // overwrite the supersede reason.
1329
+ if (preemptedActive) {
1330
+ abortRef.current?.abort();
1331
+ } else if (wasIdle) {
1332
+ window.setTimeout(() => {
1333
+ runConcurrencyRunRef.current?.(newRun);
1334
+ }, 0);
1335
+ }
1336
+ },
1337
+ [workflow, workspace.files, concurrencyContext, event],
1338
+ );
1339
+
1340
+ const handleRun = () => enqueueRun(buildCommand());
1056
1341
  const handleListJobs = () =>
1057
- runCommand(workflow ? `act -W ${workflow} -l` : "act -l");
1342
+ enqueueRun(workflow ? `act -W ${workflow} -l` : "act -l");
1343
+
1344
+ /** Cancel the currently running act invocation. */
1345
+ const stopActiveRun = useCallback(() => {
1346
+ const activeId = activeRunIdRef.current;
1347
+ if (!activeId) return;
1348
+ setConcurrencyRuns((rs) =>
1349
+ rs.map((r) =>
1350
+ r.id === activeId
1351
+ ? {
1352
+ ...r,
1353
+ status: "cancelled",
1354
+ endedAt: Date.now(),
1355
+ cancelReason: "stopped by user",
1356
+ }
1357
+ : r,
1358
+ ),
1359
+ );
1360
+ abortRef.current?.abort();
1361
+ }, []);
1362
+
1363
+ // Keep the Concurrency panel's context.event_name in sync with the
1364
+ // toolbar event picker so the read-only field always reflects reality.
1365
+ useEffect(() => {
1366
+ setConcurrencyContext((prev) =>
1367
+ prev.event_name === event ? prev : { ...prev, event_name: event },
1368
+ );
1369
+ }, [event]);
1058
1370
 
1059
1371
  const clearConsole = () => setConsoleLines([]);
1060
1372
 
@@ -1109,7 +1421,7 @@ export default function GithubActionsLabModal() {
1109
1421
  const cmd = consoleInput.trim();
1110
1422
  if (!cmd || running) return;
1111
1423
  setConsoleInput("");
1112
- runCommand(cmd);
1424
+ enqueueRun(cmd);
1113
1425
  };
1114
1426
 
1115
1427
  // ── Monaco config ─────────────────────────────────────────────────
@@ -1831,7 +2143,7 @@ interface ImportMeta {
1831
2143
  </div>
1832
2144
  </div>
1833
2145
 
1834
- {/* Right pane: tabbed Console / Jobs / History */}
2146
+ {/* Right pane: tabbed Console / Jobs / Env / History */}
1835
2147
  <div
1836
2148
  className="min-h-0 flex flex-col bg-slate-950 overflow-hidden"
1837
2149
  style={{
@@ -1845,6 +2157,8 @@ interface ImportMeta {
1845
2157
  [
1846
2158
  { id: "console", label: "Console" },
1847
2159
  { id: "jobs", label: "Jobs" },
2160
+ { id: "env", label: "Env" },
2161
+ { id: "concurrency", label: "Concurrency" },
1848
2162
  { id: "history", label: "History" },
1849
2163
  ] as const
1850
2164
  ).map((t) => (
@@ -1860,19 +2174,30 @@ interface ImportMeta {
1860
2174
  {t.id === "console" && (
1861
2175
  <Terminal className="inline w-3 h-3 mr-1 -mt-px" />
1862
2176
  )}
2177
+ {t.id === "env" && (
2178
+ <KeyRound className="inline w-3 h-3 mr-1 -mt-px" />
2179
+ )}
2180
+ {t.id === "concurrency" && (
2181
+ <GitBranch className="inline w-3 h-3 mr-1 -mt-px" />
2182
+ )}
1863
2183
  {t.label}
1864
2184
  {t.id === "jobs" && running && (
1865
2185
  <span className="ml-1 inline-block w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
1866
2186
  )}
2187
+ {t.id === "env" && environmentEntryCount > 0 && (
2188
+ <span className="ml-1 rounded bg-amber-500/15 px-1 text-[10px] text-amber-200">
2189
+ {environmentEntryCount}
2190
+ </span>
2191
+ )}
1867
2192
  </button>
1868
2193
  ))}
1869
2194
  </div>
1870
2195
  <div className="flex items-center gap-1">
1871
2196
  {running && (
1872
2197
  <button
1873
- onClick={() => abortRef.current?.abort()}
2198
+ onClick={stopActiveRun}
1874
2199
  className="p-1 rounded text-amber-300 hover:bg-slate-800/60"
1875
- title="Stop (close & restart to fully cancel)"
2200
+ title="Cancel the running act invocation"
1876
2201
  >
1877
2202
  <StopCircle className="w-3.5 h-3.5" />
1878
2203
  </button>
@@ -1964,6 +2289,153 @@ interface ImportMeta {
1964
2289
  />
1965
2290
  )}
1966
2291
 
2292
+ {rightTab === "env" && (
2293
+ <div className="flex-1 min-h-0 overflow-auto p-3 text-xs text-slate-300">
2294
+ <div className="mb-3 rounded-xl border border-amber-500/20 bg-amber-500/5 p-3">
2295
+ <div className="mb-1 flex items-center gap-2 text-sm font-semibold text-amber-200">
2296
+ <KeyRound className="h-4 w-4" />
2297
+ GitHub-style act inputs
2298
+ </div>
2299
+ <p className="leading-5 text-slate-400">
2300
+ These are written to temporary act files for each run:
2301
+ variables become{" "}
2302
+ <span className="text-amber-200">vars.*</span>, secrets
2303
+ become <span className="text-amber-200">secrets.*</span>,
2304
+ and runner env values become shell environment variables.
2305
+ Use fake or local-only values; saved lab snapshots store
2306
+ these values as plain text.
2307
+ </p>
2308
+ </div>
2309
+
2310
+ <div className="space-y-3">
2311
+ {GHA_ENVIRONMENT_SECTIONS.map((section) => {
2312
+ const rows = getEnvironmentRows(section.kind);
2313
+ return (
2314
+ <section
2315
+ key={section.kind}
2316
+ className="rounded-xl border border-slate-800 bg-slate-900/40"
2317
+ >
2318
+ <div className="flex items-start justify-between gap-2 border-b border-slate-800/70 px-3 py-2">
2319
+ <div>
2320
+ <h3 className="text-[11px] font-semibold uppercase tracking-wider text-slate-200">
2321
+ {section.title}
2322
+ </h3>
2323
+ <p className="mt-0.5 text-[11px] leading-4 text-slate-500">
2324
+ {section.help}
2325
+ </p>
2326
+ </div>
2327
+ <button
2328
+ onClick={() => addEnvironmentEntry(section.kind)}
2329
+ className="shrink-0 rounded border border-slate-700 px-2 py-1 text-[11px] text-slate-300 hover:border-amber-500/40 hover:text-amber-200"
2330
+ >
2331
+ {section.addLabel}
2332
+ </button>
2333
+ </div>
2334
+
2335
+ <div className="space-y-2 p-3">
2336
+ {rows.length === 0 ? (
2337
+ <button
2338
+ onClick={() => addEnvironmentEntry(section.kind)}
2339
+ className="w-full rounded-lg border border-dashed border-slate-700 px-3 py-3 text-left text-[11px] text-slate-500 hover:border-amber-500/40 hover:text-amber-200"
2340
+ >
2341
+ No entries yet — click to add one.
2342
+ </button>
2343
+ ) : (
2344
+ rows.map((row, index) => {
2345
+ const nameIsValid =
2346
+ !row.name.trim() ||
2347
+ GHA_ENV_NAME_RE.test(row.name.trim());
2348
+ return (
2349
+ <div
2350
+ key={`${section.kind}-${index}`}
2351
+ className="rounded-lg border border-slate-800 bg-slate-950/60 p-2"
2352
+ >
2353
+ <div className="grid grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)_auto] gap-2">
2354
+ <input
2355
+ value={row.name}
2356
+ onChange={(e) =>
2357
+ updateEnvironmentEntry(
2358
+ section.kind,
2359
+ index,
2360
+ { name: e.target.value },
2361
+ )
2362
+ }
2363
+ placeholder={section.namePlaceholder}
2364
+ className={`min-w-0 rounded border bg-slate-900 px-2 py-1.5 font-mono text-[11px] text-slate-100 outline-none placeholder:text-slate-600 ${
2365
+ nameIsValid
2366
+ ? "border-slate-700 focus:border-amber-500/60"
2367
+ : "border-red-500/60 focus:border-red-400"
2368
+ }`}
2369
+ />
2370
+ <input
2371
+ type={
2372
+ section.kind === "secrets"
2373
+ ? "password"
2374
+ : "text"
2375
+ }
2376
+ value={row.value}
2377
+ onChange={(e) =>
2378
+ updateEnvironmentEntry(
2379
+ section.kind,
2380
+ index,
2381
+ { value: e.target.value },
2382
+ )
2383
+ }
2384
+ placeholder={section.valuePlaceholder}
2385
+ className="min-w-0 rounded border border-slate-700 bg-slate-900 px-2 py-1.5 font-mono text-[11px] text-slate-100 outline-none placeholder:text-slate-600 focus:border-amber-500/60"
2386
+ />
2387
+ <button
2388
+ onClick={() =>
2389
+ removeEnvironmentEntry(
2390
+ section.kind,
2391
+ index,
2392
+ )
2393
+ }
2394
+ className="rounded px-2 text-slate-500 hover:bg-red-500/10 hover:text-red-300"
2395
+ title="Remove entry"
2396
+ >
2397
+ <Trash2 className="h-3.5 w-3.5" />
2398
+ </button>
2399
+ </div>
2400
+ {!nameIsValid && (
2401
+ <div className="mt-1 text-[10px] text-red-300">
2402
+ Use letters, numbers, and underscores; do
2403
+ not start with a number.
2404
+ </div>
2405
+ )}
2406
+ </div>
2407
+ );
2408
+ })
2409
+ )}
2410
+ </div>
2411
+ </section>
2412
+ );
2413
+ })}
2414
+ </div>
2415
+ </div>
2416
+ )}
2417
+
2418
+ {rightTab === "concurrency" && (
2419
+ <GhaConcurrencyPanel
2420
+ parsed={parseConcurrencyBlock(
2421
+ workflow ? workspace.files[workflow] : undefined,
2422
+ )}
2423
+ workflowPath={workflow}
2424
+ runs={concurrencyRuns}
2425
+ context={concurrencyContext}
2426
+ onContextChange={setConcurrencyContext}
2427
+ onClearRuns={() => {
2428
+ // Only clear finished records; keep the active/pending
2429
+ // ones because the queue depends on them.
2430
+ setConcurrencyRuns((rs) =>
2431
+ rs.filter(
2432
+ (r) => r.status === "running" || r.status === "pending",
2433
+ ),
2434
+ );
2435
+ }}
2436
+ />
2437
+ )}
2438
+
1967
2439
  {rightTab === "history" && (
1968
2440
  <GhaHistoryPanel
1969
2441
  {...(currentQuestion ? { questionId: currentQuestion.id } : {})}
@@ -335,7 +335,12 @@ export default function WorkspaceSwitcher() {
335
335
  <>
336
336
  <div className="space-y-1 max-h-48 overflow-y-auto">
337
337
  <button
338
- onClick={() => runExport(folderPicker.ws)}
338
+ onClick={() =>
339
+ runExport(
340
+ folderPicker.ws,
341
+ folderPicker.ws.driveConfig?.folderId,
342
+ )
343
+ }
339
344
  className="w-full text-left px-3 py-2 rounded-lg text-xs text-slate-300 hover:bg-slate-800 flex items-center gap-2"
340
345
  >
341
346
  <FolderOpen size={13} className="text-slate-500 shrink-0" />