langchain_agentx_stream_ui 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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;
@@ -1727,6 +1905,9 @@ function settleCompletedStageSubtree(tree, stageContainerId) {
1727
1905
  }
1728
1906
  if (node.container_id === stageContainerId) continue;
1729
1907
  if (node.status === "completed" || node.status === "failed") continue;
1908
+ if (node.status === "running" || node.status === "retrying" || node.status === "pending" || node.status === "blocked") {
1909
+ continue;
1910
+ }
1730
1911
  next = upsertRuntimeNode(next, {
1731
1912
  ...node,
1732
1913
  status: "completed",
@@ -1908,17 +2089,26 @@ function findContainerByLoopSession(tree, loopSessionId) {
1908
2089
  return findContainerIdByLoopSession(tree, loopSessionId);
1909
2090
  }
1910
2091
  var WORKFLOW_DEFAULT_LOOP_SESSION_ID = "workflow-loop-default";
1911
- function resolveAgentLoopSessionId(event, activeLoopSessionId) {
2092
+ function resolveAgentLoopSessionId(event, activeLoopSessionId, containerTree) {
1912
2093
  if (event.session_id) return event.session_id;
1913
2094
  if (isWorkflowStructureEventType(event.event_type)) return null;
1914
2095
  if (event.event_type === "task-list-snapshot") return null;
1915
2096
  const derived = deriveLoopSessionIdFromAgentEvent(event);
1916
2097
  if (derived) return derived;
2098
+ const byLoop = containersByLoopSessionId(containerTree);
2099
+ if (activeLoopSessionId) {
2100
+ const activeContainer = byLoop.get(activeLoopSessionId);
2101
+ if (activeContainer && (activeContainer.status === "running" || activeContainer.status === "retrying")) {
2102
+ return activeLoopSessionId;
2103
+ }
2104
+ }
2105
+ const runningLoopIds = [...byLoop.entries()].filter(([, node]) => node.status === "running" || node.status === "retrying").map(([loopSessionId]) => loopSessionId);
2106
+ if (runningLoopIds.length === 1) return runningLoopIds[0];
1917
2107
  if (activeLoopSessionId) return activeLoopSessionId;
1918
2108
  return WORKFLOW_DEFAULT_LOOP_SESSION_ID;
1919
2109
  }
1920
2110
  function bindLoopSessionToContainerTree(tree, event, activeLoopSessionId) {
1921
- const sessionId = resolveAgentLoopSessionId(event, activeLoopSessionId);
2111
+ const sessionId = resolveAgentLoopSessionId(event, activeLoopSessionId, tree);
1922
2112
  if (!sessionId) return tree;
1923
2113
  let nextTree = ensureEmbeddedSubworkflowContainers(tree, sessionId);
1924
2114
  if (findContainerByLoopSession(nextTree, sessionId)) return nextTree;
@@ -2157,7 +2347,7 @@ function reduceWorkflowSession(state, event, eventIndex, options) {
2157
2347
  if (isWorkflowStructureEventType(event.event_type)) {
2158
2348
  return applyStructureEvent(state, event);
2159
2349
  }
2160
- const loopSessionId = resolveAgentLoopSessionId(event, state.activeLoopSessionId) ?? WORKFLOW_DEFAULT_LOOP_SESSION_ID;
2350
+ const loopSessionId = resolveAgentLoopSessionId(event, state.activeLoopSessionId, state.containerTree) ?? WORKFLOW_DEFAULT_LOOP_SESSION_ID;
2161
2351
  let containerTree = bindLoopSessionToContainerTree(
2162
2352
  state.containerTree,
2163
2353
  event,
@@ -2176,6 +2366,11 @@ function reduceWorkflowSession(state, event, eventIndex, options) {
2176
2366
  next.containerTree,
2177
2367
  next.workflowProgress
2178
2368
  );
2369
+ let status = next.status;
2370
+ if (workflowProgress.status === "done") status = "done";
2371
+ else if (workflowProgress.status === "error") status = "error";
2372
+ else if (reducedLoop.status === "error") status = "error";
2373
+ else if (status === "connecting") status = "running";
2179
2374
  return {
2180
2375
  ...next,
2181
2376
  workflowProgress,
@@ -2184,7 +2379,85 @@ function reduceWorkflowSession(state, event, eventIndex, options) {
2184
2379
  [loopSessionId]: reducedLoop
2185
2380
  },
2186
2381
  activeLoopSessionId: loopSessionId,
2187
- status: reducedLoop.status === "error" ? "error" : next.status === "connecting" ? "running" : next.status
2382
+ status
2383
+ };
2384
+ }
2385
+ function finalizeWorkflowSessionOnStreamComplete(state) {
2386
+ if (state.status === "done" || state.status === "error") {
2387
+ return { ...state, activeLoopSessionId: null };
2388
+ }
2389
+ const tree = state.containerTree;
2390
+ const rootId = tree.rootContainerIds[0];
2391
+ if (!rootId) {
2392
+ return { ...state, status: "done", activeLoopSessionId: null };
2393
+ }
2394
+ const root = tree.containersById[rootId];
2395
+ if (!root) {
2396
+ return { ...state, status: "done", activeLoopSessionId: null };
2397
+ }
2398
+ const topLevelStages = Object.values(tree.containersById).filter(
2399
+ (node) => node.scope === "stage" && node.workflow_path === root.workflow_path && node.workflow_depth === root.workflow_depth
2400
+ );
2401
+ const progressTotal = root.display?.progress_total ?? (topLevelStages.reduce(
2402
+ (max, s) => Math.max(max, s.display?.progress_total ?? 0),
2403
+ 0
2404
+ ) || topLevelStages.length);
2405
+ const settledStageCount = topLevelStages.filter(
2406
+ (s) => s.status === "completed" || s.status === "failed" || s.status === "skipped"
2407
+ ).length;
2408
+ const progressCurrent = Math.max(
2409
+ root.display?.progress_current ?? 0,
2410
+ settledStageCount
2411
+ );
2412
+ const rootParallelItems = Object.values(tree.containersById).filter(
2413
+ (node) => node.scope === "item" && node.workflow_path === root.workflow_path && node.workflow_depth === root.workflow_depth
2414
+ );
2415
+ const rootParallelSettled = topLevelStages.length === 0 && rootParallelItems.length > 0 && rootParallelItems.every(
2416
+ (item) => item.status === "completed" || item.status === "failed" || item.status === "skipped"
2417
+ ) && !Object.values(tree.containersById).some(
2418
+ (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")
2419
+ );
2420
+ const workflowVisuallyComplete = root.status === "completed" || root.status === "failed" || progressTotal > 0 && progressCurrent >= progressTotal || rootParallelSettled;
2421
+ if (!workflowVisuallyComplete) {
2422
+ return state;
2423
+ }
2424
+ let containersById = { ...tree.containersById };
2425
+ for (const [id, node] of Object.entries(containersById)) {
2426
+ if (node.status === "running" || node.status === "pending") {
2427
+ containersById[id] = { ...node, status: "completed", is_open: false };
2428
+ }
2429
+ }
2430
+ const settledRoot = containersById[rootId] ?? root;
2431
+ if (settledRoot.status !== "completed" && settledRoot.status !== "failed") {
2432
+ containersById = {
2433
+ ...containersById,
2434
+ [rootId]: { ...settledRoot, status: "completed", is_open: false }
2435
+ };
2436
+ }
2437
+ const loopTreesBySessionId = { ...state.loopTreesBySessionId };
2438
+ for (const [loopSessionId, loopTree] of Object.entries(loopTreesBySessionId)) {
2439
+ if (loopTree.status === "running" || loopTree.status === "connecting") {
2440
+ loopTreesBySessionId[loopSessionId] = finalizeWorkflowLoopTree(loopTree, "done");
2441
+ }
2442
+ }
2443
+ const containerTree = {
2444
+ ...tree,
2445
+ containersById,
2446
+ activeContainerIds: tree.activeContainerIds.filter(
2447
+ (id) => containersById[id]?.is_open
2448
+ )
2449
+ };
2450
+ const workflowProgress = syncWorkflowProgressFromContainerTree(
2451
+ containerTree,
2452
+ state.workflowProgress
2453
+ );
2454
+ return {
2455
+ ...state,
2456
+ status: workflowProgress.status === "error" ? "error" : "done",
2457
+ containerTree,
2458
+ workflowProgress,
2459
+ loopTreesBySessionId,
2460
+ activeLoopSessionId: null
2188
2461
  };
2189
2462
  }
2190
2463
  function reduceWorkflowEvents(events, options) {
@@ -2194,180 +2467,37 @@ function reduceWorkflowEvents(events, options) {
2194
2467
  );
2195
2468
  }
2196
2469
 
2197
- // src/core/workflow/stageDoneSummary.ts
2198
- function countToolUses(tree) {
2199
- return Object.values(tree.byId).filter((node) => node.kind === "tool_call").length;
2470
+ // src/core/workflowSessionStore.ts
2471
+ var DEFAULT_HYDRATE_CHUNK_SIZE = 50;
2472
+ function nextAnimationFrame() {
2473
+ return new Promise((resolve) => {
2474
+ requestAnimationFrame(() => resolve());
2475
+ });
2200
2476
  }
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);
2477
+ function withoutHydrating(ids, loopSessionId) {
2478
+ if (!ids[loopSessionId]) return ids;
2479
+ const next = { ...ids };
2480
+ delete next[loopSessionId];
2481
+ return next;
2482
+ }
2483
+ function safeReduceWorkflow(state, event, eventIndex, options) {
2484
+ try {
2485
+ return { state: reduceWorkflowSession(state, event, eventIndex, options) };
2486
+ } catch (err) {
2487
+ const message = err instanceof Error ? err.message : String(err);
2488
+ const stack = err instanceof Error ? err.stack : void 0;
2489
+ if (import.meta.env?.DEV) {
2490
+ console.error("[langchain_agentx_stream_ui] workflow reducer error:", err);
2211
2491
  }
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
- };
2492
+ return {
2493
+ state: {
2494
+ ...state,
2495
+ internalErrors: [
2496
+ ...state.internalErrors,
2497
+ { eventIndex, eventType: event.event_type, message, stack }
2498
+ ]
2499
+ }
2500
+ };
2371
2501
  }
2372
2502
  }
2373
2503
  function finalizeWorkflowSessionOnStreamError(state, message, eventIndex, stack) {
@@ -2462,6 +2592,13 @@ function createWorkflowSessionStore(initialState = createEmptyWorkflowSessionSta
2462
2592
  )
2463
2593
  });
2464
2594
  },
2595
+ markStreamComplete() {
2596
+ set((current) => ({
2597
+ state: finalizeState(
2598
+ finalizeWorkflowSessionOnStreamComplete(current.state)
2599
+ )
2600
+ }));
2601
+ },
2465
2602
  /**
2466
2603
  * P2:将 completed loop 加入 LRU 热缓存并淘汰溢出。
2467
2604
  * running 保护由 collectProtectedLoopSessionIds 按状态自动覆盖,不再依赖永久 pin。
@@ -2716,6 +2853,76 @@ function WorkflowMarkdownPreview({
2716
2853
  return /* @__PURE__ */ jsx7("div", { className, "data-testid": testId, children: /* @__PURE__ */ jsx7(RichMarkdown, { content, deferDiagrams: true }) });
2717
2854
  }
2718
2855
 
2856
+ // src/view/workflow/workflowStageListUtils.ts
2857
+ var ACTIVE_CONTAINER_STATUSES = /* @__PURE__ */ new Set([
2858
+ "running",
2859
+ "retrying",
2860
+ "pending",
2861
+ "blocked"
2862
+ ]);
2863
+ function isDescendantContainer(containersById, ancestorId, nodeId) {
2864
+ const seen = /* @__PURE__ */ new Set();
2865
+ let current = containersById[nodeId]?.parent_container_id ?? null;
2866
+ while (current) {
2867
+ if (current === ancestorId) return true;
2868
+ if (seen.has(current)) return false;
2869
+ seen.add(current);
2870
+ current = containersById[current]?.parent_container_id ?? null;
2871
+ }
2872
+ return false;
2873
+ }
2874
+ function subtreeHasActiveWork(containersById, rootContainerId) {
2875
+ for (const node of Object.values(containersById)) {
2876
+ if (node.container_id === rootContainerId) continue;
2877
+ if (!isDescendantContainer(containersById, rootContainerId, node.container_id)) continue;
2878
+ if (ACTIVE_CONTAINER_STATUSES.has(node.status)) return true;
2879
+ }
2880
+ return false;
2881
+ }
2882
+ function stageSortKey(node) {
2883
+ return node.display?.progress_current ?? 0;
2884
+ }
2885
+ function partitionWorkflowStages(stages, maxVisibleDoneCount, containersById) {
2886
+ const sorted = [...stages].sort(
2887
+ (a, b) => stageSortKey(a) - stageSortKey(b) || a.scope_key.localeCompare(b.scope_key)
2888
+ );
2889
+ const always = [];
2890
+ const done = [];
2891
+ for (const stage of sorted) {
2892
+ if (containersById && subtreeHasActiveWork(containersById, stage.container_id)) {
2893
+ always.push(stage);
2894
+ continue;
2895
+ }
2896
+ if (stage.status === "running" || stage.status === "retrying" || stage.status === "failed" || stage.status === "pending" || stage.status === "blocked") {
2897
+ always.push(stage);
2898
+ } else if (stage.status === "completed" || stage.status === "skipped") {
2899
+ done.push(stage);
2900
+ } else {
2901
+ always.push(stage);
2902
+ }
2903
+ }
2904
+ const visibleDone = done.slice(Math.max(0, done.length - maxVisibleDoneCount));
2905
+ const visible = [...always, ...visibleDone].sort(
2906
+ (a, b) => stageSortKey(a) - stageSortKey(b) || a.scope_key.localeCompare(b.scope_key)
2907
+ );
2908
+ const hiddenCount = stages.length - visible.length;
2909
+ return {
2910
+ visible,
2911
+ hiddenCount,
2912
+ total: stages.length,
2913
+ visibleDoneCount: visibleDone.length,
2914
+ totalDoneCount: done.length
2915
+ };
2916
+ }
2917
+ function isActiveWorkflowContainer(node, activeLoopSessionId, activeContainerIds) {
2918
+ if (activeContainerIds.includes(node.container_id)) return true;
2919
+ if (node.status === "completed" || node.status === "failed" || node.status === "skipped") {
2920
+ return false;
2921
+ }
2922
+ if (node.loopSessionId && node.loopSessionId === activeLoopSessionId) return true;
2923
+ return false;
2924
+ }
2925
+
2719
2926
  // src/view/workflow/workflowContainerLineUtils.ts
2720
2927
  function formatContainerScopeMetaLabel(scope) {
2721
2928
  return scope;
@@ -2785,6 +2992,27 @@ function buildWorkflowRootMeta(node) {
2785
2992
  }
2786
2993
  return withScopeMeta(node, meta);
2787
2994
  }
2995
+ function buildEffectiveWorkflowRootMeta(node, containersById) {
2996
+ const meta = [];
2997
+ if (node.pattern) meta.push(node.pattern);
2998
+ const topStages = Object.values(containersById).filter(
2999
+ (n) => n.scope === "stage" && n.parent_container_id === node.container_id
3000
+ );
3001
+ const total = node.display?.progress_total ?? Math.max(topStages.length, 1);
3002
+ let settled = 0;
3003
+ for (const stage of topStages) {
3004
+ if (subtreeHasActiveWork(containersById, stage.container_id)) continue;
3005
+ if (stage.status === "completed" || stage.status === "failed" || stage.status === "skipped") {
3006
+ settled += 1;
3007
+ }
3008
+ }
3009
+ if (total > 0) meta.push(`${settled}/${total}`);
3010
+ return withScopeMeta(node, meta);
3011
+ }
3012
+ function isWorkflowRootVisuallyActive(node, containersById) {
3013
+ if (node.status === "running" || node.status === "retrying") return true;
3014
+ return subtreeHasActiveWork(containersById, node.container_id);
3015
+ }
2788
3016
  function buildStageLineMeta(node) {
2789
3017
  const progress = buildStageProgressMeta(node);
2790
3018
  return withScopeMeta(node, progress ? [progress] : []);
@@ -3008,9 +3236,41 @@ var WorkflowAggregateContainer = memo(
3008
3236
 
3009
3237
  // src/view/workflow/WorkflowParallelGroup.tsx
3010
3238
  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
3239
 
3013
3240
  // src/core/workflow/workflowParallelGroupMetrics.ts
3241
+ var ACTIVE_ITEM_STATUSES = /* @__PURE__ */ new Set([
3242
+ "running",
3243
+ "retrying",
3244
+ "pending",
3245
+ "blocked"
3246
+ ]);
3247
+ function countMetricsFromContainerStatus(items) {
3248
+ let running = 0;
3249
+ let completed = 0;
3250
+ let failed = 0;
3251
+ for (const item of items) {
3252
+ if (ACTIVE_ITEM_STATUSES.has(item.status)) {
3253
+ running += 1;
3254
+ continue;
3255
+ }
3256
+ switch (item.status) {
3257
+ case "completed":
3258
+ completed += 1;
3259
+ break;
3260
+ case "failed":
3261
+ failed += 1;
3262
+ break;
3263
+ default:
3264
+ break;
3265
+ }
3266
+ }
3267
+ return {
3268
+ total: items.length,
3269
+ running,
3270
+ completed,
3271
+ failed
3272
+ };
3273
+ }
3014
3274
  function readDisplayMetrics(items) {
3015
3275
  let hasMetrics = false;
3016
3276
  let running = 0;
@@ -3031,34 +3291,29 @@ function readDisplayMetrics(items) {
3031
3291
  if (total <= 0) total = items.length;
3032
3292
  return { total, running, completed, failed };
3033
3293
  }
3294
+ function mergeParallelGroupMetrics(fromDisplay, fromStatus, itemCount) {
3295
+ return {
3296
+ total: Math.max(fromDisplay.total, fromStatus.total, itemCount),
3297
+ running: Math.max(fromDisplay.running, fromStatus.running),
3298
+ completed: Math.max(fromDisplay.completed, fromStatus.completed),
3299
+ failed: Math.max(fromDisplay.failed, fromStatus.failed)
3300
+ };
3301
+ }
3034
3302
  function computeParallelGroupMetrics(items) {
3303
+ const fromStatus = countMetricsFromContainerStatus(items);
3035
3304
  const fromDisplay = readDisplayMetrics(items);
3036
- if (fromDisplay) return fromDisplay;
3037
- let running = 0;
3038
- let completed = 0;
3039
- let failed = 0;
3305
+ if (!fromDisplay) return fromStatus;
3306
+ return mergeParallelGroupMetrics(fromDisplay, fromStatus, items.length);
3307
+ }
3308
+ function parallelGroupHasActiveWork(items, loopTreesBySessionId = {}) {
3309
+ const metrics = computeParallelGroupMetrics(items);
3310
+ if (metrics.running > 0) return true;
3040
3311
  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
- }
3312
+ const loopSessionId = item.loopSessionId;
3313
+ if (!loopSessionId) continue;
3314
+ if (isWorkflowLoopTreeActive(loopTreesBySessionId[loopSessionId])) return true;
3055
3315
  }
3056
- return {
3057
- total: items.length,
3058
- running,
3059
- completed,
3060
- failed
3061
- };
3316
+ return false;
3062
3317
  }
3063
3318
  function formatParallelGroupSummary(metrics) {
3064
3319
  if (metrics.running > 0) {
@@ -3115,47 +3370,6 @@ function WorkflowStageDoneBody({
3115
3370
  );
3116
3371
  }
3117
3372
 
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
3373
  // src/view/workflow/WorkflowStageContainer.tsx
3160
3374
  import { jsx as jsx10, jsxs as jsxs5 } from "react/jsx-runtime";
3161
3375
  function containerTestId(node) {
@@ -3174,10 +3388,10 @@ function WorkflowStageContainerInner({
3174
3388
  virtualized,
3175
3389
  virtualizeThreshold,
3176
3390
  groupParallelTools,
3177
- suppressHostScopedLoop = false
3391
+ suppressHostScopedLoop = false,
3392
+ subtreeHasActiveWork: subtreeHasActiveWork2 = false
3178
3393
  }) {
3179
3394
  const { verbose } = useSessionViewOptions();
3180
- const scale = useWorkflowScaleOptions();
3181
3395
  const store = useWorkflowSessionStoreApi();
3182
3396
  const replayHandler = useWorkflowLoopReplayHandler();
3183
3397
  const activeLoopSessionId = useStore2(store, (s) => s.state.activeLoopSessionId);
@@ -3187,31 +3401,30 @@ function WorkflowStageContainerInner({
3187
3401
  (s) => s.state.stageDoneSummariesByContainerId[node.container_id]
3188
3402
  );
3189
3403
  const pinLoopSession = useStore2(store, (s) => s.pinLoopSession);
3190
- const uiStatus = mapContainerUiStatus(node.status);
3191
3404
  const isRunning = node.status === "running" || node.status === "retrying";
3192
3405
  const isDone = node.status === "completed" || node.status === "failed";
3193
3406
  const isSkipped = node.status === "skipped";
3407
+ const loopStillActive = isWorkflowLoopTreeActive(loopTree);
3408
+ const effectivelyRunning = isRunning || loopStillActive || subtreeHasActiveWork2 && isDone;
3409
+ const effectivelyDone = isDone && !subtreeHasActiveWork2 && !loopStillActive;
3410
+ const uiStatus = mapContainerUiStatus(effectivelyRunning ? "running" : node.status);
3194
3411
  const isActive = isActiveWorkflowContainer(node, activeLoopSessionId, activeContainerIds);
3195
3412
  const doneSummaryFromTree = useMemo3(
3196
3413
  () => loopTree && isDone ? buildStageDoneSummary(loopTree) : null,
3197
3414
  [loopTree, isDone]
3198
3415
  );
3199
3416
  const doneSummary = doneSummaryFromTree ?? (isDone ? cachedSummary : null);
3200
- const shouldAutoExpandRunning = isRunning && !isSkipped && (!scale.onlyExpandActiveRunning || isActive);
3417
+ const shouldAutoExpandRunning = effectivelyRunning && !isSkipped;
3201
3418
  const [expanded, setExpanded] = useState2(shouldAutoExpandRunning);
3202
3419
  useEffect4(() => {
3203
3420
  if (shouldAutoExpandRunning) {
3204
3421
  setExpanded(true);
3205
3422
  return;
3206
3423
  }
3207
- if (isRunning && !isSkipped && scale.onlyExpandActiveRunning) {
3424
+ if (effectivelyDone || isSkipped) {
3208
3425
  setExpanded(false);
3209
- return;
3210
3426
  }
3211
- if (isDone || isSkipped) {
3212
- setExpanded(false);
3213
- }
3214
- }, [shouldAutoExpandRunning, isRunning, isDone, isSkipped, scale.onlyExpandActiveRunning]);
3427
+ }, [shouldAutoExpandRunning, effectivelyDone, isSkipped]);
3215
3428
  const requestLoopHydration = useCallback2(() => {
3216
3429
  const loopSessionId = node.loopSessionId;
3217
3430
  if (!loopSessionId || loopTree) return;
@@ -3236,17 +3449,18 @@ function WorkflowStageContainerInner({
3236
3449
  window.addEventListener("keydown", onKeyDown);
3237
3450
  return () => window.removeEventListener("keydown", onKeyDown);
3238
3451
  }, [requestLoopHydration]);
3452
+ const testId = containerTestId(node);
3453
+ const isItem = node.scope === "item";
3239
3454
  const displayTitle = node.title;
3240
3455
  const usesScopedLoop = Boolean(node.loopSessionId) && !suppressHostScopedLoop;
3241
- const runningLoopTree = loopTree ?? (usesScopedLoop && isRunning && isActive ? createWorkflowRunningLoopTree() : void 0);
3456
+ const runningLoopTree = loopTree ?? (usesScopedLoop && effectivelyRunning ? createWorkflowRunningLoopTree() : void 0);
3457
+ const itemLoopVirtualized = isItem ? false : virtualized;
3242
3458
  const staticContentTeaser = !usesScopedLoop && !loopTree && node.content_blocks.length > 0 ? node.content_blocks[node.content_blocks.length - 1].preview : "";
3243
3459
  const teaserText = doneSummary?.teaser || staticContentTeaser;
3244
3460
  const expandHint = doneSummary ? formatTeaserExpandHint(doneSummary.extraLines, verbose) : null;
3245
3461
  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;
3462
+ const statusLabel = effectivelyDone ? doneLabel : suppressHostScopedLoop && effectivelyRunning ? "running" : mapContainerStatusLabel(effectivelyRunning ? "running" : node.status);
3463
+ const canToggleShell = effectivelyRunning && !isSkipped || effectivelyDone;
3250
3464
  const shellToggle = canToggleShell ? toggleExpanded : void 0;
3251
3465
  return /* @__PURE__ */ jsxs5(
3252
3466
  "div",
@@ -3272,13 +3486,13 @@ function WorkflowStageContainerInner({
3272
3486
  onToggle: shellToggle,
3273
3487
  interactive: canToggleShell,
3274
3488
  expanded,
3275
- headerTestId: isDone ? `${testId}-done` : void 0,
3489
+ headerTestId: effectivelyDone ? `${testId}-done` : void 0,
3276
3490
  titleTestId: `${testId}-title`,
3277
3491
  descriptionTestId: `${testId}-desc`,
3278
3492
  className: isItem ? "lax-workflow-parallel-item__header" : void 0
3279
3493
  }
3280
3494
  ),
3281
- isDone && !expanded && (teaserText || expandHint) ? /* @__PURE__ */ jsxs5("div", { className: "lax-workflow-stage-container__done-teaser", children: [
3495
+ effectivelyDone && !expanded && (teaserText || expandHint) ? /* @__PURE__ */ jsxs5("div", { className: "lax-workflow-stage-container__done-teaser", children: [
3282
3496
  teaserText ? /* @__PURE__ */ jsx10(
3283
3497
  "span",
3284
3498
  {
@@ -3304,7 +3518,7 @@ function WorkflowStageContainerInner({
3304
3518
  ))
3305
3519
  }
3306
3520
  ) : null,
3307
- expanded && isRunning && runningLoopTree ? /* @__PURE__ */ jsx10(
3521
+ expanded && effectivelyRunning && runningLoopTree ? /* @__PURE__ */ jsx10(
3308
3522
  "div",
3309
3523
  {
3310
3524
  className: isItem ? "lax-workflow-parallel-item__body lax-workflow-stage-container__body" : "lax-workflow-stage-container__body",
@@ -3314,20 +3528,20 @@ function WorkflowStageContainerInner({
3314
3528
  {
3315
3529
  tree: runningLoopTree,
3316
3530
  showTaskListFooter: false,
3317
- virtualized,
3531
+ virtualized: itemLoopVirtualized,
3318
3532
  virtualizeThreshold,
3319
3533
  groupParallelTools
3320
3534
  }
3321
3535
  )
3322
3536
  }
3323
3537
  ) : null,
3324
- isDone && expanded ? loopTree ? /* @__PURE__ */ jsx10(
3538
+ effectivelyDone && expanded ? loopTree ? /* @__PURE__ */ jsx10(
3325
3539
  WorkflowStageDoneBody,
3326
3540
  {
3327
3541
  node,
3328
3542
  loopTree,
3329
3543
  testId: bodyTestId(node),
3330
- virtualized,
3544
+ virtualized: itemLoopVirtualized,
3331
3545
  virtualizeThreshold,
3332
3546
  groupParallelTools,
3333
3547
  bodyClassName: isItem ? "lax-workflow-parallel-item__body lax-workflow-stage-container__body" : "lax-workflow-stage-container__body"
@@ -3349,6 +3563,7 @@ function WorkflowStageContainerInner({
3349
3563
  }
3350
3564
  function workflowStageContainerPropsEqual(prev, next) {
3351
3565
  if (prev.suppressHostScopedLoop !== next.suppressHostScopedLoop) return false;
3566
+ if (prev.subtreeHasActiveWork !== next.subtreeHasActiveWork) return false;
3352
3567
  if (prev.loopTree !== next.loopTree) return false;
3353
3568
  if (!workflowContainerViewPropsEqual(prev, next)) return false;
3354
3569
  return workflowContainerNodeVisualEqual(prev.node, next.node);
@@ -3370,34 +3585,23 @@ function WorkflowParallelGroupInner({
3370
3585
  virtualizeThreshold,
3371
3586
  groupParallelTools
3372
3587
  }) {
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
3588
  const metrics = useMemo4(() => computeParallelGroupMetrics(items), [items]);
3378
3589
  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]
3590
+ const hasActiveWork = useMemo4(
3591
+ () => parallelGroupHasActiveWork(items, loopTreesBySessionId),
3592
+ [items, loopTreesBySessionId]
3384
3593
  );
3385
3594
  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);
3595
+ const [expanded, setExpanded] = useState3(hasActiveWork);
3388
3596
  useEffect5(() => {
3389
- if (shouldAutoExpandGroup) {
3597
+ if (hasActiveWork) {
3390
3598
  setExpanded(true);
3391
3599
  return;
3392
3600
  }
3393
- if (hasRunning && scale.onlyExpandActiveRunning) {
3394
- setExpanded(false);
3395
- return;
3396
- }
3397
3601
  if (allSettled) {
3398
3602
  setExpanded(false);
3399
3603
  }
3400
- }, [shouldAutoExpandGroup, hasRunning, allSettled, scale.onlyExpandActiveRunning]);
3604
+ }, [hasActiveWork, allSettled]);
3401
3605
  const toggleExpanded = useCallback3(() => {
3402
3606
  setExpanded((prev) => !prev);
3403
3607
  }, []);
@@ -3434,7 +3638,7 @@ function WorkflowParallelGroupInner({
3434
3638
  WorkflowExpandTrigger,
3435
3639
  {
3436
3640
  summary,
3437
- expandHint: !expanded && hasRunning ? "(click to expand agents)" : !expanded && !hasRunning ? "(ctrl+o to expand)" : null,
3641
+ expandHint: !expanded && hasActiveWork ? "(click to expand agents)" : !expanded && !hasActiveWork ? "(ctrl+o to expand)" : null,
3438
3642
  onToggle: toggleExpanded,
3439
3643
  expanded,
3440
3644
  testId: `lax-workflow-parallel-group-summary-${groupKey}`,
@@ -3469,11 +3673,14 @@ var WorkflowParallelGroup = memo3(WorkflowParallelGroupInner, workflowParallelGr
3469
3673
  import { jsx as jsx12, jsxs as jsxs7 } from "react/jsx-runtime";
3470
3674
  function WorkflowRootContainer({
3471
3675
  node,
3472
- children
3676
+ children,
3677
+ containersById
3473
3678
  }) {
3474
3679
  const { verbose } = useSessionViewOptions();
3475
- const uiStatus = mapContainerUiStatus(node.status);
3476
- const statusLabel = mapWorkflowRootStatusLabel(node.status);
3680
+ const visuallyActive = containersById ? isWorkflowRootVisuallyActive(node, containersById) : node.status === "running" || node.status === "retrying";
3681
+ const uiStatus = mapContainerUiStatus(visuallyActive ? "running" : node.status);
3682
+ const statusLabel = mapWorkflowRootStatusLabel(visuallyActive ? "running" : node.status);
3683
+ const meta = containersById ? buildEffectiveWorkflowRootMeta(node, containersById) : buildWorkflowRootMeta(node);
3477
3684
  return /* @__PURE__ */ jsxs7("div", { className: "lax-workflow-root", "data-testid": "lax-workflow-root", children: [
3478
3685
  /* @__PURE__ */ jsx12(
3479
3686
  WorkflowContainerLine,
@@ -3482,7 +3689,7 @@ function WorkflowRootContainer({
3482
3689
  title: node.title,
3483
3690
  description: node.subtitle || void 0,
3484
3691
  uiStatus,
3485
- meta: buildWorkflowRootMeta(node),
3692
+ meta,
3486
3693
  statusLabel,
3487
3694
  titleTestId: "lax-workflow-root-title",
3488
3695
  descriptionTestId: "lax-workflow-root-desc",
@@ -3610,31 +3817,36 @@ function buildDoneSummary(node) {
3610
3817
  function WorkflowSubworkflowContainer({
3611
3818
  node,
3612
3819
  depth = 1,
3613
- children
3820
+ children,
3821
+ subtreeHasActiveWork: subtreeHasActiveWork2 = false
3614
3822
  }) {
3615
3823
  const { verbose } = useSessionViewOptions();
3616
- const uiStatus = mapContainerUiStatus(node.status);
3617
3824
  const isRunning = node.status === "running" || node.status === "retrying";
3618
3825
  const isDone = node.status === "completed" || node.status === "failed";
3826
+ const effectivelyRunning = isRunning || subtreeHasActiveWork2;
3827
+ const effectivelyDone = isDone && !subtreeHasActiveWork2;
3828
+ const uiStatus = mapContainerUiStatus(
3829
+ effectivelyRunning ? "running" : node.status
3830
+ );
3619
3831
  const testId = `lax-workflow-subworkflow-${node.scope_key}`;
3620
3832
  const teaser = useMemo6(() => {
3621
3833
  if (node.content_blocks.length === 0) return "";
3622
3834
  return node.content_blocks[node.content_blocks.length - 1]?.preview ?? "";
3623
3835
  }, [node.content_blocks]);
3624
- const [expanded, setExpanded] = useState5(isRunning);
3836
+ const [expanded, setExpanded] = useState5(effectivelyRunning);
3625
3837
  useEffect7(() => {
3626
- if (isRunning) setExpanded(true);
3627
- if (isDone) setExpanded(false);
3628
- }, [isRunning, isDone]);
3838
+ if (effectivelyRunning) setExpanded(true);
3839
+ else if (effectivelyDone) setExpanded(false);
3840
+ }, [effectivelyRunning, effectivelyDone]);
3629
3841
  const toggleExpanded = useCallback5(() => {
3630
3842
  setExpanded((prev) => !prev);
3631
3843
  }, []);
3632
- const shellToggle = isRunning ? toggleExpanded : isDone && expanded ? toggleExpanded : void 0;
3844
+ const shellToggle = effectivelyRunning ? toggleExpanded : effectivelyDone && expanded ? toggleExpanded : void 0;
3633
3845
  const depthStyle = {
3634
3846
  "--lax-workflow-depth": depth,
3635
3847
  "--lax-workflow-indent-unit": workflowIndentUnit(depth)
3636
3848
  };
3637
- const showShellLine = !(isDone && !expanded);
3849
+ const showShellLine = !(effectivelyDone && !expanded);
3638
3850
  return /* @__PURE__ */ jsxs9(
3639
3851
  "div",
3640
3852
  {
@@ -3651,9 +3863,11 @@ function WorkflowSubworkflowContainer({
3651
3863
  description: node.subtitle || void 0,
3652
3864
  uiStatus,
3653
3865
  meta: buildWorkflowRootMeta(node),
3654
- statusLabel: mapWorkflowRootStatusLabel(node.status),
3866
+ statusLabel: mapWorkflowRootStatusLabel(
3867
+ effectivelyRunning ? "running" : node.status
3868
+ ),
3655
3869
  onToggle: shellToggle,
3656
- interactive: isRunning,
3870
+ interactive: effectivelyRunning,
3657
3871
  expanded,
3658
3872
  titleTestId: `${testId}-title`,
3659
3873
  descriptionTestId: `${testId}-desc`,
@@ -3673,13 +3887,13 @@ function WorkflowSubworkflowContainer({
3673
3887
  !expanded && children ? /* @__PURE__ */ jsx14(
3674
3888
  WorkflowExpandTrigger,
3675
3889
  {
3676
- summary: `${node.title} \xB7 ${isDone ? buildDoneSummary(node) : "running"}`,
3890
+ summary: `${node.title} \xB7 ${effectivelyDone ? buildDoneSummary(node) : "running"}`,
3677
3891
  summaryTestId: `${testId}-title`,
3678
3892
  teaser: teaser || void 0,
3679
- expandHint: isRunning ? "(click to expand subworkflow)" : void 0,
3893
+ expandHint: effectivelyRunning ? "(click to expand subworkflow)" : void 0,
3680
3894
  onToggle: () => setExpanded(true),
3681
3895
  expanded: false,
3682
- testId: isDone ? `${testId}-done` : `${testId}-running-collapsed`,
3896
+ testId: effectivelyDone ? `${testId}-done` : `${testId}-running-collapsed`,
3683
3897
  teaserTestId: teaser ? `${testId}-teaser` : void 0
3684
3898
  }
3685
3899
  ) : null
@@ -3705,6 +3919,7 @@ function collectSiblingItems(node, containersById) {
3705
3919
  }
3706
3920
  function renderLeafContainer(node, depth, containerTree, loopTreesBySessionId, viewProps, options) {
3707
3921
  const loopTree = resolveLoopTree2(node, loopTreesBySessionId);
3922
+ const subtreeActive = options?.subtreeHasActiveWork ?? subtreeHasActiveWork(containerTree.containersById, node.container_id);
3708
3923
  switch (node.scope) {
3709
3924
  case "aggregate":
3710
3925
  return /* @__PURE__ */ jsx15(
@@ -3737,6 +3952,7 @@ function renderLeafContainer(node, depth, containerTree, loopTreesBySessionId, v
3737
3952
  loopTree,
3738
3953
  depth,
3739
3954
  suppressHostScopedLoop: options?.suppressHostScopedLoop,
3955
+ subtreeHasActiveWork: subtreeActive,
3740
3956
  ...viewProps
3741
3957
  },
3742
3958
  node.container_id
@@ -3790,6 +4006,10 @@ function renderContainerNode(node, containerTree, loopTreesBySessionId, viewProp
3790
4006
  {
3791
4007
  node,
3792
4008
  depth,
4009
+ subtreeHasActiveWork: subtreeHasActiveWork(
4010
+ containerTree.containersById,
4011
+ node.container_id
4012
+ ),
3793
4013
  children: hasNested ? nested : null
3794
4014
  },
3795
4015
  node.container_id
@@ -3851,8 +4071,8 @@ function WorkflowStageListPanel({
3851
4071
  const [extraCompletedPages, setExtraCompletedPages] = useState6(0);
3852
4072
  const maxVisibleDoneCount = scale.completedStagePageSize + extraCompletedPages * scale.completedStagePageSize;
3853
4073
  const partition = useMemo7(
3854
- () => partitionWorkflowStages(stages, maxVisibleDoneCount),
3855
- [stages, maxVisibleDoneCount]
4074
+ () => partitionWorkflowStages(stages, maxVisibleDoneCount, containerTree.containersById),
4075
+ [stages, maxVisibleDoneCount, containerTree.containersById]
3856
4076
  );
3857
4077
  if (partition.visible.length === 0) return null;
3858
4078
  const showMoreCount = Math.min(
@@ -3950,17 +4170,25 @@ function WorkflowChrome({
3950
4170
  }
3951
4171
  ) });
3952
4172
  }
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)) });
4173
+ return /* @__PURE__ */ jsx15("div", { className: "lax-workflow-chrome", "data-testid": "lax-workflow-chrome", children: rootNodes.map((workflowNode) => /* @__PURE__ */ jsx15(
4174
+ WorkflowRootContainer,
4175
+ {
4176
+ node: workflowNode,
4177
+ containersById: containerTree.containersById,
4178
+ children: renderWorkflowChildren(
4179
+ workflowNode,
4180
+ containerTree,
4181
+ loopTreesBySessionId,
4182
+ viewProps
4183
+ )
4184
+ },
4185
+ workflowNode.container_id
4186
+ )) });
3959
4187
  }
3960
4188
 
3961
4189
  // src/view/workflow/WorkflowTaskListFooter.tsx
3962
4190
  import { useMemo as useMemo8, useState as useState7 } from "react";
3963
- import { useStore as useStore4 } from "zustand";
4191
+ import { useStore as useStore3 } from "zustand";
3964
4192
 
3965
4193
  // src/view/workflow/workflowTaskListMerge.ts
3966
4194
  var STATUS_ORDER = {
@@ -4040,7 +4268,7 @@ function MessageResponse({ children }) {
4040
4268
  }
4041
4269
  function WorkflowTaskListFooter() {
4042
4270
  const store = useWorkflowSessionStoreApi();
4043
- const state = useStore4(store, (s) => s.state);
4271
+ const state = useStore3(store, (s) => s.state);
4044
4272
  const [expanded, setExpanded] = useState7(false);
4045
4273
  const footerState = useMemo8(() => deriveWorkflowTaskFooterState(state), [state]);
4046
4274
  const visible = shouldShowWorkflowTaskListFooter(state);
@@ -4080,7 +4308,7 @@ function WorkflowTaskListFooter() {
4080
4308
  }
4081
4309
 
4082
4310
  // src/view/workflow/WorkflowSession.tsx
4083
- import { useStore as useStore5 } from "zustand";
4311
+ import { useStore as useStore4 } from "zustand";
4084
4312
  import { jsx as jsx17, jsxs as jsxs12 } from "react/jsx-runtime";
4085
4313
  function WorkflowSession({
4086
4314
  source,
@@ -4103,6 +4331,7 @@ function WorkflowSession({
4103
4331
  groupParallelTools = false,
4104
4332
  workflowScale,
4105
4333
  onRequestLoopReplay,
4334
+ onStreamComplete,
4106
4335
  children
4107
4336
  }) {
4108
4337
  const storeRef = useRef3(null);
@@ -4152,39 +4381,46 @@ function WorkflowSession({
4152
4381
  workspaceRoot
4153
4382
  ]
4154
4383
  );
4155
- const containerTree = useStore5(
4384
+ const containerTree = useStore4(
4156
4385
  storeRef.current,
4157
4386
  (s) => s.state.containerTree
4158
4387
  );
4159
- const loopTreesBySessionId = useStore5(
4388
+ const loopTreesBySessionId = useStore4(
4160
4389
  storeRef.current,
4161
4390
  (s) => s.state.loopTreesBySessionId
4162
4391
  );
4163
- const sessionStatus = useStore5(
4392
+ const sessionStatus = useStore4(
4164
4393
  storeRef.current,
4165
4394
  (s) => s.state.status
4166
4395
  );
4167
- const internalErrors = useStore5(
4396
+ const internalErrors = useStore4(
4168
4397
  storeRef.current,
4169
4398
  (s) => s.state.internalErrors
4170
4399
  );
4400
+ const onErrorRef = useRef3(onError);
4401
+ const onStreamCompleteRef = useRef3(onStreamComplete);
4402
+ onErrorRef.current = onError;
4403
+ onStreamCompleteRef.current = onStreamComplete;
4171
4404
  useEffect8(() => {
4172
4405
  const store = storeRef.current;
4173
4406
  const controller = new AbortController();
4174
4407
  void source.start((event, ctx) => {
4175
4408
  store.getState().applyEvent(event, ctx);
4176
- }, controller.signal).catch((err) => {
4409
+ }, controller.signal).then(() => {
4410
+ store.getState().markStreamComplete();
4411
+ onStreamCompleteRef.current?.();
4412
+ }).catch((err) => {
4177
4413
  if (err instanceof SseTransportTerminalError) {
4178
4414
  store.getState().markAsError(err);
4179
- onError?.(err);
4415
+ onErrorRef.current?.(err);
4180
4416
  } else if (err instanceof Error) {
4181
- onError?.(err);
4417
+ onErrorRef.current?.(err);
4182
4418
  }
4183
4419
  });
4184
4420
  return () => {
4185
4421
  controller.abort();
4186
4422
  };
4187
- }, [source, onError]);
4423
+ }, [source]);
4188
4424
  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
4425
  "div",
4190
4426
  {
@@ -4407,6 +4643,7 @@ export {
4407
4643
  createReplaySource,
4408
4644
  createSessionStore,
4409
4645
  createWorkflowSessionStore,
4646
+ deriveLoopSessionIdFromAgentEvent,
4410
4647
  displayPath,
4411
4648
  entryKey,
4412
4649
  filterAgentLoopReplayEvents,