35 KiB
spec, created, status
| spec | created | status |
|---|---|---|
| docs/specs/ui/requirements/quick-chat-idle-dot.md | 2026-08-16 | complete |
Implementation Plan: Quick Chat Idle Dot
Overview
A red dot on the five Quick Chat entry icons (sidebar rail item, sidebar
shortcut, tablet header button, mobile header button, mobile task-switcher
sheet button) whenever a quick chat session completes a turn while the dialog
is closed. Purely frontend: session.state_changed and session.turn.completed
are broadcast to every connected client (their payloads carry no
workspace_id, so the gateway's workspace-broadcast path falls back to global
delivery), so the client observes every completion with no new WS
subscriptions or backend changes; per-workspace dots rely on session-tab
membership and the selector, not transport scoping. Implementation order:
unseen-idle state in the UI slice → marking hooks in the two WS handlers → dot
rendering in the entry components → E2E.
Frontend
State — apps/web/lib/state/slices/ui/
types.ts(QuickChatState): addunseenIdleByWorkspace: Record<string, Record<string, true>>— workspace id → session id → marker. Workspace ownership lives in the key, so it survives hydration of a different workspace's session list (see Hydration — non-destructive union). Also addlastSettledAtBySession: Record<string, string>— session id →updated_atof the last observed settle transition (replay guard forsession.state_changedmarking; see Marking hooks). Also addsyncRevisionByWorkspace: Record<string, number>— per-workspace mutation epoch, incremented by EVERY local server-backed tab mutation that could make an in-flight resync response stale: WS-driven upserts (upsertQuickChatSessionFromEvent),addQuickChatSession(insert AND existing-row update), theopenQuickChatpush/existing branches,removeQuickChatSession(direct-delete success path),removeQuickChatSessionsForTask, AND the optimistic rename flow (renameQuickChatSession/persistQuickChatRename— bumped BEFORE the server request, so a resync in flight cannot apply the old server name after the optimistic rename). The reconnect resync (useQuickChatResync) captures it before issuing the HTTP list fetch and DISCARDS a stale response (revision moved while in flight) — checked before EVERY response side effect, includingsetTaskSessionfor the returnedtask_sessionsrows — so neither a WS-upserted tab, a locally-deleted tab, nor an optimistic rename can be clobbered by an older list. Also add returnedtask_sessionsrows are guarded PER-ROW byupdated_at:useQuickChatResyncskips applying any returned row whose live store row has a NEWERupdated_at(the live WS row wins). This is workspace-precise —session.state_changedbroadcasts globally with noworkspace_id, and an unrelated workspace's state traffic must NOT discard a valid resync response. Also addtombstonedSessions: Record<string, { workspaceId: string; tombstonedAt: string }>— client tombstones for deleted session identities (value carries the workspace AND an ISO timestamp so the 60-minute age-prune is implementable), set byremoveQuickChatSession,removeQuickChatSessionsForTask, AND the Quick Chat dialog's delete-tab flow (use-quick-chat-modal.tshandleConfirmClosecallsremoveQuickChatSessionafter the successfuldeleteQuickChatTask— NOT barecloseQuickChatSession, which does not tombstone; setup-tab closes keepcloseQuickChatSessionsince setup ids never receive task events).syncQuickChatFromTaskEvent/upsertQuickChatSessionFromEventSKIP tombstoned session ids, so a late/out-of-ordertask.created/task.updatedcannot resurrect a deleted tab (DeleteSession publishes nosession.deletedbroadcast, and the task lifecycle subscription is ordered independently of the HTTP resync). Tombstone clearing is POSITIVE-CONFIRMATION ONLY: a tombstone is cleared solely when an authoritative workspace list for the SAME workspace (workspaceIdstored with the tombstone) positively CONTAINS the session (it is legitimately live again); an omission NEVER clears it (an older task event can still be in flight after the list), and another workspace's list NEVER touches it. Age-pruned like the ledger (60-minute retention bound, keyed ontombstonedAt). Also addsessionOwnership: Record<string, { taskId?: string; workspaceId: string }>— durable in-memory sessionId → task/workspace index, populated at EVERY session-insertion path — the INITIAL BOOT path (mergeQuickChatStateinapps/web/lib/state/default-state.ts:createAppStorerunsmergeInitialStatebefore composing the store, so boot-loaded sessions never pass throughhydrateUI),addQuickChatSession,upsertQuickChatSessionFromEvent(BOTH branches of the centralupsertQuickChatSessionreducer — a task event updating an existing row also refreshes its ownership via the centralizedindexSessionOwnershiphelper), theopenQuickChatreal-session push branch (ui-slice.tspushes a freshly started session directly, not viaaddQuickChatSession),reconcileQuickChatSessions(inserted/adopted server sessions, and refreshed for survivors), andhydrateUI— and pruned oncloseQuickChatSession,removeQuickChatSessionsForTask, and revision-guarded reconcile/resync (NOT by hydration). Without it,removeQuickChatSessionsForTask(taskId)— the only signal for task delete/archive — cannot clean a marker once the session row left the currentsessionslist (e.g. marked workspace-A session, workspace-B hydration replaces A's row, then A's task-deleted event: the reducer finds nothing and A's marker survives until a later A sync). Boot-loaded,openQuickChat, or reconcile tabs whose ownership entry is missing have the same hole on task deletion.ui-slice.ts(defaultUIState.quickChat): defaultunseenIdleByWorkspace: {}.ui-slice.tsactions:markQuickChatUnseenIdle(sessionId: string, workspaceId: string)— setsunseenIdleByWorkspace[workspaceId][sessionId] = true.clearQuickChatUnseenIdle()— clears all workspaces (used byopenQuickChat).clearQuickChatUnseenIdle(sessionId: string, workspaceId: string)— deletes one entry.removeQuickChatSession(sessionId: string)— LOOKUP PRECEDENCE isquickChat.sessionsTHENsessionOwnership(an ownership entry is authoritative ownership, NOT unknown, even when the row is absent after cross-workspace hydration): removes the tab (whichever workspace), the marker from the owning workspace's map, thesessionOwnershipentry, tombstones non-setup ids, bumpssyncRevisionByWorkspace, runs the ledger age-prune, and re-points the active tab / closes the modal exactly likecloseQuickChatSessionwhen the removed session was active. A genuinely unknown id (no row, no ownership, not setup-prefixed) is a no-op.
- Generic session-delete hook: the backend has no dedicated
session.deletednotification (DeleteSession publishes only the deleted-session error event), so the frontend delete flows are the cleanup points:useSessionActions.remove(apps/web/hooks/domains/session/use-session-actions.ts:148) callsremoveQuickChatSession(sessionId)afterremoveTaskSession(taskId, sessionId), ANDsessions-dropdown.tsx'shandleDeleteSession(directclient.request("session.delete")+loadSessions(true)) callsremoveQuickChatSession(sessionId)after a successful delete (before the refresh). The Quick Chat modal'shandleConfirmCloseroutes the NON-setup branch throughremoveQuickChatSession(after successfuldeleteQuickChatTask, and ALSO for the no-taskId case — a non-setup id whose taskId cannot be resolved is still cleaned and tombstoned via the ownership lookup, not left to barecloseQuickChatSession); only setup-tab closes keepcloseQuickChatSession. Without these, deleting a quick chat session from any surface leaves the tab, marker, and ownership entry until a later resync, and a delayed global state event keeps contributing to the stale tab. buildOpenQuickChatAction: clear all markers on open (clearQuickChatUnseenIdle()); populatesessionOwnershipfor the real-session push branch (a freshly started session is inserted directly intoquickChat.sessions, bypassingaddQuickChatSession).buildQuickChatActions:setActiveQuickChatSession: clear that session's marker (workspace from the tab entry).closeQuickChatSession: clear the closing session's marker.removeQuickChatSessionsForTask: clear markers for the removed sessions via thesessionOwnershipindex (session ids by taskId → delete their markers from their workspace maps, drop their ownership entries), THEN filter the current sessions list — works even when the session row is absent from the list after cross-workspace hydration (reducer inquick-chat-sync.ts, workspace-scoped).syncQuickChatSessions:reconcileQuickChatSessionsinquick-chat-sync.tsdrops that workspace's markers for sessions the server list no longer contains, populates/refreshes ownership for inserted/adopted sessions and survivors, TOMBSTONES each omitted non-setup session (with workspace + timestamp) BEFORE dropping its ownership, and clears tombstones ONLY for same-workspace sessions POSITIVELY present in the list (omission and other workspaces never clear). The OMISSION IDENTITY SET is the union of the prior workspace's session rows AND the workspace-scopedsessionOwnershipentries — a session known only throughsessionOwnership(its row was dropped by a cross-workspace hydration) is still omitted-and-tombstoned when the hydrated list lacks it; otherwise an empty A-list sync after a B-hydration would prune A's ownership without a tombstone and a delayed task event could re-upsert the deleted tab.
- Public store surface:
apps/web/lib/state/store.tsmanually enumerates the quick chat actions onAppState(the section declares individualUIA["…"]entries, notUISliceActions); the four new root actions (markQuickChatUnseenIdle,clearQuickChatUnseenIdle,recordQuickChatSettled,removeQuickChatSession) MUST be added to that explicit list or components/handlers selecting them fromuseAppStorewill not typecheck. - Code-quality limits (AGENTS.md):
ui-slice.tsis already 568/600 lines andbuildQuickChatActions~98/100 — the new ownership-index, tombstone, revision-epoch, ledger-pruning, marker-cleanup, andremoveQuickChatSessiontransitions MUST be extracted intoquick-chat-sync.tsor a dedicatedquick-chat-lifecycle.tsmodule (pure reducers/helpers + thin action wrappers), keepingui-slice.ts≤600 lines and every function ≤100 lines (enforced byapps/web/eslint.config.mjs). - New selector
apps/web/lib/state/slices/ui/quick-chat-unseen-selectors.ts:selectQuickChatHasUnseenIdle(state: AppState, workspaceId: string | null | undefined): boolean— true when the workspace's marker map is non-empty; returns false for nullish or unknown workspace ids (every entry-point caller passesstring | null | undefined—useAppStore((s) => s.workspaces.activeId)and the headerworkspaceId?props). Stable empty fallback, no fresh literals. - Boot payload + typed fixtures:
apps/web/app/page.tsx(loadWorkspaceState→initialState.quickChat), theQuickChatStatebuilders inapps/web/lib/state/slices/ui/quick-chat-sync.test.ts, and the full-statedraft.quickChatassignments inapps/web/lib/state/hydration/hydrator.test.tsgain ALL FIVE fields with empty defaults (unseenIdleByWorkspace: {},lastSettledAtBySession: {},sessionOwnership: {},syncRevisionByWorkspace: {},tombstonedSessions: {}) — the boot payload must explicitly reset every ephemeral map, and full-typedQuickChatStateconstructions must carry every required field. - Hydration is a NON-DESTRUCTIVE MERGE (workspace-keyed):
hydrateUI(apps/web/lib/state/hydration/hydrator.ts) merges the hydrated sessions list intodraft.quickChat.sessions— UNION: sessions absent from the live list are ADDED (ownership indexed for them); sessions already present KEEP the live tab's fields (live wins on overlap —QuickChatSessionhas noupdated_at,StateHydratorre-hydrates on every SSRinitialStatechange, and a stale SSR payload captured before atask.updatedor the optimistic rename must not regress the newer live name/taskId); tombstoned ids filtered OUT — and NEVER prunes markers/ownership and NEVER tombstones omissions (it only unions sessions and filters existing tombstones; it neither creates nor clears them).StateHydratorre-hydrates on every SSRinitialStatechange (state-hydrator.tsx:19-24), and an SSR list captured before either a local delete OR a WS-upserted live tab must not destroy the current truth: pruning markers/ownership, tombstoning omitted sessions, and positive tombstone clearing happen ONLY on the revision-guarded resync path (syncQuickChatSessions→ reconcile, afteruseQuickChatResync's epoch + task-session-revision checks). The hydrated workspace (for ownership population and tombstone filtering) derives fromstate.workspaces?.activeId(boot payload provides it viabuildBaseState, verifiedapps/web/app/page.tsx:63-67); when absent (partial payload) hydration still merges sessions but skips workspace-scoped side effects fail-safe.
Marking hooks — WS handlers
apps/web/lib/ws/handlers/agent-session.ts(registerTaskSessionHandlers,session.state_changed): after the existing fan-out, call a localmaybeMarkQuickChatUnseenIdle(store, sessionId, previousState, newState)that marks only when (a) the session id is instore.getState().quickChat.sessions(its workspace id comes from that tab entry), (b)!store.getState().quickChat.isOpen, and (c) the transition is a settle transition (previous STARTING/RUNNING → new IDLE/WAITING_FOR_INPUT/COMPLETED/FAILED/CANCELLED; previous state = store row FIRST, falling back to the payload's typedold_stateonly when no row exists — boot hydration dropstask_sessions). The REPLAY guard is a per-session settle-generation ledger, NOT the store row:quickChat.lastSettledAtBySession: Record<sessionId, string>records theupdated_atof EVERY settle transition observed for ANY session (kanban and quick chat alike, regardless ofisOpenand regardless of quick-chat membership — recording happens BEFORE the membership check), and a settle marks only when itsupdated_atis newer than the ledger entry (or no entry). The ledger is a MONOTONIC MAX per session: a settle withupdatedAt <= recordedAtis suppressed AND never overwrites the entry — an out-of-order delayed older settle must not regress the ledger, or a later replay of the newer event would falsely mark after the dialog cleared the dot. Recording before membership is required for the event-before-tab guarantee: a settle that arrives before its tab exists records the generation, so adding the tab later and replaying the original event cannot mark (the ledger already has thatupdated_at). The store row is unreliable for replay detection becausehydrateSessionrunsdeepMerge(draft.taskSessions, state.taskSessions)unconditionally — an in-flight SSR snapshot can regress a settled row back to RUNNING. A settle whose payload lacksupdated_athas NO stable generation (the fallback persistence path can publish without it —persistStrictTaskSessionStatepasses nil when the refreshed row'sUpdatedAtis zero and the publisher omits the key): it FAILS CLOSED — never marks and never records the ledger, because a replayable ungenerationed settle must not raise a dot. The ledger is age-bounded: the pure helperpruneStaleSettledLedger(bySession, now)(60-minute retention window — far longer than the event-before-tab tab-upsert, reconnect, and hydration-regression replay windows) runs atrecordQuickChatSettledAND atcloseQuickChatSession,removeQuickChatSessionsForTask,reconcileQuickChatSessions, andhydrateUIpruning; entries younger than the window are never dropped on those paths (still needed for the replay window), older entries are evicted, so a long-lived client cannot accumulate a key per settled kanban session. The ledger is ALSO size-capped at a documented maximum (500 entries): when the cap is exceeded, the oldest entries by timestamp are evicted — the practical replay window is seconds, so the cap only ever evicts entries far older than any realistic replay, and age window + size cap bound memory deterministically regardless of workload. Define the two state sets locally (do not import fromhooks/domains/session/use-session-messages.ts— avoid ahooks → lib/wsimport edge).apps/web/lib/ws/handlers/turns.ts(registerTurnsHandlers,session.turn.completed): captureconst wasCompleted = turns.bySession[sessionId]?.some(t => t.id === payload.id && t.completed_at)BEFORE the existingaddTurn/completeTurncalls (both mutate the store, so a post-mutation check would see every delivery as already completed); then, aftercompleteTurn, mark only when (a) quick chat session, (b) dialog closed, and (c)!wasCompleted— a re-delivered completion of an already-recorded turn must not mark (spec re-delivery failure mode). Allsession.turn.completedevents are treated uniformly:AbandonOpenTurns(resume/startup cleanup, rejected-message cleanup) publishes the same payload shape with no abandonment discriminator (had_outputis forced true), so an abandoned closure marks like any completion — accepted, see spec failure mode.- Receipt-time semantics (spec failure mode): both handlers evaluate
quickChat.isOpenat handling time.session.state_changedandsession.turn.completedarrive over separate event-bus subscriptions, and the gateway broadcasts both to every connected client (payloads carry noworkspace_id— seerouteBroadcast/BroadcastToWorkspacefallback), so their relative order is not guaranteed; a completion handled while the dialog is open never marks, one handled after it closed marks.
Entry-point dots
apps/web/components/app-sidebar/app-sidebar-nav-item.tsx: adddot?: booleanprop; when true, wrap the icon in arelativespan with an absolutely positioned dot (h-2 w-2 rounded-full bg-red-500 ring-2 ring-background, top-right corner,aria-hidden,data-testid="quick-chat-unseen-dot").apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx: Quick Chat nav item getsdot={selectQuickChatHasUnseenIdle(state, workspaceId)}(only rendered when collapsed).apps/web/components/app-sidebar/app-sidebar-new-task-item.tsx:RowActionButtongains an optionaldot?: boolean(same absolute dot over theh-3.5 w-3.5icon); the Quick Chat shortcut passes the selector result.apps/web/components/kanban/kanban-header.tsx(TabletQuickActions): wrap theIconMessageCirclein a relative span with the dot when the selector is true.apps/web/components/kanban/kanban-header-mobile.tsx: same for the mobile button.apps/web/components/task/mobile/session-task-switcher-sheet.tsx(TaskSwitcherSurfaceHeader,mobile-sheet-quick-chat): same for the mobile task-switcher sheet button.
No i18n impact: the dot is aria-hidden and carries no copy; labels/tooltips are unchanged.
Tests
- Marking via
session.state_changed—apps/web/lib/ws/handlers/agent-session.test.ts(extend existing describe blocks; store builder gainsquickChatstate):- table-driven over BOTH accepted active sources × every accepted settle target — for each S ∈ {STARTING, RUNNING} and T ∈ {IDLE, WAITING_FOR_INPUT, COMPLETED, FAILED, CANCELLED}: S→T marks when closed + tab exists, AND a replay of the same event (identical
updated_at, row or ledger already settled) does NOT mark; plus the reverse (any settled → active) never marks. - dialog OPEN + RUNNING→IDLE → no marker (event-before-close ordering); the settle still records
lastSettledAtBySessionso a replay after close does not mark either. - no store row + payload
old_state: RUNNING→ IDLE → marker set (old_state fallback; boot hydration has no task-session row for quick chat tabs); withold_stateabsent → falls back to the store row. - non-quick-chat (kanban) session + closed + RUNNING→IDLE → no marker.
- stale-hydration between replay: settle RUNNING→IDLE (mark) →
openQuickChatclears → simulatehydrateSessionregressing the task-session row back to RUNNING (deepMerge) → replay the ORIGINAL event verbatim (sameold_state, sameupdated_at) → NO mark, because the ledger records the earlier settle (row regression is irrelevant). - ledger re-arm: settle at t1 (mark) → open clears → new settle at t2 > t1 → mark again; replaying t2 after another clear → no mark.
- MISSING
updated_at(fail-closed): settle withoutupdated_at, closed + tab exists → NO mark and NO ledger record; after row regression + replay of the same event → still no mark — an ungenerationed settle must never raise a dot. - ledger bound: an entry within the 60-minute retention window still suppresses an event-before-tab replay;
pruneStaleSettledLedgerwith a fixednowevicts entries older than the window; re-arm still works after eviction of the old entry; eviction also runs on close/remove/reconcile/hydration paths (young entries preserved there). - ledger monotonic max: settle t1 → settle t2 (t2 > t1) →
openQuickChatclears →deepMergeregresses the row → a DELAYED t1 arrives → no mark AND the ledger stays t2 (never regressed); replaying t2 → still no mark. - ledger size cap: inserting beyond the 500-entry cap evicts the oldest entries by timestamp; entries within the cap are retained (event-before-tab replay still suppressed).
- eviction-then-replay pin (accepted behavior, spec failure mode): a
state_changedduplicate delivered AFTER its generation was age-evicted or cap-evicted marks like a new settle — pins the qualified re-delivery guarantee (suppression holds within the ledger retention only).
- table-driven over BOTH accepted active sources × every accepted settle target — for each S ∈ {STARTING, RUNNING} and T ∈ {IDLE, WAITING_FOR_INPUT, COMPLETED, FAILED, CANCELLED}: S→T marks when closed + tab exists, AND a replay of the same event (identical
- Abandoned closures (accepted behavior, spec failure mode) —
turns.test.ts: asession.turn.completedwhosecompleted_atequalsstarted_at(the backend'sisAbandonedTurnCompletionshape) for a known quick-chat tab with the dialog closed marks like any completion — pins the intentional decision NOT to filter on the discriminator. The session did settle idle and the state_changed settle transition raises the same signal. - Re-arm (spec scenario 5) —
agent-session.test.tsorturns.test.ts: quick chat session marks,openQuickChatclears all, dialog closes, a second NEWsession.turn.completed(or RUNNING→IDLE) arrives → marker is set again. Pins that clear-on-open does not permanently disarm a session. - Event-before-tab + re-delivery (spec failure modes) —
agent-session.test.ts/turns.test.ts: a settle or turn-completed event for a session absent fromquickChat.sessionsdoes not mark; forstate_changedthe settle generation is still RECORDED (membership-agnostic ledger), so after the tab is later added viaupsertQuickChatSessionFromEvent/addQuickChatSession, replaying the original event does NOT mark; kanban sessions never render a marker (membership still fails) and their replay stays suppressed. Forturn.completedthis pins the pre-mutationwasCompletedsnapshot: first delivery of a turn → marks (if closed + tab exists); duplicate delivery → no mark (turn already recorded); duplicate afteropenQuickChatcleared the marker → marker stays cleared. - Receipt-time ordering —
agent-session.test.ts/turns.test.ts: handler-level determinism for both orderings — event handled while dialog open → no mark; same event handled after dialog closed → mark (close-before-event). The separateTurnCompleted/TaskSessionStateChangedgateway subscriptions make cross-channel order undefined, so the handlers must be order-independent per event. - Marking via
session.turn.completed—apps/web/lib/ws/handlers/turns.test.ts:- quick chat session + dialog closed → marker set.
- quick chat session + dialog open → no marker.
- non-quick-chat session → no marker.
- State actions + selector — new
apps/web/lib/state/slices/ui/quick-chat-unseen.test.tsandquick-chat-unseen-selectors.test.ts:markQuickChatUnseenIdle(sessionId, workspaceId)sets in that workspace's map;clearQuickChatUnseenIdle()clears all workspaces;clearQuickChatUnseenIdle(sessionId, workspaceId)clears one.openQuickChatclears all markers.setActiveQuickChatSessionclears that session's marker.closeQuickChatSessionclears the closing session's marker + ownership entry and runs the ledger age-prune.- generic session-delete regression: a quick chat session removed via
useSessionActions.remove's success path ORsessions-dropdown.tsx'shandleDeleteSession→ tab, marker, and ownership entry all removed, ledger pruned; a delayedsession.state_changed/turn.completedfor the deleted session does NOT mark (membership fails); deleting a non-quick-chat session is a no-op. removeQuickChatSessionsForTaskclears markers of that task's sessions only (workspace-scoped).syncQuickChatSessionsreconcile drops that workspace's markers for sessions absent from the server list; two-workspace case: markers in A and B, sync A → only A pruned.- ownership-index regression: mark workspace-A session → hydrate B (A's marker AND
sessionOwnershipentry preserved) →removeQuickChatSessionsForTask(A-task)→ A's marker removed and ownership entry dropped even though A's session row is absent from the current sessions list; deleting B's task does not touch A's markers;closeQuickChatSession/ revision-guarded reconcile/resync drop ownership entries for the removed sessions; hydration PRESERVES A's ownership entry (non-destructive). - ownership-index insertion regressions: a tab inserted via the
openQuickChatreal-session push branch, a session inserted/adopted byreconcileQuickChatSessions, and a session loaded at INITIAL BOOT (mergeQuickChatState), each gain ownership entries at insertion; for all three, mark →removeQuickChatSessionsForTask→ marker and ownership removed (cross-device resync included; the boot case: boot-loaded A session → mark → hydrate B (A row dropped) → delete A task → marker removed). - selector: true for a non-empty marker map of the workspace; false for other workspaces, for nullish/unknown workspace ids, and when nothing marked; stable empty fallback.
- resync staleness regression: a deferred
listQuickChatSessionsresponse with an intervening WS task/session event (tab upserted + marker set, epoch bumped) is DISCARDED byuseQuickChatResync— the tab and marker survive, reconcile never runs with the stale list; a response with no intervening mutation applies normally; a response arriving after reconnect without mutations still reconciles. - delete-vs-resync regression: a resync fetch started before a direct session delete (tab removed, epoch bumped) returns the old list afterward → discarded, the deleted tab is NOT re-added (reconcile never runs with the stale list).
- tombstone regressions: after
removeQuickChatSession/removeQuickChatSessionsForTask/ the modal delete-tab flow, a latetask.created/task.updated(orupsertQuickChatSessionFromEvent) for the tombstoned session id is IGNORED — no tab re-added, marker stays gone; a tombstone is cleared ONLY by a same-workspace authoritative list that POSITIVELY contains the session (then a genuinely new event applies); an authoritative list OMITTING the session does NOT clear it (delayed-old-event-after-authoritative-omission); a workspace-B list never clears a workspace-A tombstone; tombstones age-prune like the ledger (60 minutes, keyed ontombstonedAt— timestamp present and pruned). - reconcile-omission tombstone: a session omitted by a resync list is tombstoned BEFORE its ownership drops — a delayed
task.created/task.updatedafter the omission is still blocked (deletion observed via list before thetask.deletedevent; cross-devicesession.deletecovered). The omission set is the union of the workspace's session rows AND itssessionOwnershipentries: A → hydrate B (A's rows dropped, ownership kept) → sync/hydrate A with an EMPTY list → A's ownership-only sessions are tombstoned before pruning, and a late task event for one is still blocked. - removal precedence:
removeQuickChatSessionon a row-absent-but-ownership-known session cleans marker + ownership + tombstones + bumps the revision (no stale-response re-add); the modal's non-setup no-taskId branch routes through the same cleanup (tombstone) instead of barecloseQuickChatSession. - hydration regressions: hydration is NON-DESTRUCTIVE — a deferred
StateHydratorrun with an SSR list captured before a local delete does NOT re-admit a deleted tab (tombstoned ids filtered) NOR clear its tombstone; an SSR list captured before a WS-upserted live tab does NOT drop or tombstone it (union merge preserves it; later lifecycle events still apply); an SSR list captured before atask.updatedor the OPTIMISTIC RENAME does NOT overwrite the live tab's newer name/taskId (live wins on overlap); partial hydration payload without an active workspace id still merges sessions fail-safe. All pruning/tombstoning/positive-clearing is exercised on the revision-guarded resync. - task-session row-freshness regression: a deferred resync response with stale
task_sessions(captured before asession.state_changedRUNNING→IDLE) applies ONLY the rows that are still fresh — the older RUNNING row for that session is SKIPPED (its live row has a newerupdated_at), while other rows and the tab list apply; an unrelated workspace'ssession.state_changedtraffic does NOT discard a valid resync response. - rename-vs-resync regression: an optimistic rename bumps the epoch before
persistQuickChatRename; a resync in flight is discarded and cannot revert the tab to the old server name. - ownership-refresh regression: updating an EXISTING quick chat session row via
openQuickChat(existing branch) oraddQuickChatSession(existing branch) with a new/changedtaskIdrefreshes itssessionOwnershipentry —removeQuickChatSessionsForTaskwith the new taskId removes the marker; a row whose ownership lacks or has a stale taskId is covered.
- Resync-driven pruning (non-destructive hydration) —
apps/web/lib/state/hydration/hydrator.test.ts+ resync tests: hydration is a NON-DESTRUCTIVE union (fixtures compile with all fields; hydrating with a server session list that omits a marked session does NOT prune anything and does NOT resurrect re-upserted tabs); pruning of that workspace's markers/ownership for omitted sessions, omission-tombstoning (union identity set incl. ownership-only sessions), and positive tombstone clearing are exercised on the REVISION-GUARDED resync (syncQuickChatSessions/reconcile) — including the A/B cross-workspace cases (B-only resync preserves A's marker while pruning B's removed sessions; a later A resync behaves correctly with A retained and with A omitted). - Ephemeral reset (spec scenario 8) —
mergeInitialStateunit test (or the hydration tests): a previous client state carrying markers and ledger entries, merged with a boot payload that omits them, yields an emptyunseenIdleByWorkspaceandlastSettledAtBySession— pins that a page reload (fresh store from default + boot payload) shows no dot. - Component rendering:
apps/web/components/app-sidebar/app-sidebar-primary-nav.test.tsx: collapsed rail Quick Chat item renders the dot when the selector is true, not otherwise.apps/web/components/app-sidebar/app-sidebar-new-task-item.test.tsx: Quick Chat shortcut renders the dot when unseen, not otherwise.apps/web/components/kanban/kanban-header-mobile.test.tsx: mobile button dot.apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx:mobile-sheet-quick-chatbutton dot.- Tablet header (
kanban-header.tsx) covered by the E2E tablet case.
Command form (from apps/): pnpm --filter @kandev/web test -- --run <file1> <file2> … per task, then the full touched-file set.
E2E Tests
New specs under apps/web/e2e/tests/chat/ using the existing helpers
(quick-chat-helpers.ts: openQuickChatWithAgent, sendQuickChatMessage) and
helpers/causal-waits.ts watchWs. All dot assertions MUST be scoped to the
entry under test — getByTestId("quick-chat-unseen-dot") matches every entry
simultaneously once a marker is active (strict-mode ambiguous), so each
assertion uses the named button's locator, e.g.
shortcut.getByTestId("quick-chat-unseen-dot"), and surfaces are asserted
individually:
- Desktop (chromium) —
quick-chat-idle-dot.spec.ts: GIVEN no quick chat, THEN no dot onsidebar-quick-chat-shortcut. Capturesession_id(andtask_id) from thePOST /api/v1/workspaces/<id>/quick-chatresponse (pattern:entity-reference-composer.spec.ts— the shared helpers return only the dialog). CallwatchWs(page)before the firstgoto, then armconst completed = ws.waitForEvent("session.turn.completed", { where: (p) => p.session_id === sessionId })BEFORE sending/slow 8s(the wait must be armed before the event fires —watchWsbuffers nothing, the gateway has no replay); send, close the dialog, awaitcompleted, THEN the dot is visible on the shortcut. Reopen → dot gone. Re-arm: arm a SECONDwaitForEventfor the same session, send the second message WHILE the dialog is open, close the dialog while it is pending, awaitcompleted, THEN the dot reappears (spec scenario 5 — the composer only accepts input while the dialog is open). - Tablet (chromium) — same lifecycle against
tablet-quick-chat-buttonusing thetabletTestPagefixture (900×900,hasTouch: true); a fine-pointer desktop context at ~1024 renders compact desktop, not the tablet header. - Mobile (mobile-chrome) —
mobile-quick-chat-idle-dot.spec.ts(themobile-chromeproject collects onlymobile-*.spec.ts): same lifecycle againstmobile-quick-chat-button(tap-driven,quick-chat-close), plus the sheet entry — navigation must happen BEFORE the marker exists (markers are ephemeral and lost onpage.goto): seed a task (apiClient.seedTask, patternmobile-quick-chat-entry.spec.ts), navigate to/t/${taskId}and wait for the session page, tapmobile-sheet-quick-chat(opens the Quick Chat setup dialog), select an agent, arm the waitForResponse forPOST /api/v1/workspaces/<id>/quick-chatimmediately BEFORE clickingquick-chat-start(that click issues the POST; the sheet tap itself does not), capturesession_idfrom the response, send/slow 8sand close the dialog, await the completion, then reopen the sheet and assert the dot onmobile-sheet-quick-chat.
Run (single shell, from apps/web): pnpm e2e:raw -g "quick chat idle dot" && pnpm e2e:raw --project=mobile-chrome -g "quick chat idle dot".
Verification Results
Targeted state, hydration, handler, and component tests passed. Typecheck and lint passed. Desktop/tablet Chromium and mobile-chrome idle-dot E2E runs passed.
Implementation Waves
Sequential (state → handlers → UI → E2E; each builds on the previous).
Open Questions
None.