@tonyclaw/agent-inspector 3.0.13 → 3.0.15

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.
Files changed (46) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-BRV9ztWd.js → CompareDrawer-UaIxykqz.js} +1 -1
  3. package/.output/public/assets/ProxyViewerContainer-DlpfcX56.js +106 -0
  4. package/.output/public/assets/{ReplayDialog-B4b6aD0q.js → ReplayDialog-BFFomU1m.js} +1 -1
  5. package/.output/public/assets/{RequestAnatomy-ybH1l2Bp.js → RequestAnatomy-DKQJBt_c.js} +1 -1
  6. package/.output/public/assets/{ResponseView-DpPKgH-Y.js → ResponseView-D7yotmne.js} +2 -2
  7. package/.output/public/assets/{StreamingChunkSequence-9f_rtMzz.js → StreamingChunkSequence-Da-6WNFG.js} +1 -1
  8. package/.output/public/assets/{_sessionId-gEmO-b2f.js → _sessionId-ByTXLS-w.js} +1 -1
  9. package/.output/public/assets/{index-JgHKqbP1.js → index-BrM8t5mc.js} +1 -1
  10. package/.output/public/assets/index-CGpPRty8.js +1 -0
  11. package/.output/public/assets/index-HRfrFVYw.css +1 -0
  12. package/.output/public/assets/{json-viewer-DFpIPmLo.js → json-viewer-DyjXQdVu.js} +1 -1
  13. package/.output/public/assets/{main-YvdO47OP.js → main-Zv_gjZMb.js} +2 -2
  14. package/.output/server/_libs/lucide-react.mjs +216 -202
  15. package/.output/server/{_sessionId-wSX-l7m2.mjs → _sessionId-GcFhWT0i.mjs} +3 -3
  16. package/.output/server/_ssr/{CompareDrawer-R4d8V0Rv.mjs → CompareDrawer-BV7dw1TN.mjs} +3 -3
  17. package/.output/server/_ssr/{ProxyViewerContainer-CDtJhGg8.mjs → ProxyViewerContainer-CcgfvaS4.mjs} +931 -393
  18. package/.output/server/_ssr/{ReplayDialog-BvmLERpW.mjs → ReplayDialog-B7sBKxIO.mjs} +4 -4
  19. package/.output/server/_ssr/{RequestAnatomy-CH-oO6mY.mjs → RequestAnatomy-s_9Veje2.mjs} +3 -3
  20. package/.output/server/_ssr/{ResponseView-D7shbJw0.mjs → ResponseView-DsOFPCvF.mjs} +3 -3
  21. package/.output/server/_ssr/{StreamingChunkSequence-DB2YHGJj.mjs → StreamingChunkSequence-CyA1XtiF.mjs} +3 -3
  22. package/.output/server/_ssr/{index-CS4QLxuv.mjs → index-CDDRt201.mjs} +2 -2
  23. package/.output/server/_ssr/index.mjs +2 -2
  24. package/.output/server/_ssr/{json-viewer-HxOn73kS.mjs → json-viewer-BFV-xtRM.mjs} +3 -3
  25. package/.output/server/_ssr/{router-l4O3mQ4T.mjs → router-B2Q7vHPM.mjs} +321 -121
  26. package/.output/server/_tanstack-start-manifest_v-Dm_GmuRz.mjs +4 -0
  27. package/.output/server/index.mjs +73 -73
  28. package/README.md +24 -0
  29. package/package.json +1 -1
  30. package/src/components/ProxyViewer.tsx +470 -197
  31. package/src/components/ProxyViewerContainer.tsx +145 -17
  32. package/src/components/ecosystem/AgentLabDialog.tsx +221 -10
  33. package/src/components/proxy-viewer/ToolTraceEvents.tsx +36 -10
  34. package/src/components/proxy-viewer/TurnGroup.tsx +4 -1
  35. package/src/lib/ecosystemContract.ts +21 -1
  36. package/src/proxy/ecosystemRegistry.ts +52 -0
  37. package/src/proxy/ecosystemTasks.ts +95 -1
  38. package/src/proxy/formats/openai/codexMetadata.ts +61 -0
  39. package/src/proxy/formats/openai/handler.ts +8 -2
  40. package/src/routes/api/ecosystem.packages.ts +2 -1
  41. package/src/routes/api/ecosystem.recipes.$recipeId.run.ts +16 -0
  42. package/styles/globals.css +54 -0
  43. package/.output/public/assets/ProxyViewerContainer-BVsv8OFb.js +0 -106
  44. package/.output/public/assets/index-BIgsQvaF.css +0 -1
  45. package/.output/public/assets/index-CF3MPYb3.js +0 -1
  46. package/.output/server/_tanstack-start-manifest_v-Dckf_NHV.mjs +0 -4
