diff --git a/packages/client/ui-chat/src/client/chat/ChatView.module.css b/packages/client/ui-chat/src/client/chat/ChatView.module.css
index 0dbb71f541..16090d200f 100644
--- a/packages/client/ui-chat/src/client/chat/ChatView.module.css
+++ b/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;
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css
index 0451f499c3..9e139f5b5c 100644
--- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css
+++ b/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]) {
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
index 7b81d4db92..84bfbcce75 100644
--- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
@@ -378,22 +378,24 @@ export function ConversationRoot({
return (
{sessionId === undefined ? null : renderSlot('conversation.session.header', {})}
-
- {sessionId === undefined ? null : renderSlot('conversation.session', {})}
- {composerSeat}
+
+
+ {sessionId === undefined ? null : renderSlot('conversation.session', {})}
+ {composerSeat}
+
+ {/* 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 => (
+
+ ))}
- {/* 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 => (
-
- ))}
)
}
diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
index 8230af0199..6576608916 100644
--- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
+++ b/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;
diff --git a/packages/client/ui-input-trigger/src/client/MenuView.module.css b/packages/client/ui-input-trigger/src/client/MenuView.module.css
index 3ef63823e5..42499397b8 100644
--- a/packages/client/ui-input-trigger/src/client/MenuView.module.css
+++ b/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. */
diff --git a/packages/client/ui-input-trigger/src/client/MenuView.tsx b/packages/client/ui-input-trigger/src/client/MenuView.tsx
index 7bdc0a76d2..64be92b20a 100644
--- a/packages/client/ui-input-trigger/src/client/MenuView.tsx
+++ b/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
:
{t(group.source as MenuKey)}
}
- {group.status === 'pending'
+ {group.status === 'pending' && group.items.length === 0
? (
diff --git a/packages/client/ui-input-trigger/src/core/menu.ts b/packages/client/ui-input-trigger/src/core/menu.ts
index 47a6090d8f..85787266a5 100644
--- a/packages/client/ui-input-trigger/src/core/menu.ts
+++ b/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': {
diff --git a/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts b/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts
index 532f608fcf..62b8c7d01b 100644
--- a/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts
+++ b/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', () => {
diff --git a/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx b/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx
index b174cb8a35..f9912ba1f9 100644
--- a/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx
+++ b/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: [
diff --git a/packages/client/ui-input-trigger/tests/service.client.spec.ts b/packages/client/ui-input-trigger/tests/service.client.spec.ts
index e63a736694..43eeba0f64 100644
--- a/packages/client/ui-input-trigger/tests/service.client.spec.ts
+++ b/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])
diff --git a/packages/client/ui-primitives/src/DiffBlock.tsx b/packages/client/ui-primitives/src/DiffBlock.tsx
index 1c7925331e..01a080bbfc 100644
--- a/packages/client/ui-primitives/src/DiffBlock.tsx
+++ b/packages/client/ui-primitives/src/DiffBlock.tsx
@@ -62,21 +62,37 @@ const ROW_CLASS: Record
= {
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()
- 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 }
}
/**
diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts
index 8415fcf059..20371add21 100644
--- a/packages/client/ui-primitives/src/index.ts
+++ b/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 {
diff --git a/packages/client/ui-primitives/src/user-text.module.css b/packages/client/ui-primitives/src/user-text.module.css
index 53a6fafe83..3e6f8ca4f0 100644
--- a/packages/client/ui-primitives/src/user-text.module.css
+++ b/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;
}
diff --git a/packages/client/ui-primitives/tests/user-text-styles.client.spec.ts b/packages/client/ui-primitives/tests/user-text-styles.client.spec.ts
index 4b06c6f3b2..d80d4f5c13 100644
--- a/packages/client/ui-primitives/tests/user-text-styles.client.spec.ts
+++ b/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',
]))
})
})
diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css
index 4a3d5e46ef..2f344ac0dd 100644
--- a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css
+++ b/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;
}
diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx
index ccf211e468..ab38bc9dbc 100644
--- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx
+++ b/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}
)}
- {suffix !== null && {suffix}}
+ {suffix !== null && (
+ {suffix}
+ )}
>
)}
>
diff --git a/packages/client/ui-tool/tests/diff-card.client.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx
index 8a263f2983..ebe2b10351 100644
--- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx
+++ b/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()