diff --git a/src/api/codexGateway.ts b/src/api/codexGateway.ts index a6e36cc..b2e6bbe 100644 --- a/src/api/codexGateway.ts +++ b/src/api/codexGateway.ts @@ -67,6 +67,7 @@ export type TelegramStatus = { mappedThreads: number lastError: string } +export type ThreadReadStateMap = Record async function callRpc(method: string, params?: unknown): Promise { try { @@ -603,6 +604,41 @@ function getErrorMessageFromPayload(payload: unknown, fallback: string): string export type ThreadTitleCache = { titles: Record; order: string[] } +function normalizeThreadReadStateMap(payload: unknown): ThreadReadStateMap { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return {} + + const next: ThreadReadStateMap = {} + for (const [threadId, readAtIso] of Object.entries(payload as Record)) { + if (typeof threadId !== 'string' || threadId.length === 0) continue + if (typeof readAtIso !== 'string' || readAtIso.length === 0) continue + next[threadId] = readAtIso + } + return next +} + +export async function getThreadReadState(): Promise { + try { + const response = await fetch('/codex-api/thread-read-state') + if (!response.ok) return null + const envelope = (await response.json()) as { data?: unknown } + return normalizeThreadReadStateMap(envelope.data) + } catch { + return null + } +} + +export async function persistThreadReadState(state: ThreadReadStateMap): Promise { + try { + await fetch('/codex-api/thread-read-state', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ state }), + }) + } catch { + // Best-effort persist + } +} + export async function getThreadTitleCache(): Promise { try { const response = await fetch('/codex-api/thread-titles') diff --git a/src/composables/useDesktopState.ts b/src/composables/useDesktopState.ts index 4526c51..2807146 100644 --- a/src/composables/useDesktopState.ts +++ b/src/composables/useDesktopState.ts @@ -14,9 +14,11 @@ import { rollbackThread, getThreadGroups, getWorkspaceRootsState, + getThreadReadState, setCodexSpeedMode, setDefaultModel, setWorkspaceRootsState, + persistThreadReadState, getThreadTitleCache, persistThreadTitle, generateThreadTitle, @@ -74,11 +76,53 @@ function loadReadStateMap(): Record { } } +function compareReadStateIso(first: string | undefined, second: string | undefined): number { + const left = typeof first === 'string' ? first.trim() : '' + const right = typeof second === 'string' ? second.trim() : '' + if (!left && !right) return 0 + if (!left) return -1 + if (!right) return 1 + return left.localeCompare(right) +} + +function mergeReadStateMaps(...maps: Array | null | undefined>): Record { + const merged: Record = {} + + for (const map of maps) { + if (!map) continue + + for (const [threadId, readAtIso] of Object.entries(map)) { + if (!threadId || !readAtIso) continue + if (compareReadStateIso(readAtIso, merged[threadId]) > 0) { + merged[threadId] = readAtIso + } + } + } + + return merged +} + +function areReadStateMapsEqual(first: Record, second: Record): boolean { + const firstEntries = Object.entries(first) + const secondEntries = Object.entries(second) + if (firstEntries.length !== secondEntries.length) return false + + for (const [threadId, readAtIso] of firstEntries) { + if (second[threadId] !== readAtIso) return false + } + + return true +} + function saveReadStateMap(state: Record): void { if (typeof window === 'undefined') return window.localStorage.setItem(READ_STATE_STORAGE_KEY, JSON.stringify(state)) } +function hasUnreadThreadUpdate(lastReadIso: string | undefined, updatedAtIso: string): boolean { + return compareReadStateIso(lastReadIso, updatedAtIso) < 0 +} + function clamp(value: number, minValue: number, maxValue: number): number { return Math.min(Math.max(value, minValue), maxValue) } @@ -1022,7 +1066,7 @@ export function useDesktopState() { const isSelected = selectedThreadId.value === thread.id const lastReadIso = readStateByThreadId.value[thread.id] const unreadByEvent = eventUnreadByThreadId.value[thread.id] === true - const unread = !isSelected && !inProgress && (unreadByEvent || lastReadIso !== thread.updatedAtIso) + const unread = !isSelected && !inProgress && (unreadByEvent || hasUnreadThreadUpdate(lastReadIso, thread.updatedAtIso)) return { ...thread, @@ -1034,6 +1078,12 @@ export function useDesktopState() { projectGroups.value = mergeThreadGroups(projectGroups.value, flaggedGroups) } + function commitReadState(nextState: Record): void { + readStateByThreadId.value = nextState + saveReadStateMap(nextState) + void persistThreadReadState(nextState) + } + function insertOptimisticThread(threadId: string, cwd: string, firstMessageText: string): void { const nowIso = new Date().toISOString() const normalizedCwd = cwd.trim() @@ -1078,8 +1128,7 @@ export function useDesktopState() { 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) } const nextScrollState = pruneThreadStateMap(scrollStateByThreadId.value, activeThreadIds) if (nextScrollState !== scrollStateByThreadId.value) { @@ -1112,11 +1161,10 @@ export function useDesktopState() { const thread = flattenThreads(sourceGroups.value).find((row) => row.id === threadId) if (!thread) return - readStateByThreadId.value = { - ...readStateByThreadId.value, + const nextReadState = mergeReadStateMaps(readStateByThreadId.value, { [threadId]: thread.updatedAtIso, - } - saveReadStateMap(readStateByThreadId.value) + }) + commitReadState(nextReadState) if (eventUnreadByThreadId.value[threadId]) { eventUnreadByThreadId.value = omitKey(eventUnreadByThreadId.value, threadId) } @@ -2140,6 +2188,21 @@ export function useDesktopState() { } } + async function syncThreadReadStateFromSharedStore(): Promise { + const sharedState = await getThreadReadState() + if (sharedState === null) return + + const mergedState = mergeReadStateMaps(sharedState, readStateByThreadId.value) + if (!areReadStateMapsEqual(readStateByThreadId.value, mergedState)) { + readStateByThreadId.value = mergedState + saveReadStateMap(mergedState) + } + + if (!areReadStateMapsEqual(sharedState, mergedState)) { + void persistThreadReadState(mergedState) + } + } + async function requestThreadTitleGeneration(threadId: string, prompt: string, cwd: string | null): Promise { if (threadTitleById.value[threadId]) return const trimmed = prompt.trim() @@ -2162,7 +2225,11 @@ export function useDesktopState() { } try { - const [groups] = await Promise.all([getThreadGroups(), loadThreadTitleCacheIfNeeded()]) + const [groups] = await Promise.all([ + getThreadGroups(), + loadThreadTitleCacheIfNeeded(), + syncThreadReadStateFromSharedStore(), + ]) await hydrateWorkspaceRootsStateIfNeeded(groups) const nextProjectOrder = mergeProjectOrder(projectOrder.value, groups) diff --git a/src/server/codexAppServerBridge.ts b/src/server/codexAppServerBridge.ts index 13f33c7..ff31e21 100644 --- a/src/server/codexAppServerBridge.ts +++ b/src/server/codexAppServerBridge.ts @@ -488,6 +488,10 @@ function trimThreadTitleCache(cache: ThreadTitleCache): ThreadTitleCache { return { titles, order } } +function normalizeThreadReadStateMap(value: unknown): Record { + return normalizeStringRecord(value) +} + function mergeThreadTitleCaches(base: ThreadTitleCache, overlay: ThreadTitleCache): ThreadTitleCache { const titles = { ...base.titles, ...overlay.titles } const order: string[] = [] @@ -530,6 +534,31 @@ async function writeThreadTitleCache(cache: ThreadTitleCache): Promise { await writeFile(statePath, JSON.stringify(payload), 'utf8') } +async function readThreadReadStateMap(): Promise> { + const statePath = getCodexGlobalStatePath() + try { + const raw = await readFile(statePath, 'utf8') + const payload = asRecord(JSON.parse(raw)) ?? {} + return normalizeThreadReadStateMap(payload['thread-read-state']) + } catch { + return {} + } +} + +async function writeThreadReadStateMap(state: Record): Promise { + const statePath = getCodexGlobalStatePath() + let payload: Record = {} + 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') +} + function getSessionIndexFileSignature(stats: { mtimeMs: number; size: number }): string { return `${String(stats.mtimeMs)}:${String(stats.size)}` } @@ -2132,6 +2161,12 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { return } + if (req.method === 'GET' && url.pathname === '/codex-api/thread-read-state') { + const state = await readThreadReadStateMap() + setJson(res, 200, { data: state }) + return + } + if (req.method === 'POST' && url.pathname === '/codex-api/thread-search') { const payload = asRecord(await readJsonBody(req)) const query = typeof payload?.query === 'string' ? payload.query.trim() : '' @@ -2181,11 +2216,22 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { return } + 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 + } + if (req.method === 'GET' && url.pathname === '/codex-api/telegram/status') { setJson(res, 200, { data: telegramBridge.getStatus() }) return } - if (req.method === 'GET' && url.pathname === '/codex-api/events') { res.statusCode = 200 res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')