@tea-agent/loop-agent 0.26.1 → 0.26.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/application/dag/generate-task-dag.js +33 -0
  3. package/dist/commands/task-source-prepare.js +6 -0
  4. package/dist/executors/shell-executor.js +111 -0
  5. package/dist/executors/shell-presets.js +12 -4
  6. package/dist/task/config-types.js +6 -0
  7. package/dist/task/contract/constants.js +1 -0
  8. package/dist/task/contract/project.js +8 -0
  9. package/dist/task/contract/schema.js +1 -0
  10. package/dist/task/frontend-preflight.js +131 -0
  11. package/dist/task/runtime.js +2 -4
  12. package/dist/task/source-prepare/build-draft.js +9 -0
  13. package/dist/task/source-prepare/completeness.js +1 -1
  14. package/dist/worker/observability/read-model.js +134 -0
  15. package/dist/worker/observe/static/state.js +61 -0
  16. package/dist/worker/observe/static/styles.css +8 -0
  17. package/dist/worker/observe/static/views/dag-graph.js +107 -31
  18. package/dist/worker/observe/static/views/dag-inspector.js +374 -157
  19. package/dist/worker/observe/static/views/dag.js +4 -11
  20. package/dist/workflows/dag/backend-test-pytest-collection.js +277 -0
  21. package/dist/workflows/dag/convergence/controller.js +110 -21
  22. package/dist/workflows/dag/frontend-implementation-contract.js +218 -17
  23. package/dist/workflows/dag/frontend-review-context.js +7 -1
  24. package/dist/workflows/dag/frontend-verification-trace.js +14 -3
  25. package/dist/workflows/dag/frontend-worktree-diff.js +14 -3
  26. package/dist/workflows/dag/init-hybrid.js +96 -34
  27. package/dist/workflows/dag/output-protocol.js +180 -7
  28. package/dist/workflows/dag/runner.js +141 -52
  29. package/dist/workflows/dag/types.js +4 -0
  30. package/dist/workflows/dag/validate.js +3 -2
  31. package/docs/templates/backend-test-dag.json +100 -8
  32. package/package.json +1 -1
  33. package/skills/loop-agent/references/hybrid-dag.md +1 -1
@@ -1287,6 +1287,12 @@ function mergeDagRun(existing, incoming) {
1287
1287
  ...(ranks && ranks.length > 0 ? { ranks } : {}),
1288
1288
  nodes: mergeDagNodes(existing.nodes, incoming.nodes),
1289
1289
  edges: incoming.edges.length > 0 ? incoming.edges : existing.edges,
1290
+ ...((incoming.controlEdges ?? existing.controlEdges)
1291
+ ? { controlEdges: incoming.controlEdges ?? existing.controlEdges }
1292
+ : {}),
1293
+ ...((incoming.convergence ?? existing.convergence)
1294
+ ? { convergence: incoming.convergence ?? existing.convergence }
1295
+ : {}),
1290
1296
  ...((incoming.dagPath ?? existing.dagPath)
1291
1297
  ? { dagPath: incoming.dagPath ?? existing.dagPath }
1292
1298
  : {}),
@@ -1321,6 +1327,10 @@ function mergeDagNode(existing, incoming) {
1321
1327
  finishedAt: incoming.finishedAt ?? existing.finishedAt,
1322
1328
  outputPreview: incoming.outputPreview ?? existing.outputPreview,
1323
1329
  errorPreview: incoming.errorPreview ?? existing.errorPreview,
1330
+ attemptCount: incoming.attemptCount ?? existing.attemptCount,
1331
+ failureCategory: incoming.failureCategory ?? existing.failureCategory,
1332
+ originKind: incoming.originKind ?? existing.originKind,
1333
+ passes: incoming.passes ?? existing.passes,
1324
1334
  };
1325
1335
  }
