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/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
): select + close stay independent.
-
-
{
- // pointerdown beats click cancellation when native views steal focus.
- if (e.button !== 0) return;
- if (tab.id !== activeTabId && !confirmLeaveEditor()) return;
- onSelectTab?.(tab.id);
- }}
- onClick={() => {
- if (tab.id !== activeTabId && !confirmLeaveEditor()) return;
- onSelectTab?.(tab.id);
- }}
+
+
{
+ 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
): select + close stay independent.
+
- {owner && (
-
-
- {owner.agentName}
-
- )}
- {tab.title}
-
- {onCloseTab && (
{
+ // pointerdown beats click cancellation when native views steal focus.
if (e.button !== 0) return;
- e.preventDefault();
- e.stopPropagation();
- if (tab.id === activeTabId && !confirmLeaveEditor()) return;
- onCloseTab(tab.id);
+ if (tab.id !== activeTabId && !confirmLeaveEditor()) return;
+ onSelectTab?.(tab.id);
}}
- onClick={e => {
- e.preventDefault();
- e.stopPropagation();
- if (tab.id === activeTabId && !confirmLeaveEditor()) return;
- onCloseTab(tab.id);
+ onClick={() => {
+ if (tab.id !== activeTabId && !confirmLeaveEditor()) return;
+ onSelectTab?.(tab.id);
}}
- className={`shrink-0 mr-1 p-0.5 rounded hover:bg-surface-overlay transition-colors ${
- active ? 'text-fg-tertiary hover:text-fg-secondary' : 'text-fg-muted hover:text-fg-secondary opacity-70 group-hover:opacity-100'
- }`}
- title={t('common:closeTab', { defaultValue: 'Close tab' })}
- aria-label={t('common:closeTab', { defaultValue: 'Close tab' })}
>
-
+ {owner && (
+
+
+ {owner.agentName}
+
+ )}
+ {tab.title}
- )}
-
- );
- })}
- {onNewTab && (
-
-
-
-
-
- )}
+ {onCloseTab && (
+ {
+ if (e.button !== 0) return;
+ e.preventDefault();
+ e.stopPropagation();
+ if (tab.id === activeTabId && !confirmLeaveEditor()) return;
+ onCloseTab(tab.id);
+ }}
+ onClick={e => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (tab.id === activeTabId && !confirmLeaveEditor()) return;
+ onCloseTab(tab.id);
+ }}
+ className={`shrink-0 mr-1 p-0.5 rounded hover:bg-surface-overlay transition-colors ${
+ active ? 'text-fg-tertiary hover:text-fg-secondary' : 'text-fg-muted hover:text-fg-secondary opacity-70 group-hover:opacity-100'
+ }`}
+ title={t('common:closeTab', { defaultValue: 'Close tab' })}
+ aria-label={t('common:closeTab', { defaultValue: 'Close tab' })}
+ >
+
+
+ )}
+
+ );
+ })}
+ {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/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
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 {
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);
+ });
+});