@@ -2,7 +2,11 @@ import { useState, useEffect, useCallback, useRef, useMemo, useTransition, type
2
2
  import { z } from "zod";
3
3
  import { CapturedLogSchema, type CapturedLog } from "../contracts";
4
4
  import { fetchJson, fetchJsonWithTimeout } from "../lib/apiClient";
5
- import { InspectorGroupsListResponseSchema, type InspectorGroup } from "../lib/groupContract";
5
+ import {
6
+ DeleteInspectorGroupsResponseSchema,
7
+ InspectorGroupsListResponseSchema,
8
+ type InspectorGroup,
9
+ } from "../lib/groupContract";
6
10
  import { ImportLogsResponseSchema, type ImportLogsResponse } from "../lib/logImportContract";
7
11
  import { useStripConfig } from "../lib/useStripConfig";
8
12
  import { OnboardingBanner } from "./OnboardingBanner";
@@ -58,6 +62,11 @@ const DeleteLogsResponseSchema = z.object({
58
62
  sessionArchiveLogsDeleted: z.number().int().nonnegative().optional(),
59
63
  });
60
64
 
65
+ type GroupCleanupResult = {
66
+ deleted: number;
67
+ ok: boolean;
68
+ };
69
+
61
70
  type SessionPageRequest =
62
71
  | { kind: "newest" }
63
72
  | { kind: "oldest" }
@@ -190,6 +199,50 @@ function fetchSessionLogsPage(
190
199
  );
191
200
  }
192
201
 
