Browse Source

perf(ui-deliverables): move overflow sizing to CSS

imccyu 1 month ago
parent
commit
e5bbee893b

+ 12 - 8
apps/web/tests/produced-files.e2e.ts

@@ -1,6 +1,6 @@
 // Web e2e scenario: the single-line produced-files summary a finished turn
 // ends with. Cold-seeds ten writes (zero model calls), then verifies the real
-// assembled lane keeps a precise +N and a capability-gated folder handoff.
+// assembled lane adapts from a coarse width budget and keeps a capability-gated folder handoff.
 // The folder request is intercepted so one real browser click can exercise
 // the full client carrier without launching a native application in CI.
 import { fileURLToPath } from 'node:url'
@@ -20,7 +20,7 @@ const OVERLAY = fileURLToPath(new URL('./produced-files.overlay.yml', import.met
 const SEED_ID = 'produced-files-web-e2e'
 const DONE = 'PRODUCED_FILES_DONE'
 
-/** Short leading names plus a long third name make the narrow lane deterministically show two. */
+/** Ten varied names exercise estimated prefix selection and CSS shrinking. */
 const PRODUCED = [
   '关于我.md',
   'index.html',
@@ -116,7 +116,7 @@ describe('web e2e: a finished turn ends with the files it produced', () => {
     page = await newEnglishPage(browser)
     // Keep the responsive sidebar available while selecting the cold seed;
     // the assertion itself narrows the conversation after navigation.
-    await page.setViewportSize({ width: 1280, height: 900 })
+    await page.setViewportSize({ width: 1800, height: 900 })
     tripwire = watchConsole(page)
     await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -127,7 +127,7 @@ describe('web e2e: a finished turn ends with the files it produced', () => {
     await scaffold?.close()
   })
 
-  it.skipIf(MODE === 'record')('keeps a narrow ten-file summary on one line with +8 and a folder action', async () => {
+  it.skipIf(MODE === 'record')('adapts a ten-file summary without leaving one line', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-produced-files'))
     const groupRow = page.locator('[role="treeitem"]').first()
     await groupRow.waitFor({ timeout: 15_000 })
@@ -137,14 +137,18 @@ describe('web e2e: a finished turn ends with the files it produced', () => {
     await sessionRow.click()
 
     await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
-    await page.setViewportSize({ width: 780, height: 900 })
     const row = page.locator('[data-produced-files-row]')
     await row.waitFor({ timeout: 15_000 })
     const chips = row.getByRole('button')
-    await expect.poll(() => chips.count()).toBe(2)
+    await expect.poll(() => chips.count()).toBe(6)
+    await expect.poll(() => row.getByText('+ 4 files', { exact: true }).isVisible()).toBe(true)
+
+    await page.setViewportSize({ width: 780, height: 900 })
+    await expect.poll(() => chips.count()).toBe(5)
     expect(await chips.nth(0).innerText()).toBe('关于我.md')
     expect(await chips.nth(1).innerText()).toBe('index.html')
-    expect(await row.getByText('+ 8 files', { exact: true }).count()).toBe(1)
+    expect(await chips.nth(4).innerText()).toBe('app.ts')
+    await expect.poll(() => row.getByText('+ 5 files', { exact: true }).isVisible()).toBe(true)
     const showFolder = page.getByRole('button', { name: 'Show in folder', exact: true })
     expect(await showFolder.count()).toBe(1)
     expect(await page.getByText('Produced', { exact: true }).count()).toBe(1)
@@ -163,7 +167,7 @@ describe('web e2e: a finished turn ends with the files it produced', () => {
       openPath.mockRestore()
     }
 
-    const tops = await row.locator(':scope > *').evaluateAll(elements =>
+    const tops = await row.locator(':scope > *:visible').evaluateAll(elements =>
       elements.map(element => element.getBoundingClientRect().top))
     expect(new Set(tops.map(top => Math.round(top))).size).toBe(1)
     const geometry = await row.evaluate(element => ({

+ 128 - 31
packages/client/ui-deliverables/src/client/ProducedFiles.module.css

@@ -1,13 +1,10 @@
-/* Turn-tail produced-files summary: one measured chip lane plus an optional
-   native-folder action below it. */
+/* Turn-tail produced-files summary with an optional native-folder action. */
 
 .root {
-  position: relative;
   display: grid;
   grid-template-columns: max-content minmax(0, 1fr);
-  align-items: center;
+  align-items: start;
   column-gap: 8px;
-  row-gap: 6px;
   margin-top: 16px;
   font-size: 13px;
   line-height: 22px;
@@ -19,22 +16,33 @@
   color: var(--dsw-alias-label-tertiary);
 }
 
-.row {
+.lane {
+  --produced-file-chip-max: 96px;
+  --produced-file-gap: 8px;
+
   grid-column: 2;
   grid-row: 1;
+  display: grid;
+  row-gap: 6px;
+  min-width: 0;
+  container-type: inline-size;
+}
+
+.row {
   display: flex;
   flex-wrap: nowrap;
   align-items: center;
-  gap: 8px;
+  gap: var(--produced-file-gap);
   min-width: 0;
   overflow: hidden;
 }
 
-/* One produced file. A link by behavior (it opens the file), a chip by shape:
-   full paths are long, while the measured lane stays on one row. */
+/* File names yield space to one another while the remainder stays readable. */
 .file {
-  flex: 0 0 auto;
-  max-width: 320px;
+  box-sizing: border-box;
+  flex: 0 1 auto;
+  min-width: 0;
+  max-width: var(--produced-file-chip-max);
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
@@ -62,13 +70,120 @@
 /* Overflow count: the row never silently drops files it did not show. */
 .more {
   flex: 0 0 auto;
+  display: none;
   white-space: nowrap;
   color: var(--dsw-alias-label-tertiary);
 }
 
+.more[data-shown='6'] {
+  display: inline;
+}
+
+.showFolder {
+  display: none;
+}
+
+.lane:has(.more[data-shown='6']) > .showFolder {
+  display: block;
+}
+
+/* Each band budgets 96px per chip, 8px gaps, and 64px for the remainder. */
+@container (max-width: 687px) {
+  .file:nth-of-type(6),
+  .more[data-shown='6'] {
+    display: none;
+  }
+
+  .more[data-shown='5'] {
+    display: inline;
+  }
+
+  .showFolder {
+    display: none;
+  }
+
+  .lane:has(.more[data-shown='5']) > .showFolder {
+    display: block;
+  }
+}
+
+@container (max-width: 583px) {
+  .file:nth-of-type(5),
+  .more[data-shown='5'] {
+    display: none;
+  }
+
+  .more[data-shown='4'] {
+    display: inline;
+  }
+
+  .showFolder {
+    display: none;
+  }
+
+  .lane:has(.more[data-shown='4']) > .showFolder {
+    display: block;
+  }
+}
+
+@container (max-width: 479px) {
+  .file:nth-of-type(4),
+  .more[data-shown='4'] {
+    display: none;
+  }
+
+  .more[data-shown='3'] {
+    display: inline;
+  }
+
+  .showFolder {
+    display: none;
+  }
+
+  .lane:has(.more[data-shown='3']) > .showFolder {
+    display: block;
+  }
+}
+
+@container (max-width: 375px) {
+  .file:nth-of-type(3),
+  .more[data-shown='3'] {
+    display: none;
+  }
+
+  .more[data-shown='2'] {
+    display: inline;
+  }
+
+  .showFolder {
+    display: none;
+  }
+
+  .lane:has(.more[data-shown='2']) > .showFolder {
+    display: block;
+  }
+}
+
+@container (max-width: 271px) {
+  .file:nth-of-type(2),
+  .more[data-shown='2'] {
+    display: none;
+  }
+
+  .more[data-shown='1'] {
+    display: inline;
+  }
+
+  .showFolder {
+    display: none;
+  }
+
+  .lane:has(.more[data-shown='1']) > .showFolder {
+    display: block;
+  }
+}
+
 .showFolder {
-  grid-column: 2;
-  grid-row: 2;
   justify-self: start;
   margin: 0;
   padding: 0 2px;
@@ -85,21 +200,3 @@
   color: var(--dsw-alias-label-secondary);
   text-decoration: underline;
 }
-
-/* Exact browser-native probes for every candidate shown count. They share the
-   visible styles but never affect layout, accessibility, or scroll width. */
-.measure {
-  position: absolute;
-  width: 0;
-  height: 0;
-  overflow: hidden;
-  visibility: hidden;
-  pointer-events: none;
-  contain: strict;
-}
-
-.probe {
-  position: absolute;
-  inset: 0 auto auto 0;
-  width: max-content;
-}

+ 35 - 101
packages/client/ui-deliverables/src/client/ProducedFiles.tsx

@@ -1,44 +1,13 @@
-import { useEffect, useLayoutEffect, useRef, useState } from 'react'
+import { useEffect } from 'react'
 import type { HostObservable, InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
 import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
 import { basename } from './turn-deliverables.ts'
 import type { NS } from './locales.ts'
 import css from './ProducedFiles.module.css'
 
-/** At most six chips compete for the one-line summary; every other path stays counted. */
+/** Maximum number of file chips rendered before the remainder counter. */
 const SHOWN_LIMIT = 6
 
-/**
- * Select the largest prefix whose measured chips and exact remainder fit.
- * @param available - usable width of the one-line file lane.
- * @param gap - computed flex gap between adjacent visible items.
- * @param chipWidths - measured widths for the candidate file chips.
- * @param moreWidthsByShown - exact localized remainder width for each shown count.
- * @returns Number of leading chips to render.
- */
-export function fitProducedFiles(
-  available: number,
-  gap: number,
-  chipWidths: readonly number[],
-  moreWidthsByShown: readonly (number | undefined)[],
-): number {
-  if (available <= 0) return chipWidths.length
-  const prefix = [0]
-  let prefixWidth = 0
-  for (const width of chipWidths) {
-    prefixWidth += width
-    prefix.push(prefixWidth)
-  }
-  let largestFit = 0
-  for (const [shown, width] of prefix.entries()) {
-    const more = moreWidthsByShown[shown]
-    const items = shown + (more === undefined ? 0 : 1)
-    const needed = width + (more ?? 0) + Math.max(0, items - 1) * gap
-    if (needed <= available) largestFit = shown
-  }
-  return largestFit
-}
-
 /** Registration-side Host capability facts. */
 export interface ProducedFilesInjected {
   /** Whether the browser itself is connected over loopback. */
@@ -71,81 +40,46 @@ export function ProducedFiles({
   useEffect(() => { ensureWorkspacePathOpen() }, [ensureWorkspacePathOpen])
   const hostCanOpenPath = useWorkspacePathOpen(available => available === true)
   const canOpenPath = isLoopback && hostCanOpenPath
-  const limit = Math.min(paths.length, SHOWN_LIMIT)
-  const [shownCount, setShownCount] = useState(limit)
-  const rowRef = useRef<HTMLDivElement>(null)
-  const chipProbes = useRef<Array<HTMLButtonElement | null>>([])
-  const moreProbe = useRef<HTMLSpanElement>(null)
-
-  useLayoutEffect(() => {
-    const row = rowRef.current
-    const remainderProbe = moreProbe.current
-    /* v8 ignore next -- React attaches both refs before the layout effect runs. */
-    if (row === null || remainderProbe === null) return
-    const measure = (): void => {
-      const styles = getComputedStyle(row)
-      const gap = Number.parseFloat(styles.columnGap || styles.gap) || 0
-      // React attaches every still-mounted callback ref before layout effects run.
-      const activeChipProbes = chipProbes.current.slice(0, limit) as HTMLButtonElement[]
-      const chips = activeChipProbes.map(probe => probe.getBoundingClientRect().width)
-      const more = Array.from({ length: limit + 1 }, (_, candidate) => {
-        if (paths.length === candidate) return undefined
-        remainderProbe.textContent = moreLabel(t, paths.length - candidate)
-        return remainderProbe.getBoundingClientRect().width
-      })
-      setShownCount(fitProducedFiles(row.clientWidth, gap, chips, more))
-    }
-    measure()
-    if (typeof ResizeObserver === 'undefined') return
-    const observer = new ResizeObserver(measure)
-    observer.observe(row)
-    for (const probe of [...chipProbes.current, moreProbe.current]) {
-      if (probe !== null) observer.observe(probe)
-    }
-    return () => { observer.disconnect() }
-  }, [limit, paths, t])
-
-  const visibleCount = Math.min(shownCount, limit)
-  const shown = paths.slice(0, visibleCount)
-  const hidden = paths.length - shown.length
+  const shown = paths.slice(0, SHOWN_LIMIT)
   return (
     <div className={css.root}>
       <span className={css.label}>{t('produced.label')}</span>
-      <div ref={rowRef} className={css.row} data-produced-files-row>
-        {shown.map(path => (
-          <button
-            key={path}
-            type="button"
-            className={css.file}
-            // The full path is the disambiguator when two turns produce files
-            // that share a basename; the chip itself stays short.
-            title={path}
-            aria-label={t('produced.open', { name: path })}
-            onClick={() => { openFile(path) }}
-          >
-            {basename(path)}
-          </button>
-        ))}
-        {hidden > 0 && <span className={css.more}>{moreLabel(t, hidden)}</span>}
-      </div>
-      {hidden > 0 && canOpenPath && (
-        <button type="button" className={css.showFolder} onClick={() => { openFile('.') }}>
-          {t('produced.showInFolder')}
-        </button>
-      )}
-      <div className={css.measure} aria-hidden="true">
-        {paths.slice(0, limit).map((path, index) => (
+      <div className={css.lane}>
+        <div className={css.row} data-produced-files-row>
+          {shown.map(path => (
+            <button
+              key={path}
+              type="button"
+              className={css.file}
+              // The full path is the disambiguator when two turns produce files
+              // that share a basename; the chip itself stays short.
+              title={path}
+              aria-label={t('produced.open', { name: path })}
+              onClick={() => { openFile(path) }}
+            >
+              {basename(path)}
+            </button>
+          ))}
+          {shown.map((_, index) => {
+            const shownCount = index + 1
+            const remainder = paths.length - shownCount
+            if (remainder <= 0) return null
+            return (
+              <span key={shownCount} className={css.more} data-shown={shownCount}>
+                {moreLabel(t, remainder)}
+              </span>
+            )
+          })}
+        </div>
+        {paths.length > 1 && canOpenPath && (
           <button
-            key={path}
-            ref={(node) => { chipProbes.current[index] = node }}
             type="button"
-            tabIndex={-1}
-            className={`${css.file} ${css.probe}`}
+            className={css.showFolder}
+            onClick={() => { openFile('.') }}
           >
-            {basename(path)}
+            {t('produced.showInFolder')}
           </button>
-        ))}
-        <span ref={moreProbe} className={`${css.more} ${css.probe}`} />
+        )}
       </div>
     </div>
   )

+ 6 - 79
packages/client/ui-deliverables/tests/produced-files.client.spec.tsx

@@ -6,7 +6,7 @@
  * (HMR safety) against the real SlotRegistry.
  */
 import { Context } from '@deepseek-ai/cordis'
-import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
+import { cleanup, fireEvent, render, within } from '@testing-library/react'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client'
 import {
@@ -21,9 +21,7 @@ import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
 import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
 import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
 import { makeTranslate, RemoteError, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
-import {
-  fitProducedFiles, ProducedFiles, type ProducedFilesInjected, type ProducedFilesProps,
-} from '../src/client/ProducedFiles.tsx'
+import { ProducedFiles, type ProducedFilesInjected, type ProducedFilesProps } from '../src/client/ProducedFiles.tsx'
 import {
   basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles,
   type DeliverablesTurnData,
@@ -32,17 +30,10 @@ import { apply, inject } from '../src/client/index.ts'
 import { en, zh } from '../src/client/locales.ts'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 
-const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
-
 afterEach(() => {
   cleanup()
   vi.restoreAllMocks()
   vi.unstubAllGlobals()
-  if (originalClientWidth === undefined) {
-    delete (HTMLElement.prototype as { clientWidth?: number }).clientWidth
-  } else {
-    Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth)
-  }
 })
 
 class TestTurnDataStore implements ConversationLocationDataStore<ConversationTurnDataMap> {
@@ -410,48 +401,9 @@ describe('ProducedFiles row', () => {
     }
   }
 
-  it('selects the largest prefix using the exact remainder width', () => {
-    expect(fitProducedFiles(230, 8, [70, 60, 60], [55, 55, 55, 55])).toBe(2)
-    expect(fitProducedFiles(145, 8, [70, 60, 60], [55, 55, 55, 55])).toBe(1)
-    expect(fitProducedFiles(300, 8, [70, 60, 60], [55, 55, 55, 55])).toBe(3)
-    // A zero-width lane is a pre-layout test/hidden state, not evidence that
-    // every chip overflowed; keep the bounded initial prefix until measured.
-    expect(fitProducedFiles(0, 8, [70, 60], [60, 50, undefined])).toBe(2)
-    expect(fitProducedFiles(128, 8, [60, 60], [70, 50, undefined])).toBe(2)
-    // Candidate-specific suffix widths matter at the 10 -> 9 digit boundary.
-    expect(fitProducedFiles(126, 8, [60], [70, 50])).toBe(1)
-    expect(fitProducedFiles(20, 8, [60], [70, 50])).toBe(0)
-  })
-
-  it('keeps one measured line, updates on resize, and opens a file or the workspace folder', () => {
-    const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts']
+  it('renders the bounded CSS candidates and opens a file or the workspace folder', () => {
+    const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts', 'h.ts']
     const openFile = vi.fn<(path: string) => void>()
-    let available = 226
-    let resize: ResizeObserverCallback | undefined
-    const disconnect = vi.fn()
-    const observeNode = vi.fn<(target: Element) => void>()
-    vi.stubGlobal('ResizeObserver', class {
-      constructor(callback: ResizeObserverCallback) { resize = callback }
-      observe(target: Element): void {
-        expect(target).toBeInstanceOf(Element)
-        observeNode(target)
-      }
-      disconnect(): void { disconnect() }
-    })
-    Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
-      configurable: true,
-      get(this: HTMLElement) { return this.hasAttribute('data-produced-files-row') ? available : 0 },
-    })
-    const rect = (width: number): DOMRect => ({
-      x: 0, y: 0, width, height: 22, top: 0, right: width, bottom: 22, left: 0,
-      toJSON: () => ({}),
-    })
-    const bounds = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
-      .mockImplementation(function getProbeRect(this: HTMLElement) {
-        if (this.closest('[aria-hidden="true"]') === null) return rect(0)
-        if (this.tagName !== 'BUTTON') return rect(60)
-        return rect(this.textContent === 'a.html' || this.textContent === 'b.css' ? 50 : 100)
-      })
 
     const view = render(
       <ProducedFiles matched={paths} openFile={openFile} {...capability(true)} t={t} />,
@@ -459,9 +411,8 @@ describe('ProducedFiles row', () => {
     expect(view.getByText('产物')).toBeTruthy()
     const row = view.container.querySelector('[data-produced-files-row]')
     if (!(row instanceof HTMLElement)) throw new Error('produced row missing')
-    // The third probe is 100px: two chips plus the remainder fit, three do not.
-    expect(within(row).getAllByRole('button')).toHaveLength(2)
-    expect(within(row).getByText('+ 5 个文件')).toBeTruthy()
+    expect(within(row).getAllByRole('button')).toHaveLength(6)
+    expect(within(row).getByText('+ 2 个文件')).toBeTruthy()
     const chip = view.getByRole('button', { name: '打开 deep/a.html' })
     expect(chip.textContent).toBe('a.html')
     expect(chip.getAttribute('title')).toBe('deep/a.html')
@@ -472,30 +423,6 @@ describe('ProducedFiles row', () => {
     const showFolder = view.getByRole('button', { name: '在文件夹中显示' })
     fireEvent.click(showFolder)
     expect(openFile).toHaveBeenLastCalledWith('.')
-
-    available = 150
-    act(() => { resize?.([], {} as ResizeObserver) })
-    expect(within(row).getAllByRole('button')).toHaveLength(1)
-    expect(within(row).getByText('+ 6 个文件')).toBeTruthy()
-
-    // A missing/unsupported computed gap falls back to zero rather than NaN.
-    vi.stubGlobal('getComputedStyle', () => ({ columnGap: '', gap: '' } as CSSStyleDeclaration))
-    available = 165
-    act(() => { resize?.([], {} as ResizeObserver) })
-    expect(within(row).getAllByRole('button')).toHaveLength(2)
-
-    // Ref callbacks leave nulls in the probe arrays when the candidate set
-    // shrinks; the replacement observer must skip those stale slots.
-    observeNode.mockClear()
-    view.rerender(
-      <ProducedFiles matched={paths.slice(0, 1)} openFile={openFile} {...capability(true)} t={t} />,
-    )
-    expect(within(row).getAllByRole('button')).toHaveLength(1)
-    expect(observeNode).toHaveBeenCalledTimes(3)
-
-    view.unmount()
-    expect(disconnect).toHaveBeenCalledTimes(2)
-    bounds.mockRestore()
   })
 
   it('keeps the folder action absent without overflow or a local native opener', () => {