Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
5.6 KiB
status, system, requirements
| status | system | requirements | |
|---|---|---|---|
| current | office |
|
Office: Slack-Style Unread Divider System Design
Purpose and boundaries
Office owns the session-global read cursor and the visit-scoped unread boundary. The task transcript consumes that boundary as one of its scroll-placement owners. This design covers the backend cursor write, frontend request correlation, local cache update, divider lifecycle, and diagnostic events.
The generic transcript auto-scroll coordinator remains owned by the UI transcript auto-scroll design. It must honor an active unread-divider target, but it does not own whether that target is current.
Requirement mapping
| Requirement | Design sections |
|---|---|
REQ-OFFICE-UNREAD-DIVIDER-001 |
Cursor persistence, Frontend request correlation, Visit and placement flow, Observability |
Cursor persistence
task_sessions.last_read_message_id stores the newest message seen for each
session. POST /api/v1/task-sessions/:id/mark-read validates that the message
belongs to the session and calls UpdateTaskSessionLastReadMessageID. The
repository compares message (created_at, id) order so delayed requests can
advance, but never regress, the persisted cursor.
The response contains only session_id and last_read_message_id. The web
client applies it through updateSessionReadCursor; it never merges a full
session snapshot over newer WebSocket state.
Frontend request correlation
useSessionReadTracking snapshots the cursor before dispatching the live
mark-read request. Request freshness is scoped by session, not by the hook's
currently rendered session or component lifetime.
The frontend keeps one latest-request generation for each session with a request in flight. A response can update the local cursor only when its generation is still the latest generation for that same session. A request for task B cannot supersede task A's response. A later request for task A does supersede an earlier task A response, including when the earlier component was hidden or unmounted.
Settling the latest response removes its in-flight generation. Older responses remain stale after that removal and cannot regress the local cursor. This keeps the coordinator bounded by active requests rather than all sessions ever opened.
Visit and placement flow
When a chat panel becomes visible, useSessionReadTracking captures the local
cursor before advancing it. After the initial message window is ready, the hook
freezes a divider anchor only when the captured cursor precedes the rendered
tail. The mark-read request advances the backend and local cursors independently
of that visit-scoped anchor.
When the user leaves and returns, the next visit reads the newest local cursor. If the prior visit reached the tail, the next visit is divider-ineligible and normal enabled transcript placement selects the bottom. This remains true when the successful mark-read response arrived while another task was active.
If a genuine unread divider exists, message-list-native.tsx owns the one-time
divider placement and message-list-native-scroll.ts delegates initial bottom
placement to it. Running-agent message or work-state updates may cause later
bottom-follow writes, but those writes must not be required to repair a stale
completed-session cursor.
Failure and recovery
- A failed mark-read request leaves the local cursor unchanged and reports the existing error. A later visible render can retry it.
- An out-of-order response for the same session is ignored by the frontend and cannot regress either the local or persisted cursor.
- A successful response received while another task is active still updates its own session's local cursor when no newer request for that session exists.
- A task/session switch, panel hide, or component unmount does not invalidate a still-current response merely because visibility changed.
Responsive behavior
Desktop Dockview and the mobile task layout use the same read-tracking hook, session store, and native transcript coordinator. The transcript stays the only vertical scroll owner. This repair changes no navigation, layout, touch target, safe-area, or user-facing copy.
Observability
Development diagnostics use createDebugLogger and carry sessionId so the
debug logger can append the owning task ID. The messages:read-tracking
namespace records visit capture, mark-read dispatch, response apply, stale
response discard, and failure. It includes request generation and message IDs,
but never message content.
The existing messages:scroll-placement namespace also records when initial
placement delegates to another owner and when a running-state or message update
performs the bottom write. Logs are emitted once per lifecycle decision or
actual write, not per render or scroll event. Together the two namespaces show
whether a repeated divider came from a stale cursor or whether a later running
agent update only masked failed initial placement.
Verification boundaries
- Hook tests hold task A's response, dispatch task B's request, and prove both sessions apply their own latest cursor without allowing same-session out-of-order regression.
- Native scroll tests prove the unread divider owns initial placement and that running-state/message writes are distinguishable from initial placement.
- Desktop and mobile Playwright tests use completed tasks, delay task A's mark-read response across a task switch, return to task A, and prove the stale divider does not move the transcript away from the bottom.