202
+ type SessionLogIdCollection = {
203
+ ids: number[];
204
+ truncated: boolean;
205
+ };
206
+
207
+ async function collectSessionLogIds(
208
+ sessionId: string,
209
+ onProgress: (logCount: number, pageCount: number) => void,
210
+ ): Promise<SessionLogIdCollection> {
211
+ const ids = new Set<number>();
212
+ let page = await fetchSessionLogsPage(sessionId, { kind: "oldest" }, SESSION_RELOAD_PAGE_LIMIT);
213
+ for (const log of page.logs) {
214
+ ids.add(log.id);
215
+ }
216
+ onProgress(ids.size, 1);
217
+
218
+ let loadedPages = 1;
219
+ while (page.hasNewer && page.newestLogId !== null && loadedPages < MAX_SESSION_RELOAD_PAGES) {
220
+ page = await fetchSessionLogsPage(
221
+ sessionId,
222
+ { kind: "newer", afterLogId: page.newestLogId },
223
+ SESSION_RELOAD_PAGE_LIMIT,
224
+ );
225
+ for (const log of page.logs) {
226
+ ids.add(log.id);
227
+ }
228
+ loadedPages += 1;
229
+ onProgress(ids.size, loadedPages);
230
+ }
231
+
232
+ return { ids: [...ids], truncated: page.hasNewer };
233
+ }
234
+
235
+ async function deleteAllGroupsForFullHistoryDelete(): Promise<GroupCleanupResult> {
236
+ try {
237
+ const result = await fetchJson("/api/groups", DeleteInspectorGroupsResponseSchema, {
238
+ method: "DELETE",
239
+ });
240
+ return { deleted: result.deleted, ok: true };
241
+ } catch {
242
+ return { deleted: 0, ok: false };
243
+ }
244
+ }
245
+
193
246
  export function mergeLogsById(
194
247
  previousLogs: readonly CapturedLog[],
195
248
  incomingLogs: readonly CapturedLog[],
@@ -685,27 +738,88 @@ export function ProxyViewerContainer({
685
738
  [showToast],
686
739
  );
687
740
 
688
- const handleDeleteCurrentView = useCallback(
689
- async (ids: number[]): Promise<void> => {
690
- if (ids.length === 0) return;
691
- const persistedIds = ids.filter((id) => Number.isInteger(id) && id >= 0);
741
+ const handleDeleteCurrentView = useCallback(async (): Promise<void> => {
742
+ const deleteRequestId = sessionPageRequestIdRef.current + 1;
743
+ sessionPageRequestIdRef.current = deleteRequestId;
744
+ setSessionPageLoading(true);
745
+ stopSessionLoadProgress();
746
+ dismissToast();
747
+
748
+ try {
749
+ if (selectedSession === "__all__") {
750
+ startSessionLoadProgress("Deleting stored history", deleteRequestId);
751
+ const result = await fetchJson("/api/logs", DeleteLogsResponseSchema, {
752
+ method: "DELETE",
753
+ });
754
+ const groupCleanup = await deleteAllGroupsForFullHistoryDelete();
755
+ if (sessionPageRequestIdRef.current !== deleteRequestId) return;
756
+ setAllLogs([]);
757
+ logIndexRef.current.clear();
758
+ setLogPage(null);
759
+ setKnownSessions([]);
760
+ setSessionMemberships([]);
761
+ setSessionPageLoading(false);
762
+ stopSessionLoadProgress();
763
+ const groupMessage = groupCleanup.ok
764
+ ? ` Cleared ${groupCleanup.deleted.toString()} group${
765
+ groupCleanup.deleted === 1 ? "" : "s"
766
+ }.`
767
+ : " Group cleanup failed; open Groups to retry Clear.";
768
+ showToast({
769
+ message: `Deleted ${result.cleared.toString()} stored log${
770
+ result.cleared === 1 ? "" : "s"
771
+ } across all sessions.${groupMessage}`,
772
+ tone: groupCleanup.ok ? "success" : "error",
773
+ });
774
+ return;
775
+ }
776
+
777
+ const sessionId = selectedSession;
778
+ startSessionLoadProgress("Deleting session history", deleteRequestId);
779
+ const collected = await collectSessionLogIds(sessionId, (logCount, pageCount) => {
780
+ updateSessionLoadProgress("Deleting session history", deleteRequestId, logCount, pageCount);
781
+ });
782
+ const idSet = new Set(collected.ids);
783
+ for (const log of allLogs) {
784
+ if (log.sessionId === sessionId) {
785
+ idSet.add(log.id);
786
+ }
787
+ }
788
+ const persistedIds = [...idSet].filter((id) => Number.isInteger(id) && id >= 0);
692
789
  if (persistedIds.length === 0) {
790
+ if (sessionPageRequestIdRef.current !== deleteRequestId) return;
791
+ setAllLogs((prev) => {
792
+ const remaining = prev.filter((log) => log.sessionId !== sessionId);
793
+ logIndexRef.current = buildLogIndex(remaining);
794
+ return remaining;
795
+ });
796
+ setLogPage((prev) =>
797
+ prev === null
798
+ ? null
799
+ : {
800
+ ...prev,
801
+ logs: prev.logs.filter((log) => log.sessionId !== sessionId),
802
+ },
803
+ );
804
+ setKnownSessions((prev) => prev.filter((session) => session !== sessionId));
805
+ setSessionPageLoading(false);
806
+ stopSessionLoadProgress();
693
807
  showToast({
694
- message: "No persisted log records were found for this view.",
695
- tone: "error",
808
+ message: "No stored log records were found for this session.",
809
+ tone: "success",
696
810
  });
697
811
  return;
698
812
  }
699
- const idSet = new Set(persistedIds);
700
- const removed = allLogs.filter((log) => idSet.has(log.id));
813
+
701
814
  const result = await fetchJson("/api/logs", DeleteLogsResponseSchema, {
702
815
  method: "DELETE",
703
816
  headers: { "content-type": "application/json" },
704
817
  body: JSON.stringify({ ids: persistedIds }),
705
818
  });
706
819
 
820
+ if (sessionPageRequestIdRef.current !== deleteRequestId) return;
707
821
  setAllLogs((prev) => {
708
- const remaining = prev.filter((log) => !idSet.has(log.id));
822
+ const remaining = prev.filter((log) => log.sessionId !== sessionId);
709
823
  logIndexRef.current = buildLogIndex(remaining);
710
824
  return remaining;
711
825
  });
@@ -714,19 +828,33 @@ export function ProxyViewerContainer({
714
828
  ? null
715
829
  : {
716
830
  ...prev,
717
- logs: prev.logs.filter((log) => !idSet.has(log.id)),
831
+ logs: prev.logs.filter((log) => log.sessionId !== sessionId),
718
832
  },
719
833
  );
720
- setKnownSessions((prev) => mergeSessionIds(prev, extractSessions(removed)));
834
+ setKnownSessions((prev) => prev.filter((session) => session !== sessionId));
835
+ setSessionPageLoading(false);
836
+ stopSessionLoadProgress();
721
837
  showToast({
722
838
  message: `Deleted ${result.cleared.toString()} stored log${
723
839
  result.cleared === 1 ? "" : "s"
724
- } from the current view.`,
725
- tone: "success",
840
+ } for this session${collected.truncated ? ". More history may remain." : "."}`,
841
+ tone: collected.truncated ? "error" : "success",
726
842
  });
727
- },
728
- [allLogs, showToast],
729
- );
843
+ } finally {
844
+ if (sessionPageRequestIdRef.current === deleteRequestId) {
845
+ setSessionPageLoading(false);
846
+ stopSessionLoadProgress();
847
+ }
848
+ }
849
+ }, [
850
+ allLogs,
851
+ dismissToast,
852
+ selectedSession,
853
+ showToast,
854
+ startSessionLoadProgress,
855
+ stopSessionLoadProgress,
856
+ updateSessionLoadProgress,
857
+ ]);
730
858
 
731
859
  const handleReloadSession = useCallback(() => {
732
860
  if (initialSessionId !== undefined) {
@@ -6,6 +6,7 @@ import {
6
6
  Copy,
7
7
  Download,
8
8
  FlaskConical,
9
+ History,
9
10
  Loader2,
10
11
  Network,
11
12
  PackageCheck,
@@ -18,8 +19,10 @@ import useSWR from "swr";
18
19
  import {
19
20
  EcosystemPackagesResponseSchema,
20
21
  EcosystemTaskResponseSchema,
22
+ EcosystemTasksResponseSchema,
21
23
  type EcosystemPackage,
22
24
  type EcosystemPackageState,
25
+ type EcosystemRecipe,
23
26
  type EcosystemRunnerPreset,
24
27
  type EcosystemTask,
25
28
  type EcosystemTaskAction,
@@ -101,6 +104,15 @@ async function fetchEcosystemTask(url: string) {
101
104
  );
102
105
  }
103
106
 
107
+ async function fetchEcosystemTasks(url: string) {
108
+ return fetchJson(
109
+ url,
110
+ EcosystemTasksResponseSchema,
111
+ undefined,
112
+ (response) => `Failed to load ecosystem tasks: ${String(response.status)}`,
113
+ );
114
+ }
115
+
104
116
  async function startEcosystemTask(
105
117
  packageId: string,
106
118
  action: EcosystemTaskAction,
@@ -114,6 +126,16 @@ async function startEcosystemTask(
114
126
  return response.task;
115
127
  }
116
128
 
129
+ async function startEcosystemRecipe(recipeId: string): Promise<EcosystemTask> {
130
+ const response = await fetchJson(
131
+ `/api/ecosystem/recipes/${encodeURIComponent(recipeId)}/run`,
132
+ EcosystemTaskResponseSchema,
133
+ { method: "POST" },
134
+ (result) => `Failed to start ecosystem recipe: ${String(result.status)}`,
135
+ );
136
+ return response.task;
137
+ }
138
+
117
139
  function stateLabel(state: EcosystemPackageState): string {
118
140
  switch (state) {
119
141
  case "installed":
@@ -164,6 +186,8 @@ function taskActionLabel(action: EcosystemTaskAction): string {
164
186
  return "Help";
165
187
  case "runner-presets":
166
188
  return "Presets";
189
+ case "recipe":
190
+ return "Recipe";
167
191
  }
168
192
  }
169
193
 
@@ -180,6 +204,18 @@ function taskStatusLabel(task: EcosystemTask): string {
180
204
  }
181
205
  }
182
206
 
207
+ function taskStatusClassName(status: EcosystemTask["status"]): string {
208
+ switch (status) {
209
+ case "queued":
210
+ case "running":
211
+ return "border-cyan-300/20 bg-cyan-300/8 text-cyan-100";
212
+ case "succeeded":
213
+ return "border-emerald-400/20 bg-emerald-400/8 text-emerald-100";
214
+ case "failed":
215
+ return "border-red-400/20 bg-red-400/8 text-red-100";
216
+ }
217
+ }
218
+
183
219
  function packagePrimaryAction(pkg: EcosystemPackage): EcosystemTaskAction | null {
184
220
  switch (pkg.state) {
185
221
  case "available":
@@ -292,15 +328,7 @@ function TaskConsole({ task }: { task: EcosystemTask | null }): JSX.Element | nu
292
328
  <div className="break-all font-mono text-[11px] text-white/35">{task.command}</div>
293
329
  </div>
294
330
  </div>
295
- <Badge
296
- variant="outline"
297
- className={cn(
298
- "shrink-0",
299
- task.status === "failed"
300
- ? "border-red-400/20 bg-red-400/8 text-red-100"
301
- : "border-cyan-300/20 bg-cyan-300/8 text-cyan-100",
302
- )}
303
- >
331
+ <Badge variant="outline" className={cn("shrink-0", taskStatusClassName(task.status))}>
304
332
  {task.status}
305
333
  </Badge>
306
334
  </div>
@@ -372,10 +400,110 @@ function RunnerPresetCard({ preset }: { preset: EcosystemRunnerPreset }): JSX.El
372
400
  );
373
401
  }
374
402
 
403
+ function RecipeCard({
404
+ recipe,
405
+ copied,
406
+ running,
407
+ onCopy,
408
+ onRun,
409
+ }: {
410
+ recipe: EcosystemRecipe;
411
+ copied: boolean;
412
+ running: boolean;
413
+ onCopy: (recipe: EcosystemRecipe) => void;
414
+ onRun: (recipe: EcosystemRecipe) => void;
415
+ }): JSX.Element {
416
+ return (
417
+ <div className="rounded-lg bg-white/[0.025] p-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.055)]">
418
+ <div className="flex flex-wrap items-start justify-between gap-3">
419
+ <div className="min-w-0">
420
+ <div className="flex min-w-0 items-start gap-2">
421
+ <FlaskConical className="size-4 shrink-0 text-cyan-200/75" />
422
+ <div className="text-sm font-semibold leading-snug">{recipe.title}</div>
423
+ </div>
424
+ <div className="mt-1 text-xs leading-5 text-muted-foreground">{recipe.description}</div>
425
+ </div>
426
+ <Badge variant="outline" className="border-white/10 bg-white/[0.04] text-white/55">
427
+ {recipe.runnable ? recipe.stage : "Template"}
428
+ </Badge>
429
+ </div>
430
+ <div className="mt-3 rounded-md bg-black/20 p-2 font-mono text-[11px] leading-5 text-white/50 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.045)]">
431
+ {recipe.command}
432
+ </div>
433
+ <div className="mt-3 flex items-center justify-between gap-2">
434
+ <span className="rounded bg-white/[0.035] px-2 py-1 font-mono text-[11px] text-white/35">
435
+ {recipe.packageId}
436
+ </span>
437
+ <div className="flex items-center gap-2">
438
+ <Button type="button" size="sm" variant="outline" onClick={() => onCopy(recipe)}>
439
+ {copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
440
+ {copied ? "Copied" : "Copy"}
441
+ </Button>
442
+ {recipe.runnable && (
443
+ <Button
444
+ type="button"
445
+ size="sm"
446
+ variant="secondary"
447
+ disabled={running}
448
+ onClick={() => onRun(recipe)}
449
+ >
450
+ {running ? (
451
+ <Loader2 className="size-3.5 animate-spin" />
452
+ ) : (
453
+ <Rocket className="size-3.5" />
454
+ )}
455
+ {running ? "Running" : "Run"}
456
+ </Button>
457
+ )}
458
+ </div>
459
+ </div>
460
+ </div>
461
+ );
462
+ }
463
+
464
+ function RecentTaskRow({
465
+ task,
466
+ selected,
467
+ onSelect,
468
+ }: {
469
+ task: EcosystemTask;
470
+ selected: boolean;
471
+ onSelect: (task: EcosystemTask) => void;
472
+ }): JSX.Element {
473
+ const label = task.recipeId ?? taskActionLabel(task.action);
474
+ return (
475
+ <div className="flex items-center justify-between gap-3 rounded-md bg-white/[0.025] px-3 py-2 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.045)]">
476
+ <div className="min-w-0">
477
+ <div className="flex min-w-0 items-center gap-2">
478
+ <History className="size-3.5 shrink-0 text-white/40" />
479
+ <div className="truncate text-xs font-semibold text-white/75">{label}</div>
480
+ <Badge variant="outline" className={cn("shrink-0", taskStatusClassName(task.status))}>
481
+ {task.status}
482
+ </Badge>
483
+ </div>
484
+ <div className="mt-1 flex min-w-0 flex-wrap items-center gap-2 font-mono text-[10px] text-white/35">
485
+ <span>{task.packageId}</span>
486
+ <span>{new Date(task.createdAt).toLocaleTimeString()}</span>
487
+ {task.exitCode !== null && <span>exit {String(task.exitCode)}</span>}
488
+ </div>
489
+ </div>
490
+ <Button
491
+ type="button"
492
+ size="sm"
493
+ variant={selected ? "secondary" : "outline"}
494
+ onClick={() => onSelect(task)}
495
+ >
496
+ {selected ? "Open" : "View"}
497
+ </Button>
498
+ </div>
499
+ );
500
+ }
501
+
375
502
  export function AgentLabDialog({ currentSessionId, logCount }: AgentLabDialogProps): JSX.Element {
376
503
  const [open, setOpen] = useState(false);
377
504
  const [copied, setCopied] = useState(false);
378
505
  const [copiedPresets, setCopiedPresets] = useState(false);
506
+ const [copiedRecipeId, setCopiedRecipeId] = useState<string | null>(null);
379
507
  const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
380
508
  const [taskError, setTaskError] = useState<string | null>(null);
381
509
  const response = useSWR("/api/ecosystem/packages", fetchEcosystemPackages, {
@@ -395,8 +523,14 @@ export function AgentLabDialog({ currentSessionId, logCount }: AgentLabDialogPro
395
523
  revalidateOnFocus: false,
396
524
  },
397
525
  );
526
+ const tasksResponse = useSWR(open ? "/api/ecosystem/tasks" : null, fetchEcosystemTasks, {
527
+ refreshInterval: 2500,
528
+ revalidateOnFocus: false,
529
+ });
398
530
  const packages = response.data?.packages ?? [];
399
531
  const runnerPresets = response.data?.runnerPresets ?? [];
532
+ const recipes = response.data?.recipes ?? [];
533
+ const recentTasks = (tasksResponse.data?.tasks ?? []).slice(0, 6);
400
534
  const installedCount = useMemo(
401
535
  () =>
402
536
  packages.filter((pkg) => pkg.state === "installed" || pkg.state === "update-available")
@@ -418,14 +552,33 @@ export function AgentLabDialog({ currentSessionId, logCount }: AgentLabDialogPro
418
552
  window.setTimeout(() => setCopiedPresets(false), 1200);
419
553
  });
420
554
  };
555
+ const copyRecipeCommand = (recipe: EcosystemRecipe): void => {
556
+ void copyTextToClipboard(recipe.command).then(() => {
557
+ setCopiedRecipeId(recipe.id);
558
+ window.setTimeout(() => setCopiedRecipeId(null), 1200);
559
+ });
560
+ };
561
+ const runRecipe = (recipe: EcosystemRecipe): void => {
562
+ setTaskError(null);
563
+ void startEcosystemRecipe(recipe.id)
564
+ .then((task) => {
565
+ setActiveTaskId(task.id);
566
+ void activeTaskResponse.mutate({ task }, { revalidate: true });
567
+ void tasksResponse.mutate();
568
+ })
569
+ .catch((error: unknown) => {
570
+ setTaskError(error instanceof Error ? error.message : "Failed to start ecosystem recipe");
571
+ });
572
+ };
421
573
 
422
574
  const activeTask = activeTaskResponse.data?.task ?? null;
423
575
 
424
576
  useEffect(() => {
425
577
  if (activeTask === null) return;
578
+ void tasksResponse.mutate();
426
579
  if (activeTask.status !== "succeeded") return;
427
580
  void response.mutate();
428
- }, [activeTask, response]);
581
+ }, [activeTask, response, tasksResponse]);
429
582
 
430
583
  const handleStartTask = (packageId: string, action: EcosystemTaskAction): void => {
431
584
  setTaskError(null);
@@ -433,6 +586,7 @@ export function AgentLabDialog({ currentSessionId, logCount }: AgentLabDialogPro
433
586
  .then((task) => {
434
587
  setActiveTaskId(task.id);
435
588
  void activeTaskResponse.mutate({ task }, { revalidate: true });
589
+ void tasksResponse.mutate();
436
590
  })
437
591
  .catch((error: unknown) => {
438
592
  setTaskError(error instanceof Error ? error.message : "Failed to start ecosystem task");
@@ -514,6 +668,40 @@ export function AgentLabDialog({ currentSessionId, logCount }: AgentLabDialogPro
514
668
  </section>
515
669
  </div>
516
670
 
671
+ <section className="mt-3">
672
+ <div className="mb-2 flex items-center justify-between gap-3">
673
+ <div className="text-sm font-semibold">Lab recipes</div>
674
+ <div className="hidden items-center gap-1 font-mono text-[11px] text-muted-foreground sm:flex">
675
+ <Sparkles className="size-3.5" />
676
+ copy-ready workflows
677
+ </div>
678
+ </div>
679
+ <div className="grid gap-3 md:grid-cols-2">
680
+ {recipes.map((recipe) => (
681
+ <RecipeCard
682
+ key={recipe.id}
683
+ recipe={recipe}
684
+ copied={copiedRecipeId === recipe.id}
685
+ running={
686
+ activeTask !== null &&
687
+ activeTask.recipeId === recipe.id &&
688
+ (activeTask.status === "queued" || activeTask.status === "running")
689
+ }
690
+ onCopy={copyRecipeCommand}
691
+ onRun={runRecipe}
692
+ />
693
+ ))}
694
+ {response.isLoading &&
695
+ recipes.length === 0 &&
696
+ [0, 1].map((index) => (
697
+ <div
698
+ key={index}
699
+ className="h-40 animate-pulse rounded-lg bg-white/[0.025] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.055)]"
700
+ />
701
+ ))}
702
+ </div>
703
+ </section>
704
+
517
705
  <section className="mt-3">
518
706
  <div className="mb-2 flex items-center justify-between gap-3">
519
707
  <div className="text-sm font-semibold">Runner presets</div>
@@ -574,6 +762,29 @@ export function AgentLabDialog({ currentSessionId, logCount }: AgentLabDialogPro
574
762
  {taskError}
575
763
  </div>
576
764
  )}
765
+ {recentTasks.length > 0 && (
766
+ <section className="mt-3">
767
+ <div className="mb-2 flex items-center justify-between gap-3">
768
+ <div className="text-sm font-semibold">Recent Lab runs</div>
769
+ <div className="hidden font-mono text-[11px] text-muted-foreground sm:block">
770
+ last {String(recentTasks.length)}
771
+ </div>
772
+ </div>
773
+ <div className="grid gap-2 md:grid-cols-2">
774
+ {recentTasks.map((task) => (
775
+ <RecentTaskRow
776
+ key={task.id}
777
+ task={task}
778
+ selected={activeTaskId === task.id}
779
+ onSelect={(selectedTask) => {
780
+ setActiveTaskId(selectedTask.id);
781
+ void activeTaskResponse.mutate({ task: selectedTask }, { revalidate: true });
782
+ }}
783
+ />
784
+ ))}
785
+ </div>
786
+ </section>
787
+ )}
577
788
  <TaskConsole task={activeTask} />
578
789
  </div>
579
790
  </DialogContent>
@@ -1,18 +1,21 @@
1
1
  import { type JSX } from "react";
2
- import { Wrench } from "lucide-react";
2
+ import { AlertTriangle, Wrench } from "lucide-react";
3
3
  import type { ToolTraceEvent } from "./viewerState";
4
4
  import { dispatchLogFocusRequest } from "./logFocus";
5
5
 
6
6
  type ToolTraceEventsProps = {
7
7
  events: ToolTraceEvent[];
8
+ warnings?: readonly string[];
8
9
  };
9
10
 
11
+ const VISIBLE_TOOL_LIMIT = 4;
12
+
10
13
  function ToolTraceEventChip({ event }: { event: ToolTraceEvent }): JSX.Element {
11
14
  return (
12
15
  <button
13
16
  type="button"
14
17
  key={event.id}
15
- className="inline-flex min-w-0 items-center gap-1.5 rounded-md bg-white/[0.035] px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-sky-400/[0.08] hover:text-sky-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
18
+ className="inline-flex h-6 max-w-[11rem] min-w-0 items-center gap-1 rounded-md bg-white/[0.028] px-1.5 text-[11px] text-muted-foreground transition-colors hover:bg-sky-400/[0.08] hover:text-sky-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
16
19
  onClick={() =>
17
20
  dispatchLogFocusRequest({
18
21
  logId: event.logId,
@@ -21,21 +24,44 @@ function ToolTraceEventChip({ event }: { event: ToolTraceEvent }): JSX.Element {
21
24
  })
22
25
  }
23
26
  aria-label={`Open response tool call ${event.name}`}
24
- title={`Open ${event.name} in Response`}
25
27
  >
26
- <Wrench className="size-3.5 shrink-0 text-sky-400/70" />
28
+ <Wrench className="size-3 shrink-0 text-sky-400/65" />
27
29
  <span className="min-w-0 truncate font-mono font-semibold">{event.name}</span>
28
30
  </button>
29
31
  );
30
32
  }
31
33
 
32
- export function ToolTraceEvents({ events }: ToolTraceEventsProps): JSX.Element | null {
33
- if (events.length === 0) return null;
34
+ export function ToolTraceEvents({
35
+ events,
36
+ warnings = [],
37
+ }: ToolTraceEventsProps): JSX.Element | null {
38
+ if (events.length === 0 && warnings.length === 0) return null;
39
+ const visibleEvents = events.slice(0, VISIBLE_TOOL_LIMIT);
40
+ const hiddenCount = Math.max(0, events.length - visibleEvents.length);
34
41
  return (
35
- <div className="mx-3 mb-2 flex flex-wrap gap-1.5">
36
- {events.map((event) => (
37
- <ToolTraceEventChip key={event.id} event={event} />
38
- ))}
42
+ <div className="mx-3 mb-2 flex min-w-0 items-center gap-1.5 overflow-hidden rounded-md bg-black/[0.10] px-2 py-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.025)]">
43
+ {events.length > 0 && (
44
+ <span className="inline-flex h-6 shrink-0 items-center gap-1 rounded-md px-1.5 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-sky-300/70">
45
+ <Wrench className="size-3" />
46
+ {events.length}
47
+ </span>
48
+ )}
49
+ {warnings.length > 0 && (
50
+ <span className="inline-flex h-6 shrink-0 items-center gap-1 rounded-md border border-amber-300/20 bg-amber-300/[0.08] px-1.5 font-mono text-[10px] font-semibold text-amber-100">
51
+ <AlertTriangle className="size-3" />
52
+ {warnings.length}
53
+ </span>
54
+ )}
55
+ <div className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
56
+ {visibleEvents.map((event) => (
57
+ <ToolTraceEventChip key={event.id} event={event} />
58
+ ))}
59
+ {hiddenCount > 0 && (
60
+ <span className="inline-flex h-6 shrink-0 items-center rounded-md bg-white/[0.025] px-1.5 font-mono text-[11px] font-semibold text-muted-foreground">
61
+ +{hiddenCount}
62
+ </span>
63
+ )}
64
+ </div>
39
65
  </div>
40
66
  );
41
67
  }
@@ -361,7 +361,10 @@ export const TurnGroup = memo(function TurnGroup({
361
361
  comparisonPredecessors.has(log.id) ? onCompareWithPrevious : undefined
362
362
  }
363
363
  />
364
- <ToolTraceEvents events={toolEventsByLogId.get(log.id) ?? []} />
364
+ <ToolTraceEvents
365
+ events={toolEventsByLogId.get(log.id) ?? []}
366
+ warnings={log.warnings ?? []}
367
+ />
365
368
  </div>
366
369
  </div>
367
370
  );
@@ -35,13 +35,31 @@ export const EcosystemRunnerPresetSchema = z.object({
35
35
  agentsDir: z.string(),
36
36
  });
37
37
 
38
+ export const EcosystemRecipeSchema = z.object({
39
+ id: z.string(),
40
+ title: z.string(),
41
+ stage: z.string(),
42
+ packageId: z.string(),
43
+ description: z.string(),
44
+ command: z.string(),
45
+ requiresSession: z.boolean(),
46
+ runnable: z.boolean(),
47
+ });
48
+
38
49
  export const EcosystemPackagesResponseSchema = z.object({
39
50
  checkedAt: z.string(),
40
51
  packages: z.array(EcosystemPackageSchema),
41
52
  runnerPresets: z.array(EcosystemRunnerPresetSchema),
53
+ recipes: z.array(EcosystemRecipeSchema),
42
54
  });
43
55
 
44
- export const EcosystemTaskActionSchema = z.enum(["install", "upgrade", "help", "runner-presets"]);
56
+ export const EcosystemTaskActionSchema = z.enum([
57
+ "install",
58
+ "upgrade",
59
+ "help",
60
+ "runner-presets",
61
+ "recipe",
62
+ ]);
45
63
 
46
64
  export const EcosystemTaskStatusSchema = z.enum(["queued", "running", "succeeded", "failed"]);
47
65
 
@@ -49,6 +67,7 @@ export const EcosystemTaskSchema = z.object({
49
67
  id: z.string(),
50
68
  packageId: z.string(),
51
69
  npmName: z.string(),
70
+ recipeId: z.string().nullable().default(null),
52
71
  action: EcosystemTaskActionSchema,
53
72
  status: EcosystemTaskStatusSchema,
54
73
  command: z.string(),
@@ -71,6 +90,7 @@ export const EcosystemTasksResponseSchema = z.object({
71
90
  export type EcosystemPackageState = z.infer<typeof EcosystemPackageStateSchema>;
72
91
  export type EcosystemPackage = z.infer<typeof EcosystemPackageSchema>;
73
92
  export type EcosystemRunnerPreset = z.infer<typeof EcosystemRunnerPresetSchema>;
93
+ export type EcosystemRecipe = z.infer<typeof EcosystemRecipeSchema>;
74
94
  export type EcosystemPackagesResponse = z.infer<typeof EcosystemPackagesResponseSchema>;
75
95
  export type EcosystemTaskAction = z.infer<typeof EcosystemTaskActionSchema>;
76
96
  export type EcosystemTaskStatus = z.infer<typeof EcosystemTaskStatusSchema>;