Skip to content

Persist thread read state across refreshes, ports, and browsers#17

Open
SHAREN wants to merge 1 commit intofriuns2:mainfrom
SHAREN:codex/pr-thread-read-state
Open

Persist thread read state across refreshes, ports, and browsers#17
SHAREN wants to merge 1 commit intofriuns2:mainfrom
SHAREN:codex/pr-thread-read-state

Conversation

@SHAREN
Copy link
Contributor

@SHAREN SHAREN commented Mar 26, 2026

Summary

Persist per-thread read state outside browser-local storage so unread dots survive page refreshes, different local ports, and different browsers on the same machine.

Problem

Right now read/unread state is effectively browser-local because it lives in localStorage.

That creates a few user-visible problems:

  • a hard refresh or cleared browser storage can bring unread dots back for threads that were already read
  • running the UI on another local port creates a different browser origin, so the unread state starts empty again
  • opening the same Codex UI in another browser/profile on the same machine does not share read state

In practice this means users see old unread indicators again and have to remember manually which threads they already checked.

What This PR Changes

  • adds GET /codex-api/thread-read-state
  • adds PUT /codex-api/thread-read-state
  • persists read timestamps in Codex global state (~/.codex/.codex-global-state.json) under thread-read-state
  • merges shared state with existing browser-local state on load, preferring the newest timestamp per thread
  • writes updated read state back to the shared store whenever a thread is marked as read
  • compares read timestamps by ordering instead of strict equality so newer shared values do not regress unread detection

Why This Approach

This keeps the current UX exactly the same while moving the source of truth for read state to a machine-local shared store that already exists for other Codex desktop/web persistence.

That makes unread markers stable across:

  • refreshes
  • local port changes
  • browser/profile changes on the same machine

Scope

This is intentionally machine-local persistence only. It does not try to sync read state across different machines or accounts.

Testing

  • npm run build
  • local runtime smoke: GET /codex-api/thread-read-state returns 200 OK

@qodo-code-review
Copy link

Review Summary by Qodo

Persist thread read state across refreshes, ports, and browsers

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Persist thread read state to shared machine-local store
• Merge shared state with browser-local state on load
• Sync read timestamps across refreshes, ports, browsers
• Compare timestamps by ordering to prevent regressions
Diagram
flowchart LR
  A["Browser localStorage"] -->|load| B["Merge Read State"]
  C["Shared Global Store"] -->|load| B
  B -->|newer timestamp| D["readStateByThreadId"]
  D -->|mark as read| E["commitReadState"]
  E -->|save| A
  E -->|persist| C
Loading

Grey Divider

File Changes

1. src/api/codexGateway.ts ✨ Enhancement +36/-0

Add API functions for thread read state

• Added ThreadReadStateMap type for thread read state mapping
• Implemented getThreadReadState() to fetch read state from server
• Implemented persistThreadReadState() to write read state to server
• Added normalizeThreadReadStateMap() helper for validation

src/api/codexGateway.ts


2. src/composables/useDesktopState.ts ✨ Enhancement +75/-8

Implement read state merging and persistence logic

• Added compareReadStateIso() to compare ISO timestamps
• Added mergeReadStateMaps() to merge multiple read state maps preferring newer timestamps
• Added areReadStateMapsEqual() to check read state equality
• Added hasUnreadThreadUpdate() to determine unread status by timestamp comparison
• Added commitReadState() to save read state locally and persist globally
• Added syncThreadReadStateFromSharedStore() to merge shared and local state on load
• Updated loadThreads() to sync read state from shared store
• Updated markThreadAsRead() and pruneThreadScopedState() to use new commit function

src/composables/useDesktopState.ts


3. src/server/codexAppServerBridge.ts ✨ Enhancement +47/-1

Add server endpoints for thread read state

• Added normalizeThreadReadStateMap() helper for validation
• Implemented readThreadReadStateMap() to read state from global store file
• Implemented writeThreadReadStateMap() to write state to global store file
• Added GET /codex-api/thread-read-state endpoint
• Added PUT /codex-api/thread-read-state endpoint

src/server/codexAppServerBridge.ts


Grey Divider

Qodo Logo

@qodo-code-review
Copy link

qodo-code-review bot commented Mar 26, 2026

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0) 📎 Requirement gaps (0) 📐 Spec deviations (0)

