langchain_agentx_stream_ui 0.2.0 → 0.2.3

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/dist/index.js CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  fixDiagramSvg,
24
24
  markEventIdSeen,
25
25
  shouldSkipDuplicateEvent
26
- } from "./chunk-2T26L3VT.js";
26
+ } from "./chunk-DGC43A5L.js";
27
27
  import {
28
28
  DefaultToolBody,
29
29
  InteractionBusContext,
@@ -32,8 +32,8 @@ import {
32
32
  getNoopInteractionBus,
33
33
  resolveToolBody,
34
34
  useInteractionBus
35
- } from "./chunk-6DYISADG.js";
36
- import "./chunk-DP7V33X7.js";
35
+ } from "./chunk-T626YBJX.js";
36
+ import "./chunk-6Z4ZF36Z.js";
37
37
  import {
38
38
  CollapseKind,
39
39
  CollapsedExploreNode,
@@ -111,7 +111,7 @@ import {
111
111
  useSessionStatus,
112
112
  useSessionViewOptions,
113
113
  useTimeline
114
- } from "./chunk-ZNXAQ4ZZ.js";
114
+ } from "./chunk-OFXC2GZI.js";
115
115
  import {
116
116
  Agent,
117
117
  AgentToolBody,
@@ -138,7 +138,7 @@ import {
138
138
  WriteToolBody,
139
139
  createDefaultToolRegistry,
140
140
  formatAgentTitle
141
- } from "./chunk-7CLAU74Y.js";
141
+ } from "./chunk-YUZXTGJJ.js";
142
142
  import {
143
143
  BodyBlockList,
144
144
  DiffView,
@@ -154,7 +154,7 @@ import {
154
154
  formatTimeoutFooter,
155
155
  parseDiffText,
156
156
  truncateCommand
157
- } from "./chunk-4RIOBLGB.js";
157
+ } from "./chunk-GBY5DJ7L.js";
158
158
 
159
159
  // src/view/AgentSession.tsx
160
160
  import { useEffect as useEffect2, useMemo, useRef as useRef2 } from "react";
@@ -275,6 +275,14 @@ function transportLog(message, level = "warn") {
275
275
  console.warn(line);
276
276
  }
277
277
  }
