From a9b548bf4b137cf58bf9d14b44935c28c53d883a Mon Sep 17 00:00:00 2001 From: shaowei <466995322@qq.com> Date: Fri, 7 Aug 2026 13:32:13 +0800 Subject: [PATCH] release: prepare CX-Codex 2.7.6 stability Prevent live conversation projection from aliasing persisted messages, defer heavy history convergence while execution is active, and avoid restarting an idle connected notification stream. Includes deterministic regression coverage, compact GitHub discovery UI, and release retention documentation. --- .agents/skills/codex-app-parity/SKILL.md | 14 + README.md | 23 +- docs/changelog.zh-CN.md | 9 + docs/release-notes-2.7.6.zh-CN.md | 47 +++ docs/release-retention.zh-CN.md | 31 ++ package-lock.json | 4 +- package.json | 2 +- scripts/regression-7420-frontend.ps1 | 141 +++++++++ scripts/verify-frontend-normalizers.mjs | 125 ++++++++ src/api/normalizers/v2.ts | 2 + .../content/ConversationRegressionFixture.vue | 107 ++++++- .../content/DocumentationShowcaseFixture.vue | 2 +- src/components/content/GithubTrendingHub.vue | 294 +++++++++++++----- src/components/content/ThreadConversation.vue | 45 +-- src/composables/connectionManager.ts | 2 +- src/composables/conversationProjection.ts | 1 + src/composables/conversationRenderPolicy.ts | 14 + src/composables/messageIdentity.ts | 2 +- src/composables/useDesktopState.ts | 21 +- src/sessionFileChange.ts | 45 +++ src/types/codex.ts | 1 + tests.md | 54 ++++ 22 files changed, 848 insertions(+), 138 deletions(-) create mode 100644 docs/release-notes-2.7.6.zh-CN.md create mode 100644 docs/release-retention.zh-CN.md create mode 100644 src/composables/conversationRenderPolicy.ts diff --git a/.agents/skills/codex-app-parity/SKILL.md b/.agents/skills/codex-app-parity/SKILL.md index 123b3f7..8cc70f6 100644 --- a/.agents/skills/codex-app-parity/SKILL.md +++ b/.agents/skills/codex-app-parity/SKILL.md @@ -1303,3 +1303,17 @@ After each feature implementation session that uses this skill: - CX-Codex now persists the pre-send signature count and reconstructs that boundary for older outbox rows before merging optimistic and authoritative messages. Stable `clientMessageId` ownership remains unchanged. - Android resume keeps the first reconciliation attempt eager but makes later retries evidence-driven, avoiding three full active-conversation reloads that can starve interaction on long mobile threads. The native Activity probes renderer responsiveness, presents recovery copy, and recreates or reloads the exact current route when the renderer hangs or is reclaimed. - Headless phone verification at 393 x 852 opened the waiting-state detail sheet with zero browser errors or failed responses. Native policy unit tests and a physical-device background/resume pass remain the Android release boundary. + +## Findings: Compact GitHub Trending Discovery (2026-08-07) + +- The installed Codex desktop bundle has no exact GitHub Trending surface, so this feature uses the closest current discovery and disclosure patterns instead of inventing false one-to-one parity. +- Codex file discovery uses a compact rounded bordered filter with an accessible label, while truncated message content uses explicit `aria-expanded`, “显示更多/收起” copy and a rotating disclosure affordance. +- CX-Codex adapts those patterns as a compact scope filter plus fixed two-column project cards. Expanded content spans the row to preserve reading width; CX-specific repository actions remain explicit because Codex has no equivalent surface. + +## Findings: Cross-process Streaming Responsiveness (2026-08-07) + +- Installed Codex `26.727.6591` virtualizes turn content, memoizes message subtrees, and batches dense deltas. CX-Codex keeps the same ownership boundary while adapting it to a browser/WebView that may mirror session files written by another desktop process. +- Two amplification paths caused the reported freeze: a quiet-window convergence repeatedly requested the complete long rollout during external execution, while every text-only delta also invalidated conversation height/cache and scroll-restore work. A bounded file projection alone was therefore insufficient. +- External session convergence now preserves `commentary/final` phase and requires the latest assistant item itself to be final. This remains correct when two Codex tasks interleave in one session file: an older sibling final cannot end a newer commentary stream. +- Conversation rendering now prunes structural caches and schedules scroll restoration only when message identity/order changes. Text-only deltas leave height correction to `ResizeObserver`, and per-message estimates use weak identity ownership instead of a small sequential LRU that thrashed on dense turns. +- At `393 x 852`, the final production stress fixture kept 1602 messages to 13 mounted items, sustained 48ms updates with 50-57ms maximum heartbeat lag, accepted an action during streaming, and produced no horizontal overflow. A real cross-process active conversation then updated from its session log without any full-history read during the observed windows. diff --git a/README.md b/README.md index 0d4cbf7..e4990b9 100644 --- a/README.md +++ b/README.md @@ -17,25 +17,10 @@ Self-hosted OpenAI Codex Web UI and Android client bridge. ## 快速入口 - 最新 Release: [github.com/Qjzn/CX-Codex/releases/latest](https://github.com/Qjzn/CX-Codex/releases/latest) -- 2.7.5 发布说明: [docs/release-notes-2.7.5.zh-CN.md](./docs/release-notes-2.7.5.zh-CN.md) -- 2.7.4 发布说明(已由 2.7.5 替代): [docs/release-notes-2.7.4.zh-CN.md](./docs/release-notes-2.7.4.zh-CN.md) -- 2.7.3 发布说明(已由 2.7.4 替代): [docs/release-notes-2.7.3.zh-CN.md](./docs/release-notes-2.7.3.zh-CN.md) -- 2.7.2 发布说明: [docs/release-notes-2.7.2.zh-CN.md](./docs/release-notes-2.7.2.zh-CN.md) -- 2.6.0 发布说明: [docs/release-notes-2.6.0.zh-CN.md](./docs/release-notes-2.6.0.zh-CN.md) -- 2.5.9 发布说明: [docs/release-notes-2.5.9.zh-CN.md](./docs/release-notes-2.5.9.zh-CN.md) -- 2.5.8 发布说明: [docs/release-notes-2.5.8.zh-CN.md](./docs/release-notes-2.5.8.zh-CN.md) -- 2.5.7 发布说明: [docs/release-notes-2.5.7.zh-CN.md](./docs/release-notes-2.5.7.zh-CN.md) -- 2.5.6 发布说明: [docs/release-notes-2.5.6.zh-CN.md](./docs/release-notes-2.5.6.zh-CN.md) -- 2.5.5 发布说明: [docs/release-notes-2.5.5.zh-CN.md](./docs/release-notes-2.5.5.zh-CN.md) -- 2.5.4 发布说明: [docs/release-notes-2.5.4.zh-CN.md](./docs/release-notes-2.5.4.zh-CN.md) -- 2.5.3 发布说明: [docs/release-notes-2.5.3.zh-CN.md](./docs/release-notes-2.5.3.zh-CN.md) -- 2.5.2 发布说明: [docs/release-notes-2.5.2.zh-CN.md](./docs/release-notes-2.5.2.zh-CN.md) -- 2.5.1 发布说明: [docs/release-notes-2.5.1.zh-CN.md](./docs/release-notes-2.5.1.zh-CN.md) -- 2.5.0 发布说明: [docs/release-notes-2.5.0.zh-CN.md](./docs/release-notes-2.5.0.zh-CN.md) -- 2.4.1 发布说明: [docs/release-notes-2.4.1.zh-CN.md](./docs/release-notes-2.4.1.zh-CN.md) -- 2.4.0 发布说明: [docs/release-notes-2.4.0.zh-CN.md](./docs/release-notes-2.4.0.zh-CN.md) -- 2.3.1 发布说明: [docs/release-notes-2.3.1.zh-CN.md](./docs/release-notes-2.3.1.zh-CN.md) -- 2.3.0 发布说明: [docs/release-notes-2.3.0.zh-CN.md](./docs/release-notes-2.3.0.zh-CN.md) +- 2.7.6 发布说明: [docs/release-notes-2.7.6.zh-CN.md](./docs/release-notes-2.7.6.zh-CN.md) +- 可靠回滚版 2.7.4: [docs/release-notes-2.7.4.zh-CN.md](./docs/release-notes-2.7.4.zh-CN.md) +- 完整更新日志: [docs/changelog.zh-CN.md](./docs/changelog.zh-CN.md) +- 发行版保留策略: [docs/release-retention.zh-CN.md](./docs/release-retention.zh-CN.md) - Windows 一条命令安装: [快速安装](#快速安装) - Android 客户端说明: [docs/android-shell.zh-CN.md](./docs/android-shell.zh-CN.md) - 平台兼容与 Slash Command 支持: [docs/platform-and-commands.zh-CN.md](./docs/platform-and-commands.zh-CN.md) diff --git a/docs/changelog.zh-CN.md b/docs/changelog.zh-CN.md index d7fd96b..0da5581 100644 --- a/docs/changelog.zh-CN.md +++ b/docs/changelog.zh-CN.md @@ -2,6 +2,15 @@ ## 未发布 +## 2.7.6 - 2026-08-07 + +- 修复会话投影在没有可见乐观消息时直接复用持久消息数组的问题;后续流式消息合并不再反向修改权威历史,避免同一条消息重复出现并触发响应式更新放大。 +- 外部 Codex 进程执行长任务时,只有最新助手消息已经进入最终阶段且没有运行、排队或待确认信号,才执行一次完整历史收敛;执行中持续使用有界会话日志投影,避免反复读取大型会话。 +- 文本流式更新不再重复清理结构缓存或调度滚动恢复;1602 条消息的手机压力场景保持约 12–13 个实际挂载节点,并在 48ms 更新期间维持可交互。 +- 已连接且只是打开着空闲会话时,不再因长时间没有通知就主动重启健康流;断线、重连或仍有同步需求时继续执行恢复,减少前后台切换中的无效重连。 +- GitHub 热门页收敛为紧凑筛选栏和手机双列卡片,长介绍按需展开,减少重复标题和大卡片占用。 +- GitHub Releases 建立稳定版保留策略:保留当前稳定版、可靠回滚版与每条历史版本线最后一个门禁全绿版本;清理只移除 Release 展示和二进制资产,Git 标签与提交历史继续保留。 + ## 2.7.5 - 2026-08-07 - 修复 Android 从后台恢复后,已进入权威历史的用户消息仍与本地待发送投影合并、导致同一问题显示两条的问题;恢复边界现在随请求持久化,旧记录也会安全推导发送前基线。 diff --git a/docs/release-notes-2.7.6.zh-CN.md b/docs/release-notes-2.7.6.zh-CN.md new file mode 100644 index 0000000..50c8ad0 --- /dev/null +++ b/docs/release-notes-2.7.6.zh-CN.md @@ -0,0 +1,47 @@ +# CX-Codex 2.7.6:会话执行更稳,长任务页面保持可操作 + +2.7.6 是聊天稳定性补丁版,修复 2.7.5 仍可能出现的重复消息与执行中页面卡顿,并收敛 GitHub Releases 的公开版本数量。升级后不需要清理会话、浏览器缓存或 Android 应用数据。 + +## 重复消息与流式执行 + +- 会话消息投影始终使用独立数组。没有待发送气泡时,新的流式消息也不会再被写回持久历史,避免一条消息变成两条或触发响应式更新循环。 +- 外部 Codex 桌面任务持续写入时,7420 优先读取最近会话的有界增量;只有最新助手消息已经最终完成且没有运行、排队或待确认信号,才做一次权威完整历史收敛。 +- 文本增量不再反复清理消息结构缓存、测量全部高度或恢复滚动位置。用户阅读历史时仍保留原位置,位于底部时继续跟随最新输出。 +- 已连接的空闲页面不会仅因暂时没有通知就重建连接;真正断线、正在执行或仍有待同步内容时,恢复路径保持不变。 + +## 手机端响应与页面体验 + +- 生产压力场景在 393 × 852 视口加载 1602 条消息并每 48ms 更新回复,只挂载 12–13 个可见消息节点;最大事件循环延迟 64ms,执行中按钮仍可立即响应。 +- 独立 headless Playwright 复核得到最大延迟 47ms,点击后流式更新继续增长,页面无横向溢出、控制台错误或失败请求。 +- GitHub 热门页改为紧凑筛选栏和手机双列卡片;默认只展示摘要,展开后再显示完整介绍与仓库地址。 + +## 安装与升级 + +Windows 一条命令安装或升级: + +```powershell +& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/Qjzn/CX-Codex/main/scripts/bootstrap-windows.ps1'))) -RemoteQuick -JsonOutput +``` + +需要卸载程序时使用官方入口;默认保留可恢复的用户配置,只有显式选择清理数据时才删除: + +```powershell +& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/Qjzn/CX-Codex/main/scripts/uninstall-windows.ps1'))) -JsonOutput +``` + +Android 请从 [GitHub 最新 Release](https://github.com/Qjzn/CX-Codex/releases/latest) 下载 `cx-codex-android-v2.7.6.apk`。正式 APK 沿用 2.5.9 之后的固定发布证书,可覆盖升级并保留服务地址与设置。 + +## 发行版整理 + +- GitHub Releases 只保留当前稳定版、一个可靠回滚版,以及每条历史版本线最后一个发布门禁全绿的归档版本。 +- 中间补丁、已撤回版本和发布门禁失败版本会从 Releases 页面移除,避免新人误装;Git 标签与提交历史不会删除,仍可审计和比对源码。 +- 具体保留规则和版本清单见 [发行版保留策略](./release-retention.zh-CN.md)。 + +## 验证与边界 + +- 前端 normalizer、生产前端、CLI、服务模块和完整 38 场景前端回归通过;完整回归耗时 499.7 秒。 +- 会话压力场景、消息投影不可变性、前台通知流恢复策略和外部会话最终阶段判断均有确定性回归。 +- 正式稳定身份仍以主分支 CI、Release 工作流、公开 ZIP/APK、SHA-256、固定 Android 签名和公开下载复核全部通过为准。 +- 浏览器与 Playwright 不能代替 Android 真机深度 Doze/进程回收验收;FCM 深度 Doze 能力仍按 issue #28 单独跟踪。 + +标签发布前,本版仍属于候选版本。只有主分支 CI、Release 工作流、公开 ZIP/APK、SHA-256、固定 Android 签名和公网 Windows 全新安装全部通过后,才视为正式稳定版本。 diff --git a/docs/release-retention.zh-CN.md b/docs/release-retention.zh-CN.md new file mode 100644 index 0000000..04f8e5e --- /dev/null +++ b/docs/release-retention.zh-CN.md @@ -0,0 +1,31 @@ +# GitHub 发行版保留策略 + +本项目减少 Releases 页面中的重复补丁和已知不稳定制品,让新人优先看到可安装、可回滚、可追溯的版本。 + +## 保留原则 + +1. 保留最新一个完成主分支 CI、Release 工作流、ZIP/APK、SHA-256 和固定签名复核的稳定版。 +2. 保留当前版本线中最近一个可靠回滚版;若最新版出现安装或会话阻断问题,可快速退回。 +3. 历史版本线只保留最后一个 GitHub 门禁全绿且主要资产完整的版本,用于兼容审计,不推荐新安装。 +4. 已撤回、发布门禁失败、只有中间补丁价值或已被同版本线稳定补丁完全替代的 Release 会被移除。 +5. 清理只删除 GitHub Release 展示对象及其二进制资产,不删除 Git 标签、提交历史、更新日志或源码发布说明。 + +## 稳定版判定 + +版本号更大不等于更稳定。至少需要同时满足: + +- 对应提交的 Linux build 和 Windows bootstrap 检查成功; +- Release 工作流成功; +- ZIP 与 SHA-256 同时存在;需要 Android 的版本还应包含正式 APK 与 APK SHA-256; +- 没有已确认的安装阻断、签名冲突、重复执行或会话卡死问题; +- 新版本发布后完成公开资产复核,才替换 `latest` 稳定身份。 + +## 当前保留目标 + +- 当前稳定版:`v2.7.6`(完成正式发布复核后生效)。 +- 可靠回滚版:`v2.7.4`。 +- 历史稳定归档:`v2.6.1`、`v2.5.9`、`v2.4.1`、`v2.3.1`、`v2.2.7`、`2.1.17`、`v0.2.0-bridge.5`、`v0.1.58-bridge.6`。 + +其中 `v2.7.3` 已因 Release ZIP 缺少构建文件而撤回,`v2.7.5` 存在本版修复的残余消息投影风险,`v2.2.8` 的 Release 检查失败;三者都不作为稳定版保留。 + +旧版本仅用于回溯。新安装和日常升级始终使用 [GitHub 最新稳定 Release](https://github.com/Qjzn/CX-Codex/releases/latest)。 diff --git a/package-lock.json b/package-lock.json index f5d68ce..97061ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cx-codex", - "version": "2.7.5", + "version": "2.7.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cx-codex", - "version": "2.7.5", + "version": "2.7.6", "license": "MIT", "dependencies": { "@capacitor/app": "^8.1.1", diff --git a/package.json b/package.json index 7fc68dd..c8a2931 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cx-codex", - "version": "2.7.5", + "version": "2.7.6", "description": "CX-Codex Web UI and browser bridge for Windows, Linux, Android, LAN, and remote self-hosted access", "type": "module", "license": "MIT", diff --git a/scripts/regression-7420-frontend.ps1 b/scripts/regression-7420-frontend.ps1 index fa97165..fb13a50 100644 --- a/scripts/regression-7420-frontend.ps1 +++ b/scripts/regression-7420-frontend.ps1 @@ -436,6 +436,8 @@ function Assert-RuntimeSnapshotOrderingSource { Assert-True ($source -match "shouldDeferCachedRpcRefresh\s*=\s*options\.forceSettledRpcRefresh\s*!==\s*true") "the forced authoritative refresh must not defer itself again" Assert-True ($source -match "options\.preferSessionLogMessages\s*===\s*true\s*&&\s*snapshot\.messageState\s*===\s*'cached'[\s\S]*?scheduleSessionLogAuthoritativeRefresh\(threadId") "session-log projections must coalesce a quiet-period authoritative refresh instead of remaining permanently lossy" Assert-True ($source -match "pendingSessionLogMessageRefresh\.add\(threadId\)[\s\S]{0,240}?scheduleSessionLogAuthoritativeRefresh\(threadId\)") "each session-log notification must reset the authoritative quiet window before its local projection begins" + Assert-True ($source -match "getSessionLogAuthoritativeRefreshAction\(\{[\s\S]*?executionActive:\s*isThreadExecutionActive\(threadId\)[\s\S]*?hasPendingServerRequest:\s*hasPendingServerRequestSignal\(threadId\)[\s\S]*?hasQueuedWork:\s*hasQueuedThreadWork\(threadId\)[\s\S]*?action\s*===\s*'defer'[\s\S]*?scheduleSessionLogAuthoritativeRefresh\(threadId\)") "session-log convergence must not issue a heavy authoritative history read while a turn or queued action is active" + Assert-True ($source -match "hasTerminalEvidence:\s*hasSettledSessionLogMessageEvidence\([\s\S]*?persistedMessagesByThreadId\.value\[threadId\][\s\S]*?action\s*===\s*'defer'") "cross-process session-log convergence must wait for a final assistant message instead of trusting a false settled light snapshot" Assert-True ($source -match "shouldForceCachedSnapshotRefresh\s*=\s*options\.force\s*===\s*true\s*&&\s*snapshot\.messageState\s*===\s*'cached'") "cached historical threads without a terminal event key must still receive an authoritative refresh" Assert-True ($source -match "!options\.olderHistory\s*&&\s*!shouldForceCachedSnapshotRefresh") "explicit older-history reads must not be blocked when a legacy thread has no terminal refresh key" Assert-True ($conversationSource -match "pendingRemoteOlderHistoryAnchor\s*=\s*anchorSnapshot[\s\S]*?emit\('loadOlderHistory'\)[\s\S]*?props\.messages\.length[\s\S]*?restoreScrollAnchorOverFrames\(anchorSnapshot,\s*6\)") "remote older-history insertion must restore the pre-request reading anchor after messages arrive" @@ -2433,6 +2435,92 @@ JSON.stringify({ Assert-True ([string]$closed.activeClass -like '*docs-skill-detail-launch*') "closing skill detail did not restore focus to its opener" } +function Assert-GithubTrendingCompactLayout { + param([string]$Session) + + $initial = Invoke-BrowserEvalJson -Session $Session -Script @' +JSON.stringify((() => { + const hub = document.querySelector('.trending-hub'); + const filter = document.querySelector('.trending-hub-filter'); + const grid = document.querySelector('.trending-hub-grid'); + const cards = Array.from(document.querySelectorAll('.trending-card:not(.trending-card-skeleton)')); + const firstCard = cards[0]; + const firstSummary = firstCard?.querySelector('.trending-card-summary'); + const firstExpand = firstCard?.querySelector('.trending-card-expand'); + const filterRect = filter?.getBoundingClientRect(); + const firstRect = firstCard?.getBoundingClientRect(); + const secondRect = cards[1]?.getBoundingClientRect(); + const gridStyle = grid ? getComputedStyle(grid) : null; + const summaryStyle = firstSummary ? getComputedStyle(firstSummary) : null; + return { + ready: !!hub && !!filter && !!grid && cards.length >= 3 && !!firstSummary && !!firstExpand, + hasLegacyHero: document.querySelector('.trending-hub-header') !== null, + filterHeight: filterRect?.height ?? 0, + columnCount: gridStyle?.gridTemplateColumns.split(' ').filter(Boolean).length ?? 0, + firstCardHeight: firstRect?.height ?? 0, + secondCardHeight: secondRect?.height ?? 0, + sameFirstRow: !!firstRect && !!secondRect && Math.abs(firstRect.top - secondRect.top) <= 1, + summaryLineClamp: summaryStyle?.webkitLineClamp || '', + expandState: firstExpand?.getAttribute('aria-expanded') || '', + expandLabel: firstExpand?.textContent?.replace(/\s+/g, ' ').trim() || '', + hasDetails: firstCard?.querySelector('.trending-card-details') !== null, + hasHorizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1 + }; +})()) +'@ + Assert-True ($initial.ready -eq $true) "GitHub trending compact fixture did not render its filter and project cards" + Assert-True ($initial.hasLegacyHero -eq $false) "GitHub trending retained the oversized duplicate hero card" + Assert-True ([double]$initial.filterHeight -le 52) "GitHub trending filter is taller than the compact control contract" + Assert-True ([int]$initial.columnCount -eq 2) "GitHub trending phone layout did not render two cards per row" + Assert-True ($initial.sameFirstRow -eq $true) "GitHub trending first two cards are not aligned in one row" + Assert-True ([Math]::Abs([double]$initial.firstCardHeight - 272) -le 2) "GitHub trending first card does not keep the 17rem collapsed height" + Assert-True ([Math]::Abs([double]$initial.firstCardHeight - [double]$initial.secondCardHeight) -le 1) "GitHub trending collapsed cards do not keep a consistent height" + Assert-True ([string]$initial.summaryLineClamp -eq '3') "GitHub trending summary is not clamped to three lines" + Assert-True ([string]$initial.expandState -eq 'false' -and [string]$initial.expandLabel -like '展开*') "GitHub trending collapsed card is missing its expand action" + Assert-True ($initial.hasDetails -eq $false) "GitHub trending rendered repository details before expansion" + Assert-True ($initial.hasHorizontalOverflow -eq $false) "GitHub trending compact grid caused horizontal overflow" + + $expanded = Invoke-BrowserEvalJson -Session $Session -Script @' +JSON.stringify((() => { + const firstExpand = document.querySelector('.trending-card-expand'); + if (!(firstExpand instanceof HTMLButtonElement)) return { clicked: false }; + firstExpand.click(); + return { clicked: true }; +})()) +'@ + Assert-True ($expanded.clicked -eq $true) "GitHub trending first card could not be expanded" + Invoke-AgentBrowser -Arguments @('--session', $Session, 'wait', '150') | Out-Null + + $expandedState = Invoke-BrowserEvalJson -Session $Session -Script @' +JSON.stringify((() => { + const grid = document.querySelector('.trending-hub-grid'); + const cards = Array.from(document.querySelectorAll('.trending-card:not(.trending-card-skeleton)')); + const firstCard = cards[0]; + const secondCard = cards[1]; + const firstExpand = firstCard?.querySelector('.trending-card-expand'); + const firstRect = firstCard?.getBoundingClientRect(); + const secondRect = secondCard?.getBoundingClientRect(); + const gridRect = grid?.getBoundingClientRect(); + return { + isExpanded: firstCard?.classList.contains('is-expanded') === true, + expandState: firstExpand?.getAttribute('aria-expanded') || '', + expandLabel: firstExpand?.textContent?.replace(/\s+/g, ' ').trim() || '', + hasDetails: firstCard?.querySelector('.trending-card-details') !== null, + spansGrid: !!firstRect && !!gridRect && Math.abs(firstRect.width - gridRect.width) <= 2, + nextRowStartsAfterExpandedCard: !!firstRect && !!secondRect && secondRect.top >= firstRect.bottom - 1, + summaryIsExpanded: firstCard?.querySelector('.trending-card-summary')?.classList.contains('is-expanded') === true, + hasHorizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1 + }; +})()) +'@ + Assert-True ($expandedState.isExpanded -eq $true) "GitHub trending expanded card is missing its state class" + Assert-True ([string]$expandedState.expandState -eq 'true' -and [string]$expandedState.expandLabel -like '收起*') "GitHub trending expanded card is missing its collapse action" + Assert-True ($expandedState.hasDetails -eq $true -and $expandedState.summaryIsExpanded -eq $true) "GitHub trending expansion did not reveal the complete project content" + Assert-True ($expandedState.spansGrid -eq $true) "GitHub trending expanded card did not span the full grid width" + Assert-True ($expandedState.nextRowStartsAfterExpandedCard -eq $true) "GitHub trending expanded card overlaps the following row" + Assert-True ($expandedState.hasHorizontalOverflow -eq $false) "GitHub trending expanded card caused horizontal overflow" +} + function Read-SettingsPanelMetrics { param([string]$Session) @@ -3799,6 +3887,47 @@ JSON.stringify((() => { return Invoke-BrowserEvalJson -Session $Session -Script $script } +function Assert-ConversationStreamingResponsiveness { + param([string]$Session) + + Invoke-AgentBrowser -Arguments @("--session", $Session, "wait", "3000") | Out-Null + $before = Invoke-BrowserEvalJson -Session $Session -Script @' +JSON.stringify((() => { + const status = document.querySelector('[data-testid="conversation-streaming-stress-status"]'); + return { + found: Boolean(status), + updateCount: Number.parseInt(status?.getAttribute('data-update-count') || '0', 10), + heartbeatCount: Number.parseInt(status?.getAttribute('data-heartbeat-count') || '0', 10), + maxHeartbeatLagMs: Number.parseInt(status?.getAttribute('data-max-heartbeat-lag-ms') || '0', 10), + actionCount: Number.parseInt(status?.getAttribute('data-action-count') || '0', 10), + mountedConversationItems: document.querySelectorAll('.conversation-list > .conversation-item').length, + totalMessageCount: Number.parseInt(document.querySelector('.conversation-list')?.getAttribute('data-message-count') || '0', 10) + }; +})()) +'@ + Assert-True ($before.found -eq $true) "streaming stress fixture status is missing" + Assert-True ([int]$before.updateCount -ge 20) "streaming stress fixture did not sustain live message updates" + Assert-True ([int]$before.heartbeatCount -ge 20) "streaming stress fixture event-loop heartbeat stopped" + Assert-True ([int]$before.maxHeartbeatLagMs -le 250) "streaming updates blocked the UI event loop for $($before.maxHeartbeatLagMs) ms" + Assert-True ([int]$before.totalMessageCount -ge 1500) "streaming stress fixture did not exercise a dense active turn" + Assert-True ([int]$before.mountedConversationItems -le 48) "streaming stress fixture mounted too many conversation items" + + Invoke-AgentBrowser -Arguments @("--session", $Session, "click", '[data-testid="conversation-streaming-stress-action"]') | Out-Null + Invoke-AgentBrowser -Arguments @("--session", $Session, "wait", "150") | Out-Null + $after = Invoke-BrowserEvalJson -Session $Session -Script @' +JSON.stringify((() => { + const status = document.querySelector('[data-testid="conversation-streaming-stress-status"]'); + return { + actionCount: Number.parseInt(status?.getAttribute('data-action-count') || '0', 10), + updateCount: Number.parseInt(status?.getAttribute('data-update-count') || '0', 10) + }; +})()) +'@ + Assert-True ([int]$after.actionCount -eq ([int]$before.actionCount + 1)) "streaming stress fixture did not accept a user action while output was active" + Assert-True ([int]$after.updateCount -gt [int]$before.updateCount) "streaming output stopped while the user interacted with the page" + Write-Step ("conversation streaming responsiveness -> " + ($before | ConvertTo-Json -Compress)) +} + function Read-ConversationLoadFailureFixtureMetrics { param([string]$Session) @@ -7116,6 +7245,12 @@ Assert-ThreadAttentionChromeSource Assert-Page -Page $trending -Name "github trending phone" -RequireTrendingHub Add-RegressionResult -Name "github-trending-phone" -Page $trending + $trendingFixtureUrl = $BaseUrl + "/#/__regression/docs-showcase?regression=frontend&view=github" + $trendingFixture = Open-And-ReadPage -Session $session -Url $trendingFixtureUrl -Width $PhoneWidth -Height $PhoneHeight + Assert-Page -Page $trendingFixture -Name "github trending compact fixture phone" -RequireTrendingHub + Assert-GithubTrendingCompactLayout -Session $session + Add-RegressionResult -Name "github-trending-compact-fixture-phone" -Page $trendingFixture + $diagnosticsPage = Open-And-ReadPage -Session $session -Url "$($BaseUrl)/diagnostics?regression=frontend" -Width $PhoneWidth -Height $PhoneHeight Assert-Page -Page $diagnosticsPage -Name "diagnostics phone" -RequiredText "Runtime Store" -RequireDiagnostics Add-RegressionResult -Name "diagnostics-phone" -Page $diagnosticsPage @@ -7224,6 +7359,12 @@ Assert-ThreadAttentionChromeSource Assert-ConversationFixture -Metrics (Read-ConversationFixtureMetrics -Session $session) -ViewportName "phone" Add-RegressionResult -Name "conversation-blocks-fixture-phone" -Page $fixturePhone + $streamingStressFixtureUrl = $BaseUrl + "/#/__regression/conversation-blocks?regression=frontend&streamStress=1" + $streamingStressFixture = Open-And-ReadPage -Session $session -Url $streamingStressFixtureUrl -Width $PhoneWidth -Height $PhoneHeight + Assert-Page -Page $streamingStressFixture -Name "conversation streaming stress fixture phone" + Assert-ConversationStreamingResponsiveness -Session $session + Add-RegressionResult -Name "conversation-streaming-stress-phone" -Page $streamingStressFixture + $imagePreviewFixtureUrl = $BaseUrl + "/#/__regression/conversation-blocks?regression=frontend&imagePreview=1" $imagePreviewFixture = Open-And-ReadPage -Session $session -Url $imagePreviewFixtureUrl -Width $PhoneWidth -Height $PhoneHeight Assert-Page -Page $imagePreviewFixture -Name "conversation image preview gestures fixture phone" diff --git a/scripts/verify-frontend-normalizers.mjs b/scripts/verify-frontend-normalizers.mjs index 286bd16..a4df94e 100644 --- a/scripts/verify-frontend-normalizers.mjs +++ b/scripts/verify-frontend-normalizers.mjs @@ -15,6 +15,7 @@ const normalizerImport = toImportPath(relative(outputRoot, join(repoRoot, 'src', const notificationReplayImport = toImportPath(relative(outputRoot, join(repoRoot, 'src', 'composables', 'notificationReplayCoordinator.ts'))) const connectionManagerImport = toImportPath(relative(outputRoot, join(repoRoot, 'src', 'composables', 'connectionManager.ts'))) const conversationViewportImport = toImportPath(relative(outputRoot, join(repoRoot, 'src', 'composables', 'conversationViewport.ts'))) +const conversationRenderPolicyImport = toImportPath(relative(outputRoot, join(repoRoot, 'src', 'composables', 'conversationRenderPolicy.ts'))) const runtimeSnapshotOrderingImport = toImportPath(relative(outputRoot, join(repoRoot, 'src', 'composables', 'runtimeSnapshotOrdering.ts'))) const runtimeExecutionRecoveryImport = toImportPath(relative(outputRoot, join(repoRoot, 'src', 'composables', 'runtimeExecutionRecovery.ts'))) const messageOutboxMergeImport = toImportPath(relative(outputRoot, join(repoRoot, 'src', 'composables', 'messageOutboxMerge.ts'))) @@ -52,6 +53,7 @@ import { conversationDistanceFromBottom, isConversationViewportAtBottom, } from '${conversationViewportImport}' +import { haveSameConversationMessageStructure } from '${conversationRenderPolicyImport}' import { shouldApplyRuntimeSnapshotVersion } from '${runtimeSnapshotOrderingImport}' import { isOptimisticOnlyExecutionEvidence } from '${runtimeExecutionRecoveryImport}' import { mergeMessageOutboxEntries, mergeMessageOutboxState } from '${messageOutboxMergeImport}' @@ -111,6 +113,8 @@ import { import { CX_SESSION_FILES_CHANGED_METHOD, getCxSessionFileChangeSyncPolicy, + getSessionLogAuthoritativeRefreshAction, + hasSettledSessionLogMessageEvidence, isCxSessionFilesChangedMethod, readCxSessionFileChangeOrigin, readCxSessionFileChangeSource, @@ -230,6 +234,18 @@ assert.deepEqual(splitCodexFileCitations('保留 :codex-file-citation{path="E:/u ]) assert.equal(CONVERSATION_BOTTOM_THRESHOLD_PX, 24) +assert.equal(haveSameConversationMessageStructure( + [{ id: 'one' }, { id: 'live', text: 'partial' }], + [{ id: 'one' }, { id: 'live', text: 'longer partial response' }], +), true) +assert.equal(haveSameConversationMessageStructure( + [{ id: 'one' }, { id: 'live' }], + [{ id: 'one' }, { id: 'tool' }, { id: 'live' }], +), false) +assert.equal(haveSameConversationMessageStructure( + [{ id: 'one' }, { id: 'two' }], + [{ id: 'two' }, { id: 'one' }], +), false) assert.equal(CX_SESSION_FILES_CHANGED_METHOD, 'cx/session-files/changed') assert.equal(isCxSessionFilesChangedMethod('cx/session-files/changed'), true) assert.equal(isCxSessionFilesChangedMethod('turn/completed'), false) @@ -259,6 +275,63 @@ assert.deepEqual( { refreshMessages: true, refreshThreads: true, preferSessionLogMessages: false }, ) assert.equal(getCxSessionFileChangeSyncPolicy('turn/completed', { source: 'session-log' }), null) +assert.equal(getSessionLogAuthoritativeRefreshAction({ + isSelected: true, + executionActive: true, + hasPendingServerRequest: false, + hasQueuedWork: false, + hasTerminalEvidence: false, +}), 'defer') +assert.equal(getSessionLogAuthoritativeRefreshAction({ + isSelected: true, + executionActive: false, + hasPendingServerRequest: true, + hasQueuedWork: false, + hasTerminalEvidence: true, +}), 'defer') +assert.equal(getSessionLogAuthoritativeRefreshAction({ + isSelected: true, + executionActive: false, + hasPendingServerRequest: false, + hasQueuedWork: true, + hasTerminalEvidence: true, +}), 'defer') +assert.equal(getSessionLogAuthoritativeRefreshAction({ + isSelected: true, + executionActive: false, + hasPendingServerRequest: false, + hasQueuedWork: false, + hasTerminalEvidence: false, +}), 'defer') +assert.equal(getSessionLogAuthoritativeRefreshAction({ + isSelected: true, + executionActive: false, + hasPendingServerRequest: false, + hasQueuedWork: false, + hasTerminalEvidence: true, +}), 'refresh') +assert.equal(getSessionLogAuthoritativeRefreshAction({ + isSelected: false, + executionActive: true, + hasPendingServerRequest: true, + hasQueuedWork: true, + hasTerminalEvidence: false, +}), 'skip') +assert.equal(hasSettledSessionLogMessageEvidence([ + { role: 'assistant', messageType: 'agentMessage', phase: 'final' }, + { role: 'user', messageType: 'userMessage' }, + { role: 'assistant', messageType: 'agentMessage', phase: 'commentary' }, +]), false) +assert.equal(hasSettledSessionLogMessageEvidence([ + { role: 'user', messageType: 'userMessage' }, + { role: 'assistant', messageType: 'agentMessage', phase: 'commentary' }, + { role: 'assistant', messageType: 'agentMessage', phase: 'final' }, +]), true) +assert.equal(hasSettledSessionLogMessageEvidence([ + { role: 'user', messageType: 'userMessage' }, + { role: 'assistant', messageType: 'agentMessage', phase: 'final' }, + { role: 'assistant', messageType: 'agentMessage', phase: 'commentary' }, +]), false) assert.equal(isOptimisticOnlyExecutionEvidence({ executionActive: true, sourceInProgress: false, @@ -375,6 +448,12 @@ assert.equal(shouldRestartNotificationStreamOnForeground({ notificationStale: true, hasSyncDemand: false, hasSelectedThread: true, +}), false) +assert.equal(shouldRestartNotificationStreamOnForeground({ + connectionState: 'connected', + notificationStale: true, + hasSyncDemand: true, + hasSelectedThread: true, }), true) assert.equal(shouldRestartNotificationStreamOnForeground({ connectionState: 'connected', @@ -740,6 +819,28 @@ assert.deepEqual( ).map((message) => message.id), ['persisted-identity-1', 'baseline-assistant', 'optimistic-user:identity-1', 'new-assistant'], ) +const persistedWithoutVisibleOptimistic = [ + { id: 'persisted-stable', role: 'assistant', text: 'Authoritative history' }, +] +const mergedWithoutVisibleOptimistic = mergeVisibleOptimisticUserMessages( + persistedWithoutVisibleOptimistic, + [], +) +assert.notStrictEqual( + mergedWithoutVisibleOptimistic, + persistedWithoutVisibleOptimistic, + 'conversation projection must not alias the persisted reactive message array', +) +mergedWithoutVisibleOptimistic.push({ + id: 'live-only', + role: 'assistant', + text: 'Streaming delta', +}) +assert.deepEqual( + persistedWithoutVisibleOptimistic.map((message) => message.id), + ['persisted-stable'], + 'adding a live projection must not mutate persisted history', +) const optimisticAfterFirst = { id: 'optimistic-user:after-first', role: 'user', text: 'First queued prompt' } const optimisticAfterLast = { id: 'optimistic-user:after-last', role: 'user', text: 'Second queued prompt' } assert.deepEqual( @@ -816,6 +917,10 @@ const projectedTurnZero = { } assert.equal(areMessageFieldsEqual(projectedTurnTwo, { ...projectedTurnTwo }), true) assert.equal(areMessageFieldsEqual(projectedTurnTwo, { ...projectedTurnTwo, text: 'changed' }), false) +assert.equal(areMessageFieldsEqual( + { ...projectedTurnTwo, messageType: 'agentMessage', phase: 'commentary' }, + { ...projectedTurnTwo, messageType: 'agentMessage', phase: 'final' }, +), false) assert.equal(areMessageFieldsEqual(projectedTurnTwo, { ...projectedTurnTwo, fileAttachments: [{ label: 'report', path: 'report.txt' }], @@ -1083,6 +1188,26 @@ assert.equal(messages.some((message) => message.messageType === 'unhandled.fileC assert.equal(messages.some((message) => message.messageType === 'unhandled.webSearch'), false) assert.equal(messages.some((message) => message.rawPayload?.includes('large internal patch details')), false) +const phasedAgentMessages = normalizeThreadMessagesV2({ + thread: { + id: 'thread-phased-agent', + cwd: 'E:\\repo', + preview: '', + updatedAt: 1, + createdAt: 1, + turns: [{ + id: 'turn-phased-agent', + status: 'completed', + items: [ + { id: 'agent-commentary', type: 'agentMessage', text: 'Still working', phase: 'commentary' }, + { id: 'agent-final', type: 'agentMessage', text: 'Done', phase: 'final' }, + ], + }], + }, +}) +assert.equal(phasedAgentMessages[0]?.phase, 'commentary') +assert.equal(phasedAgentMessages[1]?.phase, 'final') + const unloadedTurnMessages = normalizeThreadMessagesV2({ thread: { id: 'thread-items-view', diff --git a/src/api/normalizers/v2.ts b/src/api/normalizers/v2.ts index 433e479..ee6d869 100644 --- a/src/api/normalizers/v2.ts +++ b/src/api/normalizers/v2.ts @@ -262,6 +262,7 @@ function toUiMessages(item: ThreadItem, turnId = ''): UiMessage[] { if (item.type === 'agentMessage') { const text = typeof item.text === 'string' ? item.text : '' const images = extractAssistantImages(item) + const phase = rawItem.phase === 'commentary' ? 'commentary' as const : 'final' as const return [ { id: item.id, @@ -269,6 +270,7 @@ function toUiMessages(item: ThreadItem, turnId = ''): UiMessage[] { text, images: images.length > 0 ? images : undefined, messageType: item.type, + phase, }, ] } diff --git a/src/components/content/ConversationRegressionFixture.vue b/src/components/content/ConversationRegressionFixture.vue index 88805af..0114856 100644 --- a/src/components/content/ConversationRegressionFixture.vue +++ b/src/components/content/ConversationRegressionFixture.vue @@ -1,10 +1,31 @@ diff --git a/src/components/content/ThreadConversation.vue b/src/components/content/ThreadConversation.vue index 1389fbb..3ac6067 100644 --- a/src/components/content/ThreadConversation.vue +++ b/src/components/content/ThreadConversation.vue @@ -1295,6 +1295,7 @@ import { CONVERSATION_BOTTOM_THRESHOLD_PX, isConversationViewportAtBottom, } from '../../composables/conversationViewport' +import { haveSameConversationMessageStructure } from '../../composables/conversationRenderPolicy' import { hasPlanImplementationConfirmation } from '../../composables/conversationProjection' import { copyTextToClipboard } from '../../utils/clipboard' import { @@ -1313,7 +1314,7 @@ const prevCommandStatuses = ref>({}) const commandElapsedNowMs = ref(Date.now()) const observedCommandStartedAtById = ref>({}) let commandElapsedTimer: number | null = null -const estimatedMessageHeightById = new Map() +let estimatedMessageHeightByMessage = new WeakMap() let chatFeedbackMetricFrame = 0 type ThreadFirstScreenReadyMetric = { @@ -1721,7 +1722,6 @@ const REMOTE_OLDER_HISTORY_REQUEST_TIMEOUT_MS = 8000 const RECENT_DERIVED_UI_MESSAGE_LIMIT = 120 const REACTIVE_WATCH_MESSAGE_LIMIT = RECENT_DERIVED_UI_MESSAGE_LIMIT * 2 const PREPARED_MESSAGE_BLOCK_CACHE_LIMIT = 80 -const ESTIMATED_MESSAGE_HEIGHT_CACHE_LIMIT = 240 const renderableMessages = computed(() => ( props.messages.filter((message) => !shouldSuppressConversationMessage(message)) )) @@ -2850,36 +2850,16 @@ function estimatedMessageHeightSignature(message: UiMessage): string { ].join('|') } -function trimEstimatedMessageHeightCache(): void { - while (estimatedMessageHeightById.size > ESTIMATED_MESSAGE_HEIGHT_CACHE_LIMIT) { - const oldestMessageId = estimatedMessageHeightById.keys().next().value - if (typeof oldestMessageId !== 'string') return - estimatedMessageHeightById.delete(oldestMessageId) - } -} - -function pruneEstimatedMessageHeightCache(keepIds: Set): void { - for (const messageId of estimatedMessageHeightById.keys()) { - if (!keepIds.has(messageId)) { - estimatedMessageHeightById.delete(messageId) - } - } - trimEstimatedMessageHeightCache() -} - function getEstimatedMessageHeight(message: UiMessage): number { const sourceText = estimatedMessageHeightSourceText(message) const signature = estimatedMessageHeightSignature(message) - const cached = estimatedMessageHeightById.get(message.id) + const cached = estimatedMessageHeightByMessage.get(message) if (cached && cached.sourceText === sourceText && cached.signature === signature) { - estimatedMessageHeightById.delete(message.id) - estimatedMessageHeightById.set(message.id, cached) return cached.height } const height = estimateMessageHeight(message) - estimatedMessageHeightById.set(message.id, { sourceText, signature, height }) - trimEstimatedMessageHeightCache() + estimatedMessageHeightByMessage.set(message, { sourceText, signature, height }) return height } @@ -4126,7 +4106,6 @@ function trimPreparedMessageBlockCache(): void { function prunePreparedMessageBlockCache(messages: UiMessage[]): void { const keepIds = new Set(messages.map((message) => message.id)) - pruneEstimatedMessageHeightCache(keepIds) for (const messageId of preparedMessageBlocksById.keys()) { if (!keepIds.has(messageId)) { preparedMessageBlocksById.delete(messageId) @@ -5561,9 +5540,10 @@ watch( () => props.messages, async (next, previous) => { const watchedMessages = messagesForReactiveWatch(next) + const previousMessages = previous ?? EMPTY_MESSAGES + const messageStructureChanged = !haveSameConversationMessageStructure(previousMessages, next) syncObservedCommandStartTimes(watchedMessages) if (props.isLoading && !hasVisibleConversationContent.value) return - const previousMessages = previous ?? EMPTY_MESSAGES const foregroundScrollIntent = pendingForegroundScrollIntent?.threadId === props.activeThreadId ? pendingForegroundScrollIntent : null @@ -5583,8 +5563,10 @@ watch( prevCommandStatuses.value[m.id] = cur } - prunePreparedMessageBlockCache(next) - pruneMeasuredMessageHeights(renderableConversationEntries.value) + if (messageStructureChanged) { + prunePreparedMessageBlockCache(next) + pruneMeasuredMessageHeights(renderableConversationEntries.value) + } const hasNewRenderableOutput = previousMessages.length > 0 && renderableMessageSignature(next) !== renderableMessageSignature(previousMessages) @@ -5593,7 +5575,10 @@ watch( markBelowFoldUpdate() } - if (!threadSwitchScrollRestorePending) { + if ( + !threadSwitchScrollRestorePending && + (messageStructureChanged || foregroundScrollIntent !== null) + ) { await scheduleScrollRestore(shouldFollowBottom, foregroundScrollIntent?.anchorSnapshot ?? null) } }, @@ -5777,7 +5762,7 @@ watch( closeFileLinkContextMenu() failedMarkdownImageKeys.value = new Set() preparedMessageBlocksById.clear() - estimatedMessageHeightById.clear() + estimatedMessageHeightByMessage = new WeakMap() disconnectAllObservedElements(observedMessageElementsById) measuredMessageHeightById.value = {} expandedGuidedTurnIndexes.value = new Set() diff --git a/src/composables/connectionManager.ts b/src/composables/connectionManager.ts index 498ff40..46d4a65 100644 --- a/src/composables/connectionManager.ts +++ b/src/composables/connectionManager.ts @@ -137,7 +137,7 @@ export function shouldRestartNotificationStreamOnForeground(input: { return ( input.connectionState === 'connected' && input.notificationStale && - (input.hasSyncDemand || input.hasSelectedThread) + input.hasSyncDemand ) } diff --git a/src/composables/conversationProjection.ts b/src/composables/conversationProjection.ts index 7a21bbd..59868ac 100644 --- a/src/composables/conversationProjection.ts +++ b/src/composables/conversationProjection.ts @@ -88,6 +88,7 @@ export function areMessageFieldsEqual(first: UiMessage, second: UiMessage): bool areStringArraysEqual(first.images, second.images) && areFileAttachmentsEqual(first.fileAttachments, second.fileAttachments) && first.messageType === second.messageType && + first.phase === second.phase && first.rawPayload === second.rawPayload && first.isUnhandled === second.isUnhandled && areCommandExecutionsEqual(first.commandExecution, second.commandExecution) && diff --git a/src/composables/conversationRenderPolicy.ts b/src/composables/conversationRenderPolicy.ts new file mode 100644 index 0000000..f9789a2 --- /dev/null +++ b/src/composables/conversationRenderPolicy.ts @@ -0,0 +1,14 @@ +export type ConversationMessageIdentity = { + id: string +} + +export function haveSameConversationMessageStructure( + previous: readonly ConversationMessageIdentity[], + next: readonly ConversationMessageIdentity[], +): boolean { + if (previous.length !== next.length) return false + for (let index = 0; index < next.length; index += 1) { + if (previous[index]?.id !== next[index]?.id) return false + } + return true +} diff --git a/src/composables/messageIdentity.ts b/src/composables/messageIdentity.ts index 207960c..e7eb659 100644 --- a/src/composables/messageIdentity.ts +++ b/src/composables/messageIdentity.ts @@ -78,7 +78,7 @@ export function mergeVisibleOptimisticUserMessages( rememberedMetaById?: ReadonlyMap, ): UiMessage[] { const visible = filterVisibleOptimisticUserMessages(persisted, optimistic, rememberedMetaById) - if (visible.length === 0) return persisted + if (visible.length === 0) return [...persisted] const insertionsByPersistedIndex = new Map() for (const message of visible) { diff --git a/src/composables/useDesktopState.ts b/src/composables/useDesktopState.ts index 5bde0d6..a3d0883 100644 --- a/src/composables/useDesktopState.ts +++ b/src/composables/useDesktopState.ts @@ -99,7 +99,11 @@ import type { import { normalizeThreadGoal } from './threadGoal' import { isAbortLikeError } from '../api/codexErrors' import { normalizePathForUi, toProjectName } from '../pathUtils.js' -import { getCxSessionFileChangeSyncPolicy } from '../sessionFileChange' +import { + getCxSessionFileChangeSyncPolicy, + getSessionLogAuthoritativeRefreshAction, + hasSettledSessionLogMessageEvidence, +} from '../sessionFileChange' import { areUiThreadFieldsEqual, dedupeProjectThreadGroups, @@ -7314,8 +7318,21 @@ export function useDesktopState(submitCallbacks: DesktopStateSubmitCallbacks = { sessionLogAuthoritativeRefreshGenerationByThreadId.set(threadId, generation) window.setTimeout(() => { if (sessionLogAuthoritativeRefreshGenerationByThreadId.get(threadId) !== generation) return + const action = getSessionLogAuthoritativeRefreshAction({ + isSelected: selectedThreadId.value === threadId, + executionActive: isThreadExecutionActive(threadId), + hasPendingServerRequest: hasPendingServerRequestSignal(threadId), + hasQueuedWork: hasQueuedThreadWork(threadId), + hasTerminalEvidence: hasSettledSessionLogMessageEvidence( + persistedMessagesByThreadId.value[threadId] ?? [], + ), + }) + if (action === 'defer') { + scheduleSessionLogAuthoritativeRefresh(threadId) + return + } sessionLogAuthoritativeRefreshGenerationByThreadId.delete(threadId) - if (selectedThreadId.value !== threadId) return + if (action === 'skip') return void loadMessages(threadId, { silent: true, forceSettledRpcRefresh: true }).catch((error) => { if (!isAbortLikeError(error)) setSyncErrorFromUnknown(error) }) diff --git a/src/sessionFileChange.ts b/src/sessionFileChange.ts index 0df4821..37fde4a 100644 --- a/src/sessionFileChange.ts +++ b/src/sessionFileChange.ts @@ -9,6 +9,51 @@ export type CxSessionFileChangeSyncPolicy = { refreshThreads: boolean } +export type SessionLogAuthoritativeRefreshState = { + isSelected: boolean + executionActive: boolean + hasPendingServerRequest: boolean + hasQueuedWork: boolean + hasTerminalEvidence: boolean +} + +export type SessionLogAuthoritativeRefreshAction = 'skip' | 'defer' | 'refresh' + +export function getSessionLogAuthoritativeRefreshAction( + state: SessionLogAuthoritativeRefreshState, +): SessionLogAuthoritativeRefreshAction { + if (!state.isSelected) return 'skip' + if (state.executionActive || state.hasPendingServerRequest || state.hasQueuedWork) return 'defer' + if (!state.hasTerminalEvidence) return 'defer' + return 'refresh' +} + +export type SessionLogMessageEvidence = { + role: 'user' | 'assistant' | 'system' + messageType?: string + phase?: 'commentary' | 'final' +} + +export function hasSettledSessionLogMessageEvidence( + messages: readonly SessionLogMessageEvidence[], +): boolean { + let latestUserIndex = -1 + let latestAssistantIndex = -1 + let latestAssistantIsFinal = false + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index] + if (message?.role === 'user') { + latestUserIndex = index + continue + } + if (message?.role === 'assistant' && message.messageType === 'agentMessage') { + latestAssistantIndex = index + latestAssistantIsFinal = message.phase !== 'commentary' + } + } + return latestAssistantIndex > latestUserIndex && latestAssistantIsFinal +} + export function isCxSessionFilesChangedMethod(method: string): boolean { return method === CX_SESSION_FILES_CHANGED_METHOD } diff --git a/src/types/codex.ts b/src/types/codex.ts index 894ccd5..8eec01d 100644 --- a/src/types/codex.ts +++ b/src/types/codex.ts @@ -207,6 +207,7 @@ export type UiMessage = { images?: string[] fileAttachments?: UiFileAttachment[] messageType?: string + phase?: 'commentary' | 'final' rawPayload?: string isUnhandled?: boolean commandExecution?: CommandExecutionData diff --git a/tests.md b/tests.md index 189d8ac..3c81e70 100644 --- a/tests.md +++ b/tests.md @@ -15833,3 +15833,57 @@ Current evidence: - npm `10.9.3` audited 131 production dependencies through the official registry with zero info, low, moderate, high or critical vulnerabilities. - Pull requests #46 and #47 were reviewed against current main and integrated with contributor authorship preserved. Dependency pull requests #49, #50 and #51 remain outside this stability release because they change PDF/WebView rendering, Markdown rendering or the native SQLite runtime and need dedicated regression evidence. - Final clean-worktree release verification, tag workflow, public asset checks, isolated Windows installation and physical Android process-death verification are not satisfied by this pre-release evidence and must be reported separately. + +## GitHub 热门紧凑双列布局(2026-08-07) + +1. 在 `393 x 852` 视口打开 `/#/__regression/docs-showcase?regression=frontend&view=github`,页面内容区不得再出现重复的 GitHub 热门大标题卡;榜单选择和刷新操作应位于不高于 `52px` 的紧凑筛选栏内。 +2. 默认列表必须每行显示两个等高卡片;卡片高度保持 `17rem`,标题最多两行,简介最多三行,页面不得产生横向滚动。 +3. 点击任一卡片的“展开”后,只允许该卡片跨满当前行并显示完整简介和仓库地址;其余卡片顺延至下一行,不得重叠或跳出屏幕。 +4. 展开按钮必须同步 `aria-expanded=true` 并改为“收起”;再次操作应恢复默认固定高度。筛选下拉和刷新按钮应保留明确的无障碍名称与至少 `36px` 控件高度,卡片主操作保持 `44px` 触控高度。 + +Verification: + +- Run `npm.cmd run build:frontend`. +- Run `npm.cmd run test:7420:frontend` for the deterministic compact-grid, summary-clamp, expand-state and horizontal-overflow assertions. +- Capture a Headless Playwright screenshot at `393 x 852` and inspect both collapsed and expanded states before publishing UI changes. + +## 移动端执行中会话响应与跨进程同步(2026-08-07) + +1. 在另一个 Codex 桌面进程持续写入长会话时,用 `393 x 852` 视口打开同一会话。阶段回复应由有界 session-log 投影持续更新;尾部最新助手消息仍是 `commentary` 时,不得自动触发 `thread/read(includeTurns=true)`。 +2. 同一会话文件中允许并发任务交错写入。中间出现另一个任务的 `final` 后,只要尾部仍有当前任务的 `commentary`,就必须继续延期权威收敛读取;仅当最新助手消息本身为 `final`,且没有运行、排队或待确认信号时才允许一次收敛读取。 +3. 在 `/#/__regression/conversation-blocks?regression=frontend&streamStress=1` 下加载至少 1500 条同轮消息并每 48ms 更新流式回复。3 秒内更新与心跳均不少于 20 次,最大心跳延迟不高于 250ms,实际挂载消息节点不超过 48 个。 +4. 压力期间点击响应测试按钮必须立即生效,随后流式更新继续增长;页面不得横向溢出,也不得破坏会话切换滚动位置、前后台恢复意图、排队消息和等待详情交互。 + +Verification: + +- Run `npm.cmd run verify:frontend-normalizers` and `npm.cmd run verify:server-modules`. +- Run `npm.cmd run build:frontend` and `npm.cmd run build:cli`. +- Run `npm.cmd run test:7420:frontend -- -BaseUrl http://127.0.0.1:7424` against an isolated production server. +- Open the real active cross-process conversation and monitor `/codex-api/health`: session-log updates must remain visible while the observation window records zero `thread/read(includeTurns=true)`, zero queued RPCs and zero pending server requests. + +Current evidence: + +- The real long conversation previously produced a 1.5-1.7 second full-history read every 4-8 seconds. After requiring the latest assistant item itself to be terminal, a 36-second steady observation and a second explicit session-file append observation both recorded zero full reads and zero RPC queueing while new commentary appeared in the page. +- The focused phone stress probe rendered 1602 messages with 13 mounted conversation items, 48ms live updates, 50ms maximum heartbeat lag, a successful user action during streaming and no horizontal overflow. +- The complete production frontend regression passed in 462.5 seconds. Its independent stress pass measured 1602 messages, 13 mounted items and 57ms maximum heartbeat lag; composer, sidebar, image/PDF, scroll restoration, foreground recovery and task-pet scenarios also passed. + +## CX-Codex 2.7.6 conversation stability and release retention (2026-08-07) + +1. Merging live messages into the visible conversation must never mutate `persistedMessagesByThreadId`, including when no optimistic user message remains visible. +2. A connected notification stream that is merely quiet while an idle conversation is selected must stay connected. Disconnected/reconnecting streams and stale streams with real synchronization demand must still restart. +3. The 1602-message streaming fixture must mount no more than 48 conversation items, keep maximum heartbeat lag at or below 250ms, accept an action during output, continue updating afterward and avoid horizontal overflow. +4. Release retention must preserve Git tags and commit history. Only GitHub Release objects outside the documented stable/rollback/history set may be deleted. + +Verification: + +- Run `npm.cmd run verify:frontend-normalizers`, `npm.cmd run build:frontend`, `npm.cmd run build:cli` and `npm.cmd run verify:server-modules`. +- Run `npm.cmd run test:7420:frontend -- -BaseUrl http://127.0.0.1:7424 -CaptureScreenshots` against an isolated production server. +- Run a headless Playwright probe at 393 × 852 against the streaming fixture and require zero console errors and failed requests. +- Run `npm.cmd run verify:release -- -RequireCleanGit -SchemaAudit skip` from the final clean candidate worktree before tagging. + +Current evidence: + +- The new immutable-projection test failed before the fix because `mergeVisibleOptimisticUserMessages` returned the persisted array by reference; it passes after returning an independent projection. +- The foreground restart-policy test failed before the fix because a selected idle conversation forced a restart; it passes after requiring actual sync demand for a connected stale stream. +- The full 38-surface regression passed in 499.7 seconds. The built-in stress probe mounted 13 of 1602 messages with 64ms maximum lag and accepted interaction while output continued. +- Independent headless Playwright mounted 12 of 1602 messages with 47ms maximum lag; the action count increased by one, updates continued from 76 to 84, and console/request failure lists were empty.