Files
kandev/docs/specs/office/live-updates.md
T
2026-08-04 22:11:13 +01:00

38 KiB
Raw Blame History

status, created, owner
status created owner
draft 2026-05-02 cfl

Office Live Updates

Why

Every office page initially fetches data on mount and never updates after that. When an agent completes a task, changes status, posts a comment, or fans out a subtask, the user sees stale data until they manually refresh. The office model lets agents trigger other agents, so it is normal for several agents to be running concurrently; without real-time updates the user cannot see fan-out happen, cannot tell if a submitted comment was actually received, and cannot tell whose turn it is to act.

This spec captures the shared live-update contract across the office UI: WS event forwarding, per-page subscription scopes, sidebar live-agent indicators, dashboard reactivity, the cross-surface UX-parity rules that follow from those, and the optimistic-comment lifecycle.

Note on history: office-ux-parity was largely a retroactive-unification effort - it took live-presence affordances that had already shipped piecewise (sidebar per-agent badge, inline session entries in the task chat) and unified them across the task page, the sidebar Dashboard row, and the properties panel. Its requirements are folded into the baseline below rather than tracked as a separate surface.

What

A. WS event forwarding model

The backend already publishes internal events on its event bus (task.updated, task.moved, office.comment.created, agent.completed, ...). An office WS handler subscribes to office-relevant events and forwards them to connected clients scoped by the client's active workspace. The frontend WS client receives these events and updates the Zustand store; all pages read from the store, so they re-render automatically.

Forwarded events:

Backend event WS action Store update Surfaces
task.created office.task.created Append to office.tasks.items Tasks list, dashboard task count, Recent Tasks
task.updated office.task.updated Patch task in office.tasks.items Tasks list, dashboard Recent Tasks, task detail properties
task.moved office.task.moved Update task status Tasks list, dashboard metrics
office.task.status_changed office.task.status_changed Update task status Tasks list, dashboard, task detail header
office.comment.created office.comment.created Append to comments Task detail chat
agent.completed office.agent.completed Update agent status Agents list, dashboard, task detail
agent.failed office.agent.failed Update agent status Agents list, dashboard
office.agent.updated office.agent.updated Patch agent (status, identity) Sidebar, agent detail, dashboard cards
office.approval.created office.approval.created Increment inbox count Inbox, dashboard approvals card
office.approval.resolved office.approval.resolved Update inbox Inbox
office.wakeup.queued office.wakeup.queued Update agent run state Agent detail runs tab
office.activity.created office.activity.created Prepend to activity feed Dashboard recent activity, Activity page
session.state_changed session.state_changed Patch taskSessions by id Dashboard agent cards, sidebar live badges, task page header, inline session entries
session.message.added / .updated (same) Append/patch streamed messages Inline session transcript on task detail
office.run.queued office.run.queued Patch comment run status (queued) Per-comment run badge
office.run.processed office.run.processed Patch comment run status (`claimed finished

B. Subscription scopes

Each page subscribes only to events it consumes. The WS client deduplicates subscriptions automatically.

Surface Subscribes to
Dashboard task.created, task.moved, task.updated, task.status_changed, agent.completed, agent.failed, activity.created, approval.created, session.state_changed, office.agent.updated
Tasks list task.created, task.updated, task.moved, task.status_changed
Task detail comment.created, task.updated, task.status_changed, session.state_changed, session.message.added, session.message.updated, run.queued, run.processed
Agents list agent.completed, agent.failed, wakeup.queued, agent.updated, session.state_changed
Agent detail agent.completed, wakeup.queued, activity.created, agent.updated
Inbox approval.created, approval.resolved
Activity activity.created
Sidebar (global) session.state_changed, office.agent.completed, office.agent.failed, office.agent.updated

C. Workspace scoping

Events are scoped by workspace ID. The WS handler forwards an event to a client only if the event's workspace_id matches the client's active workspace. Clients send their active workspace ID on connect; this is already available from user settings.

Either (a) the office broadcaster filters by workspace_id on the server side, or (b) every forwarded payload includes workspace_id and the client-side office WS handler filters before dispatching to the store. The implementation picks one; the observable contract is the same: a client viewing workspace A MUST NOT see refetches triggered by events from workspace B.

D. Sidebar live-agent indicators

The office sidebar lists agents. Each agent row in SidebarAgentsList shows a live indicator when the agent has one or more active sessions:

  • A pulsing blue dot (animate-pulse).
  • A small text badge with the active-session count (e.g. 2 live).

When the agent has zero active sessions, the indicator collapses back to the static status dot already in place. No layout shift.

The sidebar Dashboard row also carries a workspace-wide * N live pill, where N is the total number of RUNNING | WAITING_FOR_INPUT sessions in the workspace. Visual is identical to the per-agent badge.

Active sessions are counted per-agent (not globally) for the per-agent rows. The count source is derived client-side from the existing taskSessions store keyed by agent_instance_id, kept fresh by the WS session events the client already receives - no extra fields on the agent payload required.

E. Dashboard reactivity

The dashboard surfaces specified in dashboard.md update without page refresh as events arrive:

  • office.task.created, office.task.updated, office.task.moved, office.task.status_changed cause refetch / re-render of: Recent Tasks, Tasks In Progress, the Run Activity chart, and the Recent Activity feed.
  • office.agent.completed and office.agent.failed update the Agents Enabled card subtitle (running / paused / errors line).
  • session.state_changed, office.task.updated, office.agent.updated cause the per-agent cards panel to refetch GET /api/v1/office/workspaces/:wsId/agent-summaries and replace its state. No optimistic updates - the server is the source of truth and the response is small (N agents x <=5 sessions each).

The plumbing uses the existing OfficeEventBroadcaster -> useOfficeRefetch("dashboard") pattern. No polling, no setInterval, no React Query refetch intervals.

F. Task detail live presence

While a task has at least one active session (state in {RUNNING, WAITING_FOR_INPUT}):

  • The task page header shows a small <IconLoader2 animate-spin /> Working indicator next to the task title. Clicking it scrolls the timeline so the active session entry is visible. Hidden when no active session, with no layout reservation.
  • An inline session entry appears at its chronological position in the comments timeline (one entry per session for the task, ordered by session.startedAt):
    • Active session entry is expanded by default. Header reads RUNNING * Working * for {elapsed} * ran {N} commands. Body embeds <AdvancedChatPanel taskId sessionId hideInput />. {N} is derived from messages.bySession[sessionId] filtered for type === "tool_call".
    • Completed session entry (COMPLETED | FAILED | CANCELLED) is collapsed by default to {Agent} worked for {duration} * ran {N} commands. Click re-expands the full transcript.
  • For tasks with more than 50 sessions, only the 50 most recent render inline; an explicit "Show older sessions" link expands the rest.
  • Auto-scroll: when a new active session entry first appears, scroll the chat container to the bottom only if the user is already at the bottom (within ~80px). Same rule for new streaming message chunks.

All of this is driven by session.state_changed, session.message.added, session.message.updated, office.comment.created, and office.task.updated. No polling.

G. Optimistic comments

User comments on a task render optimistically before server confirmation. The lifecycle has three observable states:

  1. Sending - between the user clicking send and the server confirming. The comment appears at the bottom of the thread within 50 ms with reduced opacity and a Sending... sub-label. The send button is disabled while the submission is in flight.
  2. Awaiting agent - server confirmed the comment is persisted, but the assignee agent has not yet replied. The "awaiting" sub-label adapts to the agent's actual situation:
    • Agent paused / stopped / pending_approval -> Queued - agent paused with a link to the agent.
    • Agent currently working on this task (a session for this task is RUNNING) -> Agent is replying... with a typing-style indicator.
    • Agent currently busy with N other tasks -> Awaiting agent ({N} ahead), where N comes from selectActiveSessionsForAgent over the existing taskSessions store (same selector the sidebar uses).
    • Agent idle, not paused -> Awaiting agent (default).
  3. Resolved - the assignee agent posts a reply comment after this user comment. The waiting affordance disappears.

When the server confirms the comment (office.comment.created for the same payload), pending styling resolves to normal with no visible flash or layout shift. When the server fails (network error, 5xx, validation error), the pending comment is removed from the thread, the draft text is restored to the textarea, the send button re-enables, and a toast surfaces the failure.

File attachments and pasted images follow the same lifecycle: pending appears immediately, error restores both the text and the file selection to the input.

A pending comment is matched against the server-confirmed comment by a client-generated UUID echoed back in the WS event payload.

H. Per-comment run-status badge

Each user comment on a task carries an associated run-status badge driven by office.run.queued and office.run.processed. The badge renders five states (queued, claimed, finished, failed, cancelled) gated on no later agent reply existing for the task. When an agent reply lands (via office.comment.created), the badge hides reactively.

The badge data flows through CommentDTO, which carries runId, runStatus, and runError joined through office_runs.idempotency_key = "task_comment:<comment_id>" via a batched lookup.

I. Agent-paused input notice

When the assignee agent (or the workspace CEO, for unassigned tasks) is paused, the chat input area shows a single inline notice above the textarea: Agent is paused - resume it for replies with a link to the agent. The notice appears before the user types and updates reactively from office.agent.updated / office.agent.status_changed. When the user resumes the agent, the notice disappears within 2 seconds without a page reload.

J. UX parity rules

The same live-presence affordances appear on every surface a task or agent is referenced:

  • Sidebar Dashboard row, sidebar per-agent rows, dashboard agent cards, and the task page header all surface live state from the same WS-driven taskSessions store. There is no surface where an agent looks idle on one page and live on another.
  • office.task.updated is a single generic event for every property mutation (priority, project, parent, assignee, blockers add / remove, participants add / remove). Status and comment events keep their existing dedicated channels (office.task.status_changed, office.comment.created). Frontend property panels subscribe via the office-task subscription path and patch the local cache by re-fetching the task DTO.
  • Property-panel edits on the task detail page are fully optimistic with rollback + toast on failure. No inline per-field error state.
  • No surface introduces a setInterval or polling fallback. If the WS connection is down, surfaces stay as-is until the connection recovers and the next event arrives.

Data model

Live updates are not a persisted feature surface — there is no live_subscriptions table, no event log replay buffer, no per-client cursor. The contract is built entirely from already-persisted entities (tasks, task_sessions, office_runs, office_comments, office_approvals, office_activity, office_agents, provider_health_state, office_route_attempts) plus three pieces of in-memory state on the backend and one piece on the frontend.

Backend in-memory state (gateway hub)

Maintained by *Hub in internal/gateway/websocket/hub.go. None of this survives a process restart.

Hub
  clients                map[*Client]bool                  // all connected sockets
  taskSubscribers        map[taskID]map[*Client]bool       // task.* notifications
  sessionSubscribers     map[sessionID]map[*Client]bool    // session.*, shell, git, file notifications
  userSubscribers        map[userID]map[*Client]bool       // user.settings.updated
  runSubscribers         map[runID]map[*Client]bool        // run.event.appended (run detail page)
  sessionMode            *sessionModeTracker               // focus → fast/slow poll mode
Client
  ID                    string         // server-generated socket id
  conn                  *websocket.Conn
  send                  chan []byte    // 256-deep outbound buffer
  subscriptions         map[taskID]bool
  sessionSubscriptions  map[sessionID]bool
  sessionFocus          map[sessionID]bool   // strict subset of sessionSubscriptions
  userSubscriptions     map[userID]bool
  runSubscriptions      map[runID]bool
  closed                bool

All maps are guarded by Hub.mu / Client.mu. Subscription state is shared between the per-client map (for cleanup on disconnect) and the per-key map (for fan-out on broadcast). The two are kept consistent under Hub.mu.Lock().

Backend broadcaster state

OfficeEventBroadcaster (internal/gateway/websocket/office_notifications.go) holds one bus.Subscription per office event subject. SessionStreamBroadcaster holds one per session-stream subject. Both are cleaned up when the parent ctx cancels.

Event bus subjects

In-memory MemoryEventBus (internal/events/bus/memory.go) maintains map[subject][]*memorySubscription. Subjects are flat strings (e.g. office.comment.created) except for the per-id fan-out path office.run.event_appended.<runID> (built by events.BuildOfficeRunEventSubject). Subscriptions are not persisted; a process restart loses every subscription and the bus re-registers them on next boot via the same RegisterEventSubscribers / RegisterOfficeNotifications calls.

Forwarded event payload

Every office event published to the bus is forwarded by OfficeEventBroadcaster as a ws.Message:

ws.Message
  id          string             // empty for notifications
  type        "notification"
  action      string             // e.g. "office.comment.created"
  payload     json.RawMessage    // event-specific shape; MUST include workspace_id when scoped
  timestamp   time.Time          // server clock, UTC
  metadata    map[string]string  // optional

The payload workspace_id field is the scoping key. Office event-payload structs (TaskMovedData, TaskUpdatedData, TaskStatusChangedData, etc., defined in internal/office/service/event_subscribers.go) embed WorkspaceID as JSON workspace_id. Routing-related payloads (OfficeProviderHealthChanged, OfficeRouteAttemptAppended, OfficeRoutingSettingsUpdated) MUST include workspace_id so the frontend filter can scope them. Events without a workspace_id (legacy or genuinely workspace-agnostic) are treated as in-scope by the frontend filter.

Frontend client state

WebSocketClient (apps/web/lib/ws/client.ts) holds ref-counted subscription maps:

WebSocketClient
  status                "idle"|"connecting"|"open"|"closed"|"error"|"reconnecting"
  subscriptions         Map<taskID, count>
  sessionSubscriptions  Map<sessionID, count>
  sessionFocusCounts    Map<sessionID, count>   // strict subset
  userSubscriptionCount number
  runSubscriptions      Map<runID, count>
  pendingRequests       Map<requestId, {resolve, reject, timeout}>
  pendingQueue          string[]                 // outbound frames buffered while not open
  reconnectAttempts     number

The store layer (apps/web/lib/state/slices/office/office-slice.ts) holds a single officeRefetchTrigger: string field that pages watch. Office WS handlers either patch the store directly (task status, agent status, routing) or call setOfficeRefetchTrigger(type) to invalidate a page-scoped fetch, where type is one of "dashboard" | "tasks" | "agents" | "inbox" | "activity" | "comments:<taskId>" | "task:<taskId>" | "runs" | "routines" | "costs" | "approvals".

API surface

WS events listed in section A above. The agent-summaries endpoint that backs dashboard agent cards is documented in dashboard.md. No HTTP endpoints are introduced by the live-updates surface itself beyond the per-property mutation events already covered by PATCH /tasks/:id and the comment-run lifecycle endpoints.

Subscription control frames

The frontend issues these as type: "request" frames; the backend replies with type: "response" {success: true, ...} or type: "error".

Action Payload Effect
task.subscribe {task_id} Adds client to taskSubscribers[task_id].
task.unsubscribe {task_id} Removes client from taskSubscribers[task_id].
session.subscribe {session_id} Adds client to sessionSubscribers[session_id]; server pushes an initial session-data snapshot (git status). Triggers session-mode recomputation.
session.unsubscribe {session_id} Removes client; recomputes session mode.
session.focus {session_id} Marks the session as actively viewed by this client; lifts polling to fast mode and re-pushes the session-data snapshot.
session.unfocus {session_id} Releases focus; debounced fallback to slow or paused mode.
user.subscribe {user_id?} Subscribes to user.settings.updated. user_id must equal store.DefaultUserID (single-user model) or the server returns ErrorCodeForbidden.
user.unsubscribe {user_id?} Inverse of user.subscribe.
run.subscribe {run_id} Subscribes to run.event.appended for one office run. Server replays no state — caller fetches the snapshot via REST.
run.unsubscribe {run_id} Inverse of run.subscribe.

State machine

WS connection (frontend)

Tracked by WebSocketClient.status. Server-side, the connection is just an open websocket.Conn; the state machine below is the observable surface used by hooks and the connection indicator in the topbar.

State Entered when Outgoing transitions
idle Client constructed, connect() not yet called. connect() → connecting.
connecting connect() called and new WebSocket(url) issued. socket.onopen → open. socket.onerror → error. socket.onclose → closed (then auto-reconnect logic).
open socket.onopen fired. Reconnect attempts reset to 0. Queued frames are flushed; resubscribe() re-sends every entry in the subscription maps. socket.onclose → closed. disconnect() → closed.
closed disconnect() called or socket closed and reconnect is disabled / cap reached. Pending requests are rejected with WebSocket connection closed. connect() → connecting.
error socket.onerror fired, or reconnect cap exceeded. Pending requests are rejected. connect() → connecting.
reconnecting Socket closed unexpectedly, reconnect is enabled, attempts < cap. A timer is armed with exponential backoff (initial 1s, multiplier 1.5, max 30s, cap 10 attempts). Timer fires → connecting. disconnect() → closed.

Server-side ping/pong: every pingPeriod (54s = 60s pong-wait × 0.9) the server sends a WS ping; missing the pong before pongWait (60s) closes the connection. Max inbound frame is 32 MiB. Current prompt files are staged over authenticated HTTP and only bounded descriptors cross this socket; legacy inline base64 attachments remain subject to the frame and compatibility limits.

Subscription state (per (client, key) pair)

State Entered when Outgoing transitions
unsubscribed Initial state, or after *.unsubscribe. No fan-out. *.subscribe → subscribed.
pending *.subscribe called while status != "open". Frame buffered in pendingQueue; refcount incremented; intent recorded in subscription map. Status becomes open → frame flushed → subscribed. disconnect() → unsubscribed.
subscribed Server responded {success:true} to *.subscribe. Client appears in the matching Hub map; broadcasts fan out. *.unsubscribe (last ref drops) → unsubscribed. Socket closes → backend unsubscribed, frontend records intent retained for resubscribe() on reconnect.

No per-message ack: notifications are fire-and-forget. Only request frames (those carrying an id) get a paired response/error and only when the client originated the request — broadcast notifications carry no id and the client never acks.

Session-mode tracker (per session)

Driven by session.focus / session.unfocus and subscriber counts. Listeners on the backend toggle agent polling cadence per workspace.

State Trigger to enter
paused No subscribers remain.
slow At least one subscriber, zero focused.
fast At least one focused subscriber. Server upgrades workspace poll cadence.

Transitions out of fast are debounced (see hub_session_mode.go) to absorb tab-switch churn.

Optimistic comment lifecycle

State Entered when Visible affordance
sending User clicks send. Local row appended within 50 ms. Faded row, Sending... sub-label, send button disabled.
awaiting_agent POST returns 2xx or matching office.comment.created arrives (whichever first). One of: Queued - agent paused, Agent is replying..., Awaiting agent (N ahead), Awaiting agent.
resolved Assignee agent posts a reply comment to the same task (office.comment.created with author_type != "user"). Awaiting indicator disappears.
failed POST returns non-2xx and no matching office.comment.created has been seen. Pending row removed; draft restored; toast surfaced.

Matching pending row to confirmation: the client embeds a generated UUID in the create-comment payload; the server echoes it back in the office.comment.created WS event so the optimistic row replaces (rather than duplicates) the confirmed one.

Permissions

The kandev backend runs in a single-user model (store.DefaultUserID = "default-user"). Authorization rules below describe the observable contract; multi-user enforcement would extend them.

  • Workspace scoping — every office WS notification carries workspace_id in its payload (when scoped). The current implementation broadcasts office notifications to every connected client and the frontend filters by workspace_id === workspaces.activeId. The observable contract is: a client viewing workspace A MUST NOT act on events originating in workspace B. Either server-side filtering or client-side filtering satisfies the contract.
  • User subscription — user.subscribe accepts only the caller's own user id. Subscribing to another user's id returns WS error forbidden (ws.ErrorCodeForbidden, "cannot subscribe to another user"). Symmetric rule for user.unsubscribe.
  • Task / session / run subscriptions — no per-key authorization in the current implementation. Any connected client can subscribe to any task_id, session_id, or run_id. Information-leak risk is bounded by the single-user model. A future multi-user iteration MUST gate task.subscribe / session.subscribe / run.subscribe on workspace membership; the gate point is Hub.SubscribeToTask etc.
  • Agent-originated subscriptions — agentctl-initiated MCP tool calls reach the backend over a tunnelled WS connection but do not carry a separate identity over this surface; agents cannot subscribe to other agents' streams because subscription frames are scoped per-socket and agents never open the user-facing office WS.
  • Subscription requests are not approvals — there is no approval / review gate on subscribing. Once authorized to connect, a client may subscribe and unsubscribe freely.

Failure modes

Dependency / scenario Observable behavior
WS network drop Socket transitions open → closed → reconnecting. Pending request promises stay armed until cleanupPendingRequests() rejects them at the reconnect cap. Subscription maps are retained. On open the client flushes the buffered outbound queue, then re-issues every subscribe / focus / user.subscribe / run.subscribe frame from resubscribe(). No replay of missed notifications — surfaces refetch on next event or stay stale until then.
Reconnect cap exceeded After maxAttempts (default 10) consecutive failures with exponential backoff capped at 30s, status moves to error, pending requests are rejected with WebSocket connection closed, and no further automatic reconnects occur. The WebSocket connectivity warning remains red; the user must reload to recover.
Server send buffer full client.send is a 256-deep buffer. When full, sendBytes logs Client send buffer full, dropping message and the message is dropped for that client only. Other clients still receive it. No retry, no replay; consumer must reconcile on next event.
Frontend handler throws The hub's frontend WS client invokes handlers in a forEach; an unhandled throw skips remaining handlers for that event but does not tear down the socket.
Event bus publish during shutdown MemoryEventBus.Publish returns event bus is closed after Close(). The publisher (orchestrator / office service) logs and continues; the broadcast is dropped. WS clients see no notification for that event.
Event bus subscription error in handler OfficeEventBroadcaster.subscribe logs failed to build office ws notification and returns nil to the bus — handler errors never propagate back to the publisher.
Cross-workspace event leaks past server Frontend isCurrentWorkspace(payload) discards it. No store mutation occurs. Refetch is not triggered.
Optimistic comment — server returns 5xx / network error Pending row removed from thread, draft text and any attached file are restored to the input, send button re-enables, and a toast (Failed to send comment - please try again.) surfaces. No automatic retry.
Optimistic comment — server confirms but WS event never arrives Pending row stays in awaiting_agent indefinitely. A page reload reconciles against the REST list. No client-side timeout flips it to failed once the POST succeeded.
office.run.queued arrives before the user comment refetch lands The badge waits — triggerRefetch("comments:<taskId>") invalidates the comment fetch and the badge renders once the next list response includes runId / runStatus.
Agent reply lands before run finishes The per-comment run-status badge hides reactively when any agent reply for the task arrives (office.comment.created with author_type != "user"), even if the run is still claimed.
Backend restart mid-session Every client transitions to reconnecting after pongWait (60s). Subscriptions are restored on the next open. In-flight notifications between the bus and the socket are lost.
Slow consumer (frontend tab in background) Browser may throttle the WS but the connection persists. Notifications queue in the OS-level buffer until the tab resumes; on resume the handlers replay in arrival order. No client-side dedup.
Duplicate notifications The frontend tolerates re-delivery — handlers are idempotent (status patches converge; refetch triggers debounce per page). Same UUID seen twice in office.comment.created does not spawn two rows because the optimistic UUID match deduplicates.
WS disabled / blocked at the network edge setStatus("error") after first failure; reconnect loop runs to cap. Out of scope: a polling fallback. Surfaces stay frozen on last-known-good data until the user reloads.

Persistence guarantees

Survives a kandev process restart

  • All entity rows that drive UI state: tasks, task_sessions, office_comments, office_runs, office_run_events, office_activity, office_approvals, office_agents, provider_health_state, office_route_attempts. On reconnect the frontend refetches each surface from REST and resumes streaming from there.
  • Event-bus subject registry is rebuilt on boot by RegisterEventSubscribers (office) and RegisterOfficeNotifications (WS) — not persisted, but deterministically reconstructed.

Does NOT survive a kandev process restart

  • Hub.clients, taskSubscribers, sessionSubscribers, userSubscribers, runSubscribers, sessionMode — all in-memory, cleared on shutdown via closeAllClients().
  • bus.MemoryEventBus.subscriptions — process-local channels.
  • WebSocketClient.pendingRequests and pendingQueue on the frontend — rejected on disconnect() cleanup.
  • Any notification mid-flight on the bus when the bus is closed.
  • The "Sending..." / "Awaiting agent" sticker on a pending optimistic comment — the row is wiped on reload; the REST refetch produces the canonical thread.

Does NOT survive a WS reconnect

  • Missed notifications during the gap. There is no replay window, no event sequence number, no last-event-id header. Surfaces reconcile by re-reading the server state via REST (driven by the setOfficeRefetchTrigger plumbing) plus any new notifications that arrive after open.
  • The "initial session-data snapshot" pushed on session.subscribe / session.focus is re-sent each time those frames are re-issued from resubscribe().

Survives a WS reconnect

  • Frontend subscription intent (subscriptions, sessionSubscriptions, sessionFocusCounts, userSubscriptionCount, runSubscriptions maps). resubscribe() replays every entry as a fresh subscribe frame on open.
  • All Zustand store slices not specifically invalidated by a refetch trigger. The store is not cleared on reconnect.

TTL / retention

  • No event log retention. There is no replay window — past-tense notifications are gone the moment the gateway hands them off.
  • No client-side cache of WS messages beyond what individual store slices choose to keep.
  • The optimistic-comment client UUID is held only for the lifetime of the pending row; once office.comment.created reconciles or the row is dropped on failure, it is forgotten.

Scenarios

  • GIVEN a user viewing the tasks list, WHEN an agent creates a subtask via mcp.create_task, THEN the new task appears in the list within ~1 second without a page refresh, driven by office.task.created.

  • GIVEN a user viewing the dashboard, WHEN an agent completes a task and moves it to REVIEW, THEN the Tasks In Progress count decreases and Recent Activity shows the status change, driven by office.task.status_changed and office.activity.created.

  • GIVEN a user viewing the dashboard for workspace A, WHEN an unrelated workspace B fires office.task.created, THEN the dashboard does NOT refetch and no request goes to /api/v1/office/workspaces/A/dashboard.

  • GIVEN a user viewing the inbox, WHEN an agent requests approval, THEN the inbox count badge increments and the approval appears in the list, driven by office.approval.created.

  • GIVEN a user viewing a task detail page, WHEN the agent posts a comment via kandev comment add, THEN the comment appears in the Chat tab without refreshing.

  • GIVEN the CEO has 1 running session, WHEN another task starts and the agent now has 2 sessions, THEN the sidebar agent row badge updates to 2 live within 2 seconds without a page refresh.

  • GIVEN the CEO has 2 running sessions, WHEN both complete, THEN the sidebar indicator returns to the idle status dot within 2 seconds.

  • GIVEN the dashboard agent cards panel is showing the CEO as finished, WHEN a wakeup lands and the CEO's session enters RUNNING, THEN within ~1 second of session.state_changed -> RUNNING the card flips to Live now with a pulsing dot and the task pill.

  • GIVEN the CEO's session reaches IDLE, WHEN the dashboard agent cards panel receives session.state_changed, THEN the card flips back to Finished {relativeTime} without manual refresh.

  • GIVEN a task is reassigned from agent A to agent B, WHEN the next render occurs, THEN the task pill moves from agent A's card to agent B's card on the dashboard agent cards panel.

  • GIVEN the user is on /office and a task is in progress, WHEN the agent transitions the task to done, THEN the Tasks In Progress count decrements and the Run Activity chart updates the current-day bar, driven by office.task.status_changed.

  • GIVEN the user types "looks good" and clicks send, WHEN the request is in flight, THEN the comment appears at the bottom of the thread with faded styling and a Sending... indicator within 50 ms, and the send button is disabled.

  • GIVEN a pending comment is showing, WHEN the server returns 201, THEN the pending styling is removed and the comment renders with the server-provided author and timestamp; no visible flash or layout shift.

  • GIVEN a pending comment is showing, WHEN the server returns 500, THEN the pending comment is removed, the draft text is restored to the textarea, the send button re-enables, and a toast says Failed to send comment - please try again.

  • GIVEN the assignee agent is paused, WHEN the user opens the task chat, THEN the input area shows Agent is paused - resume it for replies before the user types anything.

  • GIVEN the assignee agent is paused, WHEN the user submits a comment, THEN the comment is saved and shows Queued - agent paused instead of Sending....

  • GIVEN the inline "agent paused" notice is showing, WHEN the user resumes the agent, THEN the notice disappears within 2 seconds without a page reload, driven by office.agent.updated.

  • GIVEN the user posted a comment and a session is RUNNING for this task, WHEN the comment confirms, THEN it shows Agent is replying... with a typing-style indicator until the agent posts a reply comment.

  • GIVEN the user posted a comment and the assignee agent is busy running 2 other tasks, WHEN the comment confirms, THEN it shows Awaiting agent (2 ahead).

  • GIVEN a user comment is showing Awaiting agent, WHEN an agent reply comment for this task arrives via office.comment.created, THEN the awaiting indicator disappears.

  • GIVEN a user comment carries an office.run.queued event, WHEN the run progresses to claimed, THEN the per-comment run-status badge updates live without a refresh.

  • GIVEN a per-comment run-status badge is showing failed, WHEN an agent reply for the task arrives, THEN the badge hides.

  • GIVEN a task has an active session, WHEN the user opens the task detail page, THEN the page header shows <spinner /> Working next to the title, and the inline session entry appears at its chronological position in the comments timeline, expanded by default.

  • GIVEN an active session entry is rendered, WHEN the session reaches a terminal state, THEN the page-header Working indicator disappears and the inline entry collapses to a one-line summary that stays in the timeline.

  • GIVEN an active session entry's transcript is streaming, WHEN new message chunks arrive and the user is already at the bottom of the chat, THEN the chat container auto-scrolls; WHEN the user has scrolled up, THEN the chat container does not yank focus.

Out of scope

  • Polling fallbacks of any kind. If the WS connection is down, surfaces stay as-is until the connection recovers and the next event arrives.
  • Cross-workspace event subscriptions. A client only receives events for its active workspace.
  • Replacing the Zustand refetchTrigger mechanism with React Query / SWR.
  • Optimistic UI updates outside of user-initiated comments (dashboard metrics, agent state, task properties beyond comment send all wait for server confirmation via event).
  • Animating chart bar transitions on update.
  • Retry-on-error UI for failed comment sends (clicking a "retry" button on a failed comment). Draft restoration covers the common case.
  • Auto-resuming a paused agent when the user posts a comment. The notice invites manual resume; a combined "send + resume" action is a future iteration.
  • Editing user comments after submission.
  • Optimistic rendering of agent-generated comments. Those flow through the WS stream and are not user-initiated.
  • Live streaming of agent transcripts inside dashboard agent cards. Card expanded run rows are header-only in v1; embedding <AdvancedChatPanel> per row is a follow-up.
  • A global "N agents working" badge in the topbar.
  • Per-task progress percentages.
  • Click-to-jump from a sidebar live badge directly into a specific running session.