38 KiB
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_changedcause refetch / re-render of:Recent Tasks,Tasks In Progress, theRun Activitychart, and theRecent Activityfeed.office.agent.completedandoffice.agent.failedupdate theAgents Enabledcard subtitle (running / paused / errors line).session.state_changed,office.task.updated,office.agent.updatedcause the per-agent cards panel to refetchGET /api/v1/office/workspaces/:wsId/agent-summariesand 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 /> Workingindicator 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 frommessages.bySession[sessionId]filtered fortype === "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.
- Active session entry is expanded by default. Header reads
- 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:
- 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. - 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 pausedwith 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 fromselectActiveSessionsForAgentover the existingtaskSessionsstore (same selector the sidebar uses). - Agent idle, not paused ->
Awaiting agent(default).
- Agent paused / stopped / pending_approval ->
- 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
taskSessionsstore. There is no surface where an agent looks idle on one page and live on another. office.task.updatedis 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
setIntervalor 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 (raised to accommodate base64 image attachments).
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_idin its payload (when scoped). The current implementation broadcasts office notifications to every connected client and the frontend filters byworkspace_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.subscribeaccepts only the caller's own user id. Subscribing to another user's id returns WS errorforbidden(ws.ErrorCodeForbidden, "cannot subscribe to another user"). Symmetric rule foruser.unsubscribe. - Task / session / run subscriptions — no per-key authorization in the current implementation. Any connected client can subscribe to any
task_id,session_id, orrun_id. Information-leak risk is bounded by the single-user model. A future multi-user iteration MUST gatetask.subscribe/session.subscribe/run.subscribeon workspace membership; the gate point isHub.SubscribeToTasketc. - 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) andRegisterOfficeNotifications(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 viacloseAllClients().bus.MemoryEventBus.subscriptions— process-local channels.WebSocketClient.pendingRequestsandpendingQueueon the frontend — rejected ondisconnect()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
setOfficeRefetchTriggerplumbing) plus any new notifications that arrive afteropen. - The "initial session-data snapshot" pushed on
session.subscribe/session.focusis re-sent each time those frames are re-issued fromresubscribe().
Survives a WS reconnect
- Frontend subscription intent (
subscriptions,sessionSubscriptions,sessionFocusCounts,userSubscriptionCount,runSubscriptionsmaps).resubscribe()replays every entry as a fresh subscribe frame onopen. - 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.createdreconciles 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 byoffice.task.created. -
GIVEN a user viewing the dashboard, WHEN an agent completes a task and moves it to REVIEW, THEN the
Tasks In Progresscount decreases andRecent Activityshows the status change, driven byoffice.task.status_changedandoffice.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 livewithin 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 entersRUNNING, THEN within ~1 second ofsession.state_changed -> RUNNINGthe card flips toLive nowwith a pulsing dot and the task pill. -
GIVEN the CEO's session reaches
IDLE, WHEN the dashboard agent cards panel receivessession.state_changed, THEN the card flips back toFinished {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
/officeand a task is in progress, WHEN the agent transitions the task todone, THEN theTasks In Progresscount decrements and theRun Activitychart updates the current-day bar, driven byoffice.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 repliesbefore 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 pausedinstead ofSending.... -
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
RUNNINGfor this task, WHEN the comment confirms, THEN it showsAgent 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 viaoffice.comment.created, THEN the awaiting indicator disappears. -
GIVEN a user comment carries an
office.run.queuedevent, WHEN the run progresses toclaimed, 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 /> Workingnext 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
Workingindicator 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
refetchTriggermechanism 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.