Grey Divider


Action required

1. Shared read-state gets pruned 🐞 Bug ✓ Correctness
Description
pruneThreadScopedState prunes readStateByThreadId to only the currently loaded threads and now
persists that pruned map to the shared store via commitReadState(). Since thread/list is limited to
100 threads, this deletes read timestamps for older threads from the shared store and can resurrect
unread dots when those threads reappear in the top 100.
Code

src/composables/useDesktopState.ts[R1128-1132]

    const activeThreadIds = new Set(flatThreads.map((thread) => thread.id))
    const nextReadState = pruneThreadStateMap(readStateByThreadId.value, activeThreadIds)
    if (nextReadState !== readStateByThreadId.value) {
-      readStateByThreadId.value = nextReadState
-      saveReadStateMap(nextReadState)
+      commitReadState(nextReadState)
    }
Evidence
The client prunes read-state to the active thread IDs and calls commitReadState(), which persists to
the shared store; active threads come from getThreadGroups(), which only requests 100 items. This
makes the shared store inherently lossy and causes previously-read-but-not-currently-loaded threads
to lose their saved timestamps.

src/composables/useDesktopState.ts[1127-1132]
src/composables/useDesktopState.ts[1081-1085]
src/composables/useDesktopState.ts[579-585]
src/api/codexGateway.ts[93-99]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`pruneThreadScopedState()` prunes `readStateByThreadId` to only the currently loaded thread IDs and then calls `commitReadState()`, which persists that pruned map to the shared store. Because `/thread/list` is capped at 100 threads, this deletes read timestamps for threads outside the current page and defeats the goal of stable unread indicators.

## Issue Context
The UI only fetches 100 threads (`thread/list` limit), but the shared store should retain read timestamps beyond the current in-memory thread list so threads that re-enter the top 100 don't look unread again.

## Fix Focus Areas
- src/composables/useDesktopState.ts[1127-1132]
- src/composables/useDesktopState.ts[1081-1085]
- src/api/codexGateway.ts[93-99]

## Suggested fix
- Split "update local read-state" from "persist to shared store":
 - Keep pruning for `localStorage`/memory if desired.
 - Do **not** call `persistThreadReadState()` when pruning; only persist on positive events (e.g., `markThreadAsRead`, or after merging shared state).
- Alternatively, if you must persist after pruning, first merge with the existing shared store (server-side merge is preferable; see separate finding) so missing keys are not deleted.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Read-state PUT overwrites map 🐞 Bug ⛯ Reliability
Description
PUT /codex-api/thread-read-state replaces the entire stored map with the request body instead of
merging with existing state. If multiple browsers/windows are open, a stale client can overwrite and
delete other threads’ read timestamps (lost updates).
Code

src/server/codexAppServerBridge.ts[R2219-2228]