1326
1336
  function computeDurationMs(startedAt, finishedAt) {
@@ -1569,6 +1579,9 @@ async function parseDagStateFile(statePath, now) {
1569
1579
  const nodes = parseDagNodes(parsed, rankByNode, modelByNode);
1570
1580
  const edges = await parseDagEdges(runSpecPath, nodes);
1571
1581
  const state = parsed;
1582
+ const controlEdges = await deriveControlLoopEdges(runSpecPath, nodes, state);
1583
+ const convergence = projectConvergenceSummary(state);
1584
+ applyNodePasses(nodes, state);
1572
1585
  const liveness = assessDagRunLiveness({ state, now });
1573
1586
  const effectiveStatus = lifecycle
1574
1587
  ? deriveDagRunEffectiveStatus({
@@ -1647,6 +1660,8 @@ async function parseDagStateFile(statePath, now) {
1647
1660
  ...(ranks.length > 0 ? { ranks } : {}),
1648
1661
  nodes,
1649
1662
  edges,
1663
+ ...(controlEdges.length > 0 ? { controlEdges } : {}),
1664
+ ...(convergence ? { convergence } : {}),
1650
1665
  dagPath: runDir,
1651
1666
  ...(continuation ? { continuation } : {}),
1652
1667
  ...(backendTest ? { backendTest } : {}),
@@ -1683,6 +1698,125 @@ async function parseDagEdges(runPath, nodes) {
1683
1698
  }
1684
1699
  return [...edges.values()].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
1685
1700
  }
1701
+ /**
1702
+ * Derive presentation-only control-loop edges from the run spec's convergence
1703
+ * chain. When the chain contains both `review-gate-shell` and `repair-pi`, the
1704
+ * bounded repair-reverify-review loop exists at runtime and Observe renders it
1705
+ * as a dashed feedback loop. The derived edge is never merged into `edges` or
1706
+ * `ranks`, so DAG topological layout stays acyclic.
1707
+ */
1708
+ async function deriveControlLoopEdges(runPath, nodes, state) {
1709
+ const runtimeConvergence = state.convergence;
1710
+ if (!runtimeConvergence?.enabled ||
1711
+ runtimeConvergence.terminalReason === "feature-flag-off" ||
1712
+ runtimeConvergence.terminalReason === "unsupported-dag-shape") {
1713
+ return [];
1714
+ }
1715
+ const parsed = await safeReadJson(runPath);
1716
+ if (!parsed)
1717
+ return [];
1718
+ const convergence = readObject(parsed, "convergence");
1719
+ if (!convergence)
1720
+ return [];
1721
+ const chainNodeIds = readStringArray(convergence, "chainNodeIds");
1722
+ if (chainNodeIds.length === 0)
1723
+ return [];
1724
+ if (!chainNodeIds.includes("review-gate-shell") ||
1725
+ !chainNodeIds.includes("repair-pi")) {
1726
+ return [];
1727
+ }
1728
+ const known = new Set(nodes.map((node) => node.nodeId));
1729
+ if (!known.has("review-gate-shell") || !known.has("repair-pi")) {
1730
+ return [];
1731
+ }
1732
+ return [{ from: "review-gate-shell", to: "repair-pi", kind: "control-loop" }];
1733
+ }
1734
+ /**
1735
+ * Project `state.convergence` into the slim Observe summary. Keeps the budget
1736
+ * envelope even when passHistory is empty.
1737
+ */
1738
+ function projectConvergenceSummary(state) {
1739
+ const convergence = state.convergence;
1740
+ if (!convergence)
1741
+ return undefined;
1742
+ return {
1743
+ enabled: convergence.enabled,
1744
+ maxPasses: convergence.maxPasses,
1745
+ currentPass: convergence.currentPass,
1746
+ ...(convergence.terminalReason
1747
+ ? { terminalReason: convergence.terminalReason }
1748
+ : {}),
1749
+ passHistory: convergence.passHistory.map((pass) => ({
1750
+ pass: pass.pass,
1751
+ ...(pass.reason ? { reason: pass.reason } : {}),
1752
+ ...(pass.status ? { status: pass.status } : {}),
1753
+ ...(pass.hardVerifyStatus
1754
+ ? { hardVerifyStatus: pass.hardVerifyStatus }
1755
+ : {}),
1756
+ ...(pass.reviewVerdict ? { reviewVerdict: pass.reviewVerdict } : {}),
1757
+ ...(pass.reviewGateStatus
1758
+ ? { reviewGateStatus: pass.reviewGateStatus }
1759
+ : {}),
1760
+ })),
1761
+ };
1762
+ }
1763
+ /**
1764
+ * Record convergence participation from archived pass artifacts plus the live
1765
+ * current pass. The controller archives only retry/terminal failures, so an
1766
+ * active pass or a successful final pass must be derived from current node
1767
+ * state to avoid dropping its pass marker from Observe.
1768
+ */
1769
+ function applyNodePasses(nodes, state) {
1770
+ const passesByNode = new Map();
1771
+ const addPass = (nodeId, pass) => {
1772
+ const passes = passesByNode.get(nodeId) ?? new Set();
1773
+ passes.add(pass);
1774
+ passesByNode.set(nodeId, passes);
1775
+ };
1776
+ for (const pass of state.convergence?.passHistory ?? []) {
1777
+ for (const ref of pass.artifactRefs)
1778
+ addPass(ref.nodeId, pass.pass);
1779
+ }
1780
+ const initialPassNodeIds = new Set([
1781
+ "initial-review-pi",
1782
+ "initial-review-gate-shell",
1783
+ "initial-review-pass-shell",
1784
+ ]);
1785
+ for (const node of nodes) {
1786
+ if (!initialPassNodeIds.has(node.nodeId))
1787
+ continue;
1788
+ const status = (node.status ?? "").toUpperCase();
1789
+ if (["RUNNING", "FINISHED", "ERROR"].includes(status)) {
1790
+ addPass(node.nodeId, 1);
1791
+ }
1792
+ }
1793
+ const currentPass = state.convergence?.currentPass;
1794
+ if (currentPass && currentPass > 0) {
1795
+ const convergenceNodeIds = new Set([
1796
+ "process-supervisor-pi",
1797
+ "process-gate-shell",
1798
+ "repair-pi",
1799
+ "hard-verify-shell",
1800
+ "review-pi",
1801
+ "review-verdict-recovery-pi",
1802
+ "review-gate-shell",
1803
+ ]);
1804
+ for (const node of nodes) {
1805
+ if (!convergenceNodeIds.has(node.nodeId))
1806
+ continue;
1807
+ const status = (node.status ?? "").toUpperCase();
1808
+ if (["RUNNING", "FINISHED", "ERROR"].includes(status)) {
1809
+ addPass(node.nodeId, currentPass);
1810
+ }
1811
+ }
1812
+ }
1813
+ for (const node of nodes) {
1814
+ const passes = passesByNode.get(node.nodeId);
1815
+ if (passes && passes.size > 0) {
1816
+ node.passes = [...passes].sort((left, right) => left - right);
1817
+ }
1818
+ }
1819
+ }
1686
1820
  function buildRankIndex(ranks) {
1687
1821
  const index = new Map();
1688
1822
  for (let i = 0; i < ranks.length; i++) {
@@ -53,6 +53,8 @@ export const uiState = {
53
53
  dagTimelineViewportState: null,
54
54
  dagNodeOutputViewportState: null,
55
55
  dagNodeInputViewportState: null,
56
+ /** Spec-evidence inspector content scroll (single active region snapshot). */
57
+ dagNodeSpecEvidenceViewportState: null,
56
58
  runOutputViewportState: null,
57
59
  /**
58
60
  * Spec-evidence file preview drill-down. null = list view; otherwise
@@ -67,6 +69,65 @@ export const uiState = {
67
69
  /** Per-dagRunId graph viewport restore map (stable Map instance). */
68
70
  export const dagGraphViewportStates = new Map();
69
71
 
72
+ /**
73
+ * Per-context inspector content scroll map.
74
+ * Key format: dag:<dagRunId>:<nodeId>:<tab>
75
+ */
76
+ export const dagInspectorViewportStates = new Map();
77
+
78
+ /** Build a stable inspector scroll region key (AC-005 context isolation). */
79
+ export function inspectorViewportRegionKey(dagRunId, nodeId, tab) {
80
+ return `dag:${dagRunId ?? ""}:${nodeId ?? ""}:${tab ?? "output"}`;
81
+ }
82
+
83
+ export function updateDagInspectorViewportState(
84
+ _previous,
85
+ regionKey,
86
+ scrollLeft,
87
+ scrollTop,
88
+ ) {
89
+ return {
90
+ regionKey,
91
+ scrollLeft: Math.max(0, Number(scrollLeft) || 0),
92
+ scrollTop: Math.max(0, Number(scrollTop) || 0),
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Persist inspector scroll for a context. When `preserveIntent` is true and the
98
+ * incoming position is a zero clamp on short (loading) content while a non-zero
99
+ * intent already exists, keep the previous intent instead of overwriting to 0.
100
+ */
101
+ export function saveDagInspectorViewportState(
102
+ regionKey,
103
+ scrollLeft,
104
+ scrollTop,
105
+ options = {},
106
+ ) {
107
+ if (!regionKey) return null;
108
+ const left = Math.max(0, Number(scrollLeft) || 0);
109
+ const top = Math.max(0, Number(scrollTop) || 0);
110
+ if (options.preserveIntent) {
111
+ const existing = dagInspectorViewportStates.get(regionKey);
112
+ if (
113
+ existing &&
114
+ left === 0 &&
115
+ top === 0 &&
116
+ (existing.scrollLeft > 0 || existing.scrollTop > 0)
117
+ ) {
118
+ return existing;
119
+ }
120
+ }
121
+ const next = updateDagInspectorViewportState(null, regionKey, left, top);
122
+ dagInspectorViewportStates.set(regionKey, next);
123
+ return next;
124
+ }
125
+
126
+ export function getDagInspectorViewportState(regionKey) {
127
+ if (!regionKey) return null;
128
+ return dagInspectorViewportStates.get(regionKey) ?? null;
129
+ }
130
+
70
131
  /** Build a fresh session-event identity token and bump the global sequence. */
71
132
  export function makeSessionEventIdentity(dagRunId, nodeId) {
72
133
  uiState.sessionEventIdentitySeq += 1;
@@ -1522,6 +1522,14 @@ body.is-resizing-dag-graph {
1522
1522
  #dag-arrow path {
1523
1523
  fill: color-mix(in srgb, var(--hairline-strong) 80%, var(--muted));
1524
1524
  }
1525
+ .dag-edge-control {
1526
+ stroke: var(--orange);
1527
+ stroke-width: 2;
1528
+ stroke-dasharray: 6 4;
1529
+ }
1530
+ #dag-arrow-control path {
1531
+ fill: var(--orange);
1532
+ }
1525
1533
  .dag-graph-node {
1526
1534
  cursor: pointer;
1527
1535
  transition: opacity var(--transition-fast);
@@ -26,30 +26,52 @@ export function renderDagGraph(dag, dagRunId, existingWrap = null) {
26
26
  const edges = dag.edges ?? [];
27
27
  const layout = layoutDag(nodes, edges, dag.ranks ?? []);
28
28
  const wrap = existingWrap ?? el("div", "dag-graph-wrap");
29
+ // Preserve .dag-graph-viewport DOM identity across poll refreshes so the
30
+ // user's active scroll/pointer target is not destroyed mid-gesture.
31
+ let viewport =
32
+ existingWrap?.querySelector?.(":scope > .dag-graph-viewport") ??
33
+ existingWrap?.querySelector?.(".dag-graph-viewport") ??
34
+ null;
29
35
  if (existingWrap) {
30
- // reuse-dag-graph-wrap: replace children while keeping wrap identity.
31
- while (wrap.firstChild) wrap.removeChild(wrap.firstChild);
36
+ // Remove only non-interactive siblings. Keeping the viewport connected is
37
+ // important: detaching and re-inserting the same node still cancels a native
38
+ // scrollbar drag/pointer target even though object identity is unchanged.
39
+ for (const child of [...wrap.children]) {
40
+ if (child !== viewport) wrap.removeChild(child);
41
+ }
42
+ if (viewport) {
43
+ while (viewport.firstChild) viewport.removeChild(viewport.firstChild);
44
+ }
32
45
  }
33
46
  const toolbar = el("div", "dag-graph-toolbar");
34
- toolbar.appendChild(
35
- el(
36
- "span",
37
- "dag-graph-summary",
38
- `${nodes.length} 节点 · ${layout.edges.length} 条依赖`,
39
- ),
40
- );
47
+ let summaryText = `${nodes.length} 节点 · ${layout.edges.length} 条依赖`;
48
+ const convergence = dag.convergence;
49
+ if (convergence?.enabled) {
50
+ summaryText += ` · pass ${convergence.currentPass}/${convergence.maxPasses}`;
51
+ if (convergence.terminalReason) {
52
+ summaryText += ` · ${convergence.terminalReason}`;
53
+ }
54
+ }
55
+ toolbar.appendChild(el("span", "dag-graph-summary", summaryText));
41
56
  const reset = el("button", "dag-graph-reset", "重置视图");
42
57
  toolbar.appendChild(reset);
43
- wrap.appendChild(toolbar);
44
- if (edges.length === 0)
45
- wrap.appendChild(
58
+ const appendBeforeViewport = (child) => {
59
+ if (viewport?.parentNode === wrap) wrap.insertBefore(child, viewport);
60
+ else wrap.appendChild(child);
61
+ };
62
+ appendBeforeViewport(toolbar);
63
+ if (edges.length === 0) {
64
+ appendBeforeViewport(
46
65
  el(
47
66
  "p",
48
67
  "dag-graph-fallback",
49
68
  "该运行没有可用依赖数据;图按并行层级展示,节点表格仍保留完整数据。",
50
69
  ),
51
70
  );
52
- const viewport = el("div", "dag-graph-viewport");
71
+ }
72
+ if (!viewport) {
73
+ viewport = el("div", "dag-graph-viewport");
74
+ }
53
75
  viewport.dataset.dagRunId = dagRunId;
54
76
  const svg = svgEl("svg", {
55
77
  class: "dag-graph",
@@ -71,6 +93,17 @@ export function renderDagGraph(dag, dagRunId, existingWrap = null) {
71
93
  });
72
94
  marker.appendChild(svgEl("path", { d: "M 0 0 L 10 5 L 0 10 z" }));
73
95
  defs.appendChild(marker);
96
+ const controlMarker = svgEl("marker", {
97
+ id: "dag-arrow-control",
98
+ viewBox: "0 0 10 10",
99
+ refX: 9,
100
+ refY: 5,
101
+ markerWidth: 7,
102
+ markerHeight: 7,
103
+ orient: "auto-start-reverse",
104
+ });
105
+ controlMarker.appendChild(svgEl("path", { d: "M 0 0 L 10 5 L 0 10 z" }));
106
+ defs.appendChild(controlMarker);
74
107
  svg.appendChild(defs);
75
108
  const related = new Set([uiState.selectedDagNodeId]);
76
109
  for (const edge of layout.edges)
@@ -91,6 +124,28 @@ export function renderDagGraph(dag, dagRunId, existingWrap = null) {
91
124
  path.firstChild.textContent = `${edge.from} → ${edge.to}`;
92
125
  svg.appendChild(path);
93
126
  }
127
+ // 控制回环:仅由 read model 派生(dag.controlEdges),不进 layout、不参与拓扑。
128
+ // 路径从 review-gate-shell 左侧绕回 repair-pi 右侧,避开节点矩形。
129
+ const layoutByNode = new Map(layout.nodes.map((n) => [n.nodeId, n]));
130
+ for (const ctrl of dag.controlEdges ?? []) {
131
+ const from = layoutByNode.get(ctrl.from);
132
+ const to = layoutByNode.get(ctrl.to);
133
+ if (!from || !to) continue;
134
+ const x1 = from.x;
135
+ const y1 = from.y + from.height / 2;
136
+ const x2 = to.x + to.width;
137
+ const y2 = to.y + to.height / 2;
138
+ const lift = Math.max(48, (from.x - to.x) / 2);
139
+ const rise = Math.max(40, Math.abs(from.y - to.y) / 2 + 16);
140
+ const path = svgEl("path", {
141
+ d: `M ${x1} ${y1} C ${x1 - lift} ${y1 - rise}, ${x2 + lift} ${y2 - rise}, ${x2} ${y2}`,
142
+ class: "dag-edge dag-edge-control",
143
+ "marker-end": "url(#dag-arrow-control)",
144
+ });
145
+ path.appendChild(svgEl("title"));
146
+ path.firstChild.textContent = `${ctrl.from} → ${ctrl.to} (control loop)`;
147
+ svg.appendChild(path);
148
+ }
94
149
  const byId = Object.fromEntries(nodes.map((node) => [node.nodeId, node]));
95
150
  for (const position of layout.nodes) {
96
151
  const node = byId[position.nodeId] ?? { nodeId: position.nodeId };
@@ -115,7 +170,14 @@ export function renderDagGraph(dag, dagRunId, existingWrap = null) {
115
170
  executor.textContent = `◈ ${node.executor ?? "unknown"}`;
116
171
  group.appendChild(executor);
117
172
  const state = svgEl("text", { x: 14, y: 74, class: "dag-node-status" });
118
- state.textContent = `${statusSymbol(status)} ${statusLabel(node.status)} · ${formatMs(node.durationMs)}`;
173
+ let stateText = `${statusSymbol(status)} ${statusLabel(node.status)} · ${formatMs(node.durationMs)}`;
174
+ if (node.passes?.length) {
175
+ stateText += ` · P${node.passes.join(",")}`;
176
+ }
177
+ if (node.attemptCount > 0) {
178
+ stateText += ` · attempt ${node.attemptCount}`;
179
+ }
180
+ state.textContent = stateText;
119
181
  group.appendChild(state);
120
182
  const activate = () => selectDagNode(dagRunId, node.nodeId);
121
183
  group.addEventListener("click", activate);
@@ -128,36 +190,45 @@ export function renderDagGraph(dag, dagRunId, existingWrap = null) {
128
190
  svg.appendChild(group);
129
191
  }
130
192
  viewport.appendChild(svg);
131
- wrap.appendChild(viewport);
193
+ if (viewport.parentNode !== wrap) wrap.appendChild(viewport);
132
194
  const graphViewportState =
133
195
  dagGraphViewportStates.get(dagRunId) ??
134
196
  (uiState.dagGraphViewportState?.dagRunId === dagRunId
135
197
  ? uiState.dagGraphViewportState
136
198
  : null);
137
- viewport.addEventListener(
138
- "scroll",
139
- () => {
140
- uiState.dagGraphViewportState = updateDagGraphViewportState(
141
- uiState.dagGraphViewportState,
142
- dagRunId,
143
- viewport.scrollLeft,
144
- viewport.scrollTop,
145
- );
146
- dagGraphViewportStates.set(dagRunId, uiState.dagGraphViewportState);
147
- },
148
- { passive: true },
149
- );
199
+ // Bind scroll capture once per viewport instance so reuse does not stack listeners.
200
+ if (viewport.dataset.scrollBound !== "1") {
201
+ viewport.dataset.scrollBound = "1";
202
+ viewport.addEventListener(
203
+ "scroll",
204
+ () => {
205
+ const id = viewport.dataset.dagRunId;
206
+ if (!id) return;
207
+ uiState.dagGraphViewportState = updateDagGraphViewportState(
208
+ uiState.dagGraphViewportState,
209
+ id,
210
+ viewport.scrollLeft,
211
+ viewport.scrollTop,
212
+ );
213
+ dagGraphViewportStates.set(id, uiState.dagGraphViewportState);
214
+ },
215
+ { passive: true },
216
+ );
217
+ }
150
218
  if (graphViewportState) {
151
219
  const restoreViewport = () => {
220
+ if (viewport.dataset.dagRunId !== dagRunId) return;
152
221
  viewport.scrollLeft = graphViewportState.scrollLeft;
153
222
  viewport.scrollTop = graphViewportState.scrollTop;
154
223
  };
224
+ // Apply immediately so identity reuse keeps position even before paint,
225
+ // then again on rAF after SVG layout settles.
226
+ restoreViewport();
155
227
  if (typeof requestAnimationFrame === "function") {
156
228
  requestAnimationFrame(restoreViewport);
157
- } else {
158
- restoreViewport();
159
229
  }
160
230
  }
231
+ // Toolbar is rebuilt each paint; bind reset to the current viewport/dagRunId.
161
232
  reset.addEventListener("click", () => {
162
233
  uiState.dagGraphViewportState = updateDagGraphViewportState(
163
234
  uiState.dagGraphViewportState,
@@ -166,7 +237,12 @@ export function renderDagGraph(dag, dagRunId, existingWrap = null) {
166
237
  0,
167
238
  );
168
239
  dagGraphViewportStates.set(dagRunId, uiState.dagGraphViewportState);
169
- viewport.scrollTo({ left: 0, top: 0, behavior: "smooth" });
240
+ if (typeof viewport.scrollTo === "function") {
241
+ viewport.scrollTo({ left: 0, top: 0, behavior: "smooth" });
242
+ } else {
243
+ viewport.scrollLeft = 0;
244
+ viewport.scrollTop = 0;
245
+ }
170
246
  });
171
247
  return wrap;
172
248
  }