Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/convergence/protocol-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ This document is generated from [`protocols/registry.json`](../../protocols/regi

| Family | Class | Owner | Active IDs | Persisted/exported |
| --- | --- | --- | ---: | --- |
| [activity-event-projection](#activity-event-projection) | `projection` | Run activity and HTTP maintainers | 5 | true |
| [activity-event-projection](#activity-event-projection) | `projection` | Run activity and HTTP maintainers | 6 | true |
| [agent-browser-supervisor-ledger](#agent-browser-supervisor-ledger) | `internal-durable` | Agent browser, Supervisor tool, and persistence maintainers | 11 | true |
| [agent-scheduling-delivery-ledgers](#agent-scheduling-delivery-ledgers) | `internal-durable` | Agent graph, scheduler, and batch-delivery maintainers | 92 | true |
| [analyzer-interchange](#analyzer-interchange) | `external-durable` | Analyzer contract maintainers | 40 | true |
Expand Down Expand Up @@ -66,13 +66,14 @@ This document is generated from [`protocols/registry.json`](../../protocols/regi
- Readers:
- `activity-event-projection-reader` (`v1`, active) at `internal/runactivity`

<details><summary>5 active identifiers</summary>
<details><summary>6 active identifiers</summary>

- `operator_action_center.v1`
- `operator_action_item.v1`
- `run-event-poll.v1`
- `run-events.v1`
- `run_activity.v1`
- `thread_reply_updates.v1`

</details>

Expand Down
45 changes: 45 additions & 0 deletions docs/evidence/chat-unread/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Conversation unread markers

Captured on Windows in Chrome with the production `cmd/workbenchjourney` API,
SQLite store, offline model provider, and a built `web/dist`. Replies came from
real workspace-read executions. No browser API mocks or DOM edits were used.

- Before: commit `52c7da77fd83251b61d1696735a3d98f521ce37f`, UI digest
`78d6b88f6c424bb8718cf4779e7a4fc6f3cc24cd7c861580b0471563a14ddea6`.
- After: this change, UI digest
`ee1de139b321c223b4c6d8e17ecb6d5cb80a11b39b3bf516516b76d57f9ec07b`.
- Desktop viewport: 1440 × 1000. Narrow viewport: 390 × 844.

| Image | Observed behavior |
| --- | --- |
| [Before](before-unread.png) | A background conversation has a saved reply but no unread marker. |
| [Background reply](after-background-reply.png) | A new saved reply in the other conversation adds a dot before its title. The existing execution-state dot stays at the right. |
| [Saved-message history](after-history-retains-unread.png) | Opening an earlier saved message retains the unread marker. Returning to latest then clears it. |
| [Manual unread](after-manual-unread.png) | Marking the current conversation unread keeps the dot and offers “Mark as read” in its menu. |
| [Narrow sidebar](after-narrow-sidebar.png) | A new reply in the current conversation stays unread while the sidebar covers its content. Opening that conversation clears it after the latest view is available. |

The two background replies had identical text but distinct saved message IDs:
`evt-20261021191411-57d288cf6c25` and
`evt-20261021191519-4bf5a1b6b80c`. The second reply still produced an unread marker.
Manual unread also survived page reload and reconnect; clicking the current
sidebar row again cleared it. Explicit “Mark as read” cleared the marker.
Both production pages reported zero browser console errors and warnings.

Focused React checks passed for the unread store, reading confirmation,
transcript refresh, sidebar, conversation menu, app integration, and conversation
behavior (173 tests, followed by all 7 unread-store tests after adding the
malformed-record case). `npm run typecheck`, `npm run build`, `npm run check:api`,
`git diff --check`, and the protocol registry check against the backend base
passed. The build retained the existing large-chunk warning.

Tests cover failed fresh-head reads, replies arriving during a read, old pending
identities outside the latest window, empty feed continuation pages, persistence
failure, and data-store changes. Focus/visibility cancellation is covered by
these tests. Headless Chrome kept both tabs focused and visible, so tab switching
was not used as evidence for actual window-focus changes.

Final integration also includes the sidebar-width and notification changes from
its PR base. The focused combined suite passed 93 tests across seven files and
the production build passed. A navigation-acceptance regression ensures an
unaccepted file-editor leave request does not clear the target chat's manual
unread state; explicitly reopening the current route still acknowledges it.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/evidence/chat-unread/before-unread.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
175 changes: 175 additions & 0 deletions docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -27635,6 +27635,96 @@
"x-cyberagent-read-only": true
}
},
"/api/v1/threads/reply-updates": {
"get": {
"operationId": "listThreadReplyUpdates",
"summary": "Observe new saved public replies across conversations",
"description": "Read-only incremental observation of saved public assistant message identities in active and archived conversations. Omitting cursor establishes the current baseline with no historical items. Subsequent requests scan at most limit source events in global save order; an empty items array can still have has_more=true. Continue using data.cursor until has_more=false, then retain that cursor for the next poll. Cursor scope includes the exact data_store_id. Persist returned message identities before the cursor; an error returns no partial cursor. Replaying a cursor preserves message identities. Separate saved messages with equal text remain separate updates. Excludes deleted conversations, copied branch history, users, tools, hidden/internal events and provisional streaming bytes. No message body or private event payload is returned.",
"tags": [
"Threads"
],
"parameters": [
{
"name": "limit",
"in": "query",
"description": "Maximum source events scanned, including filtered events; not a requested number of visible replies",
"schema": {
"default": 50,
"maximum": 100,
"minimum": 1,
"type": "integer"
}
},
{
"name": "cursor",
"in": "query",
"description": "Opaque cursor from data.cursor, bound to the exact data store; omit only to establish a new baseline",
"schema": {
"maxLength": 512,
"minLength": 1,
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful read",
"content": {
"application/json": {
"schema": {
"additionalProperties": false,
"properties": {
"data": {
"$ref": "#/components/schemas/ThreadReplyUpdatesView"
},
"request_id": {
"maxLength": 256,
"minLength": 1,
"type": "string"
},
"version": {
"const": "api.v1",
"type": "string"
}
},
"required": [
"version",
"request_id",
"data"
],
"type": "object"
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"414": {
"$ref": "#/components/responses/RequestTooLarge"
},
"429": {
"$ref": "#/components/responses/ResourceExhausted"
},
"500": {
"$ref": "#/components/responses/InternalError"
},
"503": {
"$ref": "#/components/responses/Unavailable"
},
"504": {
"$ref": "#/components/responses/GatewayTimeout"
}
},
"x-cyberagent-read-only": true
}
},
"/api/v1/threads/search": {
"get": {
"operationId": "searchSavedThreadMessages",
Expand Down Expand Up @@ -72455,6 +72545,91 @@
],
"type": "object"
},
"ThreadReplyUpdateView": {
"additionalProperties": false,
"properties": {
"created_at": {
"format": "date-time",
"type": "string"
},
"message_id": {
"maxLength": 256,
"minLength": 1,
"type": "string"
},
"run_ordinal": {
"format": "int64",
"minimum": 1,
"type": "integer"
},
"sequence": {
"format": "int64",
"minimum": 1,
"type": "integer"
},
"thread_id": {
"maxLength": 256,
"minLength": 1,
"type": "string"
},
"thread_status": {
"enum": [
"active",
"archived"
],
"type": "string"
}
},
"required": [
"thread_id",
"message_id",
"thread_status",
"run_ordinal",
"sequence",
"created_at"
],
"type": "object"
},
"ThreadReplyUpdatesView": {
"additionalProperties": false,
"properties": {
"cursor": {
"maxLength": 512,
"minLength": 1,
"type": "string"
},
"data_store_id": {
"maxLength": 256,
"minLength": 1,
"pattern": "^ds1_[0-9a-f]{64}$",
"type": "string"
},
"has_more": {
"type": "boolean"
},
"items": {
"items": {
"$ref": "#/components/schemas/ThreadReplyUpdateView"
},
"maxItems": 100,
"type": "array"
},
"version": {
"enum": [
"thread_reply_updates.v1"
],
"type": "string"
}
},
"required": [
"version",
"data_store_id",
"cursor",
"has_more",
"items"
],
"type": "object"
},
"ThreadRequestObservationView": {
"additionalProperties": false,
"properties": {
Expand Down
86 changes: 86 additions & 0 deletions docs/thread-reply-updates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Saved reply updates

`GET /api/v1/threads/reply-updates` uses the existing read bearer and GET-only
HTTP boundary. It observes active and archived conversations without changing
their lifecycle. Deleted conversations and copied branch history are excluded.

The response uses the normal `api.v1` envelope. Its `data` has this shape:

```json
{
"version": "thread_reply_updates.v1",
"data_store_id": "ds1_…",
"cursor": "opaque-cursor",
"has_more": false,
"items": [
{
"thread_id": "thread-…",
"message_id": "event-…",
"thread_status": "active",
"run_ordinal": 2,
"sequence": 18,
"created_at": "2026-10-22T10:00:00Z"
}
]
}
```

- Omit `cursor` on first use. The server returns the current position and an
empty `items` array, so enabling observation does not mark all history unread.
An explicitly empty cursor is invalid.
- Supply the returned `data.cursor` on every later poll. This is not a
`page.next_cursor`. An idle poll can return the same cursor.
- `limit` defaults to 50 and accepts 1–100. It bounds **source events scanned**,
including filtered events. The store reads one extra event to detect more work.
`items: []` with `has_more: true` is a valid page: continue from its cursor.
- Each request captures the current highest saved event position before scanning.
`has_more: false` means it has covered all source events through that position.
Events appended after that capture remain available to the next poll.
- Items are ordered by global event save position. Appending events or creating
a successor Run does not shift a continuation. `(run_ordinal, sequence)` gives
the item's position in its Thread transcript; `created_at` is not a cursor.
- Each item identifies a new saved public assistant message or public progress
update accepted by the existing Go transcript projection. User input, queued
or cancelled input, tool output, internal events and provisional streaming
bytes cannot produce items. Neither message bodies nor raw event data appear
in the response.
- Equal text saved as separate messages retains separate identities. This feed
does not reproduce the conversation renderer's text merging. Replaying a
cursor preserves message IDs, so consumers can apply results idempotently.
- Persist the returned identities/read-state changes before persisting their
cursor. A failed request returns no partial items or advanced cursor; retry the
previous cursor. Do not discard an unread marker merely because a poll fails.
- Cursors belong to one `data_store_id`. Another database, a replacement, or a
copied database at a different path rejects the old cursor. Keep local state
under the existing connection/data-store namespace; do not apply an in-flight
response after switching that namespace. A new namespace starts with its own
baseline. Switching back can resume the original namespace's saved cursor.

The OpenAPI document and generated TypeScript schema define the exact field
bounds. This endpoint supplies observations; the frontend owns the local read
position and explicit manual-unread preference.

## Local unread markers

The sidebar marks conversations with new saved replies and offers **Mark as
read** / **Mark as unread** in each conversation's menu. The cursor, pending
message identities, and manual preference are saved together in the existing
recovery namespace for the browser origin, API URL, and data-store identity.
Polling follows empty continuation pages and retries the unchanged checkpoint
after a request failure. A failed local write leaves the previous complete
checkpoint available after reload.

Automatic reading requires the conversation's latest view, a visible and focused
window, and a scroll position at the bottom. Saved-message history, inspectors,
an open narrow-screen sidebar, and covered conversation content do not acknowledge
replies. Visible saved assistant identities are acknowledged directly. On entering
the latest view, the client first catches up the reply feed, freezes the pending
identities, and requests a fresh transcript head. Only a successful read rendered
in the same reading context acknowledges that frozen range, including older
pending messages outside the latest window. Replies arriving during the request
remain pending until observed or included in a later confirmed range.

A manual unread marker survives polling, automatic reading, and reload. Explicitly
opening the conversation from the sidebar, including clicking the current row
again, clears that manual preference. **Mark as read** clears both the manual
marker and currently known pending replies.
Loading
Loading