Parcourir la source

fix(web): keep composer context details within the viewport

Yichen Jiang il y a 3 jours
Parent
commit
ae4fc751eb

+ 2 - 2
.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md
-2026-09-07-composer-session-stats-pills.md: 8a1fc9408e9fd0fcfc127158d8165474fee33f23
-2026-09-07-composer-session-stats-pills.zh.md: ccd2fd6f7b8d909a12d846698d18e4dcd8becfa4
+2026-09-07-composer-session-stats-pills.md: 22f78dfdde854baaf1ced8c20c55042952df8c26
+2026-09-07-composer-session-stats-pills.zh.md: 6b71c6e7cf03cbd32fa4da9edf38f6aa4814310c

+ 2 - 2
.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md

@@ -13,9 +13,9 @@ The session stats strip under the composer (`StatsLine`, ui-chat, mounted on `co
 `StatsPills` (packages/client/ui-chat/src/client/chat/StatsPills.tsx) replaces `StatsLine` on the same `conversation.composer.dock` slot; the losing variant is deleted, its shared helpers (`deriveStats`, `formatDuration`, `cacheHitPercent`, `billedInputTokens`) absorbed into the new module, and the dead `stats.llm`, `stats.toolCall`, `stats.ttftAverage`, `stats.tokensPerSecond`, and `stats.tokens` locale keys removed.
 
 - **Two icon pills, two dialogs.** A gauge pill (new `IconGaugeOutline16`, dial center optically dropped to y=8.75 because the bottom-open arc reads high) shows `{turns} 轮 {steps} 步` plus output TPS and click-opens the 会话统计 dialog (LLM time, tool time, average TTFT, TPS); a log with no timed figure would open an empty dialog, so that pill renders as a static reading instead of a button. A database pill (`IconDatabaseOutline16`) shows the compact billed total plus cache-hit share and click-opens the Token 用量 dialog (cache hit, uncached input, cache read, output, and cache write when non-zero — exact counts). Both dialogs wear the shared `stat-dialog` module (portal panel, anchored placement, outside-dismiss, optionally externally owned open state) extracted for exactly this two-consumer split; the pills row owns one exclusive open slot, so opening either dialog closes the other, and each button carries an explicit `aria-label` that separates with ` · ` the segments the aria-hidden sep glyph joins visually. [Guide start page and stat pill refinements](2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns the zero cache-write omission.
-- **Data sourcing is unchanged in architecture.** Counts and times prefer the durable `sessionStats` projection with the window fold as the assembly-without-the-unit fallback ([whole-session counts](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md)); token figures ride `tokenUsage` only, so an absent projection drops the usage pill rather than showing window-derived billing. Cache writes stay in the billed total and the cache-hit denominator ([projection decision](../architecture/2026-07-29-projected-token-usage-and-request-context.md)). Context occupancy remains owned by ui-conversation's `ContextMeter`, with its ring and percentage after the two stats pills below the input card. The common dock groups statistics together and leaves the toolbar for input actions; ui-chat does not import the context component.
+- **Data sourcing is unchanged in architecture.** Counts and times prefer the durable `sessionStats` projection with the window fold as the assembly-without-the-unit fallback ([whole-session counts](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md)); token figures ride `tokenUsage` only, so an absent projection drops the usage pill rather than showing window-derived billing. Cache writes stay in the billed total and the cache-hit denominator ([projection decision](../architecture/2026-07-29-projected-token-usage-and-request-context.md)). Context occupancy remains owned by ui-conversation's `ContextMeter`, with its ring and percentage after the two stats pills below the input card. The common dock groups statistics together and leaves the toolbar for input actions; ui-chat does not import the context component. The context panel renders through a portal and uses ui-primitives for viewport-clamped positioning and outside-pointer dismissal, including when the statistics contribution is absent.
 - **Render discipline.** The row folds settled nodes only (`chat.legacy.nodes` identity), so streaming chunk frames cause zero rerenders — pinned by a render-count unit test. A session with no closed step and no billed tokens renders nothing.
-- **The composer owns dock spacing.** `InputBar` places slot contributions and `ContextMeter` in one centered flex row, supplies its 4px top clearance, and tightens the bottom clearance only while the dock has content. `StatsPills` supplies shrinkable time and billing content; it does not claim the full row width or add outer padding.
+- **The composer owns dock spacing.** `InputBar` places slot contributions and `ContextMeter` in one centered flex row, supplies 4px above the dock and 4px below it even when the slot has no visible contribution. The hero keeps no bottom padding and hides its empty dock. `StatsPills` supplies shrinkable time and billing content; it does not claim the full row width or add outer padding.
 
 ## Alternatives considered
 

+ 2 - 2
.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md

@@ -13,9 +13,9 @@ Status: implemented
 `StatsPills`(packages/client/ui-chat/src/client/chat/StatsPills.tsx)在同一 `conversation.composer.dock` 插槽上取代 `StatsLine`;落选变体已删除,其共享工具函数(`deriveStats`、`formatDuration`、`cacheHitPercent`、`billedInputTokens`)并入新模块,废弃的 `stats.llm`、`stats.toolCall`、`stats.ttftAverage`、`stats.tokensPerSecond`、`stats.tokens` 文案键一并移除。
 
 - **两个图标 pill、两个弹层。** 仪表盘 pill(新增 `IconGaugeOutline16`,因下开口圆弧视觉偏高而把表盘中心光学下移到 y=8.75)展示 `{turns} 轮 {steps} 步` 加输出 TPS,点击打开「会话统计」弹层(模型用时、工具调用用时、首 token 平均、输出速度);日志里没有任何计时数字时弹层会是空的,此时该 pill 渲染为静态读数而非按钮。数据库 pill(`IconDatabaseOutline16`)展示紧凑计费总量加缓存命中率,点击打开「Token 用量」弹层(缓存命中、未缓存输入、缓存读取、输出,以及非零时的缓存写入——精确计数)。两个弹层共用为这两处消费者抽出的 `stat-dialog` 模块(portal 面板、锚定定位、点击外部关闭、可选的外部持有开合状态);pill 行持有唯一的互斥开合槽位,打开任一弹层即关闭另一个,且每个按钮携带显式 `aria-label`,用 ` · ` 分隔 aria-hidden 分隔符在视觉上连接的两段文本。[引导起始页与统计 pill 的细化](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责缓存写入为零时省略该行的规则。
-- **数据来源架构不变。** 计数与用时优先读取持久的 `sessionStats` 投影,窗口折叠仅作无该单元装配时的回退([全会话计数](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md));token 数字只走 `tokenUsage`,投影缺席时直接不渲染用量 pill,而非展示窗口推算的计费。缓存写入仍计入计费总量与缓存命中分母([投影决定](../architecture/2026-07-29-projected-token-usage-and-request-context.zh.md))。上下文占用仍归 ui-conversation 的 `ContextMeter` 所有,在输入卡片下方、两个统计 pill 之后显示圆环和百分比。共用 dock 将统计信息放在一起,工具栏保留给输入操作;ui-chat 不导入上下文组件。
+- **数据来源架构不变。** 计数与用时优先读取持久的 `sessionStats` 投影,窗口折叠仅作无该单元装配时的回退([全会话计数](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md));token 数字只走 `tokenUsage`,投影缺席时直接不渲染用量 pill,而非展示窗口推算的计费。缓存写入仍计入计费总量与缓存命中分母([投影决定](../architecture/2026-07-29-projected-token-usage-and-request-context.zh.md))。上下文占用仍归 ui-conversation 的 `ContextMeter` 所有,在输入卡片下方、两个统计 pill 之后显示圆环和百分比。共用 dock 将统计信息放在一起,工具栏保留给输入操作;ui-chat 不导入上下文组件。上下文面板通过 portal 渲染,复用 ui-primitives 的视口内定位与外部指针关闭工具,统计贡献项缺席时也不会越界。
 - **渲染纪律。** 该行只折叠已定稿节点(`chat.legacy.nodes` 身份),流式 chunk 帧零重渲染——由渲染计数单测钉住。无已完成步且无计费 token 的会话什么都不渲染。
-- **输入框负责 dock 间距。** `InputBar` 将 slot 贡献项和 `ContextMeter` 放在同一个居中的 flex 行中,提供 4px 顶部留白,并仅在 dock 有内容时收紧底部留白。`StatsPills` 提供可收缩的时间与计费内容,不占满整行宽度,也不添加外部 padding。
+- **输入框负责 dock 间距。** `InputBar` 将 slot 贡献项和 `ContextMeter` 放在同一个居中的 flex 行中,在 dock 上下各提供 4px 留白,即使 slot 没有可见贡献项也保持该间距。hero 保持无底部留白,并隐藏空 dock。`StatsPills` 提供可收缩的时间与计费内容,不占满整行宽度,也不添加外部 padding。
 
 ## 备选方案
 

+ 86 - 0
apps/web/tests/context-meter.e2e.ts

@@ -0,0 +1,86 @@
+/** Context details stay inside the viewport with and without Chat's statistics contribution. */
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { chromium } from 'playwright'
+import { describe, expect, it } from 'vitest'
+import { fixtureUserPrompts, launchWebScaffold, selectedSessionFixture, watchConsole, webSnapshotMode } from './scaffold.ts'
+import { connectFreshWorkspace, newEnglishPage } from './support.ts'
+
+const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url))
+const NO_CHAT = fileURLToPath(new URL('./fixtures/context-meter-no-chat.patch.yml', import.meta.url))
+
+describe.skipIf(webSnapshotMode() === 'record')('web e2e: context details placement', () => {
+  it.each([true, false])('keeps details visible across viewport changes (Chat statistics: %s)', async (withStats) => {
+    const fixture = await selectedSessionFixture(FIXTURE, false)
+    const scaffold = await launchWebScaffold({
+      replayFixture: fixture,
+      compareReplaySession: false,
+      paceMs: 5,
+      ...withStats ? {} : { extraOverlayPath: NO_CHAT },
+    })
+    try {
+      const browser = await chromium.launch()
+      try {
+        const page = await newEnglishPage(browser)
+        const tripwire = watchConsole(page)
+        await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+        await connectFreshWorkspace(page, scaffold.workspaceCwd)
+        const card = page.locator('[data-composer-card]')
+        expect(await card.evaluate(element =>
+          element.parentElement!.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom,
+        )).toBe(0)
+        const prompts = fixtureUserPrompts(await readFile(fixture, 'utf8'))
+        expect(prompts).toHaveLength(1)
+        const settled = scaffold.whenTurnSettled()
+        const input = page.locator('[data-composer-input]').first()
+        await input.fill(prompts[0]!)
+        await input.press('Enter')
+        await settled
+        const trigger = page.getByRole('button', { name: /% of context used$/ })
+        await trigger.waitFor()
+        expect(await trigger.textContent()).toMatch(/^\d+%$/)
+        expect(await page.getByRole('button', { name: /tok · Cache hit/ }).count()).toBe(withStats ? 1 : 0)
+        expect(await card.evaluate((element) => {
+          const dock = element.nextElementSibling!
+          return {
+            above: getComputedStyle(dock).paddingTop,
+            below: getComputedStyle(element.parentElement!).paddingBottom,
+          }
+        })).toEqual({ above: '4px', below: '4px' })
+        await trigger.click()
+        const panel = page.getByRole('dialog', { name: 'of context used', exact: true })
+        await panel.waitFor()
+        for (const width of [390, 800, 1280]) {
+          await page.setViewportSize({ width, height: 900 })
+          await page.locator('[data-sidebar-collapsed="true"]').waitFor({
+            state: width <= 800 ? 'attached' : 'detached',
+          })
+          await page.evaluate(async () => {
+            await Promise.all(document.getAnimations()
+              .filter(animation => animation.effect?.getComputedTiming().iterations !== Infinity)
+              .map(animation => animation.finished.catch(() => undefined)))
+          })
+          await expect.poll(async () => {
+            const rect = await panel.boundingBox()
+            return rect !== null && rect.x >= 12 && rect.x + rect.width <= width - 12
+              && rect.y >= 12 && rect.y + rect.height <= 888
+          }).toBe(true)
+        }
+        await panel.getByText('System prompt', { exact: true }).click()
+        expect(await panel.isVisible()).toBe(true)
+        await page.keyboard.press('Escape')
+        await panel.waitFor({ state: 'hidden' })
+        await trigger.click()
+        await panel.waitFor()
+        await page.mouse.click(1260, 400)
+        await panel.waitFor({ state: 'hidden' })
+        expect(tripwire.pageErrors).toEqual([])
+        expect(tripwire.warnings).toEqual([])
+      } finally {
+        await browser.close()
+      }
+    } finally {
+      await scaffold.close()
+    }
+  })
+})

+ 2 - 0
apps/web/tests/fixtures/context-meter-no-chat.patch.yml

@@ -0,0 +1,2 @@
+- id: ui-chat
+  disabled: true

+ 1 - 0
apps/web/tsconfig.json

@@ -79,6 +79,7 @@
     "tests/present-svg.e2e.ts",
     "tests/composer-draft-scroll.e2e.ts",
     "tests/composer-placeholder.e2e.ts",
+    "tests/context-meter.e2e.ts",
     "tests/cordis-tool-round.e2e.ts",
     "tests/web-search-round.e2e.ts",
     "tests/file-upload-round.e2e.ts",

+ 1 - 1
packages/client/ui-chat/src/client/chat/StatsPills.tsx

@@ -331,7 +331,7 @@ export const StatsPills = memo(function StatsPills({ useChat, useProjection, t }
     && (billedInputTokens(usage) > 0 || usage.outputTokens > 0)
   if (stats.steps === 0 && !hasTokens) return null
   return (
-    <div className={css.root} data-composer-stats>
+    <div className={css.root}>
       {stats.steps > 0 && (
         <TimePill
           stats={stats}

+ 0 - 2
packages/client/ui-chat/tests/chat-stats.client.spec.tsx

@@ -161,7 +161,6 @@ describe('StatsPills', () => {
   it('renders the counts reading and usage pill and hides a brand-new empty session', () => {
     const { source } = makeSource({ nodes: [assistant(1, 1)] })
     const view = render(<StatsPills {...props(source)} />)
-    expect(view.container.querySelector('[data-composer-stats]')).toBeTruthy()
     // No timing on the fixture: the speed segment drops out and the dialog
     // would have no rows, so the counts reading stays a static pill (no button).
     expect(view.getByText('1 turns 1 steps').closest('button')).toBeNull()
@@ -177,7 +176,6 @@ describe('StatsPills', () => {
       contextPressure: {},
     })} />)
     expect(emptyView.container.textContent).toBe('')
-    expect(emptyView.container.querySelector('[data-composer-stats]')).toBeNull()
   })
 
   it.each([

+ 2 - 2
packages/client/ui-conversation/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: 6cc6821213b0b4438590e86a332fa0d0e27876a0
-README.zh.md: 4179c56cfec2b6cbeb02aef3ea4ba5e9408aefab
+README.md: e938bf2bd9f271616e587206ecb171ed34cc5f44
+README.zh.md: edd5cad195f08c235d580b7305433860a3f5c471

+ 1 - 1
packages/client/ui-conversation/README.md

@@ -36,7 +36,7 @@ Target packages declaration-merge their snapshot and Location data maps, then re
 <a id="shell-and-standard-props"></a>
 ## Shell and standard props
 
-The context-occupancy button shows a ring and percentage below the input card, after the Session statistics. Clicking it opens the token breakdown; the button stays hidden until context usage and capacity are available.
+The context-occupancy button shows a ring and percentage below the input card, after the Session statistics. Clicking it opens the token breakdown in a panel kept inside the viewport, including when no statistics are shown; the button stays hidden until context usage and capacity are available.
 
 The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module.
 

+ 1 - 1
packages/client/ui-conversation/README.zh.md

@@ -36,7 +36,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
 <a id="shell-and-standard-props"></a>
 ## Shell 与标准 props
 
-上下文占用按钮在输入卡片下方、会话统计右侧显示圆环和百分比。点击按钮可查看 token 构成;上下文用量和容量尚不可用时,按钮保持隐藏。
+上下文占用按钮在输入卡片下方、会话统计右侧显示圆环和百分比。点击按钮可在视口内的面板查看 token 构成,没有统计项时面板也不会越界;上下文用量和容量尚不可用时,按钮保持隐藏。
 
 输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。
 

+ 3 - 6
packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css

@@ -2,7 +2,6 @@
    panel (menu surface: r12, elevation-prominent). */
 
 .root {
-  position: relative;
   display: inline-flex;
   flex: none;
 }
@@ -45,12 +44,10 @@
 }
 
 .panel {
-  position: absolute;
-  bottom: calc(100% + 8px);
-  right: 0;
-  z-index: 100;
+  position: fixed;
+  z-index: 1100;
   box-sizing: border-box;
-  width: 264px;
+  width: min(264px, calc(100vw - 24px));
   padding: 12px;
   border: 0;
   border-radius: 12px;

+ 23 - 14
packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx

@@ -5,10 +5,11 @@
  * capacity. */
 
 import { useEffect, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
 import type { UseProjection } from '@deepseek-ai/dsh-api-session-controller/client'
 // Type-only: the `contextPressure` / `contextBreakdown` projection key merges.
 import type {} from '@deepseek-ai/dsh-token-meter/client'
-import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
+import { Tooltip, useAnchoredPosition, useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ComposerBarProps } from '../contract/slots.ts'
 import { contextOccupancy } from '../context-occupancy.ts'
 import css from './ContextMeter.module.css'
@@ -57,8 +58,18 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) {
   const breakdown = useProjection('contextBreakdown')
   const [open, setOpen] = useState(false)
   const rootRef = useRef<HTMLSpanElement | null>(null)
+  const panelRef = useRef<HTMLDivElement | null>(null)
   const context = contextOccupancy(pressure)
   const available = context !== null
+  const position = useAnchoredPosition({
+    open: open && available,
+    anchorRef: rootRef,
+    panelRef,
+    side: 'top',
+    gap: 8,
+    margin: 12,
+  })
+  useDismissOnOutsidePointer(rootRef, open && available, setOpen, panelRef)
 
   // A model switch can temporarily remove capacity while this component stays
   // mounted. Close the now-unavailable panel instead of preserving stale UI.
@@ -66,22 +77,13 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) {
     if (!available && open) setOpen(false)
   }, [available, open])
 
-  // Outside click / Escape close, one document listener while open (Menu's pattern).
   useEffect(() => {
     if (!open || !available) return
-    const onPointerDown = (e: PointerEvent): void => {
-      if (e.target instanceof Node && rootRef.current?.contains(e.target) === true) return
-      setOpen(false)
-    }
     const onKeyDown = (e: KeyboardEvent): void => {
       if (e.key === 'Escape') setOpen(false)
     }
-    document.addEventListener('pointerdown', onPointerDown)
     document.addEventListener('keydown', onKeyDown)
-    return () => {
-      document.removeEventListener('pointerdown', onPointerDown)
-      document.removeEventListener('keydown', onKeyDown)
-    }
+    return () => { document.removeEventListener('keydown', onKeyDown) }
   }, [available, open])
 
   if (context === null) return null
@@ -128,8 +130,14 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) {
           <span>{reading}</span>
         </button>
       </Tooltip>
-      {open && (
-        <div className={css.panel} role="dialog" aria-label={t('context.used')}>
+      {open && createPortal(
+        <div
+          ref={panelRef}
+          className={css.panel}
+          style={position ?? { visibility: 'hidden', left: 0, top: 0 }}
+          role="dialog"
+          aria-label={t('context.used')}
+        >
           <div className={css.header}>
             {/* Empty sides collapse through `.headline:empty` so the locale that
                 needs no leading (or trailing) text spends no header gap. */}
@@ -164,7 +172,8 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) {
               ))}
             </dl>
           )}
-        </div>
+        </div>,
+        document.body,
       )}
     </span>
   )

+ 2 - 7
packages/client/ui-conversation/src/client/skeleton/InputBar.module.css

@@ -7,12 +7,7 @@
      the sides narrow with the shared width axis); the bottom gradient mask
      is owned by the chat scroller. No top pad: the composer stack's gap owns
      the space above; the status strip still carries its own margin. */
-  padding: 0 var(--dsh-composer-side-clearance) 8px;
-}
-
-/* The populated dock supplies the other 4px of the bottom clearance. */
-.root:has(.dock:not(:empty)) {
-  padding-bottom: 4px;
+  padding: 0 var(--dsh-composer-side-clearance) 4px;
 }
 
 .dock {
@@ -24,7 +19,7 @@
   padding-top: 4px;
 }
 
-.dock:empty {
+.hero .dock:empty {
   display: none;
 }
 

+ 14 - 14
packages/client/ui-conversation/tests/context-meter.client.spec.tsx

@@ -51,9 +51,9 @@ describe('ContextMeter', () => {
       contextBreakdown: BREAKDOWN,
     })
     const trigger = view.getByRole('button', { name: '上下文已用 25%' })
-    expect(view.container.querySelector('[role="dialog"]')).toBeNull()
+    expect(view.queryByRole('dialog')).toBeNull()
     fireEvent.click(trigger)
-    const panel = view.container.querySelector('[role="dialog"]')!
+    const panel = view.queryByRole('dialog')!
     expect(panel.textContent).toContain('~32K / 128K')
     expect(panel.textContent).toContain('25%')
     expect(panel.textContent).toContain('上下文已用')
@@ -64,7 +64,7 @@ describe('ContextMeter', () => {
     expect(panel.getElementsByClassName(segmentClass)).toHaveLength(3)
     // Clicking the trigger again toggles the panel shut.
     fireEvent.click(trigger)
-    expect(view.container.querySelector('[role="dialog"]')).toBeNull()
+    expect(view.queryByRole('dialog')).toBeNull()
   })
 
   it('lets each locale own the headline word order around the reading', () => {
@@ -76,11 +76,11 @@ describe('ContextMeter', () => {
     fireEvent.click(zhView.getByRole('button', { name: '上下文已用 25%' }))
     // The reading follows the label in Chinese and leads it in English; both
     // headers read as one sentence rather than a concatenated fragment.
-    expect(zhView.container.querySelector('[role="dialog"]')!.textContent)
+    expect(zhView.queryByRole('dialog')!.textContent)
       .toMatch(/^上下文已用25%/)
     const enView = meter(values, tEn)
     fireEvent.click(enView.getByRole('button', { name: '25% of context used' }))
-    expect(enView.container.querySelector('[role="dialog"]')!.textContent)
+    expect(enView.queryByRole('dialog', { name: 'of context used' })!.textContent)
       .toMatch(/^25%of context used/)
   })
 
@@ -90,7 +90,7 @@ describe('ContextMeter', () => {
       contextBreakdown: BREAKDOWN,
     })
     fireEvent.click(view.getByRole('button', { name: '上下文已用 0%' }))
-    const panel = view.container.querySelector('[role="dialog"]')!
+    const panel = view.queryByRole('dialog')!
     // `.segment` carries a min-width, so a zero-width part would still paint a
     // filled sliver over an empty context.
     expect(panel.getElementsByClassName(segmentClass)).toHaveLength(0)
@@ -106,13 +106,13 @@ describe('ContextMeter', () => {
     })
     const trigger = view.getByRole('button', { name: '上下文已用 2%' })
     fireEvent.click(trigger)
-    expect(view.container.querySelector('[role="dialog"]')!.textContent).toContain('~3K / 128K')
+    expect(view.queryByRole('dialog')!.textContent).toContain('~3K / 128K')
   })
 
   it('omits the composition rows while the contextBreakdown projection is absent', () => {
     const view = meter({ contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 } })
     fireEvent.click(view.getByRole('button', { name: '上下文已用 25%' }))
-    const panel = view.container.querySelector('[role="dialog"]')!
+    const panel = view.queryByRole('dialog')!
     expect(panel.textContent).toContain('~32K / 128K')
     expect(panel.textContent).not.toContain('系统提示词')
     expect(panel.textContent).not.toContain('对话消息')
@@ -127,7 +127,7 @@ describe('ContextMeter', () => {
     }
     const view = render(<ContextMeter useProjection={(key: string) => values[key]} t={t} />)
     fireEvent.click(view.getByRole('button', { name: '上下文已用 25%' }))
-    expect(view.container.querySelector('[role="dialog"]')).not.toBeNull()
+    expect(view.queryByRole('dialog')).not.toBeNull()
 
     values = { contextPressure: { pressureTokens: 32_000 }, contextBreakdown: BREAKDOWN }
     view.rerender(<ContextMeter useProjection={(key: string) => values[key]} t={t} />)
@@ -139,7 +139,7 @@ describe('ContextMeter', () => {
     }
     view.rerender(<ContextMeter useProjection={(key: string) => values[key]} t={t} />)
     expect(view.getByRole('button', { name: '上下文已用 25%' }).getAttribute('aria-expanded')).toBe('false')
-    expect(view.container.querySelector('[role="dialog"]')).toBeNull()
+    expect(view.queryByRole('dialog')).toBeNull()
   })
 
   it('closes on outside pointerdown and Escape — but not inside clicks', () => {
@@ -150,17 +150,17 @@ describe('ContextMeter', () => {
     const trigger = view.getByRole('button', { name: '上下文已用 25%' })
     const openPanel = () => {
       fireEvent.click(trigger)
-      return view.container.querySelector('[role="dialog"]')!
+      return view.queryByRole('dialog')!
     }
     // A pointerdown inside the panel keeps it open; outside closes it.
     const again = openPanel()
     fireEvent.pointerDown(again)
-    expect(view.container.querySelector('[role="dialog"]')).not.toBeNull()
+    expect(view.queryByRole('dialog')).not.toBeNull()
     fireEvent.pointerDown(document.body)
-    expect(view.container.querySelector('[role="dialog"]')).toBeNull()
+    expect(view.queryByRole('dialog')).toBeNull()
     // Escape.
     openPanel()
     fireEvent.keyDown(document, { key: 'Escape' })
-    expect(view.container.querySelector('[role="dialog"]')).toBeNull()
+    expect(view.queryByRole('dialog')).toBeNull()
   })
 })

+ 1 - 1
snapshots/web/deepseek-messages-chat/ui.expected.md

@@ -47,7 +47,6 @@
 - button "选择模型,当前 DeepSeek-V4-Flash,推理等级 high":
   - text: DeepSeek-V4-Flash high
   - img
-- button "上下文已用 1%"
 - button "发送消息" [disabled]
 - button "1 轮 1 步 · {{throughput}} tok/s":
   - img
@@ -55,3 +54,4 @@
 - button "8.2K tok · 缓存命中 16%":
   - img
   - text: 8.2K tok缓存命中 16%
+- button "上下文已用 1%": 1%

+ 1 - 0
tsconfig.host.json

@@ -70,6 +70,7 @@
     "apps/web/tests/present-svg.e2e.ts",
     "apps/web/tests/composer-draft-scroll.e2e.ts",
     "apps/web/tests/composer-placeholder.e2e.ts",
+    "apps/web/tests/context-meter.e2e.ts",
     "apps/web/tests/cordis-tool-round.e2e.ts",
     "apps/web/tests/web-search-round.e2e.ts",
     "apps/web/tests/file-upload-round.e2e.ts",