+      if (req.method === 'PUT' && url.pathname === '/codex-api/thread-read-state') {
+        const payload = await readJsonBody(req)
+        const record = asRecord(payload)
+        if (!record) {
+          setJson(res, 400, { error: 'Invalid body: expected object' })
+          return
+        }
+        await writeThreadReadStateMap(normalizeThreadReadStateMap(record.state ?? record))
+        setJson(res, 200, { ok: true })
+        return
Evidence
The server PUT handler writes the provided state directly, and writeThreadReadStateMap assigns
payload['thread-read-state'] = ... (full replacement). The client always PUTs the entire map it
currently has, so if that map is missing keys (e.g., because another client wrote them after this
client loaded), those keys are deleted from the shared store.

src/server/codexAppServerBridge.ts[2219-2228]
src/server/codexAppServerBridge.ts[548-560]
src/api/codexGateway.ts[630-636]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The shared read-state store is updated with last-write-wins semantics: the server overwrites `thread-read-state` with whatever map the client sends. With two UIs open, a client that hasn't synced recently can erase keys written by the other client.

## Issue Context
Read-state is inherently multi-writer (refreshes, ports, browsers). The server must be robust to out-of-order/stale updates.

## Fix Focus Areas
- src/server/codexAppServerBridge.ts[2219-2228]
- src/server/codexAppServerBridge.ts[537-560]

## Suggested fix
- On PUT, implement a merge with the existing persisted map instead of replacement:
 - `const existing = await readThreadReadStateMap()`
 - `const incoming = normalizeThreadReadStateMap(record.state ?? record)`
 - For each `threadId`, keep the max(readAtIso) by timestamp ordering (ISO lexical compare is OK for `toISOString()`-style strings; otherwise parse and compare).
 - Write the merged result.
- Avoid deleting keys that are absent from the incoming payload (treat PUT as upsert/merge, or change to PATCH semantics).
- Optionally return the merged map (or a version/etag) so clients can converge quickly.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Global state write races 🐞 Bug ⛯ Reliability
Description
Multiple endpoints update the same ~/.codex/.codex-global-state.json using independent
read-modify-write cycles without serialization, so concurrent requests can clobber unrelated keys.
Adding frequent thread-read-state writes increases the likelihood of losing workspace roots or
thread title updates.
Code

src/server/codexAppServerBridge.ts[R548-560]

+async function writeThreadReadStateMap(state: Record<string, string>): Promise<void> {
+  const statePath = getCodexGlobalStatePath()
+  let payload: Record<string, unknown> = {}
+  try {
+    const raw = await readFile(statePath, 'utf8')
+    payload = asRecord(JSON.parse(raw)) ?? {}
+  } catch {
+    payload = {}
+  }
+
+  payload['thread-read-state'] = normalizeThreadReadStateMap(state)
+  await writeFile(statePath, JSON.stringify(payload), 'utf8')
+}
Evidence
All writers target the same global state path and follow the pattern: readFile → JSON.parse → mutate
one key → writeFile. If two such operations overlap, the later write can persist an older snapshot
of other keys, losing updates (e.g., thread titles or workspace roots) because there is no shared
lock/queue around global state writes.

src/server/codexAppServerBridge.ts[401-402]
src/server/codexAppServerBridge.ts[524-535]
src/server/codexAppServerBridge.ts[548-560]
src/server/codexAppServerBridge.ts[656-671]
src/composables/useDesktopState.ts[1081-1085]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`~/.codex/.codex-global-state.json` is mutated by multiple request handlers (thread titles, workspace roots, and now thread read-state). Each handler performs an uncoordinated read-modify-write of the entire JSON file, allowing concurrent requests to overwrite each other and lose unrelated keys.

## Issue Context
This PR increases write frequency (mark-as-read + pruning persistence), making collisions more likely.

## Fix Focus Areas
- src/server/codexAppServerBridge.ts[401-402]
- src/server/codexAppServerBridge.ts[524-535]
- src/server/codexAppServerBridge.ts[548-560]
- src/server/codexAppServerBridge.ts[656-671]

## Suggested fix
- Introduce a single global-state write path guarded by a per-file async mutex/queue so writes are serialized.
- Perform atomic writes (write to a temp file then `rename`) to reduce partial-write corruption risk.
- Prefer an API like `updateGlobalState((draft) => { draft[key]=... })` that ensures all endpoint updates compose safely.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

Comment on lines 1128 to 1132
const activeThreadIds = new Set(flatThreads.map((thread) => thread.id))
const nextReadState = pruneThreadStateMap(readStateByThreadId.value, activeThreadIds)
if (nextReadState !== readStateByThreadId.value) {
readStateByThreadId.value = nextReadState
saveReadStateMap(nextReadState)
commitReadState(nextReadState)
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Shared read-state gets pruned 🐞 Bug ✓ Correctness

pruneThreadScopedState prunes readStateByThreadId to only the currently loaded threads and now
persists that pruned map to the shared store via commitReadState(). Since thread/list is limited to
100 threads, this deletes read timestamps for older threads from the shared store and can resurrect
unread dots when those threads reappear in the top 100.
Agent Prompt
## Issue description
`pruneThreadScopedState()` prunes `readStateByThreadId` to only the currently loaded thread IDs and then calls `commitReadState()`, which persists that pruned map to the shared store. Because `/thread/list` is capped at 100 threads, this deletes read timestamps for threads outside the current page and defeats the goal of stable unread indicators.

## Issue Context
The UI only fetches 100 threads (`thread/list` limit), but the shared store should retain read timestamps beyond the current in-memory thread list so threads that re-enter the top 100 don't look unread again.

## Fix Focus Areas
- src/composables/useDesktopState.ts[1127-1132]
- src/composables/useDesktopState.ts[1081-1085]
- src/api/codexGateway.ts[93-99]

## Suggested fix
- Split "update local read-state" from "persist to shared store":
  - Keep pruning for `localStorage`/memory if desired.
  - Do **not** call `persistThreadReadState()` when pruning; only persist on positive events (e.g., `markThreadAsRead`, or after merging shared state).
- Alternatively, if you must persist after pruning, first merge with the existing shared store (server-side merge is preferable; see separate finding) so missing keys are not deleted.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +2219 to +2228
if (req.method === 'PUT' && url.pathname === '/codex-api/thread-read-state') {
const payload = await readJsonBody(req)
const record = asRecord(payload)
if (!record) {
setJson(res, 400, { error: 'Invalid body: expected object' })
return
}
await writeThreadReadStateMap(normalizeThreadReadStateMap(record.state ?? record))
setJson(res, 200, { ok: true })
return

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Read-state put overwrites map 🐞 Bug ⛯ Reliability

PUT /codex-api/thread-read-state replaces the entire stored map with the request body instead of
merging with existing state. If multiple browsers/windows are open, a stale client can overwrite and
delete other threads’ read timestamps (lost updates).
Agent Prompt
## Issue description
The shared read-state store is updated with last-write-wins semantics: the server overwrites `thread-read-state` with whatever map the client sends. With two UIs open, a client that hasn't synced recently can erase keys written by the other client.

## Issue Context
Read-state is inherently multi-writer (refreshes, ports, browsers). The server must be robust to out-of-order/stale updates.

## Fix Focus Areas
- src/server/codexAppServerBridge.ts[2219-2228]
- src/server/codexAppServerBridge.ts[537-560]

## Suggested fix
- On PUT, implement a merge with the existing persisted map instead of replacement:
  - `const existing = await readThreadReadStateMap()`
  - `const incoming = normalizeThreadReadStateMap(record.state ?? record)`
  - For each `threadId`, keep the max(readAtIso) by timestamp ordering (ISO lexical compare is OK for `toISOString()`-style strings; otherwise parse and compare).
  - Write the merged result.
- Avoid deleting keys that are absent from the incoming payload (treat PUT as upsert/merge, or change to PATCH semantics).
- Optionally return the merged map (or a version/etag) so clients can converge quickly.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +548 to +560
async function writeThreadReadStateMap(state: Record<string, string>): Promise<void> {
const statePath = getCodexGlobalStatePath()
let payload: Record<string, unknown> = {}
try {
const raw = await readFile(statePath, 'utf8')
payload = asRecord(JSON.parse(raw)) ?? {}
} catch {
payload = {}
}

payload['thread-read-state'] = normalizeThreadReadStateMap(state)
await writeFile(statePath, JSON.stringify(payload), 'utf8')
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Global state write races 🐞 Bug ⛯ Reliability

Multiple endpoints update the same ~/.codex/.codex-global-state.json using independent
read-modify-write cycles without serialization, so concurrent requests can clobber unrelated keys.
Adding frequent thread-read-state writes increases the likelihood of losing workspace roots or
thread title updates.
Agent Prompt
## Issue description
`~/.codex/.codex-global-state.json` is mutated by multiple request handlers (thread titles, workspace roots, and now thread read-state). Each handler performs an uncoordinated read-modify-write of the entire JSON file, allowing concurrent requests to overwrite each other and lose unrelated keys.

## Issue Context
This PR increases write frequency (mark-as-read + pruning persistence), making collisions more likely.

## Fix Focus Areas
- src/server/codexAppServerBridge.ts[401-402]
- src/server/codexAppServerBridge.ts[524-535]
- src/server/codexAppServerBridge.ts[548-560]
- src/server/codexAppServerBridge.ts[656-671]

## Suggested fix
- Introduce a single global-state write path guarded by a per-file async mutex/queue so writes are serialized.
- Perform atomic writes (write to a temp file then `rename`) to reduce partial-write corruption risk.
- Prefer an API like `updateGlobalState((draft) => { draft[key]=... })` that ensures all endpoint updates compose safely.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@SHAREN
Copy link
Contributor Author

SHAREN commented Mar 26, 2026

image

Если вкратце, то это чтобы отметка о прочитанном сохранялась

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant