Explorar el Código

fix(client): web session and input UI polish

Batch of visually verified Web UI fixes: trigger-menu z-index over the
resize handle (#3228, #3229), input scrollbar offset, tool-row file
links and diff stats (#3230), @ menu crumb alignment, light-mode
divider, and @ menu flicker while typing (#3234) via
stale-while-revalidate — a refinement hit keeps the previous items and
highlight on screen until the new generation settles, so neither the
skeleton nor the first-row focus blinks per keystroke.
Yif hace 2 semanas
padre
commit
452013effa

+ 3 - 1
packages/client/ui-chat/src/client/chat/ChatView.module.css

@@ -175,7 +175,9 @@
   position: sticky;
   bottom: 16px;
   /* Above the sticky composer (z-index 7) so the control stays clickable and
-     visible over the input card. */
+     visible over the input card. Exception: while an @/slash menu is open the
+     seat lifts to 9 (ui-conversation ConversationRoot) so the menu is never
+     covered by this control. */
   z-index: 8;
   height: 0;
   display: flex;

+ 25 - 6
packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css

@@ -1,5 +1,5 @@
 .root {
-  position: relative; /* width handles are absolute against the column */
+  position: relative; /* positioning context for slot-owned absolute chrome */
   display: flex;
   flex-direction: column;
   height: 100%;
@@ -36,9 +36,6 @@
 
 .header {
   position: relative;
-  /* Above the width handles (which start at top: 0) so the breadcrumb row and
-     header buttons stay clickable. */
-  z-index: 9;
   flex: none;
   padding: 12px 28px 0 20px;
   border-bottom: 1px solid transparent;
@@ -202,8 +199,10 @@
 }
 
 /* Width handles: 40px col-resize strips beside the transcript, absolute in
-   .root (NOT the scrollport: an absolute strip there would extend the
-   scrollable range). Inner edge sits 24px outside the content column — the
+   .body (NOT the scrollport: an absolute strip there would extend the
+   scrollable range; NOT .root: a strip from the column top would paint its
+   glow through the header's transparent background). Inner edge sits 24px
+   outside the content column — the
    same offset the glow line paints at — and the strip extends 40px outward
    from there; the outer edge is clamped to keep a 24px safe zone against the
    column edges (sidebar side and scrollbar side stay drag-free). When the
@@ -315,6 +314,17 @@
   flex: none;
 }
 
+/* Band below the header: the scrollport plus the width handles. Anchoring the
+   handles here keeps their full-height strips and glow under the header's
+   bottom edge at every header height. */
+.body {
+  position: relative;
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  min-height: 0;
+}
+
 .scrollBody {
   display: flex;
   flex: 1;
@@ -360,6 +370,15 @@
   );
 }
 
+/* An open @/slash menu (data-trigger-menu, ui-input-trigger) renders inside
+   the input card, so the seat's stacking context caps its z-index; lift the
+   seat above the back-to-bottom control (z-index 8, ui-chat ChatView) while
+   the menu is open. The steady state stays 7 so the control keeps painting
+   over the card. */
+.root[data-phase='active'] .composerSeat:has([data-trigger-menu]) {
+  z-index: 9;
+}
+
 /* Views may opt into a composer overlay while ConversationRoot retains
    ownership of the seat geometry and its active-phase precedence. */
 .scrollBody:has([data-conversation-composer-overlay]) {

+ 17 - 15
packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx

@@ -378,22 +378,24 @@ export function ConversationRoot({
   return (
     <div ref={rootResizeRef} className={css.root} data-phase={phase}>
       {sessionId === undefined ? null : renderSlot('conversation.session.header', {})}
-      <div className={css.scrollBody} data-conversation-scroll="">
-        {sessionId === undefined ? null : renderSlot('conversation.session', {})}
-        {composerSeat}
+      <div className={css.body}>
+        <div className={css.scrollBody} data-conversation-scroll="">
+          {sessionId === undefined ? null : renderSlot('conversation.session', {})}
+          {composerSeat}
+        </div>
+        {/* Width handles only while a transcript is on screen; the hero has no
+            content column to size. */}
+        {phase === 'active' && (['left', 'right'] as const).map(side => (
+          <WidthHandle
+            key={side}
+            side={side}
+            onStart={onHandleStart}
+            onDrag={onHandleDrag}
+            onCommit={onHandleCommit}
+            onEnd={onHandleEnd}
+          />
+        ))}
       </div>
-      {/* Width handles only while a transcript is on screen; the hero has no
-          content column to size. */}
-      {phase === 'active' && (['left', 'right'] as const).map(side => (
-        <WidthHandle
-          key={side}
-          side={side}
-          onStart={onHandleStart}
-          onDrag={onHandleDrag}
-          onCommit={onHandleCommit}
-          onEnd={onHandleEnd}
-        />
-      ))}
     </div>
   )
 }

+ 17 - 4
packages/client/ui-conversation/src/client/skeleton/InputBar.module.css

@@ -117,6 +117,17 @@
 .scroll {
   max-height: var(--dsh-composer-text-max-height);
   overflow-y: auto;
+  /* Keeps the bar off the card's right border; .input's right pad gives the
+     4px back so the text column is unchanged. */
+  margin-right: 4px;
+}
+
+/* The card's 22px corner arc still overlaps the scrollport's top (10px top
+   pad reaches only y=10): start the thumb's travel below the arc so it never
+   pokes outside the capsule. WebKit-path only; Firefox's thin bar has no
+   track margin, an accepted remainder. */
+.scroll::-webkit-scrollbar-track {
+  margin-top: 8px;
 }
 
 /* Auto-grow anchor: the contenteditable is in normal flow and sets the
@@ -140,10 +151,11 @@
 }
 
 /* The contenteditable draft surface (grows with its content; .scroll caps
-   and scrolls it). figma .InputText 34:10434: pl 16 / pr 12 / pt 4. */
+   and scrolls it). figma .InputText 34:10434: pl 16 / pr 12 / pt 4 — the
+   right pad is 8 here plus .scroll's 4px bar inset. */
 .input {
   box-sizing: border-box;
-  padding: 4px 12px 0 16px;
+  padding: 4px 8px 0 16px;
   font-family: var(--dsw-font-family);
   font-size: inherit;
   line-height: inherit;
@@ -170,10 +182,11 @@
   color: var(--dsw-alias-label-caption);
 }
 
-/* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly. */
+/* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly.
+   Right inset mirrors .input's 8px pad (the other 4px sits on .scroll). */
 .placeholder {
   position: absolute;
-  inset: 4px 12px auto 16px;
+  inset: 4px 8px auto 16px;
   color: var(--dsw-alias-label-caption);
   pointer-events: none;
   user-select: none;

+ 7 - 3
packages/client/ui-input-trigger/src/client/MenuView.module.css

@@ -185,16 +185,20 @@
 }
 
 /* Breadcrumb header of a drilled source: pinned outside .viewport so the
-   descent stays reversible while the candidate list scrolls under it. */
+   descent stays reversible while the candidate list scrolls under it.
+   4px sides + the crumb's own 6px = the first crumb's text sits at 10px,
+   flush with .sectionTitle and the item icons. */
 .crumbs {
   flex: none;
   display: flex;
   align-items: center;
   flex-wrap: wrap;
   gap: 2px;
-  padding: 4px 6px 6px;
+  padding: 4px 4px 6px;
   margin-bottom: 2px;
-  border-bottom: 1px solid var(--dsw-alias-border-inverted);
+  /* l1, not border-inverted (the menu frame's dark-only stroke): the divider
+     needs to survive light mode too; in dark both resolve to white/0.06. */
+  border-bottom: 1px solid var(--dsw-alias-border-l1);
 }
 /* Crumbs sit outside the listbox and take no keyboard highlight, so unlike
    .item they carry their own :hover tint with nothing to compete with. */

+ 4 - 2
packages/client/ui-input-trigger/src/client/MenuView.tsx

@@ -2,7 +2,9 @@
  * Trigger candidate menu: renders the InputTriggerService menu store into the
  * conversation.input.overlay anchor. Closed state renders null (the overlay
  * slot stays mounted); groups render in roster order under localized title
- * rows, pending groups as two skeleton rows; pointer picks route back through
+ * rows. A pending group keeps showing the items it already had (the reducer
+ * retains them across a query refinement) and falls back to two skeleton
+ * rows only while it has none; pointer picks route back through
  * the service (combobox pattern — focus never leaves the textarea, so rows
  * are mousedown-handled and the highlight is exposed via
  * aria-activedescendant on the listbox). A source publishing crumbs gets a
@@ -114,7 +116,7 @@ export function MenuView({ menu, headers, onPick, onCrumb, onHover, onDismiss, t
               {group.showGroupTitle === false || group.items.some(item => item.section !== undefined)
                 ? null
                 : <div className={css.groupTitle} role="presentation" data-source={group.source}>{t(group.source as MenuKey)}</div>}
-              {group.status === 'pending'
+              {group.status === 'pending' && group.items.length === 0
                 ? (
                   <div role="status" aria-label={t('loading')} data-source={group.source}>
                     <div className={css.skeletonRow}><span className={css.skeletonBar} style={{ width: '32%' }} /></div>

+ 11 - 3
packages/client/ui-input-trigger/src/core/menu.ts

@@ -8,7 +8,10 @@
  * reducer cannot invent groups. Opening from a closed state, the shell seeds
  * the roster with {@link seedGroups} and then dispatches `hit`; a `hit`
  * while open (query refinement) resets the existing groups to pending under
- * a new generation. Auto-close and explicit close drop the groups.
+ * a new generation while keeping their items on screen until the new fetch
+ * settles (stale-while-revalidate — the render layer shows skeletons only
+ * for a pending group with no items). Auto-close and explicit close drop
+ * the groups.
  */
 import type { InputTriggerCandidate, InputTriggerSource } from '../types.ts'
 import type { ExactMatch, MenuReduce, MenuState } from './contract.ts'
@@ -96,8 +99,13 @@ export const menuReduce: MenuReduce = (state, ev) => {
         open: true,
         hit: ev.hit,
         generation: state.generation + 1,
-        groups: state.groups.map(g => ({ ...g, status: 'pending', items: [] })),
-        highlight: null,
+        // Items and highlight survive the refinement (stale-while-revalidate):
+        // the previous query's candidates stay rendered with the highlight
+        // parked where it was while the new fetch runs, and the settled
+        // generation replaces the items and revalidates the highlight
+        // wholesale. Pending status still fences picks off the stale rows.
+        groups: state.groups.map(g => ({ ...g, status: 'pending' })),
+        highlight: state.highlight,
       }
     }
     case 'source-settled': {

+ 9 - 3
packages/client/ui-input-trigger/tests/core-menu.client.spec.ts

@@ -36,13 +36,19 @@ describe('menuReduce hit', () => {
     expect(s.highlight).toBeNull()
   })
 
-  it('re-hit resets ready groups to pending under a bumped generation', () => {
+  it('re-hit resets ready groups to pending under a bumped generation, keeping items and highlight', () => {
     let s = open(['command'])
     s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal')] })
     s = menuReduce(s, { type: 'hit', hit: hit('g') })
     expect(s.generation).toBe(2)
-    expect(s.groups).toEqual([{ source: 'command', status: 'pending', items: [] }])
-    expect(s.highlight).toBeNull()
+    // Stale-while-revalidate: the previous query's items stay until the new
+    // generation settles and replaces them, and the highlight stays parked
+    // instead of blinking off between keystrokes.
+    expect(s.groups).toEqual([{ source: 'command', status: 'pending', items: [item('goal')] }])
+    expect(s.highlight).toEqual({ source: 'command', index: 0 })
+    s = menuReduce(s, { type: 'source-settled', generation: 2, source: 'command', items: [item('grep')] })
+    expect(s.groups).toEqual([{ source: 'command', status: 'ready', items: [item('grep')] }])
+    expect(s.highlight).toEqual({ source: 'command', index: 0 })
   })
 
   it('preserves a hidden group title through re-hit and settlement', () => {

+ 9 - 0
packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx

@@ -123,6 +123,15 @@ describe('MenuView', () => {
     expect(screen.getByRole('status', { name: '正在加载…' })).toBeTruthy()
   })
 
+  it('renders retained items instead of skeletons while a refinement is pending', () => {
+    mount(openState({
+      groups: [{ source: 'command', status: 'pending', items: [{ name: 'goal' }] }],
+      highlight: null,
+    }))
+    expect(screen.getAllByRole('option').map(o => o.textContent)).toEqual(['goal'])
+    expect(screen.queryByRole('status')).toBeNull()
+  })
+
   it('titles each group with the localized source name, raw name for unknown sources, none for empty ready groups', () => {
     const { view } = mount(openState({
       groups: [

+ 19 - 0
packages/client/ui-input-trigger/tests/service.client.spec.ts

@@ -293,6 +293,25 @@ describe('track', () => {
     expect(controller.menu.getSnapshot().groups[0]!.items).toEqual([{ name: 'goal' }])
   })
 
+  it('refinement keeps the settled items on screen until the new fetch lands', async () => {
+    const cmd = deferredSource('/', 'command')
+    const { controller } = controllerBench([cmd.source])
+    controller.track('/g', 2, { tier: 'plain' }, 1)
+    cmd.pending[0]!.resolve([{ name: 'goal' }])
+    await tick()
+
+    // Stale-while-revalidate: the pending group still carries the items.
+    controller.track('/go', 3, { tier: 'plain' }, 1)
+    expect(controller.menu.getSnapshot().groups[0]).toEqual(
+      { source: 'command', status: 'pending', items: [{ name: 'goal' }] },
+    )
+    cmd.pending[1]!.resolve([{ name: 'goat' }])
+    await tick()
+    expect(controller.menu.getSnapshot().groups[0]).toEqual(
+      { source: 'command', status: 'ready', items: [{ name: 'goat' }] },
+    )
+  })
+
   it('same hit re-track refreshes the span stamp without refetching', () => {
     const cmd = deferredSource('/', 'command')
     const { controller } = controllerBench([cmd.source])

+ 23 - 9
packages/client/ui-primitives/src/DiffBlock.tsx

@@ -62,21 +62,37 @@ const ROW_CLASS: Record<DiffRow['kind'], string | undefined> = {
   gap: css.gap,
 }
 
+/**
+ * Total added/removed line counts across hunks — the same numbers the footer
+ * prints, exported so a summary row can show them without rebuilding the body.
+ * Every old-side line counts toward `removed` and every new-side line toward
+ * `added`, under {@link contentLines}'s terminator rule.
+ * @param diffs - the hunks to count.
+ * @returns the +/- totals.
+ */
+export function diffTotals(diffs: DiffHunk[]): { added: number; removed: number } {
+  let added = 0
+  let removed = 0
+  for (const diff of diffs) {
+    if (diff.oldText !== null) removed += contentLines(diff.oldText).length
+    added += contentLines(diff.newText).length
+  }
+  return { added, removed }
+}
+
 /**
  * Flatten the hunks into the body's rows plus the footer counts. A path header
  * opens each new file; a same-file second hunk (a scattered edit) opens with a
- * `⋯` gap instead of repeating the path. Every old-side line counts toward
- * `removed` and every new-side line toward `added`. The file count is of
- * DISTINCT paths, matching the TUI diff card's footer, so two hunks in one file
- * read as `1 file` on both front ends.
+ * `⋯` gap instead of repeating the path. The +/- totals are
+ * {@link diffTotals}'s. The file count is of DISTINCT paths, matching the TUI
+ * diff card's footer, so two hunks in one file read as `1 file` on both front
+ * ends.
  * @param diffs - the hunks to render.
  * @returns the body rows, the +/- totals, and the distinct-file count.
  */
 function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed: number; files: number } {
   const rows: DiffRow[] = []
   const paths = new Set<string>()
-  let added = 0
-  let removed = 0
   let prevPath: string | undefined
   for (const diff of diffs) {
     paths.add(diff.path)
@@ -86,15 +102,13 @@ function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed
     if (diff.oldText !== null) {
       for (const line of contentLines(diff.oldText)) {
         rows.push({ kind: 'del', text: line })
-        removed++
       }
     }
     for (const line of contentLines(diff.newText)) {
       rows.push({ kind: 'add', text: line })
-      added++
     }
   }
-  return { rows, added, removed, files: paths.size }
+  return { rows, ...diffTotals(diffs), files: paths.size }
 }
 
 /**

+ 1 - 1
packages/client/ui-primitives/src/index.ts

@@ -40,7 +40,7 @@ export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
 export type { TerminalBlockProps, TerminalBlockLabels } from './TerminalBlock.tsx'
 export { ReadBlock, DEFAULT_READ_MAX_LINES } from './ReadBlock.tsx'
 export type { ReadBlockProps, ReadBlockLine, ReadBlockLabels } from './ReadBlock.tsx'
-export { DiffBlock, DEFAULT_DIFF_MAX_LINES } from './DiffBlock.tsx'
+export { DiffBlock, DEFAULT_DIFF_MAX_LINES, diffTotals } from './DiffBlock.tsx'
 export type { DiffBlockProps, DiffHunk, DiffBlockLabels } from './DiffBlock.tsx'
 export { SearchBlock, DEFAULT_SEARCH_MAX_LINES } from './SearchBlock.tsx'
 export type {

+ 16 - 9
packages/client/ui-primitives/src/user-text.module.css

@@ -9,20 +9,27 @@
 }
 
 .refChip {
-  display: inline-flex;
-  align-items: center;
-  gap: 4px;
+  /* Plain inline, NOT inline-flex: a flex container takes its baseline from
+     its first flex item — the icon svg, which has no text baseline — so the
+     chip's label rode ~3px above the surrounding text in both surfaces. An
+     inline chip shares the consumer's baseline by construction. */
+  display: inline;
   margin: 0 2px;
   color: var(--dsw-alias-state-business-primary);
   font-weight: 500;
   white-space: nowrap;
-  vertical-align: baseline;
 }
 
-/* Inline reference glyphs (always ReferenceIcon svgs) ride the consumer's text
-   size: the 16px svg edge moves by the same px delta as the surrounding font. */
+/* Inline reference glyphs (always ReferenceIcon svgs) ride the consumer's own
+   font: 1em keeps the glyph at the text's size in the bubble (14px + user
+   setting), the queue preview's fixed 13px line, and any future consumer — a
+   px+delta size followed only the bubble axis and left the glyph oversized in
+   the queue row. */
 .refIcon {
-  flex: none;
-  width: calc(16px + var(--dsh-content-font-delta, 0px));
-  height: calc(16px + var(--dsh-content-font-delta, 0px));
+  width: 1em;
+  height: 1em;
+  margin-right: 4px;
+  /* Optical centering against the text: drops the glyph below the baseline by
+     a font-relative amount, so it holds across the font axis. */
+  vertical-align: -0.125em;
 }

+ 3 - 3
packages/client/ui-primitives/tests/user-text-styles.client.spec.ts

@@ -18,10 +18,10 @@ function declarations(selector: string): string[] {
 }
 
 describe('user-text.module.css font-size axis', () => {
-  it('scales reference glyphs by the shared px delta', () => {
+  it('scales reference glyphs with the consumer font', () => {
     expect(declarations('.refIcon')).toEqual(expect.arrayContaining([
-      'width: calc(16px + var(--dsh-content-font-delta, 0px))',
-      'height: calc(16px + var(--dsh-content-font-delta, 0px))',
+      'width: 1em',
+      'height: 1em',
     ]))
   })
 })

+ 24 - 4
packages/client/ui-tool/src/client/tool/components/ToolRow.module.css

@@ -103,9 +103,28 @@
   color: var(--dsw-alias-label-tertiary);
 }
 
-/* File-tool path: same geometry as .summary, with a persistent link affordance. */
+/* Diff-row +/- totals: the code font, matching the diff body the numbers
+   summarize; a wider gap keeps the digits from reading as part of the path.
+   Two px under the secondary tier (still riding the axis): mono digits read
+   optically larger than the sans path at the same size. Caption, one step
+   dimmer than the suffix's tertiary, so the digits stay behind the path. The
+   half-pixel nudge closes the baseline gap the size difference leaves under
+   the row's box-centering (a transform, so flex layout is untouched). */
+.diffStat {
+  margin-left: 10px;
+  font-family: var(--ds-font-family-code);
+  font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 2px);
+  color: var(--dsw-alias-label-caption);
+  transform: translateY(0.5px);
+}
+
+/* File-tool path: same type as .summary, with a persistent link affordance.
+   Dotted dimmed underline: visible enough to say "clickable", light enough
+   that a long path doesn't read as one heavy rule under the row. Shrink-to-fit
+   (flex 0): the click target ends where the path text ends — the row's empty
+   remainder stays the expand/collapse toggle. */
 .fileLink {
-  flex: 1 1 auto;
+  flex: 0 1 auto;
   min-width: 0;
   overflow: hidden;
   text-overflow: ellipsis;
@@ -119,8 +138,9 @@
   font-size: var(--dsh-content-font-size-secondary, 13px);
   line-height: calc(24px + var(--dsh-content-font-delta, 0px));
   color: var(--dsw-alias-label-secondary);
-  text-decoration: underline;
-  text-decoration-color: var(--dsw-alias-label-quaternary);
+  text-decoration: underline dotted;
+  text-decoration-color: var(--dsw-alias-label-tertiary);
+  text-decoration-thickness: 1px;
   text-underline-offset: 3px;
   cursor: pointer;
 }

+ 13 - 2
packages/client/ui-tool/src/client/tool/components/ToolRow.tsx

@@ -2,6 +2,7 @@ import { useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode
 import clsx from 'clsx'
 import {
   CodeBlock, DiffBlock, DisclosureRow, IconInspectOutline12, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
+  diffTotals,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
 import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../models/diff-card-model.ts'
@@ -129,7 +130,15 @@ export function ToolRow({
   // A failure must replace, not supplement, the normal summary.
   const failureLine = state === 'error' ? errorSummary ?? null : null
   const summaryText = failureLine ?? terminalBody?.description ?? summary
-  const suffix = failureLine === null ? summarySuffix ?? null : null
+  // A diff row's collapsed line carries the card's +/- totals (the same
+  // numbers the expanded footer prints) so the change size reads without
+  // expanding; an explicit summarySuffix (none today on diff rows) wins.
+  const diffStat = useMemo(() => {
+    if (diffBody === null) return null
+    const { added, removed } = diffTotals(diffBody.card.diffs)
+    return `+${added} -${removed}`
+  }, [diffBody])
+  const suffix = failureLine === null ? summarySuffix ?? diffStat : null
   const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
   const toggleExpand = () => {
     setExpanded(v => !v)
@@ -184,7 +193,9 @@ export function ToolRow({
                 {summaryText}
               </span>
             )}
-            {suffix !== null && <span className={css.summarySuffix}>{suffix}</span>}
+            {suffix !== null && (
+              <span className={clsx(css.summarySuffix, suffix === diffStat && css.diffStat)}>{suffix}</span>
+            )}
           </>
         )}
       >

+ 2 - 0
packages/client/ui-tool/tests/diff-card.client.spec.tsx

@@ -252,6 +252,8 @@ describe('FileMutationRow diff card', () => {
       call: { name: 'write', argsRaw: writeArgs },
       meta: { diffs: [] },
     }), 'write')} />)
+    // The collapsed row already carries the card's +/- totals beside the path.
+    expect(view.getByText('+1 -0')).toBeTruthy()
     // The footer counts live inside the collapsed diff card.
     toggleRow(view)
     expect(view.getByText('└ +1 -0 · 1 个文件')).toBeTruthy()