mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-12 04:01:20 +00:00
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:
@@ -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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user