Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.16.0-beta.0] — 2026-05-28

### Added

- **Incremental tile streaming during chatbox turns** (plan `docs/plans/2026-05-28-002-feat-chatbox-core-incremental-tile-streaming-plan.md`, Phase 1). Per-tool envelope dispatch lets hosts render visualizations as each MCP tool returns instead of after the LLM signals end-of-turn. Two pieces of additive observable surface:

1. **`onToolEnvelope({kind, envelope, dispatchedUuids})` callback option** on `runChatSession` and `processToolCalls`. Fires once per `visualization` / `layer_update` / `patch_update` push with the *per-call* delta of dispatched UUIDs (NOT cumulative across the turn). Mirrors the existing `onToolStatus` callback contract: host throws are swallowed via `console.warn`, never abort the engine loop. Gated on `signal?.aborted` so a user-initiated Stop between two tool dispatches prevents further tile envelopes from reaching the host. Undefined preserves end-of-turn-only behavior for legacy consumers.

2. **`tethysdash:turn-start` and `tethysdash:turn-end` window events**, dispatched from `Chatbox.jsx`. `turn-start` fires immediately after `abortRef.current` is assigned (so a Stop click landing in the dispatch tick reaches a live controller); `turn-end` fires from the shared `finally` block covering success / error / abort / `/clear` uniformly. Events carry no payload; listeners toggle a boolean. Additive — no existing event behavior changes.

- **End-of-turn dispatch sites no-op when streaming fired** (`streamedDispatchFiredRef` sentinel in `Chatbox.jsx`). Backward-compatible: when `onToolEnvelope` is not supplied or never fires, the existing end-of-turn batch dispatch runs as today. When it does fire, the three end-of-turn dispatch sites (visualization batch, unmatched-layer-updates, patch batch) skip themselves to prevent double-dispatch. The `pendingVisualizations[]` / `pendingLayerUpdates[]` / `pendingPatches[]` arrays still flow through the engine return for `dispatchBanner` and host result hooks.

### Documented

- **Stop UX contract (R8a)** at the message-append site in `Chatbox.jsx`. User-initiated stop routes through the success branch (engine returns `{aborted: true, ...}` rather than throwing) and naturally appends the partial accumulator content (or bare `(Stopped)` when empty) via `setMessages` — never via `ChatErrorPanel`. Real errors land in the catch branch which calls `setError` and surfaces via `ChatErrorPanel`. Comment block names the contract so the existing behavior survives future refactors.

### Notes

- This release is `0.16.0-beta.0` on the `beta` dist-tag. Existing `latest` consumers see no behavior change until they upgrade. The two new window events and the new engine callback option are additive surface; passive consumers (hosts that don't add listeners or supply the callback) are unaffected.

## [0.14.0] — 2026-05-20

### Changed
Expand Down
142 changes: 139 additions & 3 deletions components/Chatbox.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,11 @@ export default function Chatbox({
// update-visualization dispatch sites below. Bumped on each user send;
// captured at schedule time, compared at fire time. See helpers/scheduleDispatch.js.
const turnIdRef = useRef(0);
// Plan 2026-05-28-002 Unit 2 — set by the per-tool onToolEnvelope
// callback on its first invocation in a turn. End-of-turn dispatch sites
// check this ref; when true, they skip dispatch because tiles already
// rendered incrementally. Resets to false at the start of each turn.
const streamedDispatchFiredRef = useRef(false);

const stopGeneration = useCallback(() => { abortRef.current?.abort(); }, []);

Expand Down Expand Up @@ -787,6 +792,11 @@ export default function Chatbox({
// taken below and threaded into the rAF freshness check.
turnIdRef.current += 1;
const capturedTurnId = turnIdRef.current;
// Plan 2026-05-28-002 Unit 2 — reset the streaming-dispatch sentinel
// for this turn. Set by the per-tool onToolEnvelope callback below on
// its first invocation; read by the end-of-turn dispatch sites to
// skip when streaming already fired (R10).
streamedDispatchFiredRef.current = false;

setError("");
setThinkingBuffer("");
Expand All @@ -800,6 +810,15 @@ export default function Chatbox({
const controller = new AbortController();
abortRef.current = controller;

// Plan 2026-05-28-002 Unit 3 — fire turn-start window event so the
// host (DashboardLoader) can flip its isStreaming flag and lock the
// per-tile edit/delete affordances.
//
// INVARIANT: turn-start MUST fire AFTER abortRef.current is assigned
// (line 811) so a Stop click landing in this dispatch tick resolves
// to the live controller, not null. Do not reorder.
window.dispatchEvent(new CustomEvent("tethysdash:turn-start"));

try {
const result = await runChatSession({
prompt: userText,
Expand Down Expand Up @@ -827,6 +846,93 @@ export default function Chatbox({
connectionCache: getCache(),
// Inject domain-specific extensions (empty for generic sidebar)
...engineExtensions,
// Plan 2026-05-28-002 Unit 2 — per-tool envelope dispatch.
// Engine fires this once per visualization/layer_update/patch_update
// push. Translate into the existing DOM events so tiles render as
// each MCP tool returns instead of in one end-of-turn batch. Set the
// sentinel so the end-of-turn dispatch sites below skip themselves.
onToolEnvelope: ({ kind, envelope }) => {
// Defense-in-depth: engine already gates on signal?.aborted, but
// a stale awaited result could land here mid-abort. Skip dispatch
// if the abort signaled. No tiles flash in after Stop.
if (controller.signal.aborted) return;
// Freshness guard mirrors the rAF capturedTurnId pattern: drop
// dispatch if the user has already started a new turn.
if (capturedTurnId !== turnIdRef.current) return;

streamedDispatchFiredRef.current = true;

if (kind === "visualization") {
// Per-panel construction inlined from the end-of-turn block
// at lines 928-955. Single panel only; batch wrapper preserves
// the stale-ref-doc dispatch boundary.
const viz = envelope;
if (viz.vizType === "custom" && viz.scope && !viz.url && resolveVisualizationUrl) {
viz.url = resolveVisualizationUrl(viz);
}
let args;
if (viz.inlineData) {
args = { vizType: viz.vizType, inlineData: viz.inlineData };
} else if (viz.vizType === "custom" && viz.scope) {
const initialData = { data: viz.args || {} };
if (viz.dataKey) initialData[viz.dataKey] = viz.args || {};
args = {
url: viz.url,
scope: viz.scope,
module: viz.module,
remoteType: viz.remoteType || "vite-esm",
initialData,
};
} else {
args = viz.args;
}
const panel = { source: viz.source, args, w: viz.w, h: viz.h, uuid: viz.uuid };
window.dispatchEvent(
new CustomEvent(ADD_VISUALIZATION_EVENT, {
detail: { batch: true, panels: [panel] },
}),
);
return;
}

if (kind === "layer_update") {
// Flat shape — matches the existing single-event dispatch at
// lines 988-994 and the handler's append_layers branch in
// DashboardLayout.js which reads detail.uuid and detail.layers
// directly. NOT batched (handler does not look at detail.updates).
const lu = envelope;
if (!lu?.map_uuid || !lu?.layer) return;
window.dispatchEvent(
new CustomEvent(UPDATE_VISUALIZATION_EVENT, {
detail: {
uuid: lu.map_uuid,
operation: "append_layers",
layers: [lu.layer],
},
}),
);
return;
}

if (kind === "patch_update") {
// Batched shape — matches the existing end-of-turn patch dispatch
// at lines 1066-1088. Single-entry batch keeps the host handler
// signature untouched.
const pu = envelope;
if (!pu?.uuid || !Array.isArray(pu?.ops)) return;
const entry = { uuid: pu.uuid, ops: pu.ops };
if (pu.source) entry.source = pu.source;
window.dispatchEvent(
new CustomEvent(UPDATE_VISUALIZATION_EVENT, {
detail: {
batch: true,
operation: "apply_patch",
patches: [entry],
},
}),
);
}
},
onToolStatus: (status) => {
// Plan 2026-05-08-003: payload is now per-tool —
// {type: "tool_start" | "tool_complete", toolName, success?}
Expand Down Expand Up @@ -898,6 +1004,15 @@ export default function Chatbox({
setMessages((prev) => [...prev, ...systemMessages]);
}

// Plan 2026-05-28-002 Unit 5 — R8a: user-initiated stop renders as a
// normal assistant message via setMessages below (NOT via ChatErrorPanel).
// result.aborted=true routes through the success branch (engine returns
// {aborted: true, ...} rather than throwing), so setError is never called
// on the stop path — ChatErrorPanel stays inert. accumulatedContent is
// preserved when non-empty so a partial-streaming response survives the
// stop; otherwise we append a bare "(Stopped)" marker.
// Real errors (thrown by runChatSession) land in the catch branch below
// (line ~1230) which calls setError and surfaces via ChatErrorPanel.
const content = result.aborted
? (accumulatedContent || "(Stopped)")
: (result.assistantText || "");
Expand Down Expand Up @@ -925,7 +1040,12 @@ export default function Chatbox({
// event. Individual events in a loop cause duplicate grid item keys
// and lost items because handleAddVisualization reads a stale ref
// between dispatches (no re-render between synchronous events).
if (result.visualizations?.length > 0) {
//
// Plan 2026-05-28-002 Unit 2 — when per-tool dispatch already fired
// during the turn, skip the end-of-turn batch dispatch (R10). The
// pendingVisualizations array still flows through result.visualizations
// for downstream consumers (dispatchBanner, onResult, host hooks).
if (result.visualizations?.length > 0 && !streamedDispatchFiredRef.current) {
const panels = result.visualizations.map((viz) => {
// Resolve MFE URL for client_custom_remote plugins
if (viz.vizType === "custom" && viz.scope && !viz.url && resolveVisualizationUrl) {
Expand Down Expand Up @@ -977,10 +1097,14 @@ export default function Chatbox({
// visualization in the current batch (pre-existing maps from previous
// sessions). These grid items already exist in React state, so the
// requestAnimationFrame timing is not a concern.
//
// Plan 2026-05-28-002 Unit 2 — when streaming fired during the turn,
// skip the end-of-turn layer dispatch (R10). The per-tool callback
// already fired the flat update-visualization event for each layer.
const unmatchedUpdates = Object.entries(layerUpdatesByUuid).filter(
([uuid]) => !matchedLayerUuids.has(uuid),
);
if (unmatchedUpdates.length > 0) {
if (unmatchedUpdates.length > 0 && !streamedDispatchFiredRef.current) {
scheduleDispatchIfFresh({
getCurrentTurnId: () => turnIdRef.current,
capturedTurnId,
Expand Down Expand Up @@ -1069,7 +1193,13 @@ export default function Chatbox({
// never dispatch N events in a loop when a batch shape exists.
// Wrapped in scheduleDispatchIfFresh so a stale Turn-N rAF callback
// is skipped if Turn N+1 has started before it fires (Plan 20 #16).
if (survivingEntries.length > 0) {
//
// Plan 2026-05-28-002 Unit 2 — when streaming fired during the turn,
// skip the end-of-turn patch dispatch (R10). The per-tool callback
// already fired apply_patch events for each envelope. dispatchBanner
// / collisionWarning / whitelistWarning still compute above and reach
// the assistant-message append below.
if (survivingEntries.length > 0 && !streamedDispatchFiredRef.current) {
scheduleDispatchIfFresh({
getCurrentTurnId: () => turnIdRef.current,
capturedTurnId,
Expand Down Expand Up @@ -1116,6 +1246,12 @@ export default function Chatbox({
abortRef.current = null;
setToolStatus(null);
setLoading(false);
// Plan 2026-05-28-002 Unit 3 — fire turn-end window event so the host
// (DashboardLoader) can flip its isStreaming flag back to false and
// re-enable per-tile edit/delete affordances. Single fire site covers
// success, thrown error, abort, and /clear paths uniformly — they all
// converge here.
window.dispatchEvent(new CustomEvent("tethysdash:turn-end"));
// Clear streaming buffers regardless of how we got here. The success
// path also clears them, but on abort or thrown error the partial
// buffers would otherwise survive in state and flash on the next
Expand Down
60 changes: 60 additions & 0 deletions engine/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -835,6 +835,18 @@ export async function processToolCalls(
// preserves transient-connect behavior for callers without a cache.
connectionCache = null,
servers = null,
// Per-tool envelope dispatch callback (plan 2026-05-28-002 Unit 1).
// Fires once per `visualization` / `layer_update` / `patch_update`
// push with the per-call delta of `dispatchedUuids`. Host (Chatbox.jsx)
// translates each invocation into a window.dispatchEvent so tiles can
// appear incrementally instead of in one end-of-turn batch.
// Undefined preserves end-of-turn-only behavior for legacy consumers.
onToolEnvelope = null,
// AbortController signal forwarded from runChatSession. When aborted,
// the engine skips invoking onToolEnvelope for any envelopes that
// arrive after the abort — preserves tiles already dispatched but
// prevents further tile-render side effects (R7 success criterion).
signal = null,
},
) {
let hadError = false;
Expand All @@ -855,6 +867,23 @@ export async function processToolCalls(
}
};

// Plan 2026-05-28-002 Unit 1 — per-envelope dispatch callback for
// incremental tile streaming. Same wrapper discipline as fireStatus
// (try/catch + console.warn). Gated on signal?.aborted so user-initiated
// Stop between two tool dispatches drops further envelopes from reaching
// the host (engine still runs to LLM "done" per the no-early-return rule).
const fireToolEnvelope = (payload) => {
if (!onToolEnvelope) return;
if (signal?.aborted) return;
try {
onToolEnvelope(payload);
} catch (err) {
// Host bug — log and continue.
// eslint-disable-next-line no-console
console.warn("[chatbox-core] onToolEnvelope callback threw:", err);
}
};

for (const toolCall of toolCalls) {
let toolName = toolCall?.function?.name;
let args = toolCall?.function?.arguments ?? {};
Expand Down Expand Up @@ -1022,16 +1051,39 @@ export async function processToolCalls(
if (!state.lastReturnedUuids) state.lastReturnedUuids = {};
state.lastReturnedUuids[typeKey] = viz.uuid;
}
// Plan 2026-05-28-002 Unit 1 — fire per-envelope dispatch with the
// per-call delta (just-pushed UUID, NOT cumulative across the turn).
fireToolEnvelope({
kind: "visualization",
envelope: toolResult.visualization,
dispatchedUuids: typeof viz?.uuid === "string" && viz.uuid ? [viz.uuid] : [],
});
}

// Collect layer updates (from add_map_service_layer) before truncation
if (toolResult && typeof toolResult === "object" && toolResult.layer_update) {
state.pendingLayerUpdates.push(toolResult.layer_update);
// Layer updates carry `map_uuid`, NOT `uuid` — the existing
// dispatchedUuids slice at line ~1055 filters out non-string uuids
// and yields []; the host callback gets the same empty delta here.
// The map_uuid is reachable on envelope.map_uuid for host routing.
const lu = toolResult.layer_update;
fireToolEnvelope({
kind: "layer_update",
envelope: lu,
dispatchedUuids: typeof lu?.uuid === "string" && lu.uuid ? [lu.uuid] : [],
});
}

// Collect patch envelopes (from patch_visualization) before truncation
if (toolResult && typeof toolResult === "object" && toolResult.patch_update) {
state.pendingPatches.push(toolResult.patch_update);
const pu = toolResult.patch_update;
fireToolEnvelope({
kind: "patch_update",
envelope: pu,
dispatchedUuids: typeof pu?.uuid === "string" && pu.uuid ? [pu.uuid] : [],
});
}

// R16 — record patch_visualization rejections so the host chatbox can
Expand Down Expand Up @@ -1295,6 +1347,12 @@ export async function runChatSession({
// opening fresh and closing at end-of-turn. Default null preserves
// existing transient-connect behavior for callers without a cache.
connectionCache = null,
// Per-tool envelope callback (plan 2026-05-28-002 Unit 1). Forwarded
// into processToolCalls; fires once per visualization/layer_update/
// patch_update push so the host can dispatch incremental DOM events.
// Undefined keeps end-of-turn-only dispatch behavior for legacy
// consumers and tests.
onToolEnvelope = null,
}) {
const cacheOptions = { enabled: !!enableResultCache, conversationId };

Expand Down Expand Up @@ -1501,6 +1559,8 @@ export async function runChatSession({
cacheOptions,
connectionCache,
servers,
onToolEnvelope,
signal,
},
);

Expand Down
Loading
Loading