diff --git a/website/.vitepress/theme/custom.css b/website/.vitepress/theme/custom.css index 2a4e2f012b..f70e2d00a1 100644 --- a/website/.vitepress/theme/custom.css +++ b/website/.vitepress/theme/custom.css @@ -417,6 +417,18 @@ span.no-visual { } } +@media (min-width: 768px) and (max-width: 1029px) { + .VPNavBar:not(.has-sidebar) .VPNavBarMenu > a.VPNavBarMenuLink:last-of-type { + display: none !important; + } +} + +@media (min-width: 768px) and (max-width: 1360px) { + .VPNavBar.has-sidebar .VPNavBarMenu > a.VPNavBarMenuLink:last-of-type { + display: none !important; + } +} + @media (max-width: 767px) { .nav-item.VPNavBarCTA { display: none; @@ -506,6 +518,45 @@ span.no-visual { } } +/* Sidebar pages: compress one step earlier (each bucket shifts up) */ +@media (min-width: 1200px) and (max-width: 1360px) { + .VPNavBar.has-sidebar .VPNavBarMenuLink { + padding: 0 10px !important; + } + .VPNavBar.has-sidebar .VPSocialLink { + width: 30px !important; + } +} +@media (min-width: 1121px) and (max-width: 1199px) { + .VPNavBar.has-sidebar .VPNavBarMenuLink { + padding: 0 8px !important; + } +} +@media (min-width: 1060px) and (max-width: 1120px) { + .VPNavBar.has-sidebar .VPNavBarMenuLink { + padding: 0 6px !important; + } + .VPNavBar.has-sidebar .VPSocialLink { + width: 27px !important; + } +} +@media (min-width: 960px) and (max-width: 1059px) { + .VPNavBar.has-sidebar .VPNavBarMenuLink { + padding: 0 6px !important; + } + .VPNavBar.has-sidebar .VPSocialLink { + width: 27px !important; + } +} +@media (min-width: 768px) and (max-width: 959px) { + .VPNavBar.has-sidebar .VPNavBarMenuLink { + padding: 0 6px !important; + } + .VPNavBar.has-sidebar .VPSocialLink { + width: 27px !important; + } +} + @media (min-width: 768px) { .VPSocialLinks.VPNavBarSocialLinks.social-links { display: none !important; @@ -540,6 +591,13 @@ span.no-visual { padding: 0 2px; } } +@media (max-width: 1360px) { + .VPNavBar.has-sidebar .DocSearch-Button .DocSearch-Button-Placeholder { + visibility: hidden; + width: 4px; + padding: 0 2px; + } +} @media (min-width: 768px) { .VPNavBarSocialLinks { order: 2; @@ -556,6 +614,12 @@ span.no-visual { } } +@media (min-width: 768px) and (max-width: 1149px) { + .VPNavBar.has-sidebar .VPNavBarSocialLinks > .VPSocialLink:nth-of-type(-n + 3) { + display: none !important; + } +} + .container { max-width: 1152px; margin: 0 auto; diff --git a/website/blog/posts/2026-04-08-data-primitive-agent-loop.md b/website/blog/posts/2026-04-08-data-primitive-agent-loop.md new file mode 100644 index 0000000000..a895f4b0d2 --- /dev/null +++ b/website/blog/posts/2026-04-08-data-primitive-agent-loop.md @@ -0,0 +1,126 @@ +--- +title: "Durable\u00A0Streams \u2014 the\u00A0data\u00A0primitive for the\u00A0agent\u00A0loop" +description: >- + Agents are stateful. The agent loop accumulates a new kind of data that needs a new kind of primitive. Durable Streams is that primitive. +excerpt: >- + Agents are stateful. The agent loop accumulates a new kind of data that needs a new kind of primitive. Durable Streams is that primitive. +authors: [thruflo] +image: /img/blog/data-primitive-agent-loop/header.jpg +tags: [durable-streams, agents, sync] +outline: [2, 3] +post: true +published: true +--- + + + + + +The agent loop accumulates state: messages, token streams, tool calls and results. A new kind of data that needs a new data primitive. + +[Durable Streams](https://durablestreams.com) are persistent, addressable, real-time streams. Reactive, resumable and extensible, they are the data primitive for the agent loop. + +> [!Warning] Dive into Durable Streams +> See the [docs](https://durablestreams.com), [transports](/blog/2026/03/24/durable-transport-ai-sdks), [extensions](/blog/2026/03/26/stream-db), [examples](https://github.com/durable-streams/durable-streams/tree/main/examples) and [deploy](https://dashboard.electric-sql.cloud/?intent=create&serviceType=streams) on [Electric Cloud](/cloud). + +## The agent loop + +Agents are being deployed at massive and accelerating scale. The core execution pattern behind them is the agent loop: a cycle of observe → think → act → repeat. + +The agent receives a task, reasons about what to do, decides on an action, executes it and then feeds the result back into its own context as a new observation. + +
+ + + + + +
+ +This loop repeats. Each iteration is a full inference call where the model decides what to do next. State accumulates with every iteration. Messages, tool calls, tool call results, observations, artifacts, etc. + +If you think of an agent loop as a work cycle, this accumulated state is the work output. The longer the loop runs, the more value created. + +## A new kind of data + +At Electric, we started off building [Postgres Sync](/primitives/postgres-sync) for [state transfer in app development](/blog/2022/12/16/evolution-state-transfer). Then, as the type of software that teams were building on us evolved into AI apps and agentic systems, it became clear that [AI apps should be built on sync](/blog/2025/04/09/building-ai-apps-on-sync) too. + +But it also became clear that syncing through Postgres wasn't going to cut it. We did the math: 50 tokens per second across a thousand concurrent sessions is 50,000 writes per second. Postgres maxes out around 20,000 writes per second. Factor in centralized latency and it doesn't add up. + +Instead, we found ourselves wanting to write directly into the back of a [shape](/docs/guides/shapes). Shapes were already addressable, subscribable logs. What if we could strip out the database, avoid the centralized latency and let agents write directly into the log? + +## Enter Durable Streams + +[Durable Streams](https://durablestreams.com) are persistent, addressable, real-time streams. They are the data primitive we built specifically for the agent loop. + +
+ Visual connecting the agent loop to a data array +
+ +### Persistent, addressable, real-time streams + +A Durable Stream is a persistent, addressable, append-only log with its own URL. You can write directly to it, subscribe in real-time and replay from any position. + +At the core, Durable Streams are extremely simple. They are append-only binary logs. Built on a generalization of the battle-tested [Electric sync protocol](/docs/api/http) that delivers billions of state changes daily. + +The payload can be anything. The delivery protocol is standard HTTP. So it works everywhere, is cacheable and scalable through existing CDN infrastructure. + +### Designed for the agent loop + +Durable Streams are: + +| | | +|-|-| +| **Persistent** | So agent sessions are durable and survive disconnects and restarts | +| **Addressable** | So you can find them (every stream has a URL, every position an offset) | +| **Reactive** | So you can collaborate on the same session in real time | +| **Replayable** | So you can join, audit or restart from any point | +| **Forkable** | So you can branch sessions to explore alternatives | +| **Lightweight** | So they're trivial to spin up for every agent | +| **Low-latency** | For single-digit ms latency at the CDN edge | +| **Schema-aware** | For multiplexing structured and multi-modal data | +| **Extensible** | Via wrapper protocols and integrations | + +### Extensible layers and integrations + +Beyond the core [open protocol](https://github.com/durable-streams/durable-streams/blob/main/PROTOCOL.md), Durable Streams is designed as a composable, layered stack on top of the core binary stream primitive. This allows them to be wired into and used from agentic systems easily and for the raw streams to support structured and multi-modal data. + +The layers are growing all the time, for example including: + +- [Durable State](https://durablestreams.com/durable-state) a protocol for syncing multiplexed, structured state +- [StreamDB](https://durablestreams.com/stream-db) a type-safe reactive database in a stream +- [StreamFS](https://durablestreams.com/stream-fs) a shared filesystem for agents in a stream + +And integrations like: + +- [TanStack AI](https://durablestreams.com/tanstack-ai) adding durable sessions support to TanStack AI apps +- [Vercel AI SDK](https://durablestreams.com/vercel-ai-sdk) durable transport adapter +- [Yjs](https://durablestreams.com/yjs) for realtime collaboration and CRDT support (with snapshot discovery, compaction, cursors and user status) + +### Unlocking resilience and collaboration + +Durable Streams unlock [resilient and collaborative agent sessions](/blog/2026/01/12/durable-sessions-for-collaborative-ai). + +Users can disconnect, reconnect and resume without re-running expensive work. This unlocks real-time collaboration, where multiple users can work on the same session in real-time, and asynchronous collaboration, accessing and continuing sessions over time. + +Agents can subscribe to and build on each other's work. Users and agents can spawn and fork sub-agents, teams, swarms and hierarchies of agents with durable state at every level. Agentic systems can record the full history of every agent action and plug this into existing audit and compliance systems. + +Because Durable Streams use the [Electric delivery protocol](/docs/api/http), they support massive, elastic fan-out and concurrency through existing CDN infrastructure. Scale to zero or scale to [millions of concurrent real-time subscribers](/docs/reference/benchmarks#cloud). + +## Data primitive for the agent loop + +Agents are stateful. The agent loop accumulates state with every iteration. This state needs a new primitive. That's [Durable Streams](https://durablestreams.com). + +> [!Warning] Try Durable Streams now +> See the [docs](https://durablestreams.com), [transports](/blog/2026/03/24/durable-transport-ai-sdks), [extensions](/blog/2026/03/26/stream-db), [examples](https://github.com/durable-streams/durable-streams/tree/main/examples) and [deploy now](https://dashboard.electric-sql.cloud/?intent=create&serviceType=streams) on [Electric Cloud](/cloud). diff --git a/website/public/img/blog/data-primitive-agent-loop/header.jpg b/website/public/img/blog/data-primitive-agent-loop/header.jpg new file mode 100644 index 0000000000..63fe5864c3 Binary files /dev/null and b/website/public/img/blog/data-primitive-agent-loop/header.jpg differ diff --git a/website/public/img/blog/data-primitive-agent-loop/inline.jpg b/website/public/img/blog/data-primitive-agent-loop/inline.jpg new file mode 100644 index 0000000000..57e84fdf06 Binary files /dev/null and b/website/public/img/blog/data-primitive-agent-loop/inline.jpg differ diff --git a/website/src/components/blog/data-primitive-agent-loop/AgentLoopAnimation.vue b/website/src/components/blog/data-primitive-agent-loop/AgentLoopAnimation.vue new file mode 100644 index 0000000000..a99f85baec --- /dev/null +++ b/website/src/components/blog/data-primitive-agent-loop/AgentLoopAnimation.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/website/src/components/blog/data-primitive-agent-loop/AgentLoopSvg.vue b/website/src/components/blog/data-primitive-agent-loop/AgentLoopSvg.vue new file mode 100644 index 0000000000..c6ad726cec --- /dev/null +++ b/website/src/components/blog/data-primitive-agent-loop/AgentLoopSvg.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/website/src/components/blog/data-primitive-agent-loop/DataPipe.vue b/website/src/components/blog/data-primitive-agent-loop/DataPipe.vue new file mode 100644 index 0000000000..0cf0e68b8d --- /dev/null +++ b/website/src/components/blog/data-primitive-agent-loop/DataPipe.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/website/src/components/blog/data-primitive-agent-loop/DurableLog.vue b/website/src/components/blog/data-primitive-agent-loop/DurableLog.vue new file mode 100644 index 0000000000..47e71bf6c4 --- /dev/null +++ b/website/src/components/blog/data-primitive-agent-loop/DurableLog.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/website/src/components/blog/data-primitive-agent-loop/sequence.js b/website/src/components/blog/data-primitive-agent-loop/sequence.js new file mode 100644 index 0000000000..73592ec14b --- /dev/null +++ b/website/src/components/blog/data-primitive-agent-loop/sequence.js @@ -0,0 +1,62 @@ +// Static data for the agent-loop animation. +// +// PATH_DATA holds the irregular "shard" SVG paths that make up the loop. +// Index 0 is the spawn position (3 o'clock); the animation reuses that +// single path and rotates copies of it around the centre, so the visible +// loop is a sequence of 12 evenly-spaced rotations of SPAWN_PATH. +// +// SEQUENCE describes one full turn of the agent loop. Each entry is one +// step of observe -> think -> act -> observe, and drives both the slice +// behaviour in the SVG and the entry that gets prepended to the durable +// log. + +export const PATH_DATA = [ + `M930.228 275.038L932.832 275.118C936.251 278.737 940.83 290.85 943.098 295.98C959.4 332.826 971.994 370.219 983.899 408.709C969.747 403.431 950.281 398.181 935.458 393.756L835.082 364.502C820.579 359.505 800.685 354.308 785.509 349.674C770.173 324.569 756.132 307.542 736.81 285.156C801.26 281.342 865.733 277.969 930.228 275.038Z`, + `M38.5901 424.911C44.0648 429.126 49.5022 433.747 54.8469 438.153C101.938 476.983 149.589 515.383 196.164 554.822C198.773 571.385 202.319 587.784 206.779 603.94C208.324 609.366 215.733 627.916 214.024 631.493L211.488 630.91C154.095 609.483 88.8568 588.21 32.9479 565.127C32.9883 560.938 32.8448 556.283 32.64 552.075C30.5356 509.002 33.906 467.673 38.5901 424.911Z`, + `M40.1013 615.291L235.911 671.982C244.713 686.478 252.893 698.675 263.839 711.756C270.22 719.38 277.121 726.812 283.139 734.617C279.941 734.642 276.634 734.816 273.43 734.941C231.396 738.268 187.317 739.136 145.067 741.937C127.018 743.134 108.481 743.276 90.4537 744.911C71.7191 707.938 51.2138 655.755 40.1013 615.291Z`, + `M805.67 392.506C860.691 409.9 915.432 430.914 969.772 450.316C976.907 452.864 984.089 455.634 991.103 458.489C992.585 507.439 993.058 545.873 985.771 595.038L963.799 577.644L961.15 575.469L868.864 502.633C857.488 493.607 834.944 476.916 825.505 467.894C820.424 435.414 815.062 422.58 805.67 392.506Z`, + `M734.947 736.81C736.581 739.895 744.611 914.849 744.911 929.343C715.632 945.909 652.135 968.842 619.715 977.753L611.24 979.848C614.368 969.014 617.887 958.149 621.321 947.404C638.576 893.434 653.416 838.356 671.141 784.562C697.577 767.875 710.957 757.242 734.947 736.81Z`, + `M406.288 40.1577L408.109 40.1013L408.709 41.0367C408.244 45.8919 403.383 59.0193 401.666 64.2416C398.228 74.7229 394.922 85.2467 391.746 95.8118C378.033 140.811 364.146 192.544 349.114 236.496C330.649 247.928 313.362 260.372 296.849 274.567C293.398 277.533 290.22 280.338 286.621 283.139L285.592 282.647L285.299 279.369C284.779 258.921 283.06 237.1 282.013 216.558C280.024 174.262 277.699 131.986 275.038 89.7302C306.52 73.6976 372.332 48.1542 406.288 40.1577Z`, + `M235.362 121.114C237.688 123.486 256.761 300.902 258.835 318.81C248.035 330.741 238.195 347.554 230.455 361.769C226.352 369.307 223.21 376.995 218.554 384.405C211.598 371.514 204.834 358.525 198.266 345.442C176.216 302.282 154.549 258.937 133.265 215.411C138.614 208.771 146.684 200.575 152.678 194.258C177.805 167.356 205.491 142.865 235.362 121.114Z`, + `M805.53 639.595C810.852 643.634 886.125 796.638 890.734 807.971C870.995 831.713 845.6 855.106 822.514 875.796C811.261 884.974 799.882 894.005 788.38 902.886C784.991 884.379 782.811 855.152 780.59 835.925L765.164 705.233C780.897 687.474 794.659 660.441 805.53 639.595Z`, + `M806.247 133.265C808.879 133.899 844.841 168.161 849.217 172.55C867.623 191.009 887.329 211.994 902.886 232.81C885.496 234.033 865.071 236.651 847.502 238.551L726.671 251.99L699.772 254.784C673.319 235.805 658.586 230.45 631.494 215.73C652.055 207.245 677.54 193.952 698.089 184.177L806.247 133.265Z`, + `M314.702 765.186C317.067 764.65 329.235 774.374 332.28 776.444C349.023 787.827 366.829 795.49 384.405 804.994C369.561 813.004 352.07 821.22 336.863 828.823L211.42 890.734L198.07 878.247C170.732 853.483 139.252 819.111 117.063 789.159C125.012 787.66 136.843 786.526 145.171 785.52L195.839 779.431C234.779 774.758 275.86 769.005 314.702 765.186Z`, + `M626.347 809.722L627.441 809.839C627.851 812.214 567.777 973.558 561.778 990.477C514.821 993.263 482.011 992.52 434.292 985.782L424.911 984.406C438.735 968.5 452.259 950.872 465.587 934.422C494.324 898.813 523.3 863.405 552.512 828.2C581.18 822.596 598.035 818.438 626.347 809.722Z`, + `M494.492 32.0451C525.39 31.5819 564.204 34.7331 595.038 39.4517C569.442 71.9621 543.431 104.135 517.016 135.964C502.042 154.082 486.348 172.15 471.752 190.454L467.849 194.827C438.6 200.817 421.03 205.342 392.506 214.278C403.259 189.995 411.881 162.707 421.305 137.754C434.416 103.038 447.611 68.2192 459.909 33.2091C471.434 32.7533 482.962 32.3663 494.492 32.0451Z`, + `M829.713 509.974C833.422 511.82 855.126 533.043 859.609 537.221C897.231 572.313 934.612 607.678 971.747 643.318C960.859 675.128 946.311 707.999 931.653 738.096L916.11 769.215C903.517 747.828 890.669 722.962 878.439 700.993C857.784 664.635 837.575 628.006 817.823 591.115C820.819 575.871 823.586 563.368 825.734 547.765C827.528 534.725 828.121 522.587 829.713 509.974Z`, + `M427.828 817.822C444.663 823.654 487.98 828.683 505.924 830.444C483.256 853.869 459.788 879.652 437.716 903.654C416.859 926.176 396.191 948.875 375.713 971.746C365.746 968.027 356.265 964.506 346.503 960.284C315.226 946.86 284.617 931.913 254.785 915.493C272.404 906.949 293.642 893.137 311.073 883.225C349.223 861.529 388.67 837.408 427.828 817.822Z`, + `M102.737 254.785C137.677 313.661 172.159 372.799 206.177 432.195C199.748 463.145 196.46 482.841 193.638 514.025L170.699 493.307C159.527 482.333 146.545 472.058 135.14 461.408C105.709 433.923 73.1005 407.3 44.1517 379.649C61.2179 335.014 80.5358 296.798 102.737 254.785Z`, + `M646.021 52.2533C648.866 52.8755 662.847 58.2634 665.907 59.5363C698.863 73.2541 738.448 90.3346 769.215 108.242C755.079 115.148 736.687 126.142 722.814 133.932C689.587 152.878 656.233 171.601 622.753 190.096C612.938 195.577 603.055 200.938 593.108 206.177C566.757 198.972 541.007 196.479 514.026 193.655C527.214 178.583 544.076 161.63 557.958 146.811L646.021 52.2533Z`, +] + +// The slice that spawns at the 3 o'clock position. All visible slices are +// rotations of this single shape. +export const SPAWN_PATH = PATH_DATA[0] + +// Maximum number of live slices kept on screen at once. +export const MAX_VISIBLE_SLICES = 12 + +// One full pass of the agent loop. Behaviours: +// instant — slice pops in, pipe fires, log entry fills +// pulse — translucent ghost slice pulses (used for "thinking") +// stream — slice reveals top-down via clip-path while the log fills +export const SEQUENCE = [ + { label: `USER_MESSAGE`, behavior: `instant`, opacity: 0.8 }, + { label: `LLM_THINKING`, behavior: `pulse` }, + { label: `ASSISTANT_RESPONSE`, behavior: `stream`, opacity: 0.9 }, + { label: `TOOL_CALL`, behavior: `instant`, opacity: 0.7 }, + { label: `TOOL_RESULT`, behavior: `instant`, opacity: 0.75 }, + { label: `ASSISTANT_RESPONSE`, behavior: `stream`, opacity: 0.8 }, +] + +// Timings (ms). Pulled out so they're easy to tune. +export const TIMING = { + pulseDuration: 1500, + instantDelay: 100, + instantLogFill: 300, + streamDuration: 2000, + streamPipeAt: 0.3, // fraction of streamDuration when the pipe fires + streamLogStart: 0.4, // fraction when the log progress bar starts filling + pipeAnimation: 600, // must match CSS keyframe duration + cycleGap: 1200, +} diff --git a/website/src/components/blog/data-primitive-agent-loop/useAgentLoopAnimation.js b/website/src/components/blog/data-primitive-agent-loop/useAgentLoopAnimation.js new file mode 100644 index 0000000000..c35643c0e7 --- /dev/null +++ b/website/src/components/blog/data-primitive-agent-loop/useAgentLoopAnimation.js @@ -0,0 +1,255 @@ +// Composable that owns the reactive state for the agent-loop animation +// and runs the cycle. Components stay presentational — they read the +// refs returned here and render them. Tweak timings and behaviours via +// the SEQUENCE / TIMING constants in ./sequence.js. +// +// The cycle is explicitly started and stopped by the consumer (so the +// animation can be paused when scrolled out of view). `start()` is +// idempotent; `stop()` cancels in-flight waits and animation frames so +// the async cycle loop can unwind cleanly. + +import { onBeforeUnmount, ref } from 'vue' + +import { SEQUENCE, TIMING, MAX_VISIBLE_SLICES } from './sequence.js' + +export function useAgentLoopAnimation() { + // Live slices on the loop. Each is { id, rotation, fillOpacity, clipHeight }. + // clipHeight === null means "no clip" (fully visible). 0..1024 means the + // top-down reveal clip rect is at that height (used for the stream + // behaviour). + const slices = ref([]) + + // Pulse overlay (used for the LLM_THINKING beat). + const pulseActive = ref(false) + + // Log entries, newest first. Each is { id, label, time, progress }. + const logEntries = ref([]) + + // Bumped on each pipe trigger so the consumer can re-key the pipe-fill + // element and restart the CSS keyframe. + const pipeTick = ref(0) + + // Internal state. `stopped` short-circuits waits/frames so an in-flight + // runCycle can unwind. `wantRun` captures the caller's intent and is + // reconciled by `supervise()` — if start() fires while a previous + // runCycle is still unwinding, the supervisor picks up the new intent + // and kicks off a fresh cycle as soon as the old one completes. + let nextId = 0 + let stopped = true + let wantRun = false + let supervising = false + const pendingWaits = new Set() + const pendingFrames = new Set() + + function id() { + return ++nextId + } + + function nowFmt() { + const d = new Date() + return d.toLocaleTimeString([], { + hour12: false, + hour: `2-digit`, + minute: `2-digit`, + second: `2-digit`, + }) + } + + // A cancellable sleep. When stop() fires, the timer is cleared and the + // promise is resolved immediately so the awaiting runCycle can unwind. + function wait(ms) { + return new Promise((resolve) => { + if (stopped) { + resolve() + return + } + const entry = { resolve, timer: null } + entry.timer = setTimeout(() => { + pendingWaits.delete(entry) + resolve() + }, ms) + pendingWaits.add(entry) + }) + } + + function rafLoop(onFrame) { + return new Promise((resolve) => { + function tick(time) { + if (stopped) { + resolve() + return + } + const done = onFrame(time) + if (done) { + resolve() + return + } + const handle = requestAnimationFrame(tick) + pendingFrames.add(handle) + } + const handle = requestAnimationFrame(tick) + pendingFrames.add(handle) + }) + } + + function triggerPipe() { + pipeTick.value += 1 + } + + async function runCycle() { + let step = 0 + while (!stopped) { + const event = SEQUENCE[step % SEQUENCE.length] + + if (event.behavior === `pulse`) { + pulseActive.value = true + await wait(TIMING.pulseDuration) + pulseActive.value = false + step += 1 + continue + } + + // Push existing slices around the loop by 30deg. + slices.value = slices.value.map((s) => ({ + ...s, + rotation: s.rotation + 30, + })) + + // Add the log entry up front so the bar can be filled in place. + const entry = { + id: id(), + label: event.label, + time: nowFmt(), + progress: 0, + } + logEntries.value = [entry, ...logEntries.value] + + // Add the new slice at the spawn position. + const slice = { + id: id(), + rotation: 0, + fillOpacity: 0, + clipHeight: null, + } + slices.value = [slice, ...slices.value] + + if (event.behavior === `instant`) { + slice.fillOpacity = event.opacity + // Trigger reactivity for the mutated slice. + slices.value = [...slices.value] + + await wait(TIMING.instantDelay) + triggerPipe() + + // Fill the log progress bar over instantLogFill ms. + const start = performance.now() + await rafLoop((time) => { + const elapsed = time - start + const progress = Math.min(elapsed / TIMING.instantLogFill, 1) + entry.progress = progress + logEntries.value = [...logEntries.value] + return progress >= 1 + }) + } else if (event.behavior === `stream`) { + slice.fillOpacity = event.opacity + slice.clipHeight = 0 + slices.value = [...slices.value] + + const start = performance.now() + let pipeTriggered = false + await rafLoop((time) => { + const elapsed = time - start + const progress = Math.min(elapsed / TIMING.streamDuration, 1) + + slice.clipHeight = progress * 1024 + slices.value = [...slices.value] + + if (progress > TIMING.streamPipeAt && !pipeTriggered) { + pipeTriggered = true + triggerPipe() + } + + if (progress > TIMING.streamLogStart) { + const logProgress = + (progress - TIMING.streamLogStart) / (1 - TIMING.streamLogStart) + entry.progress = logProgress + logEntries.value = [...logEntries.value] + } + + return progress >= 1 + }) + + // Drop the clip — slice becomes fully visible. + slice.clipHeight = null + slices.value = [...slices.value] + } + + // Once the 12th slice has been filled in, hold briefly so the user + // can see the full loop, then wipe it and restart the sequence from + // the top — otherwise new slices would rotate onto existing ones + // and overwrite their content. + if (slices.value.length >= MAX_VISIBLE_SLICES) { + await wait(TIMING.cycleGap) + slices.value = [] + logEntries.value = [] + step = 0 + continue + } + + step += 1 + await wait(TIMING.cycleGap) + } + } + + // Supervisor: keeps calling runCycle as long as `wantRun` stays true. + // Only one supervisor runs at a time. If start() is called while the + // previous runCycle is still unwinding from a stop(), the supervisor's + // while-loop observes the updated wantRun after the unwind and starts + // a fresh cycle — so scrolling out and back in reliably restarts it. + async function supervise() { + if (supervising) return + supervising = true + try { + while (wantRun) { + // Clean slate for each cycle. + stopped = false + slices.value = [] + logEntries.value = [] + pulseActive.value = false + await runCycle() + } + } finally { + supervising = false + } + } + + function start() { + wantRun = true + supervise() + } + + function stop() { + wantRun = false + if (stopped) return + stopped = true + // Resolve any pending waits so runCycle can unwind past its awaits. + pendingWaits.forEach((entry) => { + clearTimeout(entry.timer) + entry.resolve() + }) + pendingWaits.clear() + pendingFrames.forEach((h) => cancelAnimationFrame(h)) + pendingFrames.clear() + } + + onBeforeUnmount(stop) + + return { + slices, + pulseActive, + logEntries, + pipeTick, + start, + stop, + } +}