diff --git a/crates/agent-gui/src/pages/chat/surfaces/ConversationPaneHost.tsx b/crates/agent-gui/src/pages/chat/surfaces/ConversationPaneHost.tsx index 68cab54c5..4ebdaba67 100644 --- a/crates/agent-gui/src/pages/chat/surfaces/ConversationPaneHost.tsx +++ b/crates/agent-gui/src/pages/chat/surfaces/ConversationPaneHost.tsx @@ -180,7 +180,6 @@ const RegisteredConversationPaneHostContent = forwardRef< const composerRef = useRef(null); const scrollFollowRef = useRef(null); const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); - const [composerFloatingOverhang, setComposerFloatingOverhang] = useState(0); const [composerCenterOffset, setComposerCenterOffset] = useState(0); // Background panes send through their own conversation-scoped pipeline; the // handler owns clear-on-send/restore-on-failure for this pane's composer. @@ -270,7 +269,6 @@ const RegisteredConversationPaneHostContent = forwardRef< isSending={isSending} isCompactionRunning={isCompactionRunning} bottomReservePx={composerOverlayHeight} - floatingOverhangPx={composerFloatingOverhang} composerCenterOffsetPx={composerCenterOffset} /> @@ -299,7 +297,6 @@ const RegisteredConversationPaneHostContent = forwardRef< onManualCompactConfirm={controller.compact} manualCompactBlocked={isCompactionRunning} onHeightChange={setComposerOverlayHeight} - onFloatingOverhangChange={setComposerFloatingOverhang} onCenterOffsetChange={setComposerCenterOffset} taskProgressBar={ ; @@ -34,7 +33,6 @@ export type ConversationComposerBindings = Omit< | "onManualCompactConfirm" | "manualCompactBlocked" | "onHeightChange" - | "onFloatingOverhangChange" | "onCenterOffsetChange" | "taskProgressBar" | "approvalBar" diff --git a/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx b/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx index 5c341f34e..e2c48d2f0 100644 --- a/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx +++ b/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx @@ -61,7 +61,6 @@ export const ChatTranscript = memo(function ChatTranscript(props: ChatTranscript liveTranscriptStore, isCompactionRunning, bottomReservePx = 0, - floatingOverhangPx = 0, composerCenterOffsetPx = 0, contentWidth, onContentWidthChange, @@ -368,7 +367,7 @@ export const ChatTranscript = memo(function ChatTranscript(props: ChatTranscript // over the transcript, so any overlap would hide the button. style={{ left: `calc(50% + ${Math.round(composerCenterOffsetPx)}px)`, - bottom: Math.ceil(bottomReservePx) + Math.ceil(floatingOverhangPx) + 16, + bottom: Math.ceil(bottomReservePx) + 16, }} > diff --git a/crates/agent-gui/src/pages/chat/transcript/transcriptTypes.ts b/crates/agent-gui/src/pages/chat/transcript/transcriptTypes.ts index 4901ff07b..5d9baf95f 100644 --- a/crates/agent-gui/src/pages/chat/transcript/transcriptTypes.ts +++ b/crates/agent-gui/src/pages/chat/transcript/transcriptTypes.ts @@ -29,11 +29,6 @@ export type ChatTranscriptProps = { liveTranscriptStore: LiveTranscriptStore; isCompactionRunning: boolean; bottomReservePx?: number; - // Composer chrome floating above the reserve line (queue panel, task - // progress pill). Deliberately excluded from bottomReservePx so the - // transcript reserve stays put; overlays anchored above the composer must - // clear it or they end up hidden underneath. - floatingOverhangPx?: number; // Horizontal offset of the composer card's center from the pane center // (positive = right). The desktop card is shifted to align with the // assistant message body; overlays centered above it must follow. diff --git a/crates/agent-gui/test/chat/composer-overlay-metrics.test.mjs b/crates/agent-gui/test/chat/composer-overlay-metrics.test.mjs index 0cb72f045..10ebfeeb3 100644 --- a/crates/agent-gui/test/chat/composer-overlay-metrics.test.mjs +++ b/crates/agent-gui/test/chat/composer-overlay-metrics.test.mjs @@ -7,168 +7,89 @@ const { measureComposerOverlay } = createTsModuleLoader().loadModule( "@liveagent/ui/lib/chat/composerOverlayMetrics.ts", ); -// Composer layer anchored to the bottom of an 800px-tall, 1000px-wide pane. const layer = { top: 700, bottom: 800, height: 100, left: 0, width: 1000 }; -// Task pill wrapper: 32px tall, mb-3 (12px) above the card column top. const pill = (columnTop) => ({ top: columnTop - 44, bottom: columnTop - 12, height: 32 }); -// Desktop card column: centered in the layer, then translated 18px right. -const column = { left: 20 + 18, width: 960 }; +const column = { left: 38, width: 960 }; -test("no floating chrome: reserve equals layer height and nothing overhangs", () => { - const metrics = measureComposerOverlay({ - layer, - queueHeight: 0, - floating: { top: 700, bottom: 700, height: 0 }, - column, - }); - assert.deepEqual(metrics, { heightPx: 100, floatingOverhangPx: 0, centerOffsetPx: 18 }); - - for (const floating of [null, undefined]) { - assert.deepEqual(measureComposerOverlay({ layer, queueHeight: 0, floating, column }), { - heightPx: 100, - floatingOverhangPx: 0, - centerOffsetPx: 18, - }); +test("reserve clears the entire composer, including queue and task pill", () => { + const withQueue = { ...layer, top: 640, height: 160 }; + for (const [currentLayer, floating, expectedHeight] of [ + [layer, null, 100], + [layer, undefined, 100], + [layer, { top: 700, bottom: 700, height: 0 }, 100], + [layer, pill(layer.top), 144], + [withQueue, null, 160], + [withQueue, pill(withQueue.top), 204], + ]) { + const metrics = measureComposerOverlay({ layer: currentLayer, floating, column }); + assert.deepEqual(metrics, { heightPx: expectedHeight, centerOffsetPx: 18 }); + const reserveTop = currentLayer.bottom - metrics.heightPx; + assert.ok(reserveTop <= currentLayer.top, "queue and input must clear the transcript"); + if (floating?.height > 0) assert.ok(reserveTop <= floating.top, "pill must clear the transcript"); } }); -test("task pill overhangs the reserve line by its height plus gap without growing the reserve", () => { - const metrics = measureComposerOverlay({ - layer, - queueHeight: 0, - floating: pill(layer.top), - column, - }); - assert.deepEqual(metrics, { heightPx: 100, floatingOverhangPx: 44, centerOffsetPx: 18 }); -}); - -test("queue panel is excluded from the reserve but counted as overhang", () => { - const withQueue = { ...layer, top: 640, height: 160 }; - const queueOnly = measureComposerOverlay({ - layer: withQueue, - queueHeight: 60, - floating: null, - column, - }); - assert.deepEqual(queueOnly, { heightPx: 100, floatingOverhangPx: 60, centerOffsetPx: 18 }); - - const queueAndPill = measureComposerOverlay({ - layer: withQueue, - queueHeight: 60, - floating: pill(withQueue.top), - column, - }); - assert.deepEqual(queueAndPill, { heightPx: 100, floatingOverhangPx: 104, centerOffsetPx: 18 }); +test("reserve grows and shrinks with queue, goal bar, approval and multiline input", () => { + for (const height of [100, 164, 220, 132, 280, 100]) { + const currentLayer = { ...layer, top: layer.bottom - height, height }; + assert.equal(measureComposerOverlay({ layer: currentLayer, floating: null, column }).heightPx, height); + assert.equal(measureComposerOverlay({ layer: currentLayer, floating: pill(currentLayer.top), column }).heightPx, height + 44); + } }); -test("center offset follows the card column, not its width", () => { - // Narrow column (content width clamp) keeps the same center shift. - const clamped = measureComposerOverlay({ - layer, - queueHeight: 0, - floating: null, - column: { left: (1000 - 692) / 2 + 18, width: 692 }, - }); - assert.equal(clamped.centerOffsetPx, 18); - - // Column flush with the layer center (gateway-style) reports no shift. - const centered = measureComposerOverlay({ - layer, - queueHeight: 0, - floating: null, - column: { left: 100, width: 800 }, - }); - assert.equal(centered.centerOffsetPx, 0); - - // Offset layer (split pane not at x=0) still measures relative to itself. - const shiftedLayer = measureComposerOverlay({ - layer: { ...layer, left: 500 }, - queueHeight: 0, - floating: null, - column: { left: 500 + 38, width: 960 }, - }); - assert.equal(shiftedLayer.centerOffsetPx, 18); - - for (const missing of [null, undefined, { left: 0, width: 0 }]) { - assert.equal( - measureComposerOverlay({ layer, queueHeight: 0, floating: null, column: missing }) - .centerOffsetPx, - 0, - ); +test("center offset follows the card column in narrow and split panes", () => { + for (const [currentLayer, currentColumn, expected] of [ + [layer, { left: (1000 - 692) / 2 + 18, width: 692 }, 18], + [layer, { left: 100, width: 800 }, 0], + [{ ...layer, left: 500 }, { left: 538, width: 960 }, 18], + [layer, null, 0], + [layer, undefined, 0], + [layer, { left: 0, width: 0 }, 0], + ]) { + assert.equal(measureComposerOverlay({ layer: currentLayer, floating: null, column: currentColumn }).centerOffsetPx, expected); } }); -test("hidden composer layer reports zero for every metric", () => { - const metrics = measureComposerOverlay({ - layer: { top: 0, bottom: 0, height: 0, left: 0, width: 0 }, - queueHeight: 0, - floating: { top: 0, bottom: 0, height: 0 }, - column: { left: 0, width: 0 }, - }); - assert.deepEqual(metrics, { heightPx: 0, floatingOverhangPx: 0, centerOffsetPx: 0 }); +test("hidden composer reserves no space, even with a stale pill rectangle", () => { + const hidden = { top: 0, bottom: 0, height: 0, left: 0, width: 0 }; + for (const floating of [null, pill(0), { top: 0, bottom: 0, height: 0 }]) { + assert.deepEqual(measureComposerOverlay({ layer: hidden, floating, column: null }), { heightPx: 0, centerOffsetPx: 0 }); + } }); -test("fractional geometry rounds and never goes negative", () => { - const fractional = { ...layer, top: 700.4, height: 99.6 }; - const metrics = measureComposerOverlay({ - layer: fractional, - queueHeight: 0, +test("fractional geometry rounds once and interior floating content adds no space", () => { + assert.equal(measureComposerOverlay({ + layer: { ...layer, top: 700.4, height: 99.6 }, floating: { top: 656.4, bottom: 688.4, height: 32 }, - column: { left: 38.4, width: 960 }, - }); - assert.deepEqual(metrics, { heightPx: 100, floatingOverhangPx: 44, centerOffsetPx: 18 }); - - const belowLine = measureComposerOverlay({ + column, + }).heightPx, 144); + assert.equal(measureComposerOverlay({ layer, - queueHeight: 0, - // Floating rect that somehow sits inside the layer must not subtract. floating: { top: 720, bottom: 752, height: 32 }, column, - }); - assert.equal(belowLine.floatingOverhangPx, 0); + }).heightPx, 100); }); const composerBarSource = fs.readFileSync( - new URL("../../../agent-ui/src/pages/chat/ChatComposerBar.tsx", import.meta.url), - "utf8", + new URL("../../../agent-ui/src/pages/chat/ChatComposerBar.tsx", import.meta.url), "utf8", ); const transcriptSource = fs.readFileSync( - new URL("../../src/pages/chat/transcript/ChatTranscript.tsx", import.meta.url), - "utf8", -); -const paneHostSource = fs.readFileSync( - new URL("../../src/pages/chat/surfaces/ConversationPaneHost.tsx", import.meta.url), - "utf8", + new URL("../../src/pages/chat/transcript/ChatTranscript.tsx", import.meta.url), "utf8", ); -test("composer bar measures the card column and task pill wrapper alongside the layer", () => { - assert.match(composerBarSource, /ref=\{composerColumnRef\}/); - assert.match(composerBarSource, /ref=\{setTaskProgressBarElement\}/); - assert.match(composerBarSource, /resizeObserver\?\.observe\(composerColumnRef\.current\)/); +test("both surfaces publish the same measured height and observe floating content", () => { + assert.equal((composerBarSource.match(/measureComposerOverlay\(\{/g) ?? []).length, 1); + assert.match(composerBarSource, /onHeightChange\?\.\(metrics\.heightPx\)/); + assert.match(composerBarSource, /"--gateway-chat-composer-overlay-height",\s*`\$\{metrics\.heightPx\}px`/); assert.match(composerBarSource, /resizeObserver\?\.observe\(taskProgressBarElement\)/); - assert.match( - composerBarSource, - /measureComposerOverlay\(\{\s*layer:\s*composerLayer\.getBoundingClientRect\(\),\s*queueHeight:[^}]*floating:\s*taskProgressBarElement\?\.getBoundingClientRect\(\),\s*column:\s*composerColumnRef\.current\?\.getBoundingClientRect\(\),\s*\}\)/s, - ); - assert.match(composerBarSource, /onFloatingOverhangChange\?\.\(metrics\.floatingOverhangPx\)/); - assert.match(composerBarSource, /onCenterOffsetChange\?\.\(metrics\.centerOffsetPx\)/); + assert.match(composerBarSource, /resizeObserver\?\.observe\(composerColumnRef\.current\)/); + assert.doesNotMatch(composerBarSource, /composerLayerHeight - queueHeight|queueHeight:/); }); -test("jump-to-bottom centers on the composer card and clears its floating chrome", () => { +test("jump-to-bottom clears the full reserve without counting the pill twice", () => { const jumpButton = transcriptSource.match(/chat-jump-to-bottom[\s\S]*? 0 ? Math.min(layer.top, floating.top) : layer.top; + const { layer, floating, column } = input; + if (layer.height <= 0) return { heightPx: 0, centerOffsetPx: 0 }; + const top = floating && floating.height > 0 ? Math.min(layer.top, floating.top) : layer.top; const centerOffsetPx = column && column.width > 0 ? Math.round(column.left + column.width / 2 - (layer.left + layer.width / 2)) : 0; return { - heightPx, - floatingOverhangPx: Math.ceil(Math.max(0, reserveLine - floatingTop)), + heightPx: Math.ceil(Math.max(0, layer.bottom - top)), centerOffsetPx, }; } diff --git a/crates/agent-ui/src/pages/chat/ChatComposerBar.tsx b/crates/agent-ui/src/pages/chat/ChatComposerBar.tsx index 99cf05d47..206438de6 100644 --- a/crates/agent-ui/src/pages/chat/ChatComposerBar.tsx +++ b/crates/agent-ui/src/pages/chat/ChatComposerBar.tsx @@ -373,12 +373,6 @@ export type ChatComposerBarProps = { /** 澄清系统提示词附带的轻量工作区信息。 */ clarifyContext?: ClarifyContext; onHeightChange?: (height: number) => void; - /** - * 预留线之上被队列面板、任务进度药丸额外占据的高度(见 - * composerOverlayMetrics)。它们不计入 onHeightChange,但浮在输入区上方的 - * 控件(回到底部按钮)要再让出这段距离才不会被盖住。仅 desktop 上报。 - */ - onFloatingOverhangChange?: (height: number) => void; /** * 卡片列中心相对输入区层中心的水平偏移(向右为正)。卡片列与正文同为居中, * 正常为 0;分屏等场景下仍按实测值平移,使居中锚定在输入区上方的控件与 @@ -477,7 +471,6 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: ChatComposer runClarifyTurn, clarifyContext, onHeightChange, - onFloatingOverhangChange, onCenterOffsetChange, taskProgressBar, approvalBar, @@ -549,7 +542,6 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: ChatComposer const scheduleComposerOverflowMeasureRef = useRef<(() => void) | null>(null); const composerLayerRef = useRef(null); const composerColumnRef = useRef(null); - const queuePanelRef = useRef(null); // 走 state 而非 ref:容器随 taskProgressBar 插槽挂载/卸载,测量 effect 要 // 跟着重新观察它。 const [taskProgressBarElement, setTaskProgressBarElement] = useState(null); @@ -1195,92 +1187,61 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: ChatComposer useEffect(() => { const composerLayer = composerLayerRef.current; + const chatFrame = overlayHeightOwnerRef?.current; if (!composerLayer) return; + if (surface === "desktop" ? !onHeightChange && !onCenterOffsetChange : !chatFrame) return; - if (surface === "desktop") { - if (!onHeightChange && !onFloatingOverhangChange && !onCenterOffsetChange) return; - - let animationFrame: number | null = null; - const measure = () => { - animationFrame = null; - if (isComposerExpandedRef.current || expandAnimationRef.current) return; - const metrics = measureComposerOverlay({ - layer: composerLayer.getBoundingClientRect(), - queueHeight: queuePanelRef.current?.getBoundingClientRect().height ?? 0, - floating: taskProgressBarElement?.getBoundingClientRect(), - column: composerColumnRef.current?.getBoundingClientRect(), - }); + let animationFrame: number | null = null; + const measure = () => { + animationFrame = null; + // 全屏编辑及其过渡保留常规占位,收起动画结束后补测。 + if (isComposerExpandedRef.current || expandAnimationRef.current) return; + const metrics = measureComposerOverlay({ + layer: composerLayer.getBoundingClientRect(), + floating: taskProgressBarElement?.getBoundingClientRect(), + column: composerColumnRef.current?.getBoundingClientRect(), + }); + if (surface === "desktop") { onHeightChange?.(metrics.heightPx); - onFloatingOverhangChange?.(metrics.floatingOverhangPx); onCenterOffsetChange?.(metrics.centerOffsetPx); - }; - const scheduleMeasure = () => { - if (animationFrame !== null) return; - animationFrame = window.requestAnimationFrame(measure); - }; - scheduleHeightMeasureRef.current = scheduleMeasure; - scheduleMeasure(); - - const resizeObserver = - typeof ResizeObserver === "undefined" ? null : new ResizeObserver(scheduleMeasure); - resizeObserver?.observe(composerLayer); - // 卡片列宽度跟随正文宽度设置,可在 composerLayer 尺寸不变时独立变化。 - if (composerColumnRef.current) resizeObserver?.observe(composerColumnRef.current); - // 药丸容器绝对定位在卡片列之外,出现/消失不会改变 composerLayer 的尺寸。 - if (taskProgressBarElement) resizeObserver?.observe(taskProgressBarElement); - window.addEventListener("resize", scheduleMeasure); - - return () => { - if (animationFrame !== null) window.cancelAnimationFrame(animationFrame); - if (scheduleHeightMeasureRef.current === scheduleMeasure) { - scheduleHeightMeasureRef.current = null; - } - resizeObserver?.disconnect(); - window.removeEventListener("resize", scheduleMeasure); - onHeightChange?.(0); - onFloatingOverhangChange?.(0); - onCenterOffsetChange?.(0); - }; - } - - const chatFrame = overlayHeightOwnerRef?.current; - if (!chatFrame) return; - - const updateComposerOverlayHeight = () => { - // 展开态占满聊天区,保留最近一次常规高度,避免底部预留跟着跳动; - // 展开/还原动画期间高度是中间值,同样不上报,动画结束后补测。 - if (isComposerExpandedRef.current || expandAnimationRef.current) return; - const composerLayerHeight = composerLayer.getBoundingClientRect().height; - const queueHeight = queuePanelRef.current?.getBoundingClientRect().height ?? 0; - chatFrame.style.setProperty( - "--gateway-chat-composer-overlay-height", - `${Math.ceil(Math.max(0, composerLayerHeight - queueHeight))}px`, - ); + } else { + chatFrame?.style.setProperty( + "--gateway-chat-composer-overlay-height", + `${metrics.heightPx}px`, + ); + } }; - scheduleHeightMeasureRef.current = updateComposerOverlayHeight; - - updateComposerOverlayHeight(); - - if (typeof ResizeObserver === "undefined") { - return () => { - scheduleHeightMeasureRef.current = null; - chatFrame.style.removeProperty("--gateway-chat-composer-overlay-height"); - }; - } + const scheduleMeasure = () => { + if (animationFrame !== null) return; + animationFrame = window.requestAnimationFrame(measure); + }; + scheduleHeightMeasureRef.current = scheduleMeasure; + scheduleMeasure(); - const resizeObserver = new ResizeObserver(() => { - updateComposerOverlayHeight(); - }); - resizeObserver.observe(composerLayer); + const resizeObserver = + typeof ResizeObserver === "undefined" ? null : new ResizeObserver(scheduleMeasure); + resizeObserver?.observe(composerLayer); + if (composerColumnRef.current) resizeObserver?.observe(composerColumnRef.current); + // 任务胶囊绝对定位,出现和消失不改变输入区层的高度。 + if (taskProgressBarElement) resizeObserver?.observe(taskProgressBarElement); + window.addEventListener("resize", scheduleMeasure); return () => { - scheduleHeightMeasureRef.current = null; - resizeObserver.disconnect(); - chatFrame.style.removeProperty("--gateway-chat-composer-overlay-height"); + if (animationFrame !== null) window.cancelAnimationFrame(animationFrame); + if (scheduleHeightMeasureRef.current === scheduleMeasure) { + scheduleHeightMeasureRef.current = null; + } + resizeObserver?.disconnect(); + window.removeEventListener("resize", scheduleMeasure); + if (surface === "desktop") { + onHeightChange?.(0); + onCenterOffsetChange?.(0); + } else { + chatFrame?.style.removeProperty("--gateway-chat-composer-overlay-height"); + } }; }, [ onHeightChange, - onFloatingOverhangChange, onCenterOffsetChange, surface, taskProgressBarElement, @@ -1342,10 +1303,7 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: ChatComposer ) : null} {goalBar || queuedTurns.length > 0 || queuePaused ? ( -
+
{/* 目标栏与指令队列同在一个纵向容器里:上下排列,不互相覆盖,并一起计入输入区高度。 goalBar 宿主没有目标时渲染为空,只有真正渲染出的目标栏才让下方队列去掉上圆角。 */} {goalBar} diff --git a/docs/plans/2026-11-03-chat-bottom-reserve.md b/docs/plans/2026-11-03-chat-bottom-reserve.md new file mode 100644 index 000000000..82a4e8292 --- /dev/null +++ b/docs/plans/2026-11-03-chat-bottom-reserve.md @@ -0,0 +1,151 @@ +# 会话底部动态占位修复与验收 + +日期:2026-11-03 + +分支:`fix-chat-bottom-ui-reserve`。实施与人工验收基线:`upstream/main` 的 `e41002bd7796ab965959c8cd1d643ae942bf951c`。提交前安全快进到 `3e0f1869b46499a3a79658310b5bacae20062f7f`,与任务及原有用户改动无文件交叉。PR 目标:上游 `main`。 + +## 问题与实现 + +输入区高度测量原先扣除队列面板,绝对定位的任务胶囊也不计入正文预留。滚动到会话底部后,最后一段输出因此可能落在这些控件下方。 + +共享的 `measureComposerOverlay` 现在从整个底部输入区及任务胶囊的最高可见位置测量到输入区底边,包含队列、目标栏、审批栏、输入卡片、状态栏和间隔。桌面端通过高度回调更新正文底部占位;WebUI 将相同结果写入各自会话面板的 CSS 变量。两个宿主共用尺寸观察及按帧合并机制。回到底部按钮使用完整预留高度,删除额外胶囊高度的重复计算。 + +全屏编辑沿用现有行为:展开和过渡期间保留常规占位,收起结束后重新测量。 + +## 工作区保护 + +已有 `git.rs`、会话同步进度文档和其它未跟踪文件保留,不纳入本次提交。 + +更新基线前,四个本地滚动修复文件与最新 main 的 Git 内容哈希完全一致。经用户于 2026-11-03 授权后,先保存到 `.git/chat-bottom-before-main-20261103-122556.tar`,再由上游已合入的同内容版本接管,完成快进更新。 + +## 自动验证 + +- 布局相关 19 个用例通过:完整几何占位、队列和胶囊组合、动态增减、隐藏、窄面板、目标栏接缝、按钮避让及滚动容器。 +- 首轮 WebUI 记录的 334 项是最后一批数字;提交前按标准命令重跑两批后更正为完整 866 项,全部通过。 +- 最新基线两端 TypeScript/Vite 构建通过(桌面 10.80 秒,WebUI 4.87 秒),`typecheck:ui`、`typecheck:gui`、`typecheck:webui` 均通过。 +- 桌面完整前端测试:最新基线七批共 3916 个用例,3915 通过、1 跳过、0 失败。标准命令 `pnpm --dir crates/agent-gui test:frontend`;WebUI 使用 `pnpm --dir crates/agent-gateway/web test`,两批 866/866 通过。运行时使用按上游发布锁 SHA256 校验的官方 K-brain v0.107.11 与 computer helper、Git Bash,并清空继承的 `K_BRAIN_BACKEND_TOKEN`。原验收基线上的桌面测试为 3914 项、3913 通过、1 跳过;此前环境失败的记录仍保留,未改写无关测试。 +- Mirror Check:通过。 +- `git diff --check`:通过。 +- 两端全量 linter(`--formatter-enabled=false`):桌面 352 文件、WebUI 154 文件通过;任务修改的六个源码文件另经 `biome check --line-ending=lf` 完整格式与 lint 检查通过。 +- 标准 lint 命令在当前 Windows 工作区受到上游文件 LF/CRLF 混用影响;显式 LF 全量检查同样会对已有 CRLF 文件报错。未批量改写无关文件,最终交付需保留这一限制。 + +上述几何、隐藏及挂接用例为自动化专用,不等同于实际界面验收。 + +补充预检根目录扫描已运行全部八批:4493 项,4488 通过、3 失败、2 跳过。两项依赖相对路径的测试从桌面包目录重跑,13/13 通过。Rust smoke 先后遇到 Cargo 未入 PATH、运行中的 sidecar 拷贝被 Windows 拒绝、包装器 180 秒超时,随后直接 Cargo 完成了真实测试:1175 项,1157 通过、14 失败、4 忽略;在 Rust 包目录单线程重跑结果相同。失败涉及 Hook 的 PowerShell 命令在继承 Bash 下运行、Unix 路径断言、Windows MSYS 受限令牌/DLL 初始化及原有用户 Git worktree 测试。本任务未修改任何 Rust 文件;用户已有的 `git.rs` 保持原样。没有切换到干净 Rust 基线重跑,不能声称整个 Rust suite 或基线均通过;这些失败不通过修改无关代码规避。 + + +## 实际界面验收记录 + +桌面端:用户于 2026-11-03 21:08:22(北京时间)明确确认桌面人工验收通过。使用当前工作区 `start-tauri-dev.bat` 启动的调试客户端,完成正常输入、1/2/3 条队列、折叠展开、多行草稿、历史阅读和回到底部共 12 个实测状态。无录屏、无连续截图。 + +WebUI:当前源码 Vite `127.0.0.1:5174` 连接用户已配置的真实 Gateway `127.0.0.1:50053`,使用真实供应商 `gpt-6.1-sol` 和专用验收会话。浏览器通过真实鼠标、键盘、窗口尺寸、刷新交互验证,仅保存文字和 DOM 几何记录,不录屏、不截图。测量证据保存在忽略目录 `.liveagent/chat-bottom-webui-results.json`。 + +已通过的 WebUI 场景: + +- 1440×960 下的真实流式输出、队列 1/2/3 条出现、三轮折叠展开、多行草稿增高及清空。 +- 全屏编辑保留常规正文占位,退出后重新测量;原草稿完整保留。轨迹页隐藏输入区,切回对话恢复占位。 +- 向上滚动后改变队列高度,阅读位置不变;回到底部按钮位于操作区上方,点击后继续跟随。 +- 390×844 下三条队列、折叠展开、多行输入及清空;文档没有横向溢出,末尾保持可见。 +- 可见窗口刷新后,恢复真实队列、运行状态与完整预留高度。 +- 分屏一侧有三条队列、另一侧无队列,分别独立占位;拖动分隔条后重新测量;单侧多行草稿不改变另一侧高度;关闭空闲面板后恢复单会话。 +- 编辑队列、保存编辑、编辑期间切换会话及返回、取消编辑恢复队列、逐项删除直到清空;每步同步收放高度,草稿不串会话。 +- 生成期间停止,停止按钮消失、已生成正文可见、底部恢复常规高度。 + +| 状态 | WebUI 正文底部预留高度 | +| --- | ---: | +| 宽屏正常输入 | 210 px | +| 宽屏 1 / 2 / 3 条队列 | 271 / 301 / 331 px | +| 宽屏折叠队列 | 241 px | +| 宽屏三条队列与多行草稿 | 431 px | +| 手机三条队列 / 折叠 | 325 / 235 px | +| 手机三条队列与多行草稿 | 399 px | +| 分屏空闲侧 / 队列侧 / 队列侧加多行草稿 | 210 / 331 / 431 px | + +初次刷新测量时验证页处于 `document.visibilityState=hidden`,浏览器暂停了按帧布局回调,读到了尚未更新的默认高度;这次结果没有计为通过。保持页面可见并重新刷新后,完整占位和底部跟随通过。分屏输入前先激活目标面板再点击编辑区,避免在焦点切换完成前注入文字。 + +取消后重新发送的真实请求最终得到“WebUI 取消后恢复正常”,并恢复空闲。首次 45 秒观察窗口超时,之后复查已完成;因此只确认恢复发送最终成功,不声称请求时延通过。真实工具读取失败后的胶囊也已验证:输入层约 191.33 px,连同胶囊与间隔覆盖约 235.33 px,正文实际预留 254 px,失败内容完整可见。随后根据已有失败结果完成只读整理,实际观察到 3/3 完成态,正文预留仍为 254 px;后续无工具排队回复结束后,胶囊消失,恢复常规 210 px。 + +胶囊与队列同时可见的稳定组合尚未完成实际验收。已尝试真实任务与排队回复,以及带固定测试选项的交互任务;运行期间观察到了等待队列、提问卡片,未观察到同时可见的任务胶囊,不能据此声称组合场景通过。一次任务终态切换的采样没有落底,稍后未进行滚动操作即恢复落底;这条过渡样本保留在原始结果中,没有算作通过。最后测试交互和后续排队回复均已结束,输入区恢复空闲、队列为空。 + +范围说明:当前 WebUI 宿主没有传入 `goalBar` 和队列暂停控制入口,无法从 WebUI 直接验收这些控件;不能记为人工通过。本轮真实请求没有自然触发供应商失败,失败分支未进行伪造验证。 + +另发现独立的状态同步问题:桌面会话在 Gateway 离线期间已完成,换端口重连后仍被 Gateway 报为 running,WebUI 因此一段时间保留停止按钮;后来旧状态自动清理。已通过接口、桌面控制台及本地同步数据库定位,尚未修复,不能据此宣称跨端状态同步通过。用户要求先完成本轮 WebUI 布局验证。 + +## 人工验收矩阵 + +用户于 2026-11-03T14:03:35Z(北京时间 22:03:35)在获知上述覆盖限制及独立同步问题后明确回复“验收通过”,并于 14:04:02Z 恢复 Goal,授权继续预检与交付。 + +先使用当前分支、当前 HEAD 的 `start-tauri-dev.bat` 启动桌面调试版;桌面通过后在连接真实后端的 WebUI 重复。使用已有真实供应商和真实会话数据,不搭建模拟后端。 + +| 场景 | 触发与步骤 | 预期结果 | +| --- | --- | --- | +| 正常会话 | 打开有答复的会话,滚到最底部 | 最后一行和工具结果完整可见,输入框上方有正常间距 | +| 队列出现及多条队列 | 发起下方长回复提示词,生成期间连续发送三条后续指令 | 队列出现后正文预留同步增加,当前输出持续可见,队列自身列表可滚动 | +| 队列反复折叠 | 展开、折叠队列至少三次 | 占位随实际高度收放,计数与按钮可用,底部不累积空白 | +| 暂停、编辑、删除和清空 | 暂停队列,编辑一项,删除其它项,最后移除全部 | 每次高度变化都避让正文;清空后释放占位;编辑取消保留队列 | +| 任务胶囊 | 运行带任务列表的任务,展开和收起任务明细,等任务完成 | 胶囊和正文不重叠;详情弹层维持原有交互;胶囊消失后占位恢复 | +| 队列与胶囊同时出现 | 在带任务列表的运行中追加排队指令 | 正文位于整个操作区上方;只计一次胶囊和间隔 | +| 目标栏 | 在验收专用会话发起下方短目标,暂停、恢复,再取消该目标 | 目标栏出现及状态切换不遮挡正文;队列与目标栏同时显示也完整 | +| 输入框增高及展开 | 输入多行草稿,展开全屏编辑后收起 | 常规多行输入同步占位;全屏编辑可用;收起后恢复正确高度和原草稿 | +| 流式输出跟随 | 停留底部,让长回复继续生成并折叠队列 | 最新输出持续位于底部操作区上方,无来回闪动 | +| 历史阅读 | 长回复期间向上滚动至少一屏,再改变队列高度 | 保持当前阅读位置,不被强制拉回最新输出 | +| 回到底部 | 阅读历史后点击回到底部 | 按钮在队列及胶囊上方可点击,落底后恢复跟随,无额外一段胶囊高度空白 | +| 切换与恢复会话 | 在有队列和无队列的会话之间切换,再返回原会话 | 每个面板独立测量;队列、草稿和原滚动状态不串到其它会话 | +| 分屏 | 并排两个会话,仅一边有队列或胶囊,调整分隔条 | 两侧占位独立,宽度变化后的换行可正确重测 | +| 窄屏与手机 | 桌面压窄面板;WebUI 使用约 390×844 的视口,重复队列折叠及输入增高 | 无横向溢出,最后一行可见,队列计数及操作仍可用 | +| 取消、失败及恢复 | 生成期间点击停止;若真实请求失败则检查错误后的底部并重试 | 取消/失败内容可见,底部控件可继续操作,重试后占位恢复 | +| WebUI 重连 | 保持有队列的真实会话,刷新 WebUI 并等待连接恢复 | 恢复后的队列和胶囊正确占位,无旧高度残留 | + +### 可复制提示词 + +长回复与队列: + +```text +请写 60 条编号说明,每条单独一段,每段两句话,解释日常软件排障方法。最后单独写“底部可见性验收结束”。不要创建或修改文件,不要调用外部工具。 +``` + +生成期间依次发送: + +```text +上一条结束后只回复“队列一完成”。 +``` + +```text +上一条结束后只回复“队列二完成”。 +``` + +```text +上一条结束后只回复“队列三完成”。 +``` + +任务胶囊: + +```text +这是界面验收。请先用 todowrite 建立三个任务:阅读一份项目文档、整理要点、给出结论。只读取 README.md 和 package.json,逐项更新任务状态并报告结果。不要修改文件,不要提交代码,不要启动服务。 +``` + +目标栏(在专用验收会话执行): + +```text +/goal 只读查看 README.md,提取 10 条功能说明,再用中文逐条解释。不要修改文件,不要提交代码,不要启动服务。 +``` + +多行草稿:在输入框粘贴 10 行文字,通过 Shift+Enter 继续增行,展开及收起输入框,最后删除草稿,无需发送。 + +## 提交前契约与风险检查 + +| 生产者 | 消费者 | 结论与覆盖 | +| --- | --- | --- | +| `measureComposerOverlay` 的 `heightPx`、`centerOffsetPx` | 共享 `ChatComposerBar` | 两端共用一次几何测量;指标测试包含零高、分数、队列与胶囊组合、窄列、分屏偏移 | +| 桌面 `onHeightChange` | `ConversationPaneHost`、`ChatTranscript` | 完整高度传入正文 spacer 和回到底部按钮;删除独立浮动偏移及类型绑定残留 | +| WebUI CSS 高度变量 | `GatewayAppView`、`GatewayConversationPaneHost` | 两个宿主各自通过 `overlayHeightOwnerRef` 写入面板,现有 spacer 与按钮使用该变量;真实单面板、分屏及窄视口验证 | +| 旧 `queueHeight`、`floatingOverhangPx`、`onFloatingOverhangChange` | 原指标、组件属性及 Omit 类型 | 相关生产代码消费者已全部清理,无协议字段或持久化变更 | +| ResizeObserver、resize 事件、动画回调 | 当前面板的测量调度 | 同帧合并;卸载取消帧、断开 observer、移除事件并清除输出;全屏与动画期间保持常规占位,收起后补测 | + +优先级只涉及全屏编辑和正常布局,未改变运行模式、授权或后端策略。面板继续使用既有 ref 与身份,无新增跨会话 Map 或键。输入为浏览器 DOM 几何,不解析正文、路径或用户命令;平台分支、文案、可选子系统及 K-brain 发布锁没有任务改动。共享组件自动供 Windows、macOS、Linux 桌面及 WebUI 使用,本机只实际验收 Windows 和浏览器,未进行 macOS/Linux 原生打包验收。 + +上游 `3e0f1869b` 的真实模块变异对照:队列场景应为 160 px、队列与胶囊场景应为 204 px,上游均返回 100 px;工作区分别返回 160 px、204 px。证据命令:`node .liveagent/chat-bottom-regression-proof.mjs`,结果在同目录 JSON;保留进 PR 的几何回归位于 `composer-overlay-metrics.test.mjs`。 + +## 交付门槛 + +当前源码桌面端必须成功构建并启动,完成浏览器宽窄视口交互验证,向用户提供验收范围。用户明确反馈“通过”前不提交、不推送、不创建 PR。验收后执行项目预检、仅暂存任务文件、推送修复分支到 fork、创建目标为上游 main 的 PR,并持续监测 required CI。