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()