summaryrefslogtreecommitdiffhomepage
path: root/.dispatch
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-12 15:08:24 +0900
committerAdam Malczewski <[email protected]>2026-06-12 15:08:24 +0900
commit94da06025d4edd23cb7eda1deb2738070e08cd30 (patch)
tree8f58526992d24aa275adc28ac92423aca8f2b3cc /.dispatch
parent1c01427631347499bcaf4187c4a4c1f8afc3d23f (diff)
downloaddispatch-web-94da06025d4edd23cb7eda1deb2738070e08cd30.tar.gz
dispatch-web-94da06025d4edd23cb7eda1deb2738070e08cd30.zip
feat(chat): multi-client live view — watch in-flight turns + user prompt on stream
- subscribe every open conversation on load + WS reconnect (resync), unsubscribe on tab close - derive a stream-based 'generating' state for watchers (Composer running indicator) - fold the user-message turn event so watchers render the prompt mid-turn (de-dup vs sender's optimistic echo) - re-pin [email protected] / [email protected]; re-mirror contracts; add user-message to the exhaustiveness guard
Diffstat (limited to '.dispatch')
-rw-r--r--.dispatch/transport-contract.reference.md59
-rw-r--r--.dispatch/wire.reference.md30
2 files changed, 83 insertions, 6 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md
index 40ced1e..86eac50 100644
--- a/.dispatch/transport-contract.reference.md
+++ b/.dispatch/transport-contract.reference.md
@@ -5,9 +5,31 @@
> hangs on a permission prompt). Your CODE still imports `@dispatch/transport-contract` normally —
> this file is for READING only.
>
-> **Orchestrator:** SNAPSHOT of `[email protected]` (the metrics endpoint shipped +
-> version-bumped + LIVE-VERIFIED). Depends on `@dispatch/[email protected]` (see `wire.reference.md`) +
-> `@dispatch/[email protected]` (see `ui-contract.reference.md`).
+> **Orchestrator:** SNAPSHOT of `[email protected]` (CR-3 user-message shipped). Depends on
+> `@dispatch/[email protected]` (see `wire.reference.md`) + `@dispatch/[email protected]` (see
+> `ui-contract.reference.md`).
+>
+> **2026-06-12 delta (CR-3 user-message handoff — package bumped `0.7.0` → `0.8.0`):** NO transport
+> shape change — it re-exports `AgentEvent` (which `chat.delta` / `/chat` NDJSON carry), and that union
+> gained the additive `TurnInputEvent` (`{ type: "user-message"; conversationId; turnId; text }`), the
+> turn's user prompt, emitted as the FIRST event of every turn (before `turn-start`) and replayed to
+> watchers/late-joiners. See the `wire.reference.md` CR-3 delta + `TurnInputEvent` for the definition.
+>
+> **2026-06-12 delta (turn-continuity handoff — package bumped `0.6.0` → `0.7.0`, ADDITIVE):** a turn
+> is no longer bound to the WS connection — it runs to completion server-side regardless of any
+> client, and any number of connections can watch the same conversation (incl. a late-joiner that
+> connects mid-turn). Two new client→server WS messages: `ChatSubscribeMessage`
+> (`{ type: "chat.subscribe"; conversationId }`) and `ChatUnsubscribeMessage`
+> (`{ type: "chat.unsubscribe"; conversationId }`); `WsClientMessage` now unions both. Server→client
+> is UNCHANGED (turn events still arrive as `chat.delta`, replayed AND live). Semantics: `chat.subscribe`
+> registers the connection + immediately REPLAYS the in-flight turn's events so far (from its
+> `turn-start`) then streams live (nothing replayed if idle); `chat.send` AUTO-subscribes the sending
+> connection (a 2nd send while generating ⇒ `chat.error` + you stay subscribed to watch the running
+> turn); `chat.unsubscribe`/socket-close drops the subscription but NEVER stops the turn; subscriptions
+> persist across turns. FE consumes via the `chat` feature + app store (re-subscribe every open
+> conversation on (re)connect + page load; derive a "running" state structurally from
+> `turn-start`…no-`done`/`turn-sealed`-yet). OUT of scope: per-step crash-resume, concurrent-send
+> arbitration, explicit stop.
>
> **2026-06-12 delta (context-size handoff — package bumped `0.5.0` → `0.6.0`, depends on
> `[email protected]`):** no NEW transport shape — the optional `contextSize?: number` rides the
@@ -293,8 +315,37 @@ export interface ChatErrorMessage {
readonly message: string;
}
+/**
+ * Client → server: start WATCHING a conversation's live turn events WITHOUT sending.
+ * On subscribe the server REPLAYS the current in-flight turn so far (from its
+ * `turn-start`) as `chat.delta`, then streams live; nothing replayed if idle (rely
+ * on `GET /conversations/:id` history). Infer "generating" from a replayed
+ * `turn-start` with no matching `done`/`turn-sealed` yet. `chat.send` already
+ * auto-subscribes the sender, so this is for conversations you VIEW but didn't send to
+ * (a 2nd device, or a reloaded/reconnected client). Idempotent per (connection, id).
+ */
+export interface ChatSubscribeMessage {
+ readonly type: "chat.subscribe";
+ readonly conversationId: string;
+}
+
+/**
+ * Client → server: stop watching a conversation's turn events on this connection.
+ * Does NOT stop/affect the turn (it runs to completion regardless of subscribers).
+ * Socket close drops all of a connection's subscriptions the same way — again
+ * WITHOUT aborting any in-flight turn.
+ */
+export interface ChatUnsubscribeMessage {
+ readonly type: "chat.unsubscribe";
+ readonly conversationId: string;
+}
+
/** Every client → server WS message: surface ops + chat ops. Discriminate on `type`. */
-export type WsClientMessage = SurfaceClientMessage | ChatSendMessage;
+export type WsClientMessage =
+ | SurfaceClientMessage
+ | ChatSendMessage
+ | ChatSubscribeMessage
+ | ChatUnsubscribeMessage;
/** Every server → client WS message: surface ops + chat ops. Discriminate on `type`. */
export type WsServerMessage = SurfaceServerMessage | ChatDeltaMessage | ChatErrorMessage;
diff --git a/.dispatch/wire.reference.md b/.dispatch/wire.reference.md
index cf1410a..40f94cf 100644
--- a/.dispatch/wire.reference.md
+++ b/.dispatch/wire.reference.md
@@ -4,8 +4,16 @@
> types WITHOUT following the `file:` dep symlink out of this repo (which hangs on a permission
> prompt). Your CODE still imports `@dispatch/wire` normally — this file is for READING only.
>
-> **Orchestrator:** SNAPSHOT of `[email protected]` (the metrics types below shipped + version-bumped).
-> Regenerate whenever `@dispatch/wire` changes.
+> **Orchestrator:** SNAPSHOT of `[email protected]` (the metrics types below + the `user-message` turn event
+> shipped + version-bumped). Regenerate whenever `@dispatch/wire` changes.
+>
+> **2026-06-12 delta (CR-3 user-message handoff — package bumped `0.5.0` → `0.6.0`, ADDITIVE):** adds a
+> new `AgentEvent` union member `TurnInputEvent` (`{ type: "user-message"; conversationId; turnId; text }`)
+> that surfaces the turn's USER prompt INTO the outward event stream. Emitted ONCE as the FIRST event of
+> every turn (before `turn-start`), so it is buffered + replayed to every subscriber — live AND late-join
+> — and rides `chat.delta`/NDJSON like any other event. Fixes CR-3 (a pure watcher couldn't see the prompt
+> until seal). The sender still echoes its own prompt optimistically, so consumers DE-DUP against that
+> (by text); a pure watcher renders it directly. Persistence/metrics unchanged. See `TurnInputEvent` below.
>
> **2026-06-12 delta (context-size handoff — package bumped `0.4.0` → `0.5.0`):** adds an OPTIONAL
> `contextSize?: number` to BOTH `TurnDoneEvent` (live `done`) and `TurnMetrics` (persisted) — the
@@ -249,6 +257,7 @@ export interface TurnMetrics {
export type AgentEvent =
| StatusEvent
| TurnStartEvent
+ | TurnInputEvent
| TurnTextDeltaEvent
| TurnReasoningDeltaEvent
| TurnToolCallEvent
@@ -274,6 +283,23 @@ export interface TurnStartEvent {
readonly turnId: string;
}
+/**
+ * The user prompt that opened this turn, surfaced INTO the turn's outward event
+ * stream so a WATCHER (subscribed but not the sender) can render the prompt
+ * mid-turn — the user message is otherwise persisted only at seal. Emitted ONCE
+ * as the FIRST event of the turn (before `turn-start`); buffered + replayed to
+ * every subscriber (live + late-join). The sender echoes its own prompt
+ * optimistically, so DE-DUP against that (by text); a pure watcher renders it
+ * directly. Carries the raw `text` passed to the provider. (Turn-scoped: it
+ * carries `turnId`, so a multi-turn transcript attributes each prompt to its turn.)
+ */
+export interface TurnInputEvent {
+ readonly type: "user-message";
+ readonly conversationId: string;
+ readonly turnId: string;
+ readonly text: string;
+}
+
/** Incremental text content from the model during a turn. */
export interface TurnTextDeltaEvent {
readonly type: "text-delta";