278
+ function isSseStreamTerminalEvent(agentEvent, expectedApplicationKind) {
279
+ const eventType = agentEvent.event_type;
280
+ if (eventType === "error") return true;
281
+ if (expectedApplicationKind === "workflow") {
282
+ return eventType === "workflow-end" || eventType === "workflow-failed";
283
+ }
284
+ return eventType === "finish";
285
+ }
278
286
  function buildResumeStreamUrl(baseUrl, lastEventId, queryParam = "last_event_id") {
279
287
  if (!lastEventId) return baseUrl;
280
288
  const sep = baseUrl.includes("?") ? "&" : "?";
@@ -420,7 +428,7 @@ function createAgentxSseSource(options) {
420
428
  } else {
421
429
  flushBufferedDelta();
422
430
  handler(agentEvent, ctx);
423
- if (agentEvent.event_type === "finish" || agentEvent.event_type === "error") {
431
+ if (isSseStreamTerminalEvent(agentEvent, expectedApplicationKind)) {
424
432
  streamTerminal = true;
425
433
  }
426
434
  }
@@ -626,6 +634,8 @@ function AgentSession({
626
634
  workspaceRoot
627
635
  ]
628
636
  );
637
+ const onErrorRef = useRef2(onError);
638
+ onErrorRef.current = onError;
629
639
  useEffect2(() => {
630
640
  const store = storeRef.current;
631
641
  const controller = new AbortController();
@@ -637,12 +647,15 @@ function AgentSession({
637
647
  if (tree.status === "running" || tree.status === "connecting") {
638
648
  store.setState({ tree: { ...tree, status: "error" } });
639
649
  }
650
+ onErrorRef.current?.(err);
651
+ } else if (err instanceof Error) {
652
+ onErrorRef.current?.(err);
640
653
  }
641
654
  });
642
655
  return () => {
643
656
  controller.abort();
644
657
  };
645
- }, [source, onError]);
658
+ }, [source]);
646
659
  return /* @__PURE__ */ jsx3(SessionStoreContext.Provider, { value: storeRef.current, children: /* @__PURE__ */ jsx3(InteractionBusContext.Provider, { value: busRef, children: /* @__PURE__ */ jsx3(NodeRegistryContext.Provider, { value: registryRef, children: /* @__PURE__ */ jsx3(MarkdownRendererContext.Provider, { value: markdownRenderer, children: /* @__PURE__ */ jsx3(ToolDisplayOptionsContext.Provider, { value: toolDisplayRef, children: /* @__PURE__ */ jsx3(SessionViewOptionsContext.Provider, { value: sessionViewRef, children: /* @__PURE__ */ jsxs("div", { className: "lax-agent-session", "data-testid": "lax-agent-session", children: [
647
660
  children ?? /* @__PURE__ */ jsx3(
648
661
  AgentLoopView,
@@ -762,6 +775,9 @@ function createEmptyWorkflowSessionState() {
762
775
  }
763
776
 
764
777
  // src/core/workflow/workflowLoopTreeUtils.ts
778
+ function isWorkflowLoopTreeActive(tree) {
779
+ return tree?.status === "running" || tree?.status === "connecting";
780
+ }
765
781
  function createWorkflowRunningLoopTree(seedMs = Date.now()) {
766
782
  const tree = createEmptyTree();
767
783
  tree.status = "running";
@@ -793,6 +809,168 @@ function finalizeWorkflowLoopTree(tree, terminalStatus = "done") {
793
809
  };
794
810
  }
795
811
 
812
+ // src/core/workflow/stageDoneSummary.ts
813
+ function countToolUses(tree) {
814
+ return Object.values(tree.byId).filter((node) => node.kind === "tool_call").length;
815
+ }
816
+ function computeDurationSeconds(tree) {
817
+ const started = tree.meta.startedAt;
818
+ if (started == null) return null;
819
+ let maxTs = started;
820
+ for (const node of Object.values(tree.byId)) {
821
+ if ("endedAt" in node && typeof node.endedAt === "number") {
822
+ maxTs = Math.max(maxTs, node.endedAt);
823
+ }
824
+ if ("startedAt" in node && typeof node.startedAt === "number") {
825
+ maxTs = Math.max(maxTs, node.startedAt);
826
+ }
827
+ }
828
+ const seconds = Math.round((maxTs - started) / 1e3);
829
+ return seconds > 0 ? seconds : null;
830
+ }
831
+ function lastTextContent(tree) {
832
+ const textNodes = Object.values(tree.byId).filter((node) => node.kind === "text");
833
+ if (textNodes.length === 0) return "";
834
+ return textNodes[textNodes.length - 1].accumulated.trim();
835
+ }
836
+ function buildStageDoneSummary(tree) {
837
+ if (tree.status !== "done" && tree.status !== "error") return null;
838
+ const toolUses = countToolUses(tree);
839
+ const duration = computeDurationSeconds(tree);
840
+ const durationSuffix = duration != null ? ` \xB7 ${duration}s` : "";
841
+ const label = toolUses > 0 ? `Done (${toolUses} tool use${toolUses === 1 ? "" : "s"}${durationSuffix})` : `Done (1 agent turn${durationSuffix})`;
842
+ const fullText = lastTextContent(tree);
843
+ const lines = fullText.split("\n").map((line) => line.trim()).filter(Boolean);
844
+ const teaser = lines[0] ?? "";
845
+ const extraLines = Math.max(0, lines.length - 1);
846
+ return { label, teaser, extraLines };
847
+ }
848
+ function formatTeaserExpandHint(extraLines, verbose) {
849
+ if (extraLines <= 0) return null;
850
+ const shown = verbose ? Math.min(extraLines, 2) : 0;
851
+ const hidden = extraLines - shown;
852
+ if (hidden <= 0) return null;
853
+ return `\u2026 +${hidden} lines (ctrl+o to expand)`;
854
+ }
855
+
856
+ // src/core/workflow/workflowLoopEviction.ts
857
+ function containersByLoopSessionId(tree) {
858
+ const map = /* @__PURE__ */ new Map();
859
+ for (const node of Object.values(tree.containersById)) {
860
+ if (node.loopSessionId) map.set(node.loopSessionId, node);
861
+ }
862
+ return map;
863
+ }
864
+ function hasRunningParallelSibling(node, tree) {
865
+ if (node.scope !== "item" || !node.parent_container_id) return false;
866
+ for (const sibling of Object.values(tree.containersById)) {
867
+ if (sibling.container_id === node.container_id) continue;
868
+ if (sibling.scope !== "item") continue;
869
+ if (sibling.parent_container_id !== node.parent_container_id) continue;
870
+ if (sibling.status === "running" || sibling.status === "retrying") return true;
871
+ }
872
+ return false;
873
+ }
874
+ function collectProtectedLoopSessionIds(state, pinnedLoopSessionIds) {
875
+ const protectedIds = new Set(Object.keys(pinnedLoopSessionIds));
876
+ const byLoop = containersByLoopSessionId(state.containerTree);
877
+ if (state.activeLoopSessionId) {
878
+ const activeContainer = byLoop.get(state.activeLoopSessionId);
879
+ if (activeContainer && (activeContainer.status === "running" || activeContainer.status === "retrying")) {
880
+ protectedIds.add(state.activeLoopSessionId);
881
+ }
882
+ }
883
+ for (const containerId of state.containerTree.activeContainerIds) {
884
+ const node = state.containerTree.containersById[containerId];
885
+ if (!node?.loopSessionId) continue;
886
+ if (node.status === "running" || node.status === "retrying") {
887
+ protectedIds.add(node.loopSessionId);
888
+ }
889
+ }
890
+ for (const node of Object.values(state.containerTree.containersById)) {
891
+ if (!node.loopSessionId) continue;
892
+ if (node.status === "running" || node.status === "retrying") {
893
+ protectedIds.add(node.loopSessionId);
894
+ }
895
+ if ((node.status === "completed" || node.status === "failed") && hasRunningParallelSibling(node, state.containerTree)) {
896
+ protectedIds.add(node.loopSessionId);
897
+ }
898
+ }
899
+ return protectedIds;
900
+ }
901
+ function evictInactiveLoopTrees(state, pinnedLoopSessionIds) {
902
+ const protectedIds = collectProtectedLoopSessionIds(state, pinnedLoopSessionIds);
903
+ for (const id of state.hotCompletedLoopSessionIds) {
904
+ protectedIds.add(id);
905
+ }
906
+ const byLoop = containersByLoopSessionId(state.containerTree);
907
+ const nextLoops = { ...state.loopTreesBySessionId };
908
+ const nextSummaries = {
909
+ ...state.stageDoneSummariesByContainerId
910
+ };
911
+ for (const [loopSessionId, tree] of Object.entries(state.loopTreesBySessionId)) {
912
+ if (protectedIds.has(loopSessionId)) continue;
913
+ const container = byLoop.get(loopSessionId);
914
+ if (!container) continue;
915
+ if (container.status !== "completed" && container.status !== "failed") continue;
916
+ const summary = buildStageDoneSummary(tree);
917
+ if (summary) {
918
+ nextSummaries[container.container_id] = summary;
919
+ }
920
+ delete nextLoops[loopSessionId];
921
+ }
922
+ return {
923
+ ...state,
924
+ loopTreesBySessionId: nextLoops,
925
+ stageDoneSummariesByContainerId: nextSummaries
926
+ };
927
+ }
928
+ function touchCompletedLoopSession(state, loopSessionId) {
929
+ const ids = state.hotCompletedLoopSessionIds.filter((id) => id !== loopSessionId);
930
+ ids.push(loopSessionId);
931
+ return {
932
+ ...state,
933
+ hotCompletedLoopSessionIds: ids
934
+ };
935
+ }
936
+ function evictCompletedLoopOverflow(state, maxHydratedCompletedLoops) {
937
+ if (state.hotCompletedLoopSessionIds.length <= maxHydratedCompletedLoops) {
938
+ return state;
939
+ }
940
+ const overflow = state.hotCompletedLoopSessionIds.length - maxHydratedCompletedLoops;
941
+ const candidateEvictIds = state.hotCompletedLoopSessionIds.slice(0, overflow);
942
+ let trimmedIds = state.hotCompletedLoopSessionIds.slice(overflow);
943
+ const byLoop = containersByLoopSessionId(state.containerTree);
944
+ const nextLoops = { ...state.loopTreesBySessionId };
945
+ const nextSummaries = {
946
+ ...state.stageDoneSummariesByContainerId
947
+ };
948
+ for (const id of candidateEvictIds) {
949
+ const tree = nextLoops[id];
950
+ if (!tree) continue;
951
+ const container = byLoop.get(id);
952
+ if (container && hasRunningParallelSibling(container, state.containerTree)) {
953
+ if (!trimmedIds.includes(id)) {
954
+ trimmedIds = [id, ...trimmedIds];
955
+ }
956
+ continue;
957
+ }
958
+ if (container) {
959
+ const summary = buildStageDoneSummary(tree);
960
+ if (summary) {
961
+ nextSummaries[container.container_id] = summary;
962
+ }
963
+ }
964
+ delete nextLoops[id];
965
+ }
966
+ return {
967
+ ...state,
968
+ loopTreesBySessionId: nextLoops,
969
+ stageDoneSummariesByContainerId: nextSummaries,
970
+ hotCompletedLoopSessionIds: trimmedIds
971
+ };
972
+ }
973
+
796
974
  // src/types/workflowDisplay.ts
797
975
  function readDisplayPayload(data) {
798
976
  const display = data.display;
@@ -1659,6 +1837,9 @@ function resolveParallelItemLoopSessionId(eventType, eventSessionId, derivedLoop
1659
1837
  if (itemKey && parsed?.taskKey === itemKey) {
1660
1838
  return eventSessionId;
1661
1839
  }
1840
+ if (itemKey && eventSessionId.endsWith(`-${itemKey}`)) {
1841
+ return eventSessionId;
1842
+ }
1662
1843
  return null;
1663
1844
  }
1664
1845
  function resolveStructureOpenLoopSessionId(eventType, eventSessionId, derivedLoopSessionId, data) {
@@ -1727,6 +1908,9 @@ function settleCompletedStageSubtree(tree, stageContainerId) {
1727
1908
  }
1728
1909
  if (node.container_id === stageContainerId) continue;
1729
1910
  if (node.status === "completed" || node.status === "failed") continue;
1911
+ if (node.status === "running" || node.status === "retrying" || node.status === "pending" || node.status === "blocked") {
1912
+ continue;
1913
+ }
1730
1914
  next = upsertRuntimeNode(next, {
1731
1915
  ...node,
1732
1916
  status: "completed",
@@ -1908,17 +2092,26 @@ function findContainerByLoopSession(tree, loopSessionId) {
1908
2092
  return findContainerIdByLoopSession(tree, loopSessionId);
1909
2093
  }
1910
2094
  var WORKFLOW_DEFAULT_LOOP_SESSION_ID = "workflow-loop-default";
1911
- function resolveAgentLoopSessionId(event, activeLoopSessionId) {
2095
+ function resolveAgentLoopSessionId(event, activeLoopSessionId, containerTree) {
1912
2096
  if (event.session_id) return event.session_id;
1913
2097
  if (isWorkflowStructureEventType(event.event_type)) return null;
1914
2098
  if (event.event_type === "task-list-snapshot") return null;
1915
2099
  const derived = deriveLoopSessionIdFromAgentEvent(event);
1916
2100
  if (derived) return derived;
2101
+ const byLoop = containersByLoopSessionId(containerTree);
2102
+ if (activeLoopSessionId) {
2103
+ const activeContainer = byLoop.get(activeLoopSessionId);
2104
+ if (activeContainer && (activeContainer.status === "running" || activeContainer.status === "retrying")) {
2105
+ return activeLoopSessionId;
2106
+ }
2107
+ }
2108
+ const runningLoopIds = [...byLoop.entries()].filter(([, node]) => node.status === "running" || node.status === "retrying").map(([loopSessionId]) => loopSessionId);
2109
+ if (runningLoopIds.length === 1) return runningLoopIds[0];
1917
2110
  if (activeLoopSessionId) return activeLoopSessionId;
1918
2111
  return WORKFLOW_DEFAULT_LOOP_SESSION_ID;
1919
2112
  }
1920
2113
  function bindLoopSessionToContainerTree(tree, event, activeLoopSessionId) {
1921
- const sessionId = resolveAgentLoopSessionId(event, activeLoopSessionId);
2114
+ const sessionId = resolveAgentLoopSessionId(event, activeLoopSessionId, tree);
1922
2115
  if (!sessionId) return tree;
1923
2116
  let nextTree = ensureEmbeddedSubworkflowContainers(tree, sessionId);
1924
2117
  if (findContainerByLoopSession(nextTree, sessionId)) return nextTree;
@@ -2157,7 +2350,7 @@ function reduceWorkflowSession(state, event, eventIndex, options) {
2157
2350
  if (isWorkflowStructureEventType(event.event_type)) {
2158
2351
  return applyStructureEvent(state, event);
2159
2352
  }
2160
- const loopSessionId = resolveAgentLoopSessionId(event, state.activeLoopSessionId) ?? WORKFLOW_DEFAULT_LOOP_SESSION_ID;
2353
+ const loopSessionId = resolveAgentLoopSessionId(event, state.activeLoopSessionId, state.containerTree) ?? WORKFLOW_DEFAULT_LOOP_SESSION_ID;
2161
2354
  let containerTree = bindLoopSessionToContainerTree(
2162
2355
  state.containerTree,
2163
2356
  event,
@@ -2176,6 +2369,11 @@ function reduceWorkflowSession(state, event, eventIndex, options) {
2176
2369
  next.containerTree,
2177
2370
  next.workflowProgress
2178
2371
  );
2372
+ let status = next.status;
2373
+ if (workflowProgress.status === "done") status = "done";
2374
+ else if (workflowProgress.status === "error") status = "error";
2375
+ else if (reducedLoop.status === "error") status = "error";
2376
+ else if (status === "connecting") status = "running";
2179
2377
  return {
2180
2378
  ...next,
2181
2379
  workflowProgress,
@@ -2184,7 +2382,85 @@ function reduceWorkflowSession(state, event, eventIndex, options) {
2184
2382
  [loopSessionId]: reducedLoop
2185
2383
  },
2186
2384
  activeLoopSessionId: loopSessionId,
2187
- status: reducedLoop.status === "error" ? "error" : next.status === "connecting" ? "running" : next.status
2385
+ status
2386
+ };
2387
+ }
2388
+ function finalizeWorkflowSessionOnStreamComplete(state) {
2389
+ if (state.status === "done" || state.status === "error") {
2390
+ return { ...state, activeLoopSessionId: null };
2391
+ }
2392
+ const tree = state.containerTree;
2393
+ const rootId = tree.rootContainerIds[0];
2394
+ if (!rootId) {
2395
+ return { ...state, status: "done", activeLoopSessionId: null };
2396
+ }
2397
+ const root = tree.containersById[rootId];
2398
+ if (!root) {
2399
+ return { ...state, status: "done", activeLoopSessionId: null };
2400
+ }
2401
+ const topLevelStages = Object.values(tree.containersById).filter(
2402
+ (node) => node.scope === "stage" && node.workflow_path === root.workflow_path && node.workflow_depth === root.workflow_depth
2403
+ );
2404
+ const progressTotal = root.display?.progress_total ?? (topLevelStages.reduce(
2405
+ (max, s) => Math.max(max, s.display?.progress_total ?? 0),
2406
+ 0
2407
+ ) || topLevelStages.length);
2408
+ const settledStageCount = topLevelStages.filter(
2409
+ (s) => s.status === "completed" || s.status === "failed" || s.status === "skipped"
2410
+ ).length;
2411
+ const progressCurrent = Math.max(
2412
+ root.display?.progress_current ?? 0,
2413
+ settledStageCount
2414
+ );
2415
+ const rootParallelItems = Object.values(tree.containersById).filter(
2416
+ (node) => node.scope === "item" && node.workflow_path === root.workflow_path && node.workflow_depth === root.workflow_depth
2417
+ );
2418
+ const rootParallelSettled = topLevelStages.length === 0 && rootParallelItems.length > 0 && rootParallelItems.every(
2419
+ (item) => item.status === "completed" || item.status === "failed" || item.status === "skipped"
2420
+ ) && !Object.values(tree.containersById).some(
2421
+ (node) => node.container_id !== rootId && node.workflow_path === root.workflow_path && node.workflow_depth === root.workflow_depth && (node.status === "running" || node.status === "retrying" || node.status === "pending")
2422
+ );
2423
+ const workflowVisuallyComplete = root.status === "completed" || root.status === "failed" || progressTotal > 0 && progressCurrent >= progressTotal || rootParallelSettled;
2424
+ if (!workflowVisuallyComplete) {
2425
+ return state;
2426
+ }
2427
+ let containersById = { ...tree.containersById };
2428
+ for (const [id, node] of Object.entries(containersById)) {
2429
+ if (node.status === "running" || node.status === "pending") {
2430
+ containersById[id] = { ...node, status: "completed", is_open: false };
2431
+ }
2432
+ }
2433
+ const settledRoot = containersById[rootId] ?? root;
2434
+ if (settledRoot.status !== "completed" && settledRoot.status !== "failed") {
2435
+ containersById = {
2436
+ ...containersById,
2437
+ [rootId]: { ...settledRoot, status: "completed", is_open: false }
2438
+ };
2439
+ }
2440
+ const loopTreesBySessionId = { ...state.loopTreesBySessionId };
2441
+ for (const [loopSessionId, loopTree] of Object.entries(loopTreesBySessionId)) {
2442
+ if (loopTree.status === "running" || loopTree.status === "connecting") {
2443
+ loopTreesBySessionId[loopSessionId] = finalizeWorkflowLoopTree(loopTree, "done");
2444
+ }
2445
+ }
2446
+ const containerTree = {
2447
+ ...tree,
2448
+ containersById,
2449
+ activeContainerIds: tree.activeContainerIds.filter(
2450
+ (id) => containersById[id]?.is_open
2451
+ )
2452
+ };
2453
+ const workflowProgress = syncWorkflowProgressFromContainerTree(
2454
+ containerTree,
2455
+ state.workflowProgress
2456
+ );
2457
+ return {
2458
+ ...state,
2459
+ status: workflowProgress.status === "error" ? "error" : "done",
2460
+ containerTree,
2461
+ workflowProgress,
2462
+ loopTreesBySessionId,
2463
+ activeLoopSessionId: null
2188
2464
  };
2189
2465
  }
2190
2466
  function reduceWorkflowEvents(events, options) {
@@ -2194,180 +2470,37 @@ function reduceWorkflowEvents(events, options) {
2194
2470
  );
2195
2471
  }
2196
2472
 
2197
- // src/core/workflow/stageDoneSummary.ts
2198
- function countToolUses(tree) {
2199
- return Object.values(tree.byId).filter((node) => node.kind === "tool_call").length;
2473
+ // src/core/workflowSessionStore.ts
2474
+ var DEFAULT_HYDRATE_CHUNK_SIZE = 50;
2475
+ function nextAnimationFrame() {
2476
+ return new Promise((resolve) => {
2477
+ requestAnimationFrame(() => resolve());
2478
+ });
2200
2479
  }
2201
- function computeDurationSeconds(tree) {
2202
- const started = tree.meta.startedAt;
2203
- if (started == null) return null;
2204
- let maxTs = started;
2205
- for (const node of Object.values(tree.byId)) {
2206
- if ("endedAt" in node && typeof node.endedAt === "number") {
2207
- maxTs = Math.max(maxTs, node.endedAt);
2208
- }
2209
- if ("startedAt" in node && typeof node.startedAt === "number") {
2210
- maxTs = Math.max(maxTs, node.startedAt);
2480
+ function withoutHydrating(ids, loopSessionId) {
2481
+ if (!ids[loopSessionId]) return ids;
2482
+ const next = { ...ids };
2483
+ delete next[loopSessionId];
2484
+ return next;
2485
+ }
2486
+ function safeReduceWorkflow(state, event, eventIndex, options) {
2487
+ try {
2488
+ return { state: reduceWorkflowSession(state, event, eventIndex, options) };
2489
+ } catch (err) {
2490
+ const message = err instanceof Error ? err.message : String(err);
2491
+ const stack = err instanceof Error ? err.stack : void 0;
2492
+ if (import.meta.env?.DEV) {
2493
+ console.error("[langchain_agentx_stream_ui] workflow reducer error:", err);
2211
2494
  }
2212
- }
2213
- const seconds = Math.round((maxTs - started) / 1e3);
2214
- return seconds > 0 ? seconds : null;
2215
- }
2216
- function lastTextContent(tree) {
2217
- const textNodes = Object.values(tree.byId).filter((node) => node.kind === "text");
2218
- if (textNodes.length === 0) return "";
2219
- return textNodes[textNodes.length - 1].accumulated.trim();
2220
- }
2221
- function buildStageDoneSummary(tree) {
2222
- if (tree.status !== "done" && tree.status !== "error") return null;
2223
- const toolUses = countToolUses(tree);
2224
- const duration = computeDurationSeconds(tree);
2225
- const durationSuffix = duration != null ? ` \xB7 ${duration}s` : "";
2226
- const label = toolUses > 0 ? `Done (${toolUses} tool use${toolUses === 1 ? "" : "s"}${durationSuffix})` : `Done (1 agent turn${durationSuffix})`;
2227
- const fullText = lastTextContent(tree);
2228
- const lines = fullText.split("\n").map((line) => line.trim()).filter(Boolean);
2229
- const teaser = lines[0] ?? "";
2230
- const extraLines = Math.max(0, lines.length - 1);
2231
- return { label, teaser, extraLines };
2232
- }
2233
- function formatTeaserExpandHint(extraLines, verbose) {
2234
- if (extraLines <= 0) return null;
2235
- const shown = verbose ? Math.min(extraLines, 2) : 0;
2236
- const hidden = extraLines - shown;
2237
- if (hidden <= 0) return null;
2238
- return `\u2026 +${hidden} lines (ctrl+o to expand)`;
2239
- }
2240
-
2241
- // src/core/workflow/workflowLoopEviction.ts
2242
- function containersByLoopSessionId(tree) {
2243
- const map = /* @__PURE__ */ new Map();
2244
- for (const node of Object.values(tree.containersById)) {
2245
- if (node.loopSessionId) map.set(node.loopSessionId, node);
2246
- }
2247
- return map;
2248
- }
2249
- function collectProtectedLoopSessionIds(state, pinnedLoopSessionIds) {
2250
- const protectedIds = new Set(Object.keys(pinnedLoopSessionIds));
2251
- const byLoop = containersByLoopSessionId(state.containerTree);
2252
- if (state.activeLoopSessionId) {
2253
- const activeContainer = byLoop.get(state.activeLoopSessionId);
2254
- if (activeContainer && (activeContainer.status === "running" || activeContainer.status === "retrying")) {
2255
- protectedIds.add(state.activeLoopSessionId);
2256
- }
2257
- }
2258
- for (const containerId of state.containerTree.activeContainerIds) {
2259
- const node = state.containerTree.containersById[containerId];
2260
- if (!node?.loopSessionId) continue;
2261
- if (node.status === "running" || node.status === "retrying") {
2262
- protectedIds.add(node.loopSessionId);
2263
- }
2264
- }
2265
- for (const node of Object.values(state.containerTree.containersById)) {
2266
- if (!node.loopSessionId) continue;
2267
- if (node.status === "running" || node.status === "retrying") {
2268
- protectedIds.add(node.loopSessionId);
2269
- }
2270
- }
2271
- return protectedIds;
2272
- }
2273
- function evictInactiveLoopTrees(state, pinnedLoopSessionIds) {
2274
- const protectedIds = collectProtectedLoopSessionIds(state, pinnedLoopSessionIds);
2275
- for (const id of state.hotCompletedLoopSessionIds) {
2276
- protectedIds.add(id);
2277
- }
2278
- const byLoop = containersByLoopSessionId(state.containerTree);
2279
- const nextLoops = { ...state.loopTreesBySessionId };
2280
- const nextSummaries = {
2281
- ...state.stageDoneSummariesByContainerId
2282
- };
2283
- for (const [loopSessionId, tree] of Object.entries(state.loopTreesBySessionId)) {
2284
- if (protectedIds.has(loopSessionId)) continue;
2285
- const container = byLoop.get(loopSessionId);
2286
- if (!container) continue;
2287
- if (container.status !== "completed" && container.status !== "failed") continue;
2288
- const summary = buildStageDoneSummary(tree);
2289
- if (summary) {
2290
- nextSummaries[container.container_id] = summary;
2291
- }
2292
- delete nextLoops[loopSessionId];
2293
- }
2294
- return {
2295
- ...state,
2296
- loopTreesBySessionId: nextLoops,
2297
- stageDoneSummariesByContainerId: nextSummaries
2298
- };
2299
- }
2300
- function touchCompletedLoopSession(state, loopSessionId) {
2301
- const ids = state.hotCompletedLoopSessionIds.filter((id) => id !== loopSessionId);
2302
- ids.push(loopSessionId);
2303
- return {
2304
- ...state,
2305
- hotCompletedLoopSessionIds: ids
2306
- };
2307
- }
2308
- function evictCompletedLoopOverflow(state, maxHydratedCompletedLoops) {
2309
- if (state.hotCompletedLoopSessionIds.length <= maxHydratedCompletedLoops) {
2310
- return state;
2311
- }
2312
- const overflow = state.hotCompletedLoopSessionIds.length - maxHydratedCompletedLoops;
2313
- const evictedIds = new Set(state.hotCompletedLoopSessionIds.slice(0, overflow));
2314
- const trimmedIds = state.hotCompletedLoopSessionIds.slice(overflow);
2315
- const byLoop = containersByLoopSessionId(state.containerTree);
2316
- const nextLoops = { ...state.loopTreesBySessionId };
2317
- const nextSummaries = {
2318
- ...state.stageDoneSummariesByContainerId
2319
- };
2320
- for (const id of evictedIds) {
2321
- const tree = nextLoops[id];
2322
- if (!tree) continue;
2323
- const container = byLoop.get(id);
2324
- if (container) {
2325
- const summary = buildStageDoneSummary(tree);
2326
- if (summary) {
2327
- nextSummaries[container.container_id] = summary;
2328
- }
2329
- }
2330
- delete nextLoops[id];
2331
- }
2332
- return {
2333
- ...state,
2334
- loopTreesBySessionId: nextLoops,
2335
- stageDoneSummariesByContainerId: nextSummaries,
2336
- hotCompletedLoopSessionIds: trimmedIds
2337
- };
2338
- }
2339
-
2340
- // src/core/workflowSessionStore.ts
2341
- var DEFAULT_HYDRATE_CHUNK_SIZE = 50;
2342
- function nextAnimationFrame() {
2343
- return new Promise((resolve) => {
2344
- requestAnimationFrame(() => resolve());
2345
- });
2346
- }
2347
- function withoutHydrating(ids, loopSessionId) {
2348
- if (!ids[loopSessionId]) return ids;
2349
- const next = { ...ids };
2350
- delete next[loopSessionId];
2351
- return next;
2352
- }
2353
- function safeReduceWorkflow(state, event, eventIndex, options) {
2354
- try {
2355
- return { state: reduceWorkflowSession(state, event, eventIndex, options) };
2356
- } catch (err) {
2357
- const message = err instanceof Error ? err.message : String(err);
2358
- const stack = err instanceof Error ? err.stack : void 0;
2359
- if (import.meta.env?.DEV) {
2360
- console.error("[langchain_agentx_stream_ui] workflow reducer error:", err);
2361
- }
2362
- return {
2363
- state: {
2364
- ...state,
2365
- internalErrors: [
2366
- ...state.internalErrors,
2367
- { eventIndex, eventType: event.event_type, message, stack }
2368
- ]
2369
- }
2370
- };
2495
+ return {
2496
+ state: {
2497
+ ...state,
2498
+ internalErrors: [
2499
+ ...state.internalErrors,
2500
+ { eventIndex, eventType: event.event_type, message, stack }
2501
+ ]
2502
+ }
2503
+ };
2371
2504
  }
2372
2505
  }
2373
2506
  function finalizeWorkflowSessionOnStreamError(state, message, eventIndex, stack) {
@@ -2462,6 +2595,13 @@ function createWorkflowSessionStore(initialState = createEmptyWorkflowSessionSta
2462
2595
  )
2463
2596
  });
2464
2597
  },
2598
+ markStreamComplete() {
2599
+ set((current) => ({
2600
+ state: finalizeState(
2601
+ finalizeWorkflowSessionOnStreamComplete(current.state)
2602
+ )
2603
+ }));
2604
+ },
2465
2605
  /**
2466
2606
  * P2:将 completed loop 加入 LRU 热缓存并淘汰溢出。
2467
2607
  * running 保护由 collectProtectedLoopSessionIds 按状态自动覆盖,不再依赖永久 pin。
@@ -2716,6 +2856,76 @@ function WorkflowMarkdownPreview({
2716
2856
  return /* @__PURE__ */ jsx7("div", { className, "data-testid": testId, children: /* @__PURE__ */ jsx7(RichMarkdown, { content, deferDiagrams: true }) });
2717
2857
  }
2718
2858
 
2859
+ // src/view/workflow/workflowStageListUtils.ts
2860
+ var ACTIVE_CONTAINER_STATUSES = /* @__PURE__ */ new Set([
2861
+ "running",
2862
+ "retrying",
2863
+ "pending",
2864
+ "blocked"
2865
+ ]);
2866
+ function isDescendantContainer(containersById, ancestorId, nodeId) {
2867
+ const seen = /* @__PURE__ */ new Set();
2868
+ let current = containersById[nodeId]?.parent_container_id ?? null;
2869
+ while (current) {
2870
+ if (current === ancestorId) return true;
2871
+ if (seen.has(current)) return false;
2872
+ seen.add(current);
2873
+ current = containersById[current]?.parent_container_id ?? null;
2874
+ }
2875
+ return false;
2876
+ }
2877
+ function subtreeHasActiveWork(containersById, rootContainerId) {
2878
+ for (const node of Object.values(containersById)) {
2879
+ if (node.container_id === rootContainerId) continue;
2880
+ if (!isDescendantContainer(containersById, rootContainerId, node.container_id)) continue;
2881
+ if (ACTIVE_CONTAINER_STATUSES.has(node.status)) return true;
2882
+ }
2883
+ return false;
2884
+ }
2885
+ function stageSortKey(node) {
2886
+ return node.display?.progress_current ?? 0;
2887
+ }
2888
+ function partitionWorkflowStages(stages, maxVisibleDoneCount, containersById) {
2889
+ const sorted = [...stages].sort(
2890
+ (a, b) => stageSortKey(a) - stageSortKey(b) || a.scope_key.localeCompare(b.scope_key)
2891
+ );
2892
+ const always = [];
2893
+ const done = [];
2894
+ for (const stage of sorted) {
2895
+ if (containersById && subtreeHasActiveWork(containersById, stage.container_id)) {
2896
+ always.push(stage);
2897
+ continue;
2898
+ }
2899
+ if (stage.status === "running" || stage.status === "retrying" || stage.status === "failed" || stage.status === "pending" || stage.status === "blocked") {
2900
+ always.push(stage);
2901
+ } else if (stage.status === "completed" || stage.status === "skipped") {
2902
+ done.push(stage);
2903
+ } else {
2904
+ always.push(stage);
2905
+ }
2906
+ }
2907
+ const visibleDone = done.slice(Math.max(0, done.length - maxVisibleDoneCount));
2908
+ const visible = [...always, ...visibleDone].sort(
2909
+ (a, b) => stageSortKey(a) - stageSortKey(b) || a.scope_key.localeCompare(b.scope_key)
2910
+ );
2911
+ const hiddenCount = stages.length - visible.length;
2912
+ return {
2913
+ visible,
2914
+ hiddenCount,
2915
+ total: stages.length,
2916
+ visibleDoneCount: visibleDone.length,
2917
+ totalDoneCount: done.length
2918
+ };
2919
+ }
2920
+ function isActiveWorkflowContainer(node, activeLoopSessionId, activeContainerIds) {
2921
+ if (activeContainerIds.includes(node.container_id)) return true;
2922
+ if (node.status === "completed" || node.status === "failed" || node.status === "skipped") {
2923
+ return false;
2924
+ }
2925
+ if (node.loopSessionId && node.loopSessionId === activeLoopSessionId) return true;
2926
+ return false;
2927
+ }
2928
+
2719
2929
  // src/view/workflow/workflowContainerLineUtils.ts
2720
2930
  function formatContainerScopeMetaLabel(scope) {
2721
2931
  return scope;
@@ -2785,6 +2995,27 @@ function buildWorkflowRootMeta(node) {
2785
2995
  }
2786
2996
  return withScopeMeta(node, meta);
2787
2997
  }
2998
+ function buildEffectiveWorkflowRootMeta(node, containersById) {
2999
+ const meta = [];
3000
+ if (node.pattern) meta.push(node.pattern);
3001
+ const topStages = Object.values(containersById).filter(
3002
+ (n) => n.scope === "stage" && n.parent_container_id === node.container_id
3003
+ );
3004
+ const total = node.display?.progress_total ?? Math.max(topStages.length, 1);
3005
+ let settled = 0;
3006
+ for (const stage of topStages) {
3007
+ if (subtreeHasActiveWork(containersById, stage.container_id)) continue;
3008
+ if (stage.status === "completed" || stage.status === "failed" || stage.status === "skipped") {
3009
+ settled += 1;
3010
+ }
3011
+ }
3012
+ if (total > 0) meta.push(`${settled}/${total}`);
3013
+ return withScopeMeta(node, meta);
3014
+ }
3015
+ function isWorkflowRootVisuallyActive(node, containersById) {
3016
+ if (node.status === "running" || node.status === "retrying") return true;
3017
+ return subtreeHasActiveWork(containersById, node.container_id);
3018
+ }
2788
3019
  function buildStageLineMeta(node) {
2789
3020
  const progress = buildStageProgressMeta(node);
2790
3021
  return withScopeMeta(node, progress ? [progress] : []);
@@ -3008,9 +3239,41 @@ var WorkflowAggregateContainer = memo(
3008
3239
 
3009
3240
  // src/view/workflow/WorkflowParallelGroup.tsx
3010
3241
  import { useCallback as useCallback3, useEffect as useEffect5, useMemo as useMemo4, useState as useState3, memo as memo3 } from "react";
3011
- import { useStore as useStore3 } from "zustand";
3012
3242
 
3013
3243
  // src/core/workflow/workflowParallelGroupMetrics.ts
3244
+ var ACTIVE_ITEM_STATUSES = /* @__PURE__ */ new Set([
3245
+ "running",
3246
+ "retrying",
3247
+ "pending",
3248
+ "blocked"
3249
+ ]);
3250
+ function countMetricsFromContainerStatus(items) {
3251
+ let running = 0;
3252
+ let completed = 0;
3253
+ let failed = 0;
3254
+ for (const item of items) {
3255
+ if (ACTIVE_ITEM_STATUSES.has(item.status)) {
3256
+ running += 1;
3257
+ continue;
3258
+ }
3259
+ switch (item.status) {
3260
+ case "completed":
3261
+ completed += 1;
3262
+ break;
3263
+ case "failed":
3264
+ failed += 1;
3265
+ break;
3266
+ default:
3267
+ break;
3268
+ }
3269
+ }
3270
+ return {
3271
+ total: items.length,
3272
+ running,
3273
+ completed,
3274
+ failed
3275
+ };
3276
+ }
3014
3277
  function readDisplayMetrics(items) {
3015
3278
  let hasMetrics = false;
3016
3279
  let running = 0;
@@ -3031,34 +3294,29 @@ function readDisplayMetrics(items) {
3031
3294
  if (total <= 0) total = items.length;
3032
3295
  return { total, running, completed, failed };
3033
3296
  }
3297
+ function mergeParallelGroupMetrics(fromDisplay, fromStatus, itemCount) {
3298
+ return {
3299
+ total: Math.max(fromDisplay.total, fromStatus.total, itemCount),
3300
+ running: Math.max(fromDisplay.running, fromStatus.running),
3301
+ completed: Math.max(fromDisplay.completed, fromStatus.completed),
3302
+ failed: Math.max(fromDisplay.failed, fromStatus.failed)
3303
+ };
3304
+ }
3034
3305
  function computeParallelGroupMetrics(items) {
3306
+ const fromStatus = countMetricsFromContainerStatus(items);
3035
3307
  const fromDisplay = readDisplayMetrics(items);
3036
- if (fromDisplay) return fromDisplay;
3037
- let running = 0;
3038
- let completed = 0;
3039
- let failed = 0;
3308
+ if (!fromDisplay) return fromStatus;
3309
+ return mergeParallelGroupMetrics(fromDisplay, fromStatus, items.length);
3310
+ }
3311
+ function parallelGroupHasActiveWork(items, loopTreesBySessionId = {}) {
3312
+ const metrics = computeParallelGroupMetrics(items);
3313
+ if (metrics.running > 0) return true;
3040
3314
  for (const item of items) {
3041
- switch (item.status) {
3042
- case "running":
3043
- case "retrying":
3044
- running += 1;
3045
- break;
3046
- case "completed":
3047
- completed += 1;
3048
- break;
3049
- case "failed":
3050
- failed += 1;
3051
- break;
3052
- default:
3053
- break;
3054
- }
3315
+ const loopSessionId = item.loopSessionId;
3316
+ if (!loopSessionId) continue;
3317
+ if (isWorkflowLoopTreeActive(loopTreesBySessionId[loopSessionId])) return true;
3055
3318
  }
3056
- return {
3057
- total: items.length,
3058
- running,
3059
- completed,
3060
- failed
3061
- };
3319
+ return false;
3062
3320
  }
3063
3321
  function formatParallelGroupSummary(metrics) {
3064
3322
  if (metrics.running > 0) {
@@ -3115,47 +3373,6 @@ function WorkflowStageDoneBody({
3115
3373
  );
3116
3374
  }
3117
3375
 
3118
- // src/view/workflow/workflowStageListUtils.ts
3119
- function stageSortKey(node) {
3120
- return node.display?.progress_current ?? 0;
3121
- }
3122
- function partitionWorkflowStages(stages, maxVisibleDoneCount) {
3123
- const sorted = [...stages].sort(
3124
- (a, b) => stageSortKey(a) - stageSortKey(b) || a.scope_key.localeCompare(b.scope_key)
3125
- );
3126
- const always = [];
3127
- const done = [];
3128
- for (const stage of sorted) {
3129
- if (stage.status === "running" || stage.status === "retrying" || stage.status === "failed" || stage.status === "pending" || stage.status === "blocked") {
3130
- always.push(stage);
3131
- } else if (stage.status === "completed" || stage.status === "skipped") {
3132
- done.push(stage);
3133
- } else {
3134
- always.push(stage);
3135
- }
3136
- }
3137
- const visibleDone = done.slice(Math.max(0, done.length - maxVisibleDoneCount));
3138
- const visible = [...always, ...visibleDone].sort(
3139
- (a, b) => stageSortKey(a) - stageSortKey(b) || a.scope_key.localeCompare(b.scope_key)
3140
- );
3141
- const hiddenCount = stages.length - visible.length;
3142
- return {
3143
- visible,
3144
- hiddenCount,
3145
- total: stages.length,
3146
- visibleDoneCount: visibleDone.length,
3147
- totalDoneCount: done.length
3148
- };
3149
- }
3150
- function isActiveWorkflowContainer(node, activeLoopSessionId, activeContainerIds) {
3151
- if (activeContainerIds.includes(node.container_id)) return true;
3152
- if (node.status === "completed" || node.status === "failed" || node.status === "skipped") {
3153
- return false;
3154
- }
3155
- if (node.loopSessionId && node.loopSessionId === activeLoopSessionId) return true;
3156
- return false;
3157
- }
3158
-
3159
3376
  // src/view/workflow/WorkflowStageContainer.tsx
3160
3377
  import { jsx as jsx10, jsxs as jsxs5 } from "react/jsx-runtime";
3161
3378
  function containerTestId(node) {
@@ -3174,10 +3391,10 @@ function WorkflowStageContainerInner({
3174
3391
  virtualized,
3175
3392
  virtualizeThreshold,
3176
3393
  groupParallelTools,
3177
- suppressHostScopedLoop = false
3394
+ suppressHostScopedLoop = false,
3395
+ subtreeHasActiveWork: subtreeHasActiveWork2 = false
3178
3396
  }) {
3179
3397
  const { verbose } = useSessionViewOptions();
3180
- const scale = useWorkflowScaleOptions();
3181
3398
  const store = useWorkflowSessionStoreApi();
3182
3399
  const replayHandler = useWorkflowLoopReplayHandler();
3183
3400
  const activeLoopSessionId = useStore2(store, (s) => s.state.activeLoopSessionId);
@@ -3187,37 +3404,37 @@ function WorkflowStageContainerInner({
3187
3404
  (s) => s.state.stageDoneSummariesByContainerId[node.container_id]
3188
3405
  );
3189
3406
  const pinLoopSession = useStore2(store, (s) => s.pinLoopSession);
3190
- const uiStatus = mapContainerUiStatus(node.status);
3191
3407
  const isRunning = node.status === "running" || node.status === "retrying";
3192
3408
  const isDone = node.status === "completed" || node.status === "failed";
3193
3409
  const isSkipped = node.status === "skipped";
3410
+ const loopStillActive = isWorkflowLoopTreeActive(loopTree);
3411
+ const effectivelyRunning = isRunning || loopStillActive || subtreeHasActiveWork2 && isDone;
3412
+ const effectivelyDone = isDone && !subtreeHasActiveWork2 && !loopStillActive;
3413
+ const uiStatus = mapContainerUiStatus(effectivelyRunning ? "running" : node.status);
3194
3414
  const isActive = isActiveWorkflowContainer(node, activeLoopSessionId, activeContainerIds);
3195
3415
  const doneSummaryFromTree = useMemo3(
3196
3416
  () => loopTree && isDone ? buildStageDoneSummary(loopTree) : null,
3197
3417
  [loopTree, isDone]
3198
3418
  );
3199
3419
  const doneSummary = doneSummaryFromTree ?? (isDone ? cachedSummary : null);
3200
- const shouldAutoExpandRunning = isRunning && !isSkipped && (!scale.onlyExpandActiveRunning || isActive);
3420
+ const shouldAutoExpandRunning = effectivelyRunning && !isSkipped;
3201
3421
  const [expanded, setExpanded] = useState2(shouldAutoExpandRunning);
3202
3422
  useEffect4(() => {
3203
3423
  if (shouldAutoExpandRunning) {
3204
3424
  setExpanded(true);
3205
3425
  return;
3206
3426
  }
3207
- if (isRunning && !isSkipped && scale.onlyExpandActiveRunning) {
3427
+ if (effectivelyDone || isSkipped) {
3208
3428
  setExpanded(false);
3209
- return;
3210
3429
  }
3211
- if (isDone || isSkipped) {
3212
- setExpanded(false);
3213
- }
3214
- }, [shouldAutoExpandRunning, isRunning, isDone, isSkipped, scale.onlyExpandActiveRunning]);
3430
+ }, [shouldAutoExpandRunning, effectivelyDone, isSkipped]);
3215
3431
  const requestLoopHydration = useCallback2(() => {
3432
+ if (suppressHostScopedLoop) return;
3216
3433
  const loopSessionId = node.loopSessionId;
3217
3434
  if (!loopSessionId || loopTree) return;
3218
3435
  pinLoopSession(loopSessionId);
3219
3436
  void replayHandler?.(loopSessionId);
3220
- }, [node.loopSessionId, loopTree, pinLoopSession, replayHandler]);
3437
+ }, [suppressHostScopedLoop, node.loopSessionId, loopTree, pinLoopSession, replayHandler]);
3221
3438
  const toggleExpanded = useCallback2(() => {
3222
3439
  setExpanded((prev) => {
3223
3440
  const next = !prev;
@@ -3236,17 +3453,18 @@ function WorkflowStageContainerInner({
3236
3453
  window.addEventListener("keydown", onKeyDown);
3237
3454
  return () => window.removeEventListener("keydown", onKeyDown);
3238
3455
  }, [requestLoopHydration]);
3456
+ const testId = containerTestId(node);
3457
+ const isItem = node.scope === "item";
3239
3458
  const displayTitle = node.title;
3240
3459
  const usesScopedLoop = Boolean(node.loopSessionId) && !suppressHostScopedLoop;
3241
- const runningLoopTree = loopTree ?? (usesScopedLoop && isRunning && isActive ? createWorkflowRunningLoopTree() : void 0);
3460
+ const runningLoopTree = loopTree ?? (usesScopedLoop && effectivelyRunning ? createWorkflowRunningLoopTree() : void 0);
3461
+ const itemLoopVirtualized = isItem ? false : virtualized;
3242
3462
  const staticContentTeaser = !usesScopedLoop && !loopTree && node.content_blocks.length > 0 ? node.content_blocks[node.content_blocks.length - 1].preview : "";
3243
3463
  const teaserText = doneSummary?.teaser || staticContentTeaser;
3244
3464
  const expandHint = doneSummary ? formatTeaserExpandHint(doneSummary.extraLines, verbose) : null;
3245
3465
  const doneLabel = doneSummary?.label ?? "done";
3246
- const statusLabel = isDone ? doneLabel : suppressHostScopedLoop && isRunning ? "running" : mapContainerStatusLabel(node.status);
3247
- const testId = containerTestId(node);
3248
- const isItem = node.scope === "item";
3249
- const canToggleShell = isRunning && !isSkipped || isDone;
3466
+ const statusLabel = effectivelyDone ? doneLabel : suppressHostScopedLoop && effectivelyRunning ? "running" : mapContainerStatusLabel(effectivelyRunning ? "running" : node.status);
3467
+ const canToggleShell = effectivelyRunning && !isSkipped || effectivelyDone;
3250
3468
  const shellToggle = canToggleShell ? toggleExpanded : void 0;
3251
3469
  return /* @__PURE__ */ jsxs5(
3252
3470
  "div",
@@ -3272,13 +3490,13 @@ function WorkflowStageContainerInner({
3272
3490
  onToggle: shellToggle,
3273
3491
  interactive: canToggleShell,
3274
3492
  expanded,
3275
- headerTestId: isDone ? `${testId}-done` : void 0,
3493
+ headerTestId: effectivelyDone ? `${testId}-done` : void 0,
3276
3494
  titleTestId: `${testId}-title`,
3277
3495
  descriptionTestId: `${testId}-desc`,
3278
3496
  className: isItem ? "lax-workflow-parallel-item__header" : void 0
3279
3497
  }
3280
3498
  ),
3281
- isDone && !expanded && (teaserText || expandHint) ? /* @__PURE__ */ jsxs5("div", { className: "lax-workflow-stage-container__done-teaser", children: [
3499
+ effectivelyDone && !expanded && (teaserText || expandHint) ? /* @__PURE__ */ jsxs5("div", { className: "lax-workflow-stage-container__done-teaser", children: [
3282
3500
  teaserText ? /* @__PURE__ */ jsx10(
3283
3501
  "span",
3284
3502
  {
@@ -3304,7 +3522,7 @@ function WorkflowStageContainerInner({
3304
3522
  ))
3305
3523
  }
3306
3524
  ) : null,
3307
- expanded && isRunning && runningLoopTree ? /* @__PURE__ */ jsx10(
3525
+ expanded && effectivelyRunning && runningLoopTree ? /* @__PURE__ */ jsx10(
3308
3526
  "div",
3309
3527
  {
3310
3528
  className: isItem ? "lax-workflow-parallel-item__body lax-workflow-stage-container__body" : "lax-workflow-stage-container__body",
@@ -3314,25 +3532,25 @@ function WorkflowStageContainerInner({
3314
3532
  {
3315
3533
  tree: runningLoopTree,
3316
3534
  showTaskListFooter: false,
3317
- virtualized,
3535
+ virtualized: itemLoopVirtualized,
3318
3536
  virtualizeThreshold,
3319
3537
  groupParallelTools
3320
3538
  }
3321
3539
  )
3322
3540
  }
3323
3541
  ) : null,
3324
- isDone && expanded ? loopTree ? /* @__PURE__ */ jsx10(
3542
+ effectivelyDone && expanded ? loopTree ? /* @__PURE__ */ jsx10(
3325
3543
  WorkflowStageDoneBody,
3326
3544
  {
3327
3545
  node,
3328
3546
  loopTree,
3329
3547
  testId: bodyTestId(node),
3330
- virtualized,
3548
+ virtualized: itemLoopVirtualized,
3331
3549
  virtualizeThreshold,
3332
3550
  groupParallelTools,
3333
3551
  bodyClassName: isItem ? "lax-workflow-parallel-item__body lax-workflow-stage-container__body" : "lax-workflow-stage-container__body"
3334
3552
  }
3335
- ) : /* @__PURE__ */ jsxs5(
3553
+ ) : suppressHostScopedLoop ? null : /* @__PURE__ */ jsxs5(
3336
3554
  "div",
3337
3555
  {
3338
3556
  className: "lax-workflow-stage-container__body lax-workflow-loop-replay-pending",
@@ -3349,6 +3567,7 @@ function WorkflowStageContainerInner({
3349
3567
  }
3350
3568
  function workflowStageContainerPropsEqual(prev, next) {
3351
3569
  if (prev.suppressHostScopedLoop !== next.suppressHostScopedLoop) return false;
3570
+ if (prev.subtreeHasActiveWork !== next.subtreeHasActiveWork) return false;
3352
3571
  if (prev.loopTree !== next.loopTree) return false;
3353
3572
  if (!workflowContainerViewPropsEqual(prev, next)) return false;
3354
3573
  return workflowContainerNodeVisualEqual(prev.node, next.node);
@@ -3370,34 +3589,23 @@ function WorkflowParallelGroupInner({
3370
3589
  virtualizeThreshold,
3371
3590
  groupParallelTools
3372
3591
  }) {
3373
- const scale = useWorkflowScaleOptions();
3374
- const store = useWorkflowSessionStoreApi();
3375
- const activeLoopSessionId = useStore3(store, (s) => s.state.activeLoopSessionId);
3376
- const activeContainerIds = useStore3(store, (s) => s.state.containerTree.activeContainerIds);
3377
3592
  const metrics = useMemo4(() => computeParallelGroupMetrics(items), [items]);
3378
3593
  const summary = formatParallelGroupSummary(metrics);
3379
- const hasRunning = metrics.running > 0;
3380
- const isNestedGroup = groupKey.includes(">");
3381
- const hasActiveItem = useMemo4(
3382
- () => items.some((item) => isActiveWorkflowContainer(item, activeLoopSessionId, activeContainerIds)),
3383
- [items, activeLoopSessionId, activeContainerIds]
3594
+ const hasActiveWork = useMemo4(
3595
+ () => parallelGroupHasActiveWork(items, loopTreesBySessionId),
3596
+ [items, loopTreesBySessionId]
3384
3597
  );
3385
3598
  const allSettled = metrics.total > 0 && metrics.running === 0 && metrics.completed + metrics.failed >= metrics.total;
3386
- const shouldAutoExpandGroup = hasRunning && (isNestedGroup || !scale.onlyExpandActiveRunning || hasActiveItem);
3387
- const [expanded, setExpanded] = useState3(shouldAutoExpandGroup);
3599
+ const [expanded, setExpanded] = useState3(hasActiveWork);
3388
3600
  useEffect5(() => {
3389
- if (shouldAutoExpandGroup) {
3601
+ if (hasActiveWork) {
3390
3602
  setExpanded(true);
3391
3603
  return;
3392
3604
  }
3393
- if (hasRunning && scale.onlyExpandActiveRunning) {
3394
- setExpanded(false);
3395
- return;
3396
- }
3397
3605
  if (allSettled) {
3398
3606
  setExpanded(false);
3399
3607
  }
3400
- }, [shouldAutoExpandGroup, hasRunning, allSettled, scale.onlyExpandActiveRunning]);
3608
+ }, [hasActiveWork, allSettled]);
3401
3609
  const toggleExpanded = useCallback3(() => {
3402
3610
  setExpanded((prev) => !prev);
3403
3611
  }, []);
@@ -3434,7 +3642,7 @@ function WorkflowParallelGroupInner({
3434
3642
  WorkflowExpandTrigger,
3435
3643
  {
3436
3644
  summary,
3437
- expandHint: !expanded && hasRunning ? "(click to expand agents)" : !expanded && !hasRunning ? "(ctrl+o to expand)" : null,
3645
+ expandHint: !expanded && hasActiveWork ? "(click to expand agents)" : !expanded && !hasActiveWork ? "(ctrl+o to expand)" : null,
3438
3646
  onToggle: toggleExpanded,
3439
3647
  expanded,
3440
3648
  testId: `lax-workflow-parallel-group-summary-${groupKey}`,
@@ -3469,11 +3677,14 @@ var WorkflowParallelGroup = memo3(WorkflowParallelGroupInner, workflowParallelGr
3469
3677
  import { jsx as jsx12, jsxs as jsxs7 } from "react/jsx-runtime";
3470
3678
  function WorkflowRootContainer({
3471
3679
  node,
3472
- children
3680
+ children,
3681
+ containersById
3473
3682
  }) {
3474
3683
  const { verbose } = useSessionViewOptions();
3475
- const uiStatus = mapContainerUiStatus(node.status);
3476
- const statusLabel = mapWorkflowRootStatusLabel(node.status);
3684
+ const visuallyActive = containersById ? isWorkflowRootVisuallyActive(node, containersById) : node.status === "running" || node.status === "retrying";
3685
+ const uiStatus = mapContainerUiStatus(visuallyActive ? "running" : node.status);
3686
+ const statusLabel = mapWorkflowRootStatusLabel(visuallyActive ? "running" : node.status);
3687
+ const meta = containersById ? buildEffectiveWorkflowRootMeta(node, containersById) : buildWorkflowRootMeta(node);
3477
3688
  return /* @__PURE__ */ jsxs7("div", { className: "lax-workflow-root", "data-testid": "lax-workflow-root", children: [
3478
3689
  /* @__PURE__ */ jsx12(
3479
3690
  WorkflowContainerLine,
@@ -3482,7 +3693,7 @@ function WorkflowRootContainer({
3482
3693
  title: node.title,
3483
3694
  description: node.subtitle || void 0,
3484
3695
  uiStatus,
3485
- meta: buildWorkflowRootMeta(node),
3696
+ meta,
3486
3697
  statusLabel,
3487
3698
  titleTestId: "lax-workflow-root-title",
3488
3699
  descriptionTestId: "lax-workflow-root-desc",
@@ -3610,31 +3821,36 @@ function buildDoneSummary(node) {
3610
3821
  function WorkflowSubworkflowContainer({
3611
3822
  node,
3612
3823
  depth = 1,
3613
- children
3824
+ children,
3825
+ subtreeHasActiveWork: subtreeHasActiveWork2 = false
3614
3826
  }) {
3615
3827
  const { verbose } = useSessionViewOptions();
3616
- const uiStatus = mapContainerUiStatus(node.status);
3617
3828
  const isRunning = node.status === "running" || node.status === "retrying";
3618
3829
  const isDone = node.status === "completed" || node.status === "failed";
3830
+ const effectivelyRunning = isRunning || subtreeHasActiveWork2;
3831
+ const effectivelyDone = isDone && !subtreeHasActiveWork2;
3832
+ const uiStatus = mapContainerUiStatus(
3833
+ effectivelyRunning ? "running" : node.status
3834
+ );
3619
3835
  const testId = `lax-workflow-subworkflow-${node.scope_key}`;
3620
3836
  const teaser = useMemo6(() => {
3621
3837
  if (node.content_blocks.length === 0) return "";
3622
3838
  return node.content_blocks[node.content_blocks.length - 1]?.preview ?? "";
3623
3839
  }, [node.content_blocks]);
3624
- const [expanded, setExpanded] = useState5(isRunning);
3840
+ const [expanded, setExpanded] = useState5(effectivelyRunning);
3625
3841
  useEffect7(() => {
3626
- if (isRunning) setExpanded(true);
3627
- if (isDone) setExpanded(false);
3628
- }, [isRunning, isDone]);
3842
+ if (effectivelyRunning) setExpanded(true);
3843
+ else if (effectivelyDone) setExpanded(false);
3844
+ }, [effectivelyRunning, effectivelyDone]);
3629
3845
  const toggleExpanded = useCallback5(() => {
3630
3846
  setExpanded((prev) => !prev);
3631
3847
  }, []);
3632
- const shellToggle = isRunning ? toggleExpanded : isDone && expanded ? toggleExpanded : void 0;
3848
+ const shellToggle = effectivelyRunning ? toggleExpanded : effectivelyDone && expanded ? toggleExpanded : void 0;
3633
3849
  const depthStyle = {
3634
3850
  "--lax-workflow-depth": depth,
3635
3851
  "--lax-workflow-indent-unit": workflowIndentUnit(depth)
3636
3852
  };
3637
- const showShellLine = !(isDone && !expanded);
3853
+ const showShellLine = !(effectivelyDone && !expanded);
3638
3854
  return /* @__PURE__ */ jsxs9(
3639
3855
  "div",
3640
3856
  {
@@ -3651,9 +3867,11 @@ function WorkflowSubworkflowContainer({
3651
3867
  description: node.subtitle || void 0,
3652
3868
  uiStatus,
3653
3869
  meta: buildWorkflowRootMeta(node),
3654
- statusLabel: mapWorkflowRootStatusLabel(node.status),
3870
+ statusLabel: mapWorkflowRootStatusLabel(
3871
+ effectivelyRunning ? "running" : node.status
3872
+ ),
3655
3873
  onToggle: shellToggle,
3656
- interactive: isRunning,
3874
+ interactive: effectivelyRunning,
3657
3875
  expanded,
3658
3876
  titleTestId: `${testId}-title`,
3659
3877
  descriptionTestId: `${testId}-desc`,
@@ -3673,13 +3891,13 @@ function WorkflowSubworkflowContainer({
3673
3891
  !expanded && children ? /* @__PURE__ */ jsx14(
3674
3892
  WorkflowExpandTrigger,
3675
3893
  {
3676
- summary: `${node.title} \xB7 ${isDone ? buildDoneSummary(node) : "running"}`,
3894
+ summary: `${node.title} \xB7 ${effectivelyDone ? buildDoneSummary(node) : "running"}`,
3677
3895
  summaryTestId: `${testId}-title`,
3678
3896
  teaser: teaser || void 0,
3679
- expandHint: isRunning ? "(click to expand subworkflow)" : void 0,
3897
+ expandHint: effectivelyRunning ? "(click to expand subworkflow)" : void 0,
3680
3898
  onToggle: () => setExpanded(true),
3681
3899
  expanded: false,
3682
- testId: isDone ? `${testId}-done` : `${testId}-running-collapsed`,
3900
+ testId: effectivelyDone ? `${testId}-done` : `${testId}-running-collapsed`,
3683
3901
  teaserTestId: teaser ? `${testId}-teaser` : void 0
3684
3902
  }
3685
3903
  ) : null
@@ -3705,6 +3923,7 @@ function collectSiblingItems(node, containersById) {
3705
3923
  }
3706
3924
  function renderLeafContainer(node, depth, containerTree, loopTreesBySessionId, viewProps, options) {
3707
3925
  const loopTree = resolveLoopTree2(node, loopTreesBySessionId);
3926
+ const subtreeActive = options?.subtreeHasActiveWork ?? subtreeHasActiveWork(containerTree.containersById, node.container_id);
3708
3927
  switch (node.scope) {
3709
3928
  case "aggregate":
3710
3929
  return /* @__PURE__ */ jsx15(
@@ -3737,6 +3956,7 @@ function renderLeafContainer(node, depth, containerTree, loopTreesBySessionId, v
3737
3956
  loopTree,
3738
3957
  depth,
3739
3958
  suppressHostScopedLoop: options?.suppressHostScopedLoop,
3959
+ subtreeHasActiveWork: subtreeActive,
3740
3960
  ...viewProps
3741
3961
  },
3742
3962
  node.container_id
@@ -3790,6 +4010,10 @@ function renderContainerNode(node, containerTree, loopTreesBySessionId, viewProp
3790
4010
  {
3791
4011
  node,
3792
4012
  depth,
4013
+ subtreeHasActiveWork: subtreeHasActiveWork(
4014
+ containerTree.containersById,
4015
+ node.container_id
4016
+ ),
3793
4017
  children: hasNested ? nested : null
3794
4018
  },
3795
4019
  node.container_id
@@ -3851,8 +4075,8 @@ function WorkflowStageListPanel({
3851
4075
  const [extraCompletedPages, setExtraCompletedPages] = useState6(0);
3852
4076
  const maxVisibleDoneCount = scale.completedStagePageSize + extraCompletedPages * scale.completedStagePageSize;
3853
4077
  const partition = useMemo7(
3854
- () => partitionWorkflowStages(stages, maxVisibleDoneCount),
3855
- [stages, maxVisibleDoneCount]
4078
+ () => partitionWorkflowStages(stages, maxVisibleDoneCount, containerTree.containersById),
4079
+ [stages, maxVisibleDoneCount, containerTree.containersById]
3856
4080
  );
3857
4081
  if (partition.visible.length === 0) return null;
3858
4082
  const showMoreCount = Math.min(
@@ -3950,17 +4174,25 @@ function WorkflowChrome({
3950
4174
  }
3951
4175
  ) });
3952
4176
  }
3953
- return /* @__PURE__ */ jsx15("div", { className: "lax-workflow-chrome", "data-testid": "lax-workflow-chrome", children: rootNodes.map((workflowNode) => /* @__PURE__ */ jsx15(WorkflowRootContainer, { node: workflowNode, children: renderWorkflowChildren(
3954
- workflowNode,
3955
- containerTree,
3956
- loopTreesBySessionId,
3957
- viewProps
3958
- ) }, workflowNode.container_id)) });
4177
+ return /* @__PURE__ */ jsx15("div", { className: "lax-workflow-chrome", "data-testid": "lax-workflow-chrome", children: rootNodes.map((workflowNode) => /* @__PURE__ */ jsx15(
4178
+ WorkflowRootContainer,
4179
+ {
4180
+ node: workflowNode,
4181
+ containersById: containerTree.containersById,
4182
+ children: renderWorkflowChildren(
4183
+ workflowNode,
4184
+ containerTree,
4185
+ loopTreesBySessionId,
4186
+ viewProps
4187
+ )
4188
+ },
4189
+ workflowNode.container_id
4190
+ )) });
3959
4191
  }
3960
4192
 
3961
4193
  // src/view/workflow/WorkflowTaskListFooter.tsx
3962
4194
  import { useMemo as useMemo8, useState as useState7 } from "react";
3963
- import { useStore as useStore4 } from "zustand";
4195
+ import { useStore as useStore3 } from "zustand";
3964
4196
 
3965
4197
  // src/view/workflow/workflowTaskListMerge.ts
3966
4198
  var STATUS_ORDER = {
@@ -4040,7 +4272,7 @@ function MessageResponse({ children }) {
4040
4272
  }
4041
4273
  function WorkflowTaskListFooter() {
4042
4274
  const store = useWorkflowSessionStoreApi();
4043
- const state = useStore4(store, (s) => s.state);
4275
+ const state = useStore3(store, (s) => s.state);
4044
4276
  const [expanded, setExpanded] = useState7(false);
4045
4277
  const footerState = useMemo8(() => deriveWorkflowTaskFooterState(state), [state]);
4046
4278
  const visible = shouldShowWorkflowTaskListFooter(state);
@@ -4080,7 +4312,7 @@ function WorkflowTaskListFooter() {
4080
4312
  }
4081
4313
 
4082
4314
  // src/view/workflow/WorkflowSession.tsx
4083
- import { useStore as useStore5 } from "zustand";
4315
+ import { useStore as useStore4 } from "zustand";
4084
4316
  import { jsx as jsx17, jsxs as jsxs12 } from "react/jsx-runtime";
4085
4317
  function WorkflowSession({
4086
4318
  source,
@@ -4103,6 +4335,7 @@ function WorkflowSession({
4103
4335
  groupParallelTools = false,
4104
4336
  workflowScale,
4105
4337
  onRequestLoopReplay,
4338
+ onStreamComplete,
4106
4339
  children
4107
4340
  }) {
4108
4341
  const storeRef = useRef3(null);
@@ -4152,39 +4385,46 @@ function WorkflowSession({
4152
4385
  workspaceRoot
4153
4386
  ]
4154
4387
  );
4155
- const containerTree = useStore5(
4388
+ const containerTree = useStore4(
4156
4389
  storeRef.current,
4157
4390
  (s) => s.state.containerTree
4158
4391
  );
4159
- const loopTreesBySessionId = useStore5(
4392
+ const loopTreesBySessionId = useStore4(
4160
4393
  storeRef.current,
4161
4394
  (s) => s.state.loopTreesBySessionId
4162
4395
  );
4163
- const sessionStatus = useStore5(
4396
+ const sessionStatus = useStore4(
4164
4397
  storeRef.current,
4165
4398
  (s) => s.state.status
4166
4399
  );
4167
- const internalErrors = useStore5(
4400
+ const internalErrors = useStore4(
4168
4401
  storeRef.current,
4169
4402
  (s) => s.state.internalErrors
4170
4403
  );
4404
+ const onErrorRef = useRef3(onError);
4405
+ const onStreamCompleteRef = useRef3(onStreamComplete);
4406
+ onErrorRef.current = onError;
4407
+ onStreamCompleteRef.current = onStreamComplete;
4171
4408
  useEffect8(() => {
4172
4409
  const store = storeRef.current;
4173
4410
  const controller = new AbortController();
4174
4411
  void source.start((event, ctx) => {
4175
4412
  store.getState().applyEvent(event, ctx);
4176
- }, controller.signal).catch((err) => {
4413
+ }, controller.signal).then(() => {
4414
+ store.getState().markStreamComplete();
4415
+ onStreamCompleteRef.current?.();
4416
+ }).catch((err) => {
4177
4417
  if (err instanceof SseTransportTerminalError) {
4178
4418
  store.getState().markAsError(err);
4179
- onError?.(err);
4419
+ onErrorRef.current?.(err);
4180
4420
  } else if (err instanceof Error) {
4181
- onError?.(err);
4421
+ onErrorRef.current?.(err);
4182
4422
  }
4183
4423
  });
4184
4424
  return () => {
4185
4425
  controller.abort();
4186
4426
  };
4187
- }, [source, onError]);
4427
+ }, [source]);
4188
4428
  return /* @__PURE__ */ jsx17(WorkflowSessionStoreContext.Provider, { value: storeRef.current, children: /* @__PURE__ */ jsx17(WorkflowScaleContext.Provider, { value: scaleOptions, children: /* @__PURE__ */ jsx17(WorkflowLoopReplayProvider, { onRequestLoopReplay, children: /* @__PURE__ */ jsx17(InteractionBusContext.Provider, { value: busRef, children: /* @__PURE__ */ jsx17(NodeRegistryContext.Provider, { value: registryRef, children: /* @__PURE__ */ jsx17(MarkdownRendererContext.Provider, { value: markdownRenderer, children: /* @__PURE__ */ jsx17(ToolDisplayOptionsContext.Provider, { value: toolDisplayRef, children: /* @__PURE__ */ jsx17(SessionViewOptionsContext.Provider, { value: sessionViewRef, children: /* @__PURE__ */ jsxs12(
4189
4429
  "div",
4190
4430
  {
@@ -4407,6 +4647,7 @@ export {
4407
4647
  createReplaySource,
4408
4648
  createSessionStore,
4409
4649
  createWorkflowSessionStore,
4650
+ deriveLoopSessionIdFromAgentEvent,
4410
4651
  displayPath,
4411
4652
  entryKey,
4412
4653
  filterAgentLoopReplayEvents,