From 51de5c84d2c936a173646fdf0c99531024d47cbf Mon Sep 17 00:00:00 2001 From: muggle-stack Date: Tue, 22 Sep 2026 17:41:48 +0800 Subject: [PATCH 1/3] fix(history): reconcile Claude aliases across history pages - Match live browser ids to unique native user ids when browsing older pages. - Preserve mounted rows, native detail identities and expanded process content. - Repair cached duplicate copies and cover pagination in Chromium and WebKit. --- web/src/history-browse.ts | 27 +++------ web/src/history-merge.ts | 57 +++++++++++++++++++ web/src/history-page-cache.ts | 24 ++------ web/tests/history-browse.test.ts | 81 +++++++++++++++++++++++++++ web/tests/history-browser.fixture.tsx | 49 +++++++++++++--- web/tests/history-browser.spec.ts | 19 +++++++ web/tests/history-page-cache.test.ts | 43 ++++++++++++++ 7 files changed, 255 insertions(+), 45 deletions(-) diff --git a/web/src/history-browse.ts b/web/src/history-browse.ts index 90c3e7ff..798ca9aa 100644 --- a/web/src/history-browse.ts +++ b/web/src/history-browse.ts @@ -1,6 +1,7 @@ import { installAuthoritativeTurnDetailPage, mergeAuthoritativeTurnDetail, + mergeHistoryPageCopies, reconcileProvenCompactionOrphans, } from "./history-merge.ts"; import { @@ -186,24 +187,14 @@ function segmentToPage(segment: HistoryBrowseSegment): HistoryBrowsePage { /** Newer segments win canonical overlap so an optimistic live id stays mounted * after its native history row materializes. */ function dedupeSegments(segments: readonly HistoryBrowseSegment[]): HistoryBrowseSegment[] { - const next = segments.map((segment) => ({ - ...segment, - turns: [...segment.turns], - })); - const seen = new Set(); - for (let segmentIndex = next.length - 1; segmentIndex >= 0; segmentIndex -= 1) { - const segment = next[segmentIndex]; - const retained: Turn[] = []; - for (let turnIndex = segment.turns.length - 1; turnIndex >= 0; turnIndex -= 1) { - const turn = segment.turns[turnIndex]; - const key = canonicalTurnId(turn); - if (seen.has(key)) continue; - seen.add(key); - retained.unshift(turn); - } - segment.turns = retained; - } - return next; + const copies = mergeHistoryPageCopies(flattenSegments(segments)); + let offset = 0; + return segments.map((segment) => { + const turns = copies.slice(offset, offset + segment.turns.length) + .filter((turn): turn is Turn => turn != null); + offset += segment.turns.length; + return { ...segment, turns }; + }); } function flattenSegments(segments: readonly HistoryBrowseSegment[]): Turn[] { diff --git a/web/src/history-merge.ts b/web/src/history-merge.ts index 6fa8eb21..a78c690b 100644 --- a/web/src/history-merge.ts +++ b/web/src/history-merge.ts @@ -1146,6 +1146,63 @@ function mergeTurn( }; } +/** Reconcile overlapping browse/cache pages by native user identity. Keep the + * last copy's position, display id and lifecycle; older copies can supply the + * native lookup id and already-expanded detail. Null slots let browse keep its + * page/cursor ownership without regrouping rows or changing live runtime state. + * + * A client alias is usable only when it names one native row across the whole + * window. Task/fork ids, matching prose and timestamps are never row identity: + * multiple steered prompts may legitimately share all three. */ +export function mergeHistoryPageCopies(turns: readonly Turn[]): (Turn | null)[] { + const canonicalId = (turn: Turn) => turn.historyTurnId || turn.id; + const aliases = new Map>(); + const nativeIds = new Set(); + for (const turn of turns) { + const nativeId = canonicalId(turn); + if (turn.historyTurnId || (turn.clientMsgId && turn.clientMsgId !== turn.id)) { + nativeIds.add(nativeId); + } + for (const alias of [turn.clientMsgId, turn.historyTurnId ? turn.id : undefined]) { + if (!alias || alias === nativeId) continue; + const targets = aliases.get(alias) ?? new Set(); + targets.add(nativeId); + aliases.set(alias, targets); + } + } + + const result: (Turn | null)[] = [...turns]; + const indexes = new Map(); + turns.forEach((turn, index) => { + let key = canonicalId(turn); + if (!turn.historyTurnId && !nativeIds.has(key)) { + const targets = aliases.get(key); + if (targets?.size === 1) key = targets.values().next().value!; + } + const previousIndex = indexes.get(key); + indexes.set(key, index); + if (previousIndex == null) return; + const previous = result[previousIndex]!; + const merged = mergeTurn(previous, turn, !turn.done, "second"); + result[previousIndex] = null; + result[index] = { + ...merged, + historyTurnId: key !== turn.id ? key : turn.historyTurnId, + detailEventCount: Math.max( + previous.detailEventCount ?? 0, turn.detailEventCount ?? 0), + // These are display-only copies. An older completed page cannot settle + // a newer open row, nor reopen a newer terminal or restore its old error. + done: turn.done, + interrupted: turn.interrupted, + error: turn.error, + progress: turn.progress, + terminalSource: turn.terminalSource, + doneTs: turn.done ? merged.doneTs : turn.doneTs, + }; + }); + return result; +} + function restoreAuthoritativeLifecycle(merged: Turn, history: Turn, live: Turn): Turn { const restored = { ...merged, diff --git a/web/src/history-page-cache.ts b/web/src/history-page-cache.ts index ec66e7e9..f43578ab 100644 --- a/web/src/history-page-cache.ts +++ b/web/src/history-page-cache.ts @@ -1,8 +1,6 @@ -import { - canonicalTurnId, - type HistoryBrowsePage, -} from "./history-browse.ts"; +import type { HistoryBrowsePage } from "./history-browse.ts"; import type { Block, Turn } from "./domain/conversation.ts"; +import { mergeHistoryPageCopies } from "./history-merge.ts"; /** Deliberately independent from cache.ts. Deep-history browsing is best-effort * page storage and must never make an upgrade/failure of the replay/session @@ -186,7 +184,8 @@ export function sanitizeHistoryPageForCache( ): HistoryBrowsePage { return { pageKey: page.pageKey, - turns: page.turns.map(sanitizeTurn), + turns: mergeHistoryPageCopies(page.turns.map(sanitizeTurn)) + .filter((turn): turn is Turn => turn != null), hasOlder: !!page.hasOlder, olderCursor: page.olderCursor ?? null, hasNewer: page.isLatest ? false : !!page.hasNewer || !!page.newerPageKey, @@ -254,19 +253,8 @@ function mergePageTurns( existing: readonly Turn[], incoming: readonly Turn[], ): Turn[] { - const turns = [...existing]; - const indexes = new Map(); - turns.forEach((turn, index) => indexes.set(canonicalTurnId(turn), index)); - for (const turn of incoming) { - const key = canonicalTurnId(turn); - const index = indexes.get(key); - if (index == null) { - indexes.set(key, turns.length); - turns.push(turn); - } else { - turns[index] = turn; - } - } + const turns = mergeHistoryPageCopies([...existing, ...incoming]) + .filter((turn): turn is Turn => turn != null); turns.sort((left, right) => { if (left.ts != null && right.ts != null && left.ts !== right.ts) { return left.ts - right.ts; diff --git a/web/tests/history-browse.test.ts b/web/tests/history-browse.test.ts index 17abcf60..e67c2175 100644 --- a/web/tests/history-browse.test.ts +++ b/web/tests/history-browse.test.ts @@ -327,6 +327,87 @@ assert.deepEqual(overlap.turns.map((item) => item.id), [ "1", "optimistic-2", "3", ]); +// Claude live rows use browser ids; native summary pages carry the mapping in +// clientMsgId. Only the first greeting and the newest four rows have already +// been reconciled when an older page overlaps the still-resident live turns. +const claudeHistory = Array.from({ length: 10 }, (_, index) => turn( + `claude-native-${index + 1}`, { + clientMsgId: `claude-browser-${index + 1}`, + prompt: index === 0 ? "hi claude" : `Claude question ${index + 1}`, + ts: 1_000 + index * 1_000, + blocks: [{ + kind: "text", message_id: `claude-answer-${index + 1}`, + channel: "final", text: `answer ${index + 1}`, done: true, + }], + }, +)); +const claudeLive = claudeHistory.map((item, index) => + index === 0 || index >= 6 ? item : { + ...item, id: item.clientMsgId!, clientMsgId: undefined, + }); +claudeLive[1] = { + ...claudeLive[1], + detailLoaded: true, + blocks: [{ + kind: "tool", tool_use_id: "claude-tool", message_id: "claude-tool-message", + tool: "Read", input: { path: "example.txt" }, done: true, + }, ...claudeLive[1].blocks], +}; +const claudeOlderPage: HistoryBrowsePage = { + pageKey: "claude-older", turns: claudeHistory.slice(0, 6), + hasOlder: false, olderCursor: claudeHistory[0].id, + newerPageKey: "claude-latest", +}; +const claudeOverlap = createHistoryBrowse({ + scopeKey: "machine-a\u0000code\u0000claude", + sid: "claude-session", revision: "claude-revision", viewId: "claude-view", + baseTurns: claudeLive, basePageKey: "claude-latest", hasOlder: true, + olderCursor: claudeHistory[6].id, olderPage: claudeOlderPage, + limits: limits(20), +}).projection; +assert.deepEqual(claudeOverlap.turns.map((item) => item.prompt), + claudeHistory.map((item) => item.prompt), + "overlapping Claude pages keep ten unique turns with the greeting first"); +assert.deepEqual(claudeOverlap.turns.map((item) => item.id), + claudeLive.map((item) => item.id), "existing display ids stay mounted"); +assert.equal(claudeOverlap.turns[1].historyTurnId, claudeHistory[1].id, + "the merged row retains its native detail lookup id"); +assert.equal(claudeOverlap.turns[1].blocks.filter((block) => + block.kind === "tool" && block.tool_use_id === "claude-tool").length, 1, + "already expanded process detail survives the summary overlap"); +assert.deepEqual(prependOlderPage(claudeOverlap, claudeOlderPage).projection.turns, + claudeOverlap.turns, "loading the same older page again is idempotent"); + +const ambiguousBrowserRows = createHistoryBrowse({ + scopeKey, sid: "ambiguous-browser", revision: "r", viewId: "v", + baseTurns: [turn("shared-browser", { prompt: "repeat", ts: 3 })], + basePageKey: "latest", hasOlder: true, olderCursor: "cursor", + olderPage: { + pageKey: "older", turns: [ + turn("native-a", { clientMsgId: "shared-browser", prompt: "repeat", ts: 1 }), + turn("native-b", { clientMsgId: "shared-browser", prompt: "repeat", ts: 2 }), + ], hasOlder: false, + }, +}).projection; +assert.equal(ambiguousBrowserRows.turns.length, 3, + "ambiguous client aliases and repeated prompts never erase distinct turns"); + +const claudeRunning = createHistoryBrowse({ + scopeKey, sid: "claude-running", revision: "r", viewId: "v", + baseTurns: [{ ...claudeLive[1], done: false, doneTs: undefined }], + basePageKey: "latest", hasOlder: true, olderCursor: "cursor", + olderPage: { + pageKey: "older", turns: [{ ...claudeHistory[1], interrupted: true, error: "old terminal" }], + hasOlder: false, + }, +}).projection; +assert.equal(claudeRunning.turns.length, 1); +assert.equal(claudeRunning.turns[0].done, false); +assert.equal(claudeRunning.turns[0].interrupted, undefined); +assert.equal(claudeRunning.turns[0].error, undefined); +assert.equal(claudeRunning.turns[0].doneTs, undefined, + "an older browse page never supplies a terminal to the newer open copy"); + // Loading older rows evicts completed rows from the newer edge down to the low // water mark and returns the complete pre-eviction segment for IndexedDB. const tailEviction = createHistoryBrowse({ diff --git a/web/tests/history-browser.fixture.tsx b/web/tests/history-browser.fixture.tsx index f288b273..31b635ff 100644 --- a/web/tests/history-browser.fixture.tsx +++ b/web/tests/history-browser.fixture.tsx @@ -608,7 +608,19 @@ function reducerHistoryEvent( }; } -function reducerHistoryInitialState(cachedPagingRace = false): AppState { +const CLAUDE_ALIAS_HISTORY: Turn[] = Array.from({ length: 10 }, (_, index) => ({ + id: `claude-native-${index + 1}`, + clientMsgId: `claude-browser-${index + 1}`, + prompt: index === 0 ? "hi claude" : `Claude question ${index + 1}`, + ts: 1_790_000_000_000 + index * 60_000, + done: true, + blocks: [{ + kind: "text", message_id: `claude-answer-${index + 1}`, + channel: "final", text: `Claude answer ${index + 1}`, done: true, + }], +})); + +function reducerHistoryInitialState(cachedPagingRace = false, claudeAliases = false): AppState { let state: AppState = { ...initialState, focusedSid: REDUCER_SESSION_A, @@ -617,6 +629,20 @@ function reducerHistoryInitialState(cachedPagingRace = false): AppState { [REDUCER_SESSION_B]: createRuntime(), }, }; + if (claudeAliases) { + state.runtimes[REDUCER_SESSION_A] = { + ...createRuntime(), + historyRevision: REDUCER_HISTORY_REVISION, + historyGeneration: REDUCER_HISTORY_GENERATION, + turns: CLAUDE_ALIAS_HISTORY.map((turn, index) => index === 0 ? turn : { + ...turn, id: turn.clientMsgId!, clientMsgId: undefined, + }), + }; + return reduce(state, { type: "event", event: reducerHistoryEvent( + REDUCER_SESSION_A, CLAUDE_ALIAS_HISTORY.slice(6), + { buildSeq: 1, hasMore: true }, + ) }); + } if (cachedPagingRace) { return reduce(state, { type: "hydrate_cache", @@ -658,6 +684,9 @@ function reducerHistoryInitialState(cachedPagingRace = false): AppState { } function ReducerHistoryBrowserFixture() { + const claudeAliases = useMemo( + () => new URLSearchParams(window.location.search).has("claude-aliases"), [], + ); const cachedPagingRace = useMemo( () => new URLSearchParams(window.location.search).has("cached-paging"), [], @@ -665,7 +694,7 @@ function ReducerHistoryBrowserFixture() { const [state, dispatch] = useReducer( reduce, cachedPagingRace, - reducerHistoryInitialState, + (cached) => reducerHistoryInitialState(cached, claudeAliases), ); const stateRef = useRef(state); stateRef.current = state; @@ -740,7 +769,8 @@ function ReducerHistoryBrowserFixture() { before: browse.olderCursor!, page: { pageKey: "reducer-older-page", - turns: Array.from({ length: 20 }, (_, index) => + turns: claudeAliases ? CLAUDE_ALIAS_HISTORY.slice(0, 6) + : Array.from({ length: 20 }, (_, index) => finalTurn(`reducer-m${index + 1}`, index === 14 ? 4 : 3)), hasOlder: false, olderCursor: null, @@ -749,7 +779,7 @@ function ReducerHistoryBrowserFixture() { }); }, 25); return { accepted: true, viewId }; - }, []); + }, [claudeAliases]); const refreshLiveRuntime = useCallback(() => { const current = stateRef.current; @@ -759,19 +789,20 @@ function ReducerHistoryBrowserFixture() { type: "event", event: reducerHistoryEvent( REDUCER_SESSION_A, - Array.from({ length: 16 }, (_, index) => + claudeAliases ? CLAUDE_ALIAS_HISTORY.slice(6) + : Array.from({ length: 16 }, (_, index) => finalTurn(`reducer-m${index + 25}`, index === 15 ? 5 : 3)), { buildSeq: nextBuildSeq, hasMore: true, - oldestId: "reducer-m25", - newestId: "reducer-m40", + oldestId: claudeAliases ? CLAUDE_ALIAS_HISTORY[6].id : "reducer-m25", + newestId: claudeAliases ? CLAUDE_ALIAS_HISTORY[9].id : "reducer-m40", liveSeq: target?.lastLiveSeq ?? 0, }, ), }); setRefreshes((value) => value + 1); - }, []); + }, [claudeAliases]); const switchSession = useCallback(() => { const target = stateRef.current.focusedSid === REDUCER_SESSION_A @@ -803,7 +834,7 @@ function ReducerHistoryBrowserFixture() { { + await page.goto("/tests/history-browser.html?reducer-pipeline=1&claude-aliases=1"); + const expected = ["hi claude", ...Array.from({ length: 9 }, (_, index) => + `Claude question ${index + 2}`)]; + await expect(page.getByTestId("reducer-turn-count")).toHaveText("10"); + await pauseOutputAndScrollToHistoryStart(page); + await page.getByRole("button", { name: "加载更早的历史" }).click(); + await expect(page.getByTestId("load-count")).toHaveText("1"); + await expect(page.getByTestId("reducer-turn-count")).toHaveText("10"); + await expect(page.getByTestId("reducer-unique-turn-count")).toHaveText("10"); + await expect(page.locator(".ubub")).toHaveText(expected); + await page.getByTestId("reducer-live-refresh").click(); + await expect(page.locator(".ubub")).toHaveText(expected); + await page.getByTestId("switch-session").click(); + await page.getByTestId("switch-session").click(); + await pauseOutputAndScrollToHistoryStart(page); + await expect(page.locator(".ubub")).toHaveText(expected); +}); + test("authoritative paging returns after an IndexedDB first paint", async ({ page, }) => { diff --git a/web/tests/history-page-cache.test.ts b/web/tests/history-page-cache.test.ts index 301070a3..74887a09 100644 --- a/web/tests/history-page-cache.test.ts +++ b/web/tests/history-page-cache.test.ts @@ -288,6 +288,49 @@ assert.deepEqual(mergedPage?.turns.map((item) => item.id), [ "1", "native-2", "3", ]); +// A live-only Claude row can be evicted before its native summary arrives. +// Both write orders must converge without relying on prompt text or a prior +// historyTurnId binding, and old cached duplicates must heal on read as well. +const nativeClaudeRow = turn("claude-native", { + clientMsgId: "claude-browser", prompt: "same question", ts: 2, + detailEventCount: 16, processDetailState: "present", +}); +const liveClaudeRow = turn("claude-browser", { + prompt: "same question", ts: 2, +}); +for (const [index, copies] of [ + [liveClaudeRow, nativeClaudeRow], + [nativeClaudeRow, liveClaudeRow], +].entries()) { + const pageKey = `claude-alias-${index}`; + for (const copy of copies) { + assert.equal((await cache.putPage(scope, { + pageKey, turns: [copy], hasOlder: false, + })).ok, true); + } + const page = await cache.getPage(scope, pageKey); + assert.equal(page?.turns.length, 1); + assert.equal(page?.turns[0].historyTurnId ?? page?.turns[0].id, "claude-native"); + assert.equal(page?.turns[0].clientMsgId, "claude-browser"); + assert.equal(page?.turns[0].detailEventCount, 16); + const stored = storage.records.get(cache.pageKey(scope, pageKey)) as HistoryPageCacheStoredRecord; + stored.page.turns = [nativeClaudeRow, liveClaudeRow]; + assert.equal((await cache.getPage(scope, pageKey))?.turns.length, 1, + "existing cache pollution is repaired without discarding history pages"); +} + +const repeatedClaudePage = sanitizeHistoryPageForCache({ + pageKey: "repeated-claude-prompts", + turns: [ + nativeClaudeRow, + { ...nativeClaudeRow, id: "claude-native-other", clientMsgId: "claude-browser-other" }, + turn("display-collision", { historyTurnId: "native-first" }), + turn("display-collision", { historyTurnId: "native-second" }), + ], +}); +assert.equal(repeatedClaudePage.turns.length, 4, + "identical questions and display-id collisions keep separate native rows"); + // Concurrent partial writes to one logical page must serialize their // read/merge/write cycle. Otherwise both reads can observe an empty page and // the last writer silently drops the other turn. From dde60fa1c85453c359cf1c74c478303e5e7b6569 Mon Sep 17 00:00:00 2001 From: muggle-stack Date: Tue, 22 Sep 2026 18:58:42 +0800 Subject: [PATCH 2/3] chore(release): prepare v4.0.3 history fixes - Align product, installer and Web metadata at version 4.0.3. - Document history alias reconciliation and the existing update workflow. --- CHANGELOG.md | 12 +++++++++ CHANGELOG_zh.md | 10 ++++++++ README.md | 2 +- README_en.md | 2 +- cc_remote/__init__.py | 2 +- deploy/install.sh | 2 +- docs/installation.md | 4 +-- docs/installation_en.md | 4 +-- docs/releases/v4.0.3.md | 45 +++++++++++++++++++++++++++++++++ tests/test_product_version.py | 2 +- web/package-lock.json | 4 +-- web/package.json | 2 +- web/public/cc-remote-build.json | 2 +- 13 files changed, 80 insertions(+), 13 deletions(-) create mode 100644 docs/releases/v4.0.3.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a097328..1d2a7094 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ [中文](CHANGELOG_zh.md) +## v4.0.3 + +Fix duplicated and misplaced Claude messages when browsing overlapping history pages. +Wire protocol remains v72. See the bilingual [release notes](docs/releases/v4.0.3.md). + +- Reconcile optimistic browser message IDs with their unique native user-message + aliases across loaded and cached history pages, keeping the newer copy's position. +- Preserve expanded process details and native detail lookup IDs without changing + live completion state. Keep distinct native messages even when their text matches. +- Repair duplicate copies when reading cached pages. Cover pagination and reloads + with reliability tests and Chromium/iPhone WebKit regressions. + ## v4.0.2 Fix stale Claude activity and coordinate device upgrades with their Relay. diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 46d297d8..9c3fdabf 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -2,6 +2,16 @@ [English](CHANGELOG.md) +## v4.0.3 + +修复 Claude 历史翻页时对话重复、后续问题被放到开头的问题。通信协议保持 v72。 +详见[双语发布说明](docs/releases/v4.0.3.md)。 + +- 根据唯一的原生用户消息别名合并浏览器实时记录与历史分页,保留较新副本的位置。 +- 保留已展开的处理过程及原生详情查询标识,不改动实时完成状态;不同原生消息 + 即使文字相同也不会被误合并。 +- 读取缓存页面时修复重复副本,补充翻页、重新加载及 Chromium/iPhone WebKit 回归覆盖。 + ## v4.0.2 修复 Claude 假运行状态,并让设备更新与 VPS 协调执行。通信协议保持 v72。 diff --git a/README.md b/README.md index b0a4875d..5d5c115f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 自托管 · 多会话 · 多设备 · 实时工具过程 · Code / Work · Web / PWA / TUI -**产品版本:v4.0.2** · Wire protocol v72 +**产品版本:v4.0.3** · Wire protocol v72 [English](README_en.md) · [功能对照](#引擎与功能) · [快速开始](#快速开始) · [终端工作台](#terminal-workspace) · [安装与升级](#安装与升级) · [文档](#文档) · [更新记录](CHANGELOG_zh.md) diff --git a/README_en.md b/README_en.md index 278625dc..d6ee6ad9 100644 --- a/README_en.md +++ b/README_en.md @@ -4,7 +4,7 @@ Self-hosted · Multiple sessions and devices · Live tool activity · Code / Work · Web / PWA / TUI -**Product version: v4.0.2** · Wire protocol v72 +**Product version: v4.0.3** · Wire protocol v72 [中文](README.md) · [Engine comparison](#engines-and-features) · [Quick start](#quick-start) · [Terminal workspace](#terminal-workspace) · [Install and upgrade](#install-and-upgrade) · [Documentation](#documentation) · [Changelog](CHANGELOG.md) diff --git a/cc_remote/__init__.py b/cc_remote/__init__.py index 9c7f1884..adcc2589 100644 --- a/cc_remote/__init__.py +++ b/cc_remote/__init__.py @@ -5,4 +5,4 @@ - control link: client <-> relay(WS) <-> wrapper <-> ClaudeSDKClient <-> cc """ -__version__ = "4.0.2" +__version__ = "4.0.3" diff --git a/deploy/install.sh b/deploy/install.sh index 1057b922..bff236d9 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -2,7 +2,7 @@ # Download, verify, and run a role-specific cc-remote release installer. set -euo pipefail -VERSION="${CC_REMOTE_VERSION:-4.0.2}" +VERSION="${CC_REMOTE_VERSION:-4.0.3}" REPOSITORY="${CC_REMOTE_GITHUB_REPOSITORY:-muggle-stack/cc-remote}" BASE_URL="${CC_REMOTE_RELEASE_BASE_URL:-https://github.com/$REPOSITORY/releases/download/v$VERSION}" diff --git a/docs/installation.md b/docs/installation.md index e027ba9b..0a8ad78b 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -28,11 +28,11 @@ Web,Wrapper 包只含本机控制端;两者都自带 `uv`,安装时创建 ### 1)下载并校验引导脚本 在 GitHub Release 页面确认版本与 release attestation,再在待安装机器下载同一版本的 -`install.sh` 和 `SHA256SUMS`。下例使用 `4.0.2`;请先确认对应版本已发布,或替换为已选定的已发布 tag +`install.sh` 和 `SHA256SUMS`。下例使用 `4.0.3`;请先确认对应版本已发布,或替换为已选定的已发布 tag (变量中不带开头的 `v`)。该路径不会自动安装尚未发布的开发分支: ```bash -export CC_REMOTE_VERSION=4.0.2 +export CC_REMOTE_VERSION=4.0.3 release_base="https://github.com/muggle-stack/cc-remote/releases/download/v${CC_REMOTE_VERSION}" curl -fLO "$release_base/install.sh" curl -fLO "$release_base/SHA256SUMS" diff --git a/docs/installation_en.md b/docs/installation_en.md index d9d2105b..9a26bdd2 100644 --- a/docs/installation_en.md +++ b/docs/installation_en.md @@ -30,13 +30,13 @@ repository, install Node, or paste tokens into service definitions. ### 1) Download and verify the bootstrap Confirm the version and release attestation on GitHub, then download -`install.sh` and `SHA256SUMS` from that same release. The example uses `4.0.2`; +`install.sh` and `SHA256SUMS` from that same release. The example uses `4.0.3`; first confirm that it is published, or replace it with the published tag you selected (without the leading `v`). This does not select an unpublished development-branch build: ```bash -export CC_REMOTE_VERSION=4.0.2 +export CC_REMOTE_VERSION=4.0.3 release_base="https://github.com/muggle-stack/cc-remote/releases/download/v${CC_REMOTE_VERSION}" curl -fLO "$release_base/install.sh" curl -fLO "$release_base/SHA256SUMS" diff --git a/docs/releases/v4.0.3.md b/docs/releases/v4.0.3.md new file mode 100644 index 00000000..3d2f9075 --- /dev/null +++ b/docs/releases/v4.0.3.md @@ -0,0 +1,45 @@ +# cc-remote v4.0.3 + +## 中文 + +v4.0.3 修复 Claude 历史对话重复、后续问题被放到第一条问候之前的问题。 +产品版本 **4.0.3**,通信协议保持 **v72**。 + +### 本次修复 + +- 浏览历史和读取缓存时,按原生用户消息及其唯一浏览器别名合并同一条消息, + 避免翻页、重连或重新加载后出现两份对话和顺序错乱。 +- 保留已展开的思考/工具过程和正确的详情查询标识;历史副本不会改写较新记录 + 的运行、完成或错误状态。 +- 同文但身份不同的真实消息仍分别保留,不使用文字、时间戳或共享任务 ID 去重。 + +### 升级 + +在独立终端执行 `cc-remote update`;Linux 的系统级安装使用 +`sudo cc-remote update`。更新会先检查配对的 VPS;其他设备各自更新时, +已是目标版本的 VPS 会跳过。更新后重新加载网页或 PWA,让浏览器使用新的历史合并逻辑。 +本次修复不修改原生 Claude JSONL,也不需要删除会话或手工清空历史。 + +v4.0.0 用户按[安装与升级](https://github.com/muggle-stack/cc-remote/blob/v4.0.3/docs/installation.md) +完成一次安装器升级,再使用更新命令。旧 Release 保留不变。 + +## English + +v4.0.3 fixes duplicated Claude conversations and later prompts appearing before +the initial greeting while browsing history. Product version **4.0.3**, wire protocol **v72**. + +- Reconcile native user-message identities and their unique browser aliases across + loaded and cached history pages, preventing duplicate copies and misplaced rows. +- Preserve expanded process details and native detail lookup IDs. Older history + copies cannot override the newer copy's running, completed or error state. +- Keep genuinely distinct messages even when their text matches; matching prose, + timestamps and shared task IDs are not used as deduplication keys. + +Run `cc-remote update` in an independent terminal (`sudo cc-remote update` for +system-wide Linux installs). The updater checks the paired Relay first and skips +it if already at the target version. Update other devices individually, then +reload Web/PWA to load the new history merge logic. Native Claude JSONL files are +unchanged; deleting sessions or manually clearing history is unnecessary. + +v4.0.0 requires a one-time [installer upgrade](https://github.com/muggle-stack/cc-remote/blob/v4.0.3/docs/installation_en.md) +before the update command is available. Existing releases remain available. diff --git a/tests/test_product_version.py b/tests/test_product_version.py index cb297837..b28057b0 100644 --- a/tests/test_product_version.py +++ b/tests/test_product_version.py @@ -13,7 +13,7 @@ def test_product_version_is_consistent_across_runtime_and_web_metadata(): - assert __version__ == "4.0.2" + assert __version__ == "4.0.3" assert re.fullmatch(r"[1-9]\d*\.\d+\.\d+", __version__) package = json.loads((ROOT / "web/package.json").read_text()) diff --git a/web/package-lock.json b/web/package-lock.json index 7ded58d6..30b1b19c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "web", - "version": "4.0.2", + "version": "4.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "web", - "version": "4.0.2", + "version": "4.0.3", "license": "MIT", "dependencies": { "@tanstack/react-virtual": "3.14.8", diff --git a/web/package.json b/web/package.json index 02a1950a..c9886b07 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "web", "private": true, - "version": "4.0.2", + "version": "4.0.3", "author": "muggle", "license": "MIT", "type": "module", diff --git a/web/public/cc-remote-build.json b/web/public/cc-remote-build.json index e3270f8f..eca0e52d 100644 --- a/web/public/cc-remote-build.json +++ b/web/public/cc-remote-build.json @@ -1,4 +1,4 @@ { - "version": "4.0.2", + "version": "4.0.3", "protocol": 72 } From 4ea675585300781a20516d5d2b9a395807e7eeb1 Mon Sep 17 00:00:00 2001 From: muggle-stack Date: Tue, 22 Sep 2026 19:24:46 +0800 Subject: [PATCH 3/3] fix(history): preserve cached row order on partial updates - Keep established cached row positions while merging newer native or alias copies. - Cover missing and equal timestamps, reordered partial writes, and updated content. - Clarify cache ordering behavior in the v4.0.3 release notes. --- CHANGELOG.md | 4 +++- CHANGELOG_zh.md | 3 ++- docs/releases/v4.0.3.md | 3 +++ web/src/history-merge.ts | 18 ++++++++++----- web/src/history-page-cache.ts | 7 +++--- web/tests/history-page-cache.test.ts | 33 ++++++++++++++++++++++++++++ 6 files changed, 57 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d2a7094..742b151c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,9 @@ Fix duplicated and misplaced Claude messages when browsing overlapping history p Wire protocol remains v72. See the bilingual [release notes](docs/releases/v4.0.3.md). - Reconcile optimistic browser message IDs with their unique native user-message - aliases across loaded and cached history pages, keeping the newer copy's position. + aliases across loaded and cached history pages. +- Keep established row order during partial cache updates, including when + timestamps are missing or equal. - Preserve expanded process details and native detail lookup IDs without changing live completion state. Keep distinct native messages even when their text matches. - Repair duplicate copies when reading cached pages. Cover pagination and reloads diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 9c3fdabf..a135b9ec 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -7,7 +7,8 @@ 修复 Claude 历史翻页时对话重复、后续问题被放到开头的问题。通信协议保持 v72。 详见[双语发布说明](docs/releases/v4.0.3.md)。 -- 根据唯一的原生用户消息别名合并浏览器实时记录与历史分页,保留较新副本的位置。 +- 根据唯一的原生用户消息别名合并浏览器实时记录与历史分页。 +- 局部更新缓存时保留消息原来的顺序,即使时间戳缺失或相同。 - 保留已展开的处理过程及原生详情查询标识,不改动实时完成状态;不同原生消息 即使文字相同也不会被误合并。 - 读取缓存页面时修复重复副本,补充翻页、重新加载及 Chromium/iPhone WebKit 回归覆盖。 diff --git a/docs/releases/v4.0.3.md b/docs/releases/v4.0.3.md index 3d2f9075..2c443bac 100644 --- a/docs/releases/v4.0.3.md +++ b/docs/releases/v4.0.3.md @@ -9,6 +9,7 @@ v4.0.3 修复 Claude 历史对话重复、后续问题被放到第一条问候 - 浏览历史和读取缓存时,按原生用户消息及其唯一浏览器别名合并同一条消息, 避免翻页、重连或重新加载后出现两份对话和顺序错乱。 +- 局部更新缓存时保留消息原来的顺序,即使时间戳缺失或相同。 - 保留已展开的思考/工具过程和正确的详情查询标识;历史副本不会改写较新记录 的运行、完成或错误状态。 - 同文但身份不同的真实消息仍分别保留,不使用文字、时间戳或共享任务 ID 去重。 @@ -30,6 +31,8 @@ the initial greeting while browsing history. Product version **4.0.3**, wire pro - Reconcile native user-message identities and their unique browser aliases across loaded and cached history pages, preventing duplicate copies and misplaced rows. +- Preserve established row order during partial cache updates even when timestamps + are missing or equal. - Preserve expanded process details and native detail lookup IDs. Older history copies cannot override the newer copy's running, completed or error state. - Keep genuinely distinct messages even when their text matches; matching prose, diff --git a/web/src/history-merge.ts b/web/src/history-merge.ts index a78c690b..a864d57e 100644 --- a/web/src/history-merge.ts +++ b/web/src/history-merge.ts @@ -1147,14 +1147,18 @@ function mergeTurn( } /** Reconcile overlapping browse/cache pages by native user identity. Keep the - * last copy's position, display id and lifecycle; older copies can supply the - * native lookup id and already-expanded detail. Null slots let browse keep its - * page/cursor ownership without regrouping rows or changing live runtime state. + * last copy's display id and lifecycle; older copies can supply the native + * lookup id and already-expanded detail. Browse keeps the last copy's position + * and page ownership; partial cache writes keep the established first slot. + * Null slots preserve page boundaries without changing live runtime state. * * A client alias is usable only when it names one native row across the whole * window. Task/fork ids, matching prose and timestamps are never row identity: * multiple steered prompts may legitimately share all three. */ -export function mergeHistoryPageCopies(turns: readonly Turn[]): (Turn | null)[] { +export function mergeHistoryPageCopies( + turns: readonly Turn[], + position: "first" | "last" = "last", +): (Turn | null)[] { const canonicalId = (turn: Turn) => turn.historyTurnId || turn.id; const aliases = new Map>(); const nativeIds = new Set(); @@ -1180,12 +1184,14 @@ export function mergeHistoryPageCopies(turns: readonly Turn[]): (Turn | null)[] if (targets?.size === 1) key = targets.values().next().value!; } const previousIndex = indexes.get(key); - indexes.set(key, index); + const retainedIndex = position === "first" ? previousIndex ?? index : index; + indexes.set(key, retainedIndex); if (previousIndex == null) return; const previous = result[previousIndex]!; const merged = mergeTurn(previous, turn, !turn.done, "second"); result[previousIndex] = null; - result[index] = { + result[index] = null; + result[retainedIndex] = { ...merged, historyTurnId: key !== turn.id ? key : turn.historyTurnId, detailEventCount: Math.max( diff --git a/web/src/history-page-cache.ts b/web/src/history-page-cache.ts index f43578ab..d5331487 100644 --- a/web/src/history-page-cache.ts +++ b/web/src/history-page-cache.ts @@ -247,13 +247,14 @@ function validRecord( && validPage(record.page); } -/** Merge repeated partial evictions of one logical page. New input wins a - * canonical overlap, while existing-only rows remain recoverable. */ +/** Merge repeated partial evictions of one logical page. New input wins an + * overlap's content without moving its established slot; existing-only rows + * remain recoverable even when timestamps are missing or equal. */ function mergePageTurns( existing: readonly Turn[], incoming: readonly Turn[], ): Turn[] { - const turns = mergeHistoryPageCopies([...existing, ...incoming]) + const turns = mergeHistoryPageCopies([...existing, ...incoming], "first") .filter((turn): turn is Turn => turn != null); turns.sort((left, right) => { if (left.ts != null && right.ts != null && left.ts !== right.ts) { diff --git a/web/tests/history-page-cache.test.ts b/web/tests/history-page-cache.test.ts index 74887a09..73b26740 100644 --- a/web/tests/history-page-cache.test.ts +++ b/web/tests/history-page-cache.test.ts @@ -283,6 +283,39 @@ const mergedWrite = await cache.putPage(scope, { newerPageKey: "page-2", }); assert.equal(mergedWrite.ok, true); + +// A partial write updates rows in place even when timestamps cannot establish +// their order. Native alias materialization must also retain the cached slot. +for (const ts of [undefined, 42]) { + for (const alias of [false, true]) { + const partialCache = new HistoryPageCache({ storage: new MemoryStorage() }); + const pageKey = `partial-order-${ts ?? "missing"}-${alias}`; + const page = { + pageKey, + turns: [turn("row-a", { ts }), turn("row-b", { ts, done: false }), turn("row-c", { ts })], + hasOlder: false, + olderCursor: null, + }; + assert.equal((await partialCache.putPage(scope, page)).ok, true); + const updated = turn(alias ? "native-b" : "row-b", { + ts, + clientMsgId: alias ? "row-b" : undefined, + blocks: [{ kind: "text", message_id: "answer-b", channel: "final", text: "updated answer", done: true }], + }); + assert.equal((await partialCache.putPage(scope, { ...page, turns: [updated] })).ok, true); + const reloaded = await partialCache.getPage(scope, pageKey); + assert.deepEqual(reloaded?.turns.map((item) => item.id), ["row-a", updated.id, "row-c"], + "a middle-row update retains cached order without relying on timestamps"); + assert.equal(reloaded?.turns[1].done, true, "the incoming lifecycle still wins"); + assert.deepEqual(reloaded?.turns[1].blocks, updated.blocks); + assert.equal((await partialCache.putPage(scope, { + ...page, turns: [turn("row-c", { ts }), updated, turn("row-d", { ts })], + })).ok, true); + assert.deepEqual((await partialCache.getPage(scope, pageKey))?.turns.map((item) => item.id), + ["row-a", updated.id, "row-c", "row-d"], + "reordered overlapping subsets keep established slots and append genuinely new rows"); + } +} const mergedPage = await cache.getPage(scope, "page-1"); assert.deepEqual(mergedPage?.turns.map((item) => item.id), [ "1", "native-2", "3",