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.
This commit is contained in:
Yif
2026-08-28 11:16:00 +08:00
parent 57aba7695b
commit 452013effa
17 changed files with 203 additions and 65 deletions
@@ -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;
@@ -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]) {
@@ -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>
)
}
@@ -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;
@@ -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. */
@@ -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>
@@ -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': {
@@ -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', () => {
@@ -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: [
@@ -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])
@@ -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
View File
@@ -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 {
@@ -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;
}
@@ -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',
]))
})
})
@@ -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;
}
@@ -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>
)}
</>
)}
>
@@ -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()