From 1a839823efc039a1c09b2df99729b59e845b8f2d Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Fri, 2 Oct 2026 21:38:33 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(web-ui):=20=E4=BF=AE=E5=A4=8D=E3=80=8C?= =?UTF-8?q?=E6=97=A7=E5=9B=9E=E5=A4=8D=E6=B0=94=E6=B3=A1=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E5=87=BA=E7=8E=B0=E5=9C=A8=E6=9C=80=E6=96=B0=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E4=B9=8B=E5=90=8E=E3=80=8D=E2=80=94=20=E5=A4=B1=E6=95=88?= =?UTF-8?q?=E7=9A=84=20streaming=20=E5=B0=BE=E5=B7=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象(2026-10-02 老板报告):Team Chat 里一条更早的 Agent 回复气泡,重新出现在 最新消息之后(重复气泡,且携带乐观发送时间戳,例如 21:09:26 而 DB 行是 21:10:23)。 根因(三个环节互相咬合): 1. ConversationBufferManager.mergeDbWithCache 把**任何**缓存里 isStreaming 的 Agent 气泡无条件追加到最后(当作「直播尾巴」)。其 rawCreatedAt 是乐观发送时间(早于 该轮 user 消息的落库时间),因此无论怎么排都会落在错误位置。 2. 该 stale 气泡的 id 是乐观本地 id,与 DB 持久化 id 不同 → byId 去重失效 → 重复。 3. isCacheFresher 用「条数/文本长度/段数」判断缓存是否更新。多出来的这条 stale 行 让缓存**永远显得比 DB 新** → DB(排序权威)永远无法覆盖它 → 自我延续。 根因来源:同一条消息被写入两处缓冲(显示 buffer 与 session cache),而流结束时 finalize 只更新被路由到的那一个,另一个残留 isStreaming: true 的幽灵。 修复(收敛为一条不变量:**只有本会话真的有流在跑时,缓存里的 isStreaming 气泡才 算权威;一旦空闲,DB 是唯一权威**): - 新增 ConversationBufferManager.isStreamLiveForSession():streamingSessions 是正向 归属记录;streaming 阶段下「空集合」是 beginStream 早于 session 注册的窗口,不能 读作「没有流」,否则会误杀在飞的占位气泡。 - mergeDbWithCache(db, cache, streamLive):非 live 时丢弃缓存里的 isStreaming 行, 不再当作尾巴追加。 - isCacheFresher(sid, dbMsgs, streamLive):非 live 时把 isStreaming 行排除在新鲜度 比较之外,使权威 DB 列表能正常覆盖陈旧缓存(解除自我延续)。 - restoreFromCache():切会话恢复显示时同样剔除 stale streaming 行,避免幽灵立刻 回到视图。 - applyLoadResult():计算一次 streamLive 并贯穿上述两处。 验证: - ConversationBufferManager 16/16 通过(含 3 个新增回归:stale 尾巴不追加、别的会话 流式时本会话幽灵被丢弃、restoreFromCache 不复活幽灵;并把 2 个断言旧行为的用例 改写为新不变量下的真实保护行为)。 - web-ui 全量 642/642 通过(37 文件);tsc --noEmit 0 错误。 --- .../src/lib/ConversationBufferManager.test.ts | 108 ++++++++++++++---- .../src/lib/ConversationBufferManager.ts | 72 ++++++++++-- 2 files changed, 147 insertions(+), 33 deletions(-) diff --git a/packages/web-ui/src/lib/ConversationBufferManager.test.ts b/packages/web-ui/src/lib/ConversationBufferManager.test.ts index f63f21f4..9c665c56 100644 --- a/packages/web-ui/src/lib/ConversationBufferManager.test.ts +++ b/packages/web-ui/src/lib/ConversationBufferManager.test.ts @@ -41,64 +41,122 @@ describe('ConversationBufferManager.applyLoadResult cache merge', () => { expect(ids).toEqual(['u1', 'a2']); }); - it('merges cache-only streaming tail after DB loads', () => { + it('does NOT touch the display while THIS session is streaming (in-flight bubble preserved)', () => { + // While this session's stream runs, the stream owns the view: a DB snapshot + // arriving mid-stream must not clobber the live bubble. This is why the merge + // (and its tail handling) is deliberately not reached in that state. const mgr = new ConversationBufferManager(); mgr.currentConvKey = 'conv'; mgr.setActiveSession('conv', 'sess_1'); mgr.loadingSession = 'sess_1'; + mgr.beginStream('conv'); + mgr.addStreamSession('conv', 'sess_1'); mgr.updateMessages( 'conv', () => [ msg('u1', 'user', 'hello', '2026-08-02T07:04:00.000Z'), - { ...msg('tail', 'agent', 'streaming tail not yet in DB', '2026-08-02T07:06:00.000Z'), isStreaming: true }, + { ...msg('live', 'agent', 'streaming…', '2026-08-02T07:04:00.000Z'), isStreaming: true }, ], 'sess_1', ); - const dbMsgs = [ + const r = mgr.applyLoadResult('conv', 'sess_1', [ msg('u1', 'user', 'hello', '2026-08-02T07:04:00.000Z'), - msg('a2', 'agent', 'committed reply', '2026-08-02T07:05:00.000Z'), - ]; - - const r = mgr.applyLoadResult('conv', 'sess_1', dbMsgs); - const ids = r.newMessages!.map(m => m.id); - expect(ids).toEqual(['u1', 'a2', 'tail']); + ]); + // No display write → the in-flight bubble in the display buffer is untouched. + expect(r.displayChanged).toBe(false); }); - it('regression: streaming tail with optimistic (earlier) timestamp never lands above the user message', () => { - // Multi-tab direct mode: user sends M in tab A, switches to tab B, then back - // to A while the agent is still streaming. On return the DB already persisted - // M (server time LATER than the optimistic send-time), but the agent reply is - // cache-only with an OPTIMISTIC rawCreatedAt (earlier). Chronological merge - // previously inserted the streaming agent bubble BEFORE the persisted user - // message — "user bubble appears below the agent streaming bubble". + it('regression: a STALE streaming bubble (no live stream) is dropped, not appended after the newest message', () => { + // 2026-10-02 report: an OLD reply bubble re-appeared AFTER the newest + // message. Mechanism: the stream for an earlier turn had already finished + // and its reply was persisted (DB row cm_real), but the local copy kept + // `isStreaming: true` in the session cache under a DIFFERENT (optimistic) + // id. The merge appended it as a "live tail" — a duplicate of an OLD reply + // at the bottom of the thread — and because that extra row also made the + // cache look "fresher", the authoritative DB list could never replace it + // (self-perpetuating). With no stream in flight the DB is the sole + // authority, so the stale row must be dropped. const mgr = new ConversationBufferManager(); mgr.currentConvKey = 'conv'; mgr.setActiveSession('conv', 'sess_1'); mgr.loadingSession = 'sess_1'; + // NOTE: no beginStream/addStreamSession — the stream is long over. - // Cache: user M (server id) + still-streaming agent tail. The tail's - // rawCreatedAt is the optimistic send-time (EARLIER than DB persist time). + // Stale cache: the optimistic in-flight bubble of an ALREADY-FINISHED turn. + // Its rawCreatedAt is the optimistic send-time (OLDER than the DB row), so it + // can never be ordered correctly either. mgr.updateMessages( 'conv', () => [ - msg('u1', 'user', 'hello', '2026-08-02T07:05:00.000Z'), - { ...msg('tail', 'agent', 'partial stream', '2026-08-02T07:04:30.000Z'), isStreaming: true }, + msg('u1', 'user', '盘一下改动', '2026-08-02T07:04:00.000Z'), + { ...msg('agent_optimistic', 'agent', '老板,盘完了。可以发——', '2026-08-02T07:04:00.000Z'), isStreaming: true }, ], 'sess_1', ); - // DB only has the persisted user message (agent reply not flushed yet). + // DB: the authoritative, correctly ordered rows — the reply IS persisted. const dbMsgs = [ - msg('u1', 'user', 'hello', '2026-08-02T07:05:00.000Z'), + msg('u1', 'user', '盘一下改动', '2026-08-02T07:04:00.000Z'), + msg('cm_real', 'agent', '老板,盘完了。可以发——', '2026-08-02T07:05:00.000Z'), ]; const r = mgr.applyLoadResult('conv', 'sess_1', dbMsgs); const ids = r.newMessages!.map(m => m.id); - // User bubble MUST come before the streaming agent tail. - expect(ids.indexOf('u1')).toBeLessThan(ids.indexOf('tail')); - expect(ids).toEqual(['u1', 'tail']); + // Exactly the DB rows, in DB order — the stale streaming copy is gone. + expect(ids).toEqual(['u1', 'cm_real']); + expect(ids).not.toContain('agent_optimistic'); + }); + + it('regression: restoreFromCache never resurrects a stale streaming bubble into the view', () => { + const mgr = new ConversationBufferManager(); + mgr.currentConvKey = 'conv'; + mgr.setActiveSession('conv', 'sess_1'); + // No stream in flight. + mgr.updateMessages( + 'conv', + () => [ + msg('u1', 'user', 'hi', '2026-08-02T07:04:00.000Z'), + { ...msg('agent_stale', 'agent', 'old streaming ghost', '2026-08-02T07:04:00.000Z'), isStreaming: true }, + ], + 'sess_1', + ); + + const restored = mgr.restoreFromCache('conv', 'sess_1')!; + expect(restored.map(m => m.id)).toEqual(['u1']); + // And the display buffer must not carry the ghost either. + expect(mgr.msgBuffers.get('conv')!.some(m => m.id === 'agent_stale')).toBe(false); + }); + + it('regression: when ANOTHER session streams, this session\u2019s stale streaming ghost is dropped, not appended', () => { + // Multi-tab direct mode: session A streams (phase is per-convKey → 'streaming'), + // the user loads session B. B's OWN cache carries a leftover isStreaming bubble + // from a stream that already ended. It must never be appended as a "live tail": + // the DB is the authority for B, whose stream is NOT running. + const mgr = new ConversationBufferManager(); + mgr.currentConvKey = 'conv'; + mgr.beginStream('conv'); + mgr.addStreamSession('conv', 'sess_a'); + mgr.setActiveSession('conv', 'sess_b'); + mgr.loadingSession = 'sess_b'; + + mgr.updateMessages( + 'conv', + () => [ + msg('u1', 'user', 'hello', '2026-08-02T07:05:00.000Z'), + { ...msg('ghost', 'agent', 'stale tail', '2026-08-02T07:04:30.000Z'), isStreaming: true }, + ], + 'sess_b', + ); + + const r = mgr.applyLoadResult('conv', 'sess_b', [ + msg('u1', 'user', 'hello', '2026-08-02T07:05:00.000Z'), + msg('a1', 'agent', 'committed reply', '2026-08-02T07:06:00.000Z'), + ]); + const ids = r.newMessages!.map(m => m.id); + expect(ids).toEqual(['u1', 'a1']); + expect(ids).not.toContain('ghost'); }); it('allows a DB load for a session while ANOTHER session of the same agent is streaming (phase is per-convKey, guard is per-session)', () => { diff --git a/packages/web-ui/src/lib/ConversationBufferManager.ts b/packages/web-ui/src/lib/ConversationBufferManager.ts index d5f167c2..6fb9bdfd 100644 --- a/packages/web-ui/src/lib/ConversationBufferManager.ts +++ b/packages/web-ui/src/lib/ConversationBufferManager.ts @@ -173,7 +173,15 @@ export class ConversationBufferManager { msgs: ChatMsg[], ): BufferWriteResult { const cache = this.sessionMsgCache.get(sessionId); - const cacheIsFresher = this.isCacheFresher(sessionId, msgs); + // A cached `isStreaming` bubble is authoritative ONLY while a stream is in + // flight for THIS session. Once no stream is live it is stale local state — + // the finalize ran on whichever buffer was routed and left the other one + // behind. Both the freshness heuristic and the merge must ignore it, or the + // stale row keeps the cache looking newer than the DB forever and the + // authoritative list never gets to replace it (2026-10-02 report: an OLD + // reply bubble re-appearing AFTER the newest message). + const streamLive = this.isStreamLiveForSession(convKey, sessionId); + const cacheIsFresher = this.isCacheFresher(sessionId, msgs, streamLive); if (!cacheIsFresher) { this.sessionMsgCache.set(sessionId, msgs); this.touchSessionCache(sessionId); @@ -207,7 +215,11 @@ export class ConversationBufferManager { // the assistant reply). A fresher cache may only add live-tail messages // that the DB does not have yet — it must never hide DB user messages, // which is what caused "user bubble after the agent reply" until refresh. - const displayMsgs = this.mergeDbWithCache(msgs, cacheIsFresher ? cache : undefined); + const displayMsgs = this.mergeDbWithCache( + msgs, + cacheIsFresher ? cache : undefined, + streamLive, + ); this.msgBuffers.set(convKey, displayMsgs); this.loadingSession = sessionId; this.completeLoad(convKey); @@ -232,7 +244,11 @@ export class ConversationBufferManager { * bubble is by definition the latest in-flight response, so the tail is * authoritative regardless of timestamps. */ - private mergeDbWithCache(dbMsgs: ChatMsg[], cache?: ChatMsg[]): ChatMsg[] { + private mergeDbWithCache( + dbMsgs: ChatMsg[], + cache?: ChatMsg[], + streamLive = false, + ): ChatMsg[] { if (!cache || cache.length === 0) return [...dbMsgs]; const byId = new Set(); const out: ChatMsg[] = []; @@ -247,6 +263,13 @@ export class ConversationBufferManager { for (const cm of cache) { if (byId.has(cm.id)) continue; if (cm.sender === 'agent' && cm.isStreaming) { + // Only a LIVE stream may contribute a tail. With no stream running for + // this session, an `isStreaming` cache row is stale local state: its turn + // is either already persisted in the DB or already finalized locally, so + // keeping it strands a duplicate of an OLD reply at the bottom of the + // thread (its rawCreatedAt is the optimistic send time, so it can never + // be ordered correctly either). The DB is the sole authority once idle. + if (!streamLive) continue; streamingTail.push(cm); byId.add(cm.id); } else { @@ -319,16 +342,33 @@ export class ConversationBufferManager { restoreFromCache(key: string, sessionId: string): ChatMsg[] | undefined { const cached = this.sessionMsgCache.get(sessionId); if (cached && cached.length > 0) { - this.msgBuffers.set(key, cached); - return cached; + // Never restore a stale streaming row into the view: it would render an old + // duplicate bubble at the tail until the next DB load heals it. + const live = this.isStreamLiveForSession(key, sessionId); + const usable = live + ? cached + : cached.filter(m => !(m.sender === 'agent' && m.isStreaming && !m.isStopped)); + if (usable.length === 0) { + this.msgBuffers.delete(key); + return undefined; + } + this.msgBuffers.set(key, usable); + return usable; } this.msgBuffers.delete(key); return undefined; } - isCacheFresher(sessionId: string, dbMsgs: ChatMsg[]): boolean { - const cache = this.sessionMsgCache.get(sessionId); - if (!cache || cache.length === 0) return false; + isCacheFresher(sessionId: string, dbMsgs: ChatMsg[], streamLive = false): boolean { + const raw = this.sessionMsgCache.get(sessionId); + if (!raw || raw.length === 0) return false; + // Stale streaming rows must not count as freshness evidence: the extra row + // they carry is exactly what makes the cache look newer than the DB forever + // and stops the authoritative DB list from ever replacing it. + const cache = streamLive + ? raw + : raw.filter(m => !(m.sender === 'agent' && m.isStreaming && !m.isStopped)); + if (cache.length === 0) return false; if (cache.length > dbMsgs.length) return true; const cacheTextLen = cache.reduce((s, m) => s + m.text.length, 0); const dbTextLen = dbMsgs.reduce((s, m) => s + m.text.length, 0); @@ -338,6 +378,22 @@ export class ConversationBufferManager { return cacheSegLen > dbSegLen; } + /** + * Is a stream genuinely in flight for `sessionId` in this conversation? + * + * `streamingSessions` is the positive ownership record (`setStreamSession` on + * session_start, released on done/abort/error). An EMPTY set during a + * `streaming` phase is the pre-registration window — `beginStream` flips the + * phase before the session mark lands — so it must NOT be read as "no stream": + * that window is exactly where the in-flight placeholder legitimately lives. + */ + private isStreamLiveForSession(convKey: string, sessionId: string): boolean { + if (this.getPhase(convKey) !== 'streaming') return false; + const set = this.streamingSessions.get(convKey); + if (!set || set.size === 0) return true; + return set.has(sessionId); + } + // ── Send / stream tracking ── incrementSend(key: string): void { From 3428cd8a85f794a1c59dbebac2e42643a8395f89 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Fri, 2 Oct 2026 21:48:18 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(web-ui):=20=E6=B0=94=E6=B3=A1=E9=AB=98?= =?UTF-8?q?=E5=BA=A6=E8=87=AA=E9=80=82=E5=BA=94=E5=86=85=E5=AE=B9=EF=BC=88?= =?UTF-8?q?=E5=8E=BB=E6=8E=89=E5=B5=8C=E5=A5=97=E6=BB=9A=E5=8A=A8=EF=BC=89?= =?UTF-8?q?+=20=E6=B5=81=E5=BC=8F=E6=B0=94=E6=B3=A1=E5=8A=A8=E6=80=81?= =?UTF-8?q?=E8=BE=B9=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两个问题(2026-10-02 老板报告,均为聊天气泡): ① 气泡高度有时包不住内容 → 内容右侧出现滚动条 根因:气泡内的执行卡片给「工具输出 / 展开详情 / 子代理日志 / coding 预览」 各自套了 `max-h-*` + `overflow-y-auto`,于是滚动条长在**内容块自己**身上, 而不是由气泡自然撑开: - ExecutionTimeline.tsx:883 运行中实时输出 max-h-32 + overflow-y-auto - ExecutionTimeline.tsx:898 展开的详情面板 max-h-80 + overflow-y-auto - ExecutionTimeline.tsx:164 子代理日志面板 max-h-64 + overflow-y-auto - CodingToolCard.tsx:227 coding 预览 max-h-48 + overflow-y-auto - ExecutionTimeline.tsx:1124 仅当「embedded 且运行中」才免 cap —— 工具一跑完 就重新套上 cap,于是同一个块在结束瞬间才长出滚动条("有时候"的来源) 修法:在气泡内(embedded)一律不设高度上限,由气泡包住内容。过长的文本仍 由 TruncatedPre 的「展开/收起」承担(那是显式交互,不是隐式滚动条)。 模态框与 Work/AgentProfile 的非 embedded 分支保持原有高度上限(那里的父容器 自己负责滚动),行为不变。 ② 流式输出中的气泡缺少「正在输出」的动态边框 现状:`.streaming-bubble` 只有 min-height,没有任何视觉活动感 修法:复用仓库里已有的动画基建(`@property --border-angle` + `border-rotate` keyframes,原本只服务于执行卡片的 exec-card-active),给流式气泡加一圈**锥形 扫描光环**: - 用「遮罩环」(mask-composite: exclude)而不是 border-image —— border-image 不遵守 border-radius,会把圆角切方; - `inset: -5px` 画在内容外侧:光环不挤占文字,且开始/结束**不产生任何重排** (没有 padding 变化),回复结束的瞬间气泡不会跳一下; - 由 Team.tsx 的 `showStreamingBubble` 控制:流一结束即摘掉 class,特效自动 收束 —— 正是「只有输出完成,才结束这个特效」; - 附带 `prefers-reduced-motion` 降级(静态环),尊重系统设置。 验证: - web-ui `tsc --noEmit` 0 错误 - web-ui 全量 642/642 通过(37 文件) - 气泡路径上残留的内层滚动框 = 0(保留的 2 处分别在模态框与 non-embedded 分支) --- .../web-ui/src/components/CodingToolCard.tsx | 2 +- .../src/components/ExecutionTimeline.tsx | 14 ++++--- packages/web-ui/src/index.css | 38 ++++++++++++++++++- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/packages/web-ui/src/components/CodingToolCard.tsx b/packages/web-ui/src/components/CodingToolCard.tsx index 68141639..0f8ddb17 100644 --- a/packages/web-ui/src/components/CodingToolCard.tsx +++ b/packages/web-ui/src/components/CodingToolCard.tsx @@ -224,7 +224,7 @@ export function CodingToolCard({ session, className }: CodingToolCardProps) { Raw output {expanded && ( -
+            
               {rawOutput}
             
)} diff --git a/packages/web-ui/src/components/ExecutionTimeline.tsx b/packages/web-ui/src/components/ExecutionTimeline.tsx index 1ca7b1ed..3207c252 100644 --- a/packages/web-ui/src/components/ExecutionTimeline.tsx +++ b/packages/web-ui/src/components/ExecutionTimeline.tsx @@ -161,7 +161,7 @@ function ToolDetailContent({ info, time }: { info: ToolCallInfo; time?: string } {visibleSubagentLogs && visibleSubagentLogs.length > 0 && (

{t('execution.subagentExecution')}

-
+
{logCount > SUBAGENT_LOG_RENDER_LIMIT && (
… {logCount - SUBAGENT_LOG_RENDER_LIMIT} earlier steps omitted
)} @@ -880,7 +880,7 @@ export function ToolCallRow({ info, time, hideApprovalCards }: {
{/* Live streaming output (only while running) — render trailing window only */} {info.liveOutput && info.status === 'running' && ( -
+          
             {info.liveOutput.length > TOOL_DETAIL_PREVIEW_CHARS
               ? info.liveOutput.slice(info.liveOutput.length - TOOL_DETAIL_PREVIEW_CHARS)
               : info.liveOutput}
@@ -892,10 +892,12 @@ export function ToolCallRow({ info, time, hideApprovalCards }: {
             {latestSubagentLine}
           
)} - {/* Inline expandable detail (args + result), height-capped with its own scroll — - a lighter alternative to the old modal, consistent with the thinking block. */} + {/* Inline expandable detail (args + result). NO height cap / inner scroll: + the bubble itself grows to fit — a nested scrollbar inside a bubble is + what made replies look truncated. Very long text is still trimmed by + TruncatedPre's "show more" affordance, not by a scroll box. */} {expanded && hasDetail && ( -
+
)} @@ -1121,7 +1123,7 @@ export function CompactExecutionCard({ entries, streamingText, isActive, onExpan
{streamingText ? ( -
+
) : statusDetail ? ( diff --git a/packages/web-ui/src/index.css b/packages/web-ui/src/index.css index b5ec6a34..cbc7dba9 100644 --- a/packages/web-ui/src/index.css +++ b/packages/web-ui/src/index.css @@ -573,9 +573,45 @@ html.electron-darwin .electron-mac-top-safe { left: 100%; } -/* Streaming chat bubble: no left accent bar (it looked like a quote). */ +/* Streaming chat bubble: a living conic "sweep" ring while the turn is being + produced. Team.tsx applies `.streaming-bubble` only while the stream is live + and drops it the instant the turn ends, so the effect stops by itself. + Drawn as a masked ring rather than a border-image: border-image ignores + border-radius and would square off the bubble's corners. */ .streaming-bubble { + position: relative; min-height: 2.5rem; + border-radius: 0.75rem; +} +.streaming-bubble::after { + content: ''; + position: absolute; + /* Sits just OUTSIDE the content: the ring never crowds the text, and — more + importantly — starting/stopping the effect reflows nothing (no padding + change), so the bubble does not jump the moment a reply finishes. */ + inset: -5px; + border-radius: 1.0625rem; /* concentric: 0.75rem + the 5px outset */ + padding: 1px; + pointer-events: none; + background: conic-gradient( + from var(--border-angle), + transparent 0deg, + color-mix(in oklab, var(--color-brand-500) 40%, transparent) 60deg, + var(--color-brand-500) 120deg, + color-mix(in oklab, var(--color-brand-400) 65%, transparent) 180deg, + transparent 280deg + ); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + mask-composite: exclude; + animation: border-rotate 2.6s linear infinite; +} +@media (prefers-reduced-motion: reduce) { + .streaming-bubble::after { + animation: none; + background: color-mix(in oklab, var(--color-brand-500) 45%, transparent); + } } /* Find-in-conversation: the bubble the current hit landed on flashes so the eye From ad79a61878245d0cde91a0c29a54b62e5c122fc0 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Sat, 3 Oct 2026 13:22:42 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(web-ui):=20=E5=8F=B3=E4=BE=A7=E6=A0=8F?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E6=BB=9A=E5=8A=A8=E6=9D=A1=E7=A7=BB=E5=88=B0?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E4=B8=8B=E6=96=B9=E7=8B=AC=E7=AB=8B=E8=BD=A8?= =?UTF-8?q?=E9=81=93=EF=BC=8C=E6=B6=88=E9=99=A4=E7=82=B9=E5=87=BB=E8=AF=AF?= =?UTF-8?q?=E8=A7=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:标签条用原生 `scrollbar-thin`。macOS 的滚动条是浮层(实测 offsetHeight - clientHeight = 0,不占布局),画在 28px 滚动视口的内部底边;而标签按钮下沿距视口 底只有 2.3px,故 hover 显形时缩略块必然压住标签下半部分,点标签下缘就变成拖拽滚动条。 原生浮层的厚度/出现时机/是否占位由系统决定,CSS 无法可靠挪开。 修复: - 标签条改 `scrollbar-hide`:原生条彻底不绘制,从机制上消除抢占点击的可能; - 新增 TabScrollRail:自绘滚动轨道,在标签下方独占一行(独立布局带,结构上不可能 重叠),仅在溢出时出现,缩略块随滚动同步,支持下拖与点击跳转; - 外层 `self-stretch + flex-col justify-end`:标签行位置基本不变(实测上移 1px), 轨道落在标签下方原本的留白内。 实测(真实 Chromium,逐字复刻标记 + 本包构建产物 CSS): - 改前浮层条压住标签下沿(视觉确认);改后 scrollbar-width: none,轨道顶边位于标签 底边下方 5.3px,标签底部干净; - 轨道几何:缩略块宽 = 视口/内容(26.8%),滚到最右端右缘贴齐轨道末端(差 0.01px)。 测试:新增 test/tabScrollRail.test.ts(8 例,锁定可见门槛/比例/最小可抓取宽度/边界 夹取);web-ui 全量 650/650 通过;tsc 全包 0 错误;产物 CSS/JS 已含新类与轨道节点。 --- packages/web-ui/src/components/RightPanel.tsx | 204 +++++++++--------- .../web-ui/src/components/TabScrollRail.tsx | 149 +++++++++++++ packages/web-ui/test/tabScrollRail.test.ts | 61 ++++++ 3 files changed, 316 insertions(+), 98 deletions(-) create mode 100644 packages/web-ui/src/components/TabScrollRail.tsx create mode 100644 packages/web-ui/test/tabScrollRail.test.ts diff --git a/packages/web-ui/src/components/RightPanel.tsx b/packages/web-ui/src/components/RightPanel.tsx index 74750c4e..acf7173f 100644 --- a/packages/web-ui/src/components/RightPanel.tsx +++ b/packages/web-ui/src/components/RightPanel.tsx @@ -14,6 +14,7 @@ import { EmbeddedBrowser } from './EmbeddedBrowser.tsx'; import { EmbeddedTerminal, type EmbeddedTerminalApi } from './EmbeddedTerminal.tsx'; import { DeliverableShareModal } from './DeliverableShareModal.tsx'; import { createDeliverableShareService, canShareDeliverableFormat, type DeliverableShareRecord } from '../lib/deliverableShare.ts'; +import TabScrollRail from './TabScrollRail.tsx'; import type { RightPanelMode, RightPanelPayload, RightPanelTab } from '../contexts/LayoutContext.tsx'; type TabOwner = { agentId: string; agentName: string }; @@ -830,114 +831,121 @@ export function RightPanel({
)} + {/* 标签区 = 「标签行 + 滚动轨道」两行结构。 + 轨道独占下一行,结构上不可能压住标签;旧实现用原生滚动条,而 macOS 的滚动条 + 是浮层,会画在标签行内部底边之上 —— hover 时缩略块显形,点标签下缘就变成 + 拖拽滚动条(本次误操作的根因)。原生浮层的厚度/时机/占位由系统决定,CSS 无法 + 可靠地挪开它,故改为自绘。justify-end 让标签行几乎保持原位,轨道落在原留白里。 */} {(showTabs || onNewTab) ? ( -
{ - const el = e.currentTarget; - if (el.scrollWidth <= el.clientWidth) return; - if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; - el.scrollLeft += e.deltaY; - e.preventDefault(); - }} - > - {tabs?.map(tab => { - const active = tab.id === activeTabId; - const pageId = tab.payload.kind === 'url' ? tab.payload.pageId : undefined; - const termId = tab.payload.kind === 'terminal' ? tab.payload.terminalId : undefined; - const owner = pageId != null - ? tabOwners[pageId] - : (termId ? termOwners[termId] : undefined); - const tabTitle = owner - ? `${tab.title} — ${t('common:browserTabControlledBy', { name: owner.agentName })}` - : tab.title; - return ( - // Sibling buttons (never nest - {onCloseTab && ( - )} -
- ); - })} - {onNewTab && ( - - )} + {onCloseTab && ( + + )} +
+ ); + })} + {onNewTab && ( + + )} + + ) : ( diff --git a/packages/web-ui/src/components/TabScrollRail.tsx b/packages/web-ui/src/components/TabScrollRail.tsx new file mode 100644 index 00000000..e7c2a662 --- /dev/null +++ b/packages/web-ui/src/components/TabScrollRail.tsx @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'; + +/** + * TabScrollRail — 标签条专用的横向滚动指示轨道。 + * + * 为什么自绘而不用原生滚动条: + * macOS 的滚动条是「浮层(overlay)」——它画在滚动视口的**内部**底边之上。 + * 右侧栏标签条只有约 28px 高,浮层条恰好压在标签按钮的下半部分: + * hover 时缩略块显形,点击标签下缘就会变成「拖拽滚动条」,造成误操作。 + * 原生浮层的厚度、出现时机、是否占位都由系统/浏览器决定,无法用 CSS 可靠地挪开。 + * + * 因此这里改为:标签条保持 `scrollbar-hide`(原生条永不绘制), + * 在标签**下方单独一行**渲染本轨道——独立布局带,结构上不可能与标签重叠; + * 且只要有溢出就常显(原生浮层块只在滚动/hover 时闪现,可发现性差)。 + */ + +export type RailMetric = { left: number; width: number; viewport: number }; + +/** 缩略块最小宽度(轨道百分比),保证几乎占满时仍可抓取。 */ +const MIN_THUMB_PCT = 8; + +/** + * 由滚动度量推导轨道的可见性与缩略块几何。 + * 纯函数,便于单测锁定口径(与 DOM 无关)。 + */ +export function computeRailGeometry(m: RailMetric): { + visible: boolean; + leftPct: number; + widthPct: number; + overflow: number; +} { + const { left, width, viewport } = m; + const overflow = Math.max(0, width - viewport); + if (overflow <= 0 || width <= 0 || viewport <= 0) { + return { visible: false, leftPct: 0, widthPct: 0, overflow: 0 }; + } + const widthPct = Math.max(MIN_THUMB_PCT, (viewport / width) * 100); + // 夹住左边界,保证 left + width <= 100,缩略块不会越过轨道末端。 + const leftPct = Math.max(0, Math.min(100 - widthPct, (left / width) * 100)); + return { visible: true, leftPct, widthPct, overflow }; +} + +const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v)); + +export default function TabScrollRail({ + scrollRef, + watch, +}: { + /** 被观察的横向滚动容器。 */ + scrollRef: RefObject; + /** 变化时重新测量(如标签数量变化)。 */ + watch?: string | number; +}) { + const [metric, setMetric] = useState({ left: 0, width: 0, viewport: 0 }); + const trackRef = useRef(null); + const dragRef = useRef<{ pointerId: number; startX: number; startLeft: number } | null>(null); + + const sync = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + const next: RailMetric = { left: el.scrollLeft, width: el.scrollWidth, viewport: el.clientWidth }; + setMetric(prev => + prev.left === next.left && prev.width === next.width && prev.viewport === next.viewport ? prev : next, + ); + }, [scrollRef]); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + sync(); + el.addEventListener('scroll', sync, { passive: true }); + // 观察容器与每个子项的尺寸变化:标签增删/标题换行都会改变 scrollWidth。 + const ro = new ResizeObserver(sync); + ro.observe(el); + for (const child of Array.from(el.children)) ro.observe(child); + return () => { + el.removeEventListener('scroll', sync); + ro.disconnect(); + }; + }, [scrollRef, sync, watch]); + + const geo = computeRailGeometry(metric); + + const onPointerDown = (e: React.PointerEvent) => { + const el = scrollRef.current; + const track = trackRef.current; + if (!el || !track || !geo.visible || e.button !== 0) return; + const rect = track.getBoundingClientRect(); + if (rect.width <= 0) return; + const thumbW = (geo.widthPct / 100) * rect.width; + const thumbL = (geo.leftPct / 100) * rect.width; + const x = e.clientX - rect.left; + if (x < thumbL || x > thumbL + thumbW) { + // 点在轨道空白处:先让缩略块中心对准点击位置,再从该处继续拖拽。 + const ratio = el.scrollWidth / rect.width; + el.scrollLeft = clamp((x - thumbW / 2) * ratio, 0, geo.overflow); + } + dragRef.current = { pointerId: e.pointerId, startX: e.clientX, startLeft: el.scrollLeft }; + try { + track.setPointerCapture(e.pointerId); + } catch { + /* 指针捕获失败不影响拖拽(走冒泡路径)。 */ + } + e.preventDefault(); + e.stopPropagation(); + }; + + const onPointerMove = (e: React.PointerEvent) => { + const drag = dragRef.current; + const el = scrollRef.current; + const track = trackRef.current; + if (!drag || drag.pointerId !== e.pointerId || !el || !track) return; + const rect = track.getBoundingClientRect(); + if (rect.width <= 0) return; + const ratio = el.scrollWidth / rect.width; + el.scrollLeft = clamp(drag.startLeft + (e.clientX - drag.startX) * ratio, 0, geo.overflow); + }; + + const endDrag = (e: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== e.pointerId) return; + dragRef.current = null; + try { + trackRef.current?.releasePointerCapture(e.pointerId); + } catch { + /* 已释放。 */ + } + }; + + if (!geo.visible) return null; + + return ( +
+
+
+ ); +} diff --git a/packages/web-ui/test/tabScrollRail.test.ts b/packages/web-ui/test/tabScrollRail.test.ts new file mode 100644 index 00000000..74a333e1 --- /dev/null +++ b/packages/web-ui/test/tabScrollRail.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { computeRailGeometry } from '../src/components/TabScrollRail.tsx'; + +/** + * 口径锁定:轨道几何是纯函数,刻意与 DOM 解耦。 + * + * 为什么不在 happy-dom 里断言真实布局 —— happy-dom 不做排版,`scrollWidth` / + * `clientWidth` 恒为 0,写出来的「断言」只会绿得毫无意义(仓库里已有此类假绿教训)。 + * 真正依赖排版的几何在浏览器里验证;这里锁定的是**数学**: + * 可见门槛、缩略块比例、最小可抓取宽度、以及「缩略块永不越出轨道」的边界夹取。 + */ +describe('computeRailGeometry', () => { + it('无溢出时不渲染轨道', () => { + const g = computeRailGeometry({ left: 0, width: 400, viewport: 400 }); + expect(g.visible).toBe(false); + expect(g.overflow).toBe(0); + }); + + it('内容比视口窄时同样不渲染(浮点残差不算溢出)', () => { + expect(computeRailGeometry({ left: 0, width: 399.5, viewport: 400 }).visible).toBe(false); + }); + + it('有溢出时可见,并给出正确的溢出量与比例', () => { + const g = computeRailGeometry({ left: 50, width: 1000, viewport: 500 }); + expect(g.visible).toBe(true); + expect(g.overflow).toBe(500); + expect(g.widthPct).toBeCloseTo(50, 5); // 视口占内容的一半 + expect(g.leftPct).toBeCloseTo(5, 5); // 滚到 50/1000 + }); + + it('缩略块最小宽度 8%:内容极长时仍可抓取', () => { + const g = computeRailGeometry({ left: 0, width: 100_000, viewport: 400 }); + expect(g.visible).toBe(true); + expect(g.widthPct).toBe(8); // 原始 0.4% 被抬到下限 + }); + + it('滚到最右端时缩略块右缘正好贴住轨道末端(不越界)', () => { + const width = 1000; + const viewport = 500; + const g = computeRailGeometry({ left: width - viewport, width, viewport }); + expect(g.leftPct + g.widthPct).toBeCloseTo(100, 5); + }); + + it('触发最小宽度夹取时,滚到最右端仍不越界', () => { + const width = 100_000; + const viewport = 400; + const g = computeRailGeometry({ left: width - viewport, width, viewport }); + expect(g.leftPct).toBeCloseTo(100 - 8, 5); // 夹到 100 - widthPct + expect(g.leftPct + g.widthPct).toBeCloseTo(100, 5); + }); + + it('越界 / 回弹的 scrollLeft 不会把缩略块推到负位', () => { + const g = computeRailGeometry({ left: -30, width: 1000, viewport: 500 }); + expect(g.leftPct).toBe(0); + }); + + it('退化输入(宽度为 0)不渲染,避免除零', () => { + expect(computeRailGeometry({ left: 0, width: 0, viewport: 0 }).visible).toBe(false); + expect(computeRailGeometry({ left: 0, width: 500, viewport: 0 }).visible).toBe(false); + }); +});