docs: remove implementation narration from prose

This commit is contained in:
Turtle
2026-08-21 22:22:23 +08:00
parent d04adbc73c
commit 6b3e971805
225 changed files with 256 additions and 1755 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md
2026-08-09-concrete-prose-names-actors-and-recorded-facts.md: b7df5403ea11ff8ba32be0f9bede5a32d5bcd6ee
2026-08-09-concrete-prose-names-actors-and-recorded-facts.zh.md: 8a45d452bfd98c1fe87a9a42bd4930273d034ca8
2026-08-09-concrete-prose-names-actors-and-recorded-facts.md: 887a3666501a97e0930fe7eb4282603f537c17e8
2026-08-09-concrete-prose-names-actors-and-recorded-facts.zh.md: 0202be5c22c75ff4feec225cdff12b772d64d1cf
@@ -16,6 +16,8 @@ Maintained prose names the exact actor, action, source, event, field, file, or p
The rule applies to Markdown, READMEs, active Agent Notes, JSDoc and comments, prompts, diagnostics, and user-visible strings. An audit judges each sentence separately; it does not replace a term across the repository with one preferred synonym. The edited sentence preserves actor, action, conditions, order, modality, exceptions, ownership, failure behavior, and consequences.
Comments retain only facts that nearby code cannot express. Documentation stays at its owning level and omits private control flow and rare implementation cases unless they change supported behavior, safe use, compatibility, data integrity, security, or another maintained contract. The [simplification workflow](../../../skills/dsh-find-simplifications/SKILL.md) applies this rule while surveying code and prose together.
Exact code identifiers, public APIs, durable fields, protocol members, type names, headings with external references, and filenames stay unchanged unless a coordinated contract rename is independently required. Surrounding prose explains their fields or behavior directly. Generated documents and catalogs update from their owning source.
Before using `contract`, `boundary`, or `shape`, writers check whether the sentence means a more specific rule, operation, data structure, field set, validation point, timing point, API, type, or failure condition. `Contract` remains correct for preconditions, postconditions, invariants, compatibility promises, and other obligations that callers, callees, implementers, providers, producers, or consumers rely on. `Boundary` remains correct for a literal security, trust, wire, process, serialization, transaction, or lifecycle division. `Shape` remains correct when the structural form itself is the subject and no narrower term such as fields, schema, type, union variant, file layout, or export form states the fact. Code and API names containing these words remain unchanged unless a separate coordinated rename is required.
@@ -16,6 +16,8 @@ Status: implemented
该规则适用于 Markdown、README、活跃 Agent Note、JSDoc 与注释、提示词、诊断信息和用户可见字符串。审查会分别判断每个句子,不会在整个仓库中用一个偏好的近义词统一替换某个术语。编辑后的句子保留执行者、动作、条件、顺序、情态、例外、归属、失败行为和后果。
注释只保留附近代码无法表达的事实。文档停留在内容归属的层级;除非私有控制流或罕见实现情形会改变受支持行为、安全使用、兼容性、数据完整性、安全保障或另一项持续维护的约定,否则文档不描述它们。[简化工作流](../../../skills/dsh-find-simplifications/SKILL.md)在同时巡查代码与行文时应用此规则。
除非另一项独立需求明确要求协调重命名约定,否则确切的代码标识符、公开 API、持久字段、协议成员、类型名、带有外部引用的标题和文件名均保持不变。它们周围的行文直接说明其字段或行为。生成的文档和目录在维护它们的源文件修改后更新。
使用 `contract``boundary``shape` 之前,写作者要确认句子是否实际指更具体的规则、操作、数据结构、字段集合、校验点、时间点、API、类型或失败条件。调用方、被调用方、实现方、提供方、生产方或消费方依赖的前置条件、后置条件、不变量、兼容性承诺及其他义务仍可准确称为 `contract`。真实的安全、信任、wire、进程、序列化、事务或生命周期分界仍可准确称为 `boundary`。当结构形式本身就是主题,且字段、schema、类型、联合变体、文件布局或导出形式等更窄的词无法说明事实时,仍可使用 `shape`。除非另一项独立需求要求协调重命名,否则包含这些词的代码和 API 名称保持不变。
@@ -1,6 +1,6 @@
---
name: dsh-find-simplifications
description: 'Use when working in the deepseek-harness repo to find non-obvious simplification candidates, write proposed Agent Notes or inline TODO/FIXME/XXX notes, audit or coalesce superseded Agent Notes, or fold worthwhile simplification ideas from another PR; especially for dead, duplicated, speculative, over-built, added-then-removed, or hand-rolled-where-a-dependency-exists surfaces.'
description: 'Use when working in the deepseek-harness repo to find non-obvious simplification candidates, remove redundant comments or implementation-heavy documentation, write proposed Agent Notes or inline TODO/FIXME/XXX notes, audit or coalesce superseded Agent Notes, or fold worthwhile simplification ideas from another PR; especially for dead, duplicated, speculative, over-built, added-then-removed, or hand-rolled-where-a-dependency-exists surfaces.'
---
# Finding DeepSeek Harness Simplifications
@@ -44,6 +44,13 @@ If subagents are unavailable, simulate the same breadth yourself. Do not let the
Start with the largest production-code deltas. A broad simplification audit that stops after obvious unused symbols can miss the files where duplicated lifecycle or defensive machinery carries most of the cost.
## Simplify Prose With The Code
Treat comments and documentation as maintained surface area. Apply [dsh-prose-standard](../dsh-prose-standard/SKILL.md) when a survey includes prose.
- Delete comments that restate code or explain behavior owned elsewhere; keep required local contracts.
- Keep docs at their owning level; omit implementation details and rare cases unless they change a maintained contract.
## Audit Trust And Lifecycle Boundaries
For every defensive copy, freeze, validator, and callback capture, name where the value came from and who owns it next. Same-process typed service/plugin calls ordinarily borrow readonly values; parsers, config loaders, queues, model/tool JSON, durable files, workers, processes, and wire decoders own or validate their data. Tests built around hostile getters, fake typed objects, callback replacement, or mutation after a same-process handoff are evidence of a potentially speculative contract, not automatic justification for keeping it.
+1 -1
View File
@@ -117,7 +117,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
- **Source plane vs artifact plane, never mixed.** Static gates and tests resolve workspace imports through tsconfig `paths` to `src` and pass on a clean tree; gates consuming built `lib/` declare that dependency ([layout](docs/development.md#typescript-project-layout)).
- **Keep compiler faces explicit.** Each package uses one aggregate except `api/remotes`; repo-wide programs seed a face config, never the root solution ([layout](docs/development.md#typescript-project-layout)).
- **An empty `catch` names what it swallows** and why nothing else can reach it; keep the `try` to one statement.
- Do not comment on facts obvious from code.
- **Keep comments local.** Do not restate code, explain distant behavior unless locally required, or expand unrelated comments ([rationale](.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md)).
- **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction.
- **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR.
- **Non-trivial changes MUST include an Agent Note in the same PR;** only mechanical/local edits are exempt ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)).
+1 -4
View File
@@ -1,8 +1,6 @@
#!/usr/bin/env node
/**
* dsh — command-line entry. Dynamic imports per mode keep unrelated modes out
* of each dispatch path; the adapter prints and exits for
* `--help`/`--version`/a parse error, so only a valid mode reaches the switch.
* Command-line entry for dsh.
* @module @deepseek-ai/dsh/bin
*/
@@ -16,7 +14,6 @@ import { parseDshArgs } from './args.ts'
// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit
// one directory under apps/cli, so the checked-in manifest resolves with the
// same relative hop from either artifact.
/** This app's version, read from its checked-in package.json. */
function readVersion(): string {
const manifest = JSON.parse(
readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'),
+1 -5
View File
@@ -1,8 +1,4 @@
/**
* Web application entry: thin bootstrap over the shell library. Everything —
* module-table seeding, the boot page, and the UI-renderer handoff — lives
* in @deepseek-ai/dsh-client-web; this file only finds the mount point.
*/
/** Browser entry for the Web client. */
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
const el = document.getElementById('root')
+2 -2
View File
@@ -3,10 +3,10 @@
* configured loader path and fails loud if that assumption changes.
*/
/** Throwing stand-in for node:module's createRequire (never reached in the browser boot). */
/** Fail if browser boot reaches Node's module loader. */
export const createRequire = (): never => {
throw new Error('node:module is not available in the browser')
}
/** Erased type peer for the vendored loader's type-only LoadHookContext import. */
/** Type-only peer for the vendored loader. */
export type LoadHookContext = never
+9 -46
View File
@@ -1,16 +1,5 @@
// Web e2e scenario: the composer-takeover approval panel under a long
// command. The shipped composition confines bash through the sandbox policy
// and routes its escalation through the approval seam, so a read-only session
// asked to write a file produces a REAL pending approval — the panel renders
// in the browser, the test measures its geometry, answers through it, and the
// escalated command then runs. Replay is deterministic: the denial, the
// escalation retry and its command text arrive from replayed chunks, and the
// answer click is the test's own gesture (the same sanctioned reaction to
// model content as the question composer: the turn cannot complete without it).
//
// Geometry is the point of the scenario. The command is unbounded model text,
// and an uncapped card grows with it until the refuse/allow buttons leave the
// viewport — an approval the user could see and not answer.
// Browser geometry for a pending approval whose model-supplied command would
// push the actions outside the viewport without a capped text region.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
@@ -29,17 +18,12 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/approval-composer', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
// The scenario's one golden: the waiting panel. Everything the answered state
// proves is asserted directly — see the world-state block at the end.
// The golden covers the stable waiting panel; direct assertions cover its answer.
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const MODE = webSnapshotMode()
// Irreducible payload: the command has to be long enough to pass the card's
// height cap, which is the only command length that reproduces an action row pushed off
// screen. Unrelated tokens, not a repeated word — a repeated word is what the
// model compressed into `printf 'alpha %.0s' {1..400}` while recording, and a
// short command proves nothing here. The formula keeps the source small; the
// model receives the expanded literal it has to put in the command.
// Unrelated tokens keep the recorded model from compressing the payload into a
// short shell loop that would not overflow the card.
const TOKENS = Array.from({ length: 220 }, (_, index) => `tok${((index + 1) * 7919 % 99991).toString(36)}`).join(' ')
const PROMPT = `Write a file named notes.txt in the workspace containing exactly this text on one line: ${TOKENS}. Use one bash command with the literal text inline. Then reply with the single word DONE and stop.`
@@ -77,19 +61,12 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
// The composer's own text cap, measured on the live draft scrollport before
// the takeover replaces it — the box that carries the cap, while the
// textarea inside it is as tall as the whole draft. The panel's scroll
// region must stop at the same height (the designer's requirement: one cap
// for the composer seat), and measuring it here keeps the assertion free of
// the px value itself.
// Derive the expected cap from the live composer instead of duplicating its pixel value.
await input.fill(CAP_PROBE)
const composerCap = await input.evaluate(el => el.closest('[data-input-scroll]')?.clientHeight ?? 0)
expect(composerCap).toBeGreaterThan(0)
await input.fill('')
// Read-only: the mode whose denial the model escalates from. Switched
// through the shipped access-mode chip, not a test-only override.
await page.locator('[aria-label^="Access mode"]').click()
await page.getByRole('menuitem', { name: 'Read Only' }).click()
await expect.poll(
@@ -101,22 +78,15 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
await input.fill(PROMPT)
await input.press('Enter')
// The panel takes over the input area while the tool blocks. Its presence
// is a STABLE waiting state (it stays until answered), so waitFor is
// race-free.
const panel = page.locator('[data-approval-key]')
await panel.waitFor({ timeout: MODE === 'record' ? 180_000 : 60_000 })
const scroll = panel.locator('[data-approval-scroll]')
await expect.poll(() => scroll.getByText(/tok/).count(), { timeout: 15_000 }).toBeGreaterThan(0)
if (MODE !== 'record') {
// This golden owns the stable waiting surface; the answered golden below
// owns the resulting transcript.
const snapshot = await captureStableAria(page, '[data-approval-key]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
// The uncapped-card hazard the header names, measured at the lane
// baseline and at a short viewport, on the live panel.
const original = page.viewportSize() ?? { width: 1680, height: 1000 }
for (const height of [1000, 700]) {
await page.setViewportSize({ width: 900, height })
@@ -140,11 +110,8 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
})
expect(geometry.buttons).toBe(2)
expect(geometry.scrolls).toBe(true)
// One cap for the seat: the panel's text region stops where the
// composer draft does (sub-pixel tolerance for the shared padding).
// The panel and composer share one cap; allow sub-pixel layout variance.
expect(Math.abs(geometry.capped - composerCap)).toBeLessThan(1)
// Both buttons stay inside the card AND inside the viewport — the
// answerable state the cap exists to guarantee.
expect(geometry.actionsTop).toBeGreaterThan(0)
expect(geometry.actionsBottom).toBeLessThanOrEqual(geometry.viewport)
expect(geometry.actionsBottom).toBeLessThanOrEqual(geometry.cardBottom)
@@ -159,12 +126,8 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
await recordFixture(scaffold, sessionId, FIXTURE)
return
}
// World state: the granted escalation is what let the command run, and the
// panel leaves with the regular composer restored. Asserted on the world
// and the DOM rather than through a transcript golden — the denied first
// attempt renders the OS's own refusal ("Operation not permitted" on
// macOS, "Read-only file system" on Linux), so the answered transcript is
// not a platform-neutral golden surface.
// The denied attempt contains platform-specific OS text, so direct state
// and DOM assertions cover the answered outcome.
expect(JSON.stringify(sessionEvents.filter(e => e.type === 'approval/decided').at(-1)))
.toContain('allowed-once')
const written = await readFile(join(scaffold.workspaceCwd, 'workspace', 'notes.txt'), 'utf8')
+4 -11
View File
@@ -1,8 +1,5 @@
// Web e2e scenario: the session-header background-job list over the real
// host. No model call is involved — a genuine `run_in_background` bash call
// registers with `ctx.jobs`, and the assertion chain is the whole delivery
// path: registry change feed → api-proxy `session/jobs` frame → the client's
// `jobsBySession` mirror → the header action.
// Session-header background jobs driven by a real `ctx.jobs` entry. No model
// call is involved.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
@@ -30,7 +27,7 @@ const SEED_ID = 'background-job-list-web-e2e'
const COMMAND = 'sleep 45'
/**
* Wait for the Host to publish the live Agent that opening a session resumes.
* Wait for opening a session to publish its live Agent.
* @param scaffold - the booted web scaffold.
* @param sessionId - the opened session's identity.
* @returns the registered Agent instance.
@@ -82,9 +79,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => {
it('shows a running background job in the session header without a refresh', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-running'))
// Point assertion, not a poll: `expect.poll` retries until a predicate
// holds, so polling for zero passes at t=0 and proves nothing. The
// "renders nothing without a task" branch is owned by the component suite.
// Polling for zero would pass at t=0 before delivery and prove nothing.
const trigger = page.getByRole('button', { name: '1 background job running' })
expect(await trigger.count()).toBe(0)
@@ -116,8 +111,6 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-settled'))
expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested')
// The trigger drops its live count once the task leaves running/stopping,
// which is also the proof that settlement reached the browser unprompted.
const idle = page.getByRole('button', { name: '1 background job' })
await idle.waitFor({ timeout: 20_000 })
+2 -21
View File
@@ -1,11 +1,4 @@
// Web e2e scenario: a Code Mode round trip. The scaffold boots the SAME
// shipped tree with the tools row patched to mode: code (the run_code-only
// wire), a real chromium sends a prompt engineered to elicit one run_code
// program with several sub-calls, and the UI must render the code-variant
// parent row with its always-visible nested sub-rows — each sub-row the same
// component a native call renders through — plus details-panel resolution for
// a clicked sub-row. Drive steps wait only on generic completion
// (whenTurnSettled); assertion steps run in replay/refresh only.
// Code Mode browser round trip with nested sub-calls and details selection.
// Record: DSH_SNAPSHOT=record rewrites session.jsonl, then a keyless
// DSH_SNAPSHOT=refresh regenerates ui.expected.md.
import { readFile } from 'node:fs/promises'
@@ -24,9 +17,7 @@ const FIXTURE = fileURLToPath(new URL('./snapshots/code-mode-round/session.jsonl
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/code-mode-round/ui.expected.md', import.meta.url))
const MODE = webSnapshotMode()
// The scenario's one drive prompt: elicits one program with a bash sub-call
// and a failing read the program tolerates — the sub-row set the assertions
// need. Never asserted against model prose.
// Elicits the successful and failed sub-rows this scenario asserts.
const PROMPT = 'Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt '
+ 'catching its error in the program. Return an object with both outcomes. Then reply DONE and stop.'
@@ -48,7 +39,6 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Fresh world: connect a Workspace so the composer scenarios start live.
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
@@ -60,7 +50,6 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
it('drives the recorded prompt to a settled turn (all modes)', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-drive'))
if (MODE !== 'record') {
// Drift guard: the committed fixture must carry exactly the drive prompt.
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
}
const input = page.locator('textarea').first()
@@ -75,11 +64,9 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
}, 200_000)
it.skipIf(MODE === 'record')('the durable log carries run_code with full-content sub-dispatches', () => {
// Wire discipline: code mode collapsed the call surface to run_code.
const calls = sessionEvents.filter(event => event.type === 'tool/call')
expect(calls.length).toBeGreaterThanOrEqual(1)
expect(new Set(calls.map(call => (call.data as { name: string }).name))).toEqual(new Set(['run_code']))
// Sub-dispatches logged with the complete tool/result vocabulary.
const dispatches = sessionEvents.filter(event => (event.type as string) === 'tool/code-dispatch')
expect(dispatches.length).toBeGreaterThanOrEqual(2)
for (const dispatch of dispatches) {
@@ -107,14 +94,9 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
// description as its summary (the presentCall contract).
const codeRow = page.locator('[data-variant="code"]').first()
await codeRow.waitFor({ timeout: 10_000 })
// Nested rows are visible WITHOUT any expand interaction, inside the
// sub-call nest, each rendered by the same components as native rows:
// the bash sub-call landed in the bash sample registration.
const nest = page.locator('[data-subcalls]').first()
await nest.waitFor({ timeout: 10_000 })
expect(await nest.locator('[data-sample="bash"]').count()).toBeGreaterThanOrEqual(1)
// The failing read sub-call wears the same error state a native failed
// row wears (the recorded program tolerates a read of missing.txt).
expect(await nest.locator('[data-state="error"]').count()).toBeGreaterThanOrEqual(1)
}, 60_000)
@@ -124,7 +106,6 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
const frame = page.locator('[style*="grid-template-columns"]').first()
expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
await nest.locator('[data-sample="bash"]').first().click()
// Tool rows do not drive layout geometry; the Session's default panel stays closed.
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
})
+10 -77
View File
@@ -1,35 +1,6 @@
// Web e2e scenario: a composer draft longer than the 14-line cap scrolls its
// GLYPHS AND ITS CARET AS ONE.
//
// The composer paints its text in two stacked layers (see
// packages/client/ui-conversation/src/client/skeleton/InputBar.module.css): the
// `<textarea>` carries the value, the selection and the caret but renders its
// own glyphs `color: transparent`, and every visible character is painted by the
// `[data-input-backdrop]` div underneath it, which also carries the claim-token
// highlight, the chips and the ghost hint.
//
// Two layers can only stay together by moving together. They do: both sit
// inside `[data-input-scroll]`, the composer's single scrolling box, and are as
// tall as the whole draft — so one offset, applied by the browser, moves the
// caret and the words in the same frame. Scrolling the textarea and assigning
// its offset to the backdrop looks equivalent and is not: a wheel gesture is
// composited off the main thread, so the assignment lands frames late and the
// caret visibly flies ahead of the text it belongs to.
//
// That failure is what the same-task measurement below pins. Every metric here
// is read through the caret's own coordinate frame — where the textarea puts
// line n — against where the backdrop paints line n, because that difference is
// the defect a user sees, and it is the one number a mirror between two boxes
// cannot hold at zero.
//
// Only a real engine can show any of this. Scrolling is layout: jsdom reports
// `scrollHeight === clientHeight` for every element and never scrolls one, so
// the unit spec in packages/client/ui-conversation/tests/input-bar.client.spec.tsx can
// only assert that one scrollport contains both layers.
//
// Zero model calls: a fresh workspace's blank session already carries a live
// composer, and the scenario only types into it. A stray stream would fail loud
// with NO_ADAPTER.
// Browser geometry for the composer's caret and visible text layers. A
// same-task gap probe detects deferred scroll synchronization that DOM-only
// tests cannot observe.
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
@@ -42,13 +13,7 @@ import {
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-draft-scroll', import.meta.url))
/**
* Committed golden of the composer's two-layer scroll geometry. The change
* alters no accessible name, so the aria goldens the other scenarios commit are
* byte-identical with and without it; this records the relations instead, which
* makes a shift in the cap or in the layer coupling a reviewable diff rather
* than an assertion someone has to reconstruct.
*/
/** Scroll geometry is absent from ARIA snapshots, so this scenario records it directly. */
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
const MODE = webSnapshotMode()
@@ -75,20 +40,11 @@ const DRAFT_TRAILING_NEWLINE = `${DRAFT}\n`
/** The composer's text layers as the browser lays them out. */
interface ComposerMetrics {
/** True when the draft is taller than the capped box — the situation under test. */
overflows: boolean
/** Visible height of the scrollport's content box: the cap in pixels. */
clientHeight: number
/** Whole lines that fit in the visible box, at the composer's own line-height. */
visibleLines: number
/** The composer's one scroll offset, which the caret and the glyphs both follow. */
scrollTop: number
/** Furthest that offset can go. */
scrollMax: number
/**
* Scrollable overflow the textarea holds on its own — 0, or a second offset
* exists that nothing keeps equal to this one.
*/
inputScrollable: number
/**
* Distance between where the caret sits for a draft line and where the
@@ -98,25 +54,14 @@ interface ComposerMetrics {
*/
caretGlyphGap: number
/**
* How much that gap moves when the offset changes inside a single task: 0
* here, because one box carries both layers. Assigning one box's offset to
* another cannot be 0 — a scroll event is dispatched after the task that
* moved the box, so between the two there is a frame with the caret at the
* new offset and the glyphs at the old one.
* How much the caret-to-glyph gap moves when the offset changes before a
* scroll listener can run.
*/
gapShiftOnScroll: number
/**
* Top of the LAST draft line relative to the visible box's top, in pixels: at
* most `clientHeight` when that line is on screen.
*/
lastLineOffset: number
/** Top of the FIRST draft line relative to the visible box's top: negative once it has scrolled out. */
firstLineOffset: number
/** Content width the textarea wraps at. */
inputWrapWidth: number
/** Content width the backdrop wraps at — equal, or the layers break lines in different places. */
backdropWrapWidth: number
/** Content width the hidden auto-grow mirror wraps at — it decides the box's height. */
mirrorWrapWidth: number
}
@@ -187,14 +132,8 @@ function measureComposer(page: Page): Promise<ComposerMetrics> {
}
/**
* Render the golden body.
*
* Absolute glyph coordinates are deliberately absent: they depend on font
* metrics and would make the fixture fail on a machine that measures text
* differently — a golden that needs re-recording per platform documents the
* platform, not the behavior. What is recorded is the cap, the caret-to-glyph
* relation, and which lines are on screen, each a comparison that survives any
* layout keeping the coupling.
* Render platform-neutral comparisons instead of font-dependent glyph
* coordinates.
* @param top - metrics with the draft scrolled to its start.
* @param bottom - metrics with the draft scrolled to its end.
* @param trailingNewline - metrics with the trailing-newline draft scrolled to its end.
@@ -328,9 +267,7 @@ describe('web e2e: composer draft scrolling', () => {
const input = page.locator('textarea:enabled').first()
await input.hover()
const resting = (await measureComposer(page)).caretGlyphGap
// One delta past the whole draft: the box clamps at its own end, and the
// wheel-chaining handler leaves it native because the box is not yet at its
// edge when the gesture starts (the chaining itself is owned by the unit spec).
// One delta past the whole draft: the box clamps at its own end.
await page.mouse.wheel(0, 2000)
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 })
.toBeGreaterThan(0)
@@ -453,9 +390,7 @@ describe('web e2e: composer draft scrolling', () => {
const data = new DataTransfer()
data.setData('text/plain', text)
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
// The ordinary case, without a trailing newline, so the collapsed branch
// of the reveal keeps a real engine under it; the case above owns the
// after-newline branch.
// Keep a final glyph so the collapsed caret position has a client rect.
}, `\n${DRAFT}`)
await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBeGreaterThan(0)
@@ -465,8 +400,6 @@ describe('web e2e: composer draft scrolling', () => {
}, 60_000)
it('commits exactly the fixtures it reads', async () => {
// Zero model calls, so the scenario records no session fixture: the geometry
// golden is the whole inventory.
await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
})
+5 -68
View File
@@ -1,47 +1,6 @@
// Web e2e scenario: the input card holds one horizontal position across the
// Chat and Trajectory tabs.
//
// The composer seat is the same node in both tabs, but it measures itself
// against a different edge in each (see
// packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css).
// In Chat it is a sticky CHILD of the column's scroller, so it rides that
// scroller's content box — the box a space-consuming scrollbar shortens. A view
// that opts into a composer overlay (`data-conversation-composer-overlay`, which
// Trajectory declares and which moves the column's own scrolling into the view)
// gets an absolutely positioned seat instead, laid out against the padding box,
// which the scrollbar never reduces.
//
// The column handles the two edges without reserving the gutter on both: Chat
// keeps `scrollbar-gutter: stable` so its seat's content box never jumps as the
// transcript starts to scroll; the overlay branch does NOT reserve (the view
// owns its own scrollers, so a reserved gutter would only narrow the view's
// content by the bar's width), and the overlay seat instead gives back the
// bar's width (`right: var(--dsh-scrollbar-width)`) so both seats measure the
// same width and the card does not move.
//
// Only a real engine can show this. The seat's geometry is layout: jsdom gives
// every element a zero-sized box and reports no scrollbar at all, so a unit spec
// can assert the declarations exist but not that the two states land in the same
// place. What is asserted here is the user-visible fact — the card does not move
// — measured as the distance between the two tabs' card rectangles.
//
// The browser is launched WITHOUT Playwright's default `--hide-scrollbars`,
// which is load-bearing rather than incidental. Under that argument a scroll
// container's bar consumes no layout width at all, so the two tabs agree with
// and without the compensation and every comparison below holds vacuously —
// measured: the uncompensated cascade leaves both tabs' bands at 0 there,
// against 8 and 0 with the argument dropped. Dropping it is also the faithful
// configuration: ui-theme's scrollbar.css gives `::-webkit-scrollbar` a width,
// and a bar that occupies layout space is what the product actually draws.
//
// The scenario runs that uncompensated cascade in the page — the overlay seat's
// `right` compensation dropped to 0 — and measures the same two tabs through
// it, which is what keeps the equal rectangles above from being explained by a
// tab switch that never reached the layout. It is the reported symptom as a
// number: the card moves 4px, half the 8px band, on each edge.
//
// Zero model calls: a seeded cold session renders from its log, and switching
// tabs asks the host for nothing. A stray stream would fail loud with NO_ADAPTER.
// Browser geometry for the input card across Chat and Trajectory. The browser
// must expose layout-consuming scrollbars, and an uncompensated control keeps
// equal rectangles from passing vacuously.
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
@@ -55,17 +14,7 @@ import {
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-tab-geometry', import.meta.url))
/**
* Committed golden of where the input card sits in each tab, at a wide viewport
* (card at its width cap) and a narrow one (card shrinking with the column).
*
* Absolute coordinates are deliberately absent: they depend on the sidebar's
* laid-out width and on font metrics, so committing them would produce a fixture
* that has to be re-recorded per platform. What is recorded is the distance
* between the two tabs' rectangles, which is zero when the compensation holds and
* the bar's width when it does not — including under the control, so the golden
* carries the shift the uncompensated cascade produces rather than only its absence.
*/
/** Records platform-neutral distances between the two tabs' card rectangles. */
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
const MODE = webSnapshotMode()
@@ -127,21 +76,13 @@ const CONTROL_CSS = `
/** The column scroller and the input card as the browser lays them out, in one tab. */
interface TabMetrics {
/** Resolved `scrollbar-gutter` on the column's scroller. */
gutter: string
/** Resolved `overflow-x`: `hidden` in both states, so neither grows a horizontal bar. */
overflowX: string
/** Resolved `overflow-y`: `auto` in both states, which is the form WebKit honours the gutter on. */
overflowY: string
/** Border-box width minus client width: the space the scrollbar takes out of the content area. */
band: number
/** True when the column's scroller actually scrolls — only Chat does. */
scrolls: boolean
/** Left edge of the input card in viewport coordinates. */
cardLeft: number
/** Right edge of the input card. */
cardRight: number
/** Width of the input card, capped at the composer card max width. */
cardWidth: number
}
@@ -149,11 +90,8 @@ interface TabMetrics {
interface TabComparison {
chat: TabMetrics
trajectory: TabMetrics
/** Distance between the two tabs' card left edges: 0 when the card holds its position. */
leftShift: number
/** Distance between the two tabs' card right edges. */
rightShift: number
/** Difference between the two tabs' card widths. */
widthShift: number
}
@@ -306,8 +244,7 @@ describe('web e2e: input card position across view tabs', () => {
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await seedSession(scaffold, FIXTURE.log, SEED_ID)
// Scrollbars must take layout space here or the scenario proves nothing;
// see the file header for the measurement behind dropping this argument.
// Scrollbars must take layout space here or the comparison is vacuous.
browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] })
page = await newEnglishPage(browser, WIDE_VIEWPORT.height)
tripwire = watchConsole(page)
+16 -134
View File
@@ -1,67 +1,8 @@
// Web e2e scenario: the sidebar session list's scrollbar as the browser
// actually lays it out — the observable half of the themed scrollbars
// (packages/client/ui-theme/src/styles/scrollbar.css plus the
// `scrollbar-gutter: stable` reservation on WorkspaceBrowser's `.list`). The
// ui-theme/ui-workspace unit specs read the CSS text; only a real engine
// reports the reserved gutter width and the substituted `scrollbar-color`, so
// those two facts live here.
//
// Zero model calls: the list only has to overflow, so the scenario seeds many
// cold sessions from another spec's committed fixture (seeded-history's
// seed.jsonl, reused read-only — this spec needs row count, not new recorded
// content) and never launches a replay row. A stray stream would fail loud
// with NO_ADAPTER.
//
// Headless-chromium caveats, load-bearing for what is asserted below.
//
// Headless chromium defaults to an OVERLAY scrollbar: one drawn on top of the
// content, consuming no layout width unless something reserves space. That is
// the mode in which the reported symptom exists at all, so this environment
// reproduces it rather than merely approximating it — without either
// declaration the list's band is 0 and the bar covers 7px of the relative
// time. (Under a classic space-consuming bar, `clientWidth` already excludes
// the bar and nothing can be covered; a headed run under xvfb behaves that way
// and cannot show the symptom.)
//
// The consequence for assertions: comparing the time element's right edge
// against the list's CLIENT-area right edge holds in both states and proves
// nothing, because with an overlay bar the client edge is the border edge. The
// two signals that do separate the states are the reserved band width and
// `timeCoveredBy`, which measures the overlap against the bar's own width.
//
// Both the `scrollbar-gutter: stable` reservation and the sheet's
// `::-webkit-scrollbar` width are needed for that band, and neither suffices:
// measured on the running app, deleting either one takes the band from 8 to 0
// while the other stays in force. The gutter states that space be reserved; the
// pseudo-element width is what makes chromium treat the bar as occupying layout
// space in the first place.
//
// That conjunction is why `band` and `timeCoveredBy` are both asserted and
// neither replaces the other. Removing only the gutter leaves `timeCoveredBy` at
// 0, because the bar is then 8px wide and the row's right padding is also 8px,
// so it abuts the timestamp without covering it; `band` catches that case.
// Removing both is what produces the reported overlap, and `timeCoveredBy`
// measures it at 7.
//
// The thumb is a pointer affordance (ui-sidebar rebinds the indirection pair
// to `transparent` while the pointer is outside the column), so every
// measurement below states which pointer position it was taken at: the
// scenario parks the pointer over the sidebar before asserting a colour, and
// the quiet state and its linger get their own test.
//
// Chromium also takes the `::-webkit-scrollbar*` path, not the standard
// properties: scrollbar.css gates `scrollbar-width`/`scrollbar-color` behind
// `@supports not selector(::-webkit-scrollbar)`, which is false here. The
// resolved standard properties therefore read `auto`, and that reading is
// asserted — a concrete value would mean the gate leaked and silenced the
// pseudo-element rules. What the theme test measures instead is the pair the
// pseudo-element rules read: the indirection variables as they resolve ON the
// list, plus the `::-webkit-scrollbar-thumb:hover` declaration as it stands in
// the cascade. The hover thumb colour is not observable any other way —
// chromium folds the `:hover` rule into `getComputedStyle(el,
// '::-webkit-scrollbar-thumb')`, so that query reports the hover colour at
// rest and cannot pin either state (measured by deleting the hover rule live:
// the same query flipped from the hover colour to the resting one).
// Browser geometry for the sidebar scrollbar reservation and theme. Headless
// Chromium uses overlay scrollbars, so the reserved band and `timeCoveredBy`
// together distinguish reserved space from a bar painted over content. Its
// computed pseudo-element style also folds in `:hover`, so the test reads that
// declaration from the cascade.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
@@ -76,14 +17,7 @@ import { newEnglishPage, saveFailureShot } from './support.ts'
const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/sidebar-scrollbar', import.meta.url))
/**
* Committed golden of the resolved scrollbar style and geometry, in both
* palettes. The aria goldens the other scenarios commit cannot carry this
* change: it alters no DOM and no accessible name, so their normalized trees are
* byte-identical with and without it. This one records the values instead, which
* makes an unintended shift in thumb colour, band width, or rendering path a
* reviewable diff rather than an assertion someone has to think about.
*/
/** Geometry and resolved style are absent from ARIA snapshots, so this scenario records them directly. */
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
const MODE = webSnapshotMode()
/** Enough rows that the list overflows the 800px-tall viewport's sidebar; the scenario asserts the overflow rather than trusting it. */
@@ -91,42 +25,24 @@ const SEED_COUNT = 24
/** Geometry and resolved scrollbar style of one scroll container, measured in the page. */
interface ListMetrics {
/** Resolved `scrollbar-gutter`. */
gutter: string
/** Resolved `::-webkit-scrollbar` width: the pseudo-element path's own sizing. */
width: string
/** Resolved `::-webkit-scrollbar-track` background. */
track: string
/** Resolved `scrollbar-width`, expected `auto` because the gate excludes chromium. */
standardWidth: string
/** Resolved `scrollbar-color`, expected `auto` for the same reason. */
standardColor: string
/** `::-webkit-scrollbar-thumb:hover` background declarations found in the cascade, in sheet order. */
hoverRules: string[]
/** `--dsh-scrollbar-thumb` resolved on the list, serialized as a colour. */
token: string
/** `--dsh-scrollbar-thumb-hover` resolved on the list, serialized the same way. */
hoverToken: string
/** True when the list actually scrolls. */
overflows: boolean
/** Border-box width minus client width: the space the scrollbar takes out of the content area. */
band: number
/** Distance from the scrollbar's right edge to the sidebar edge. */
scrollbarEdgeOffset: number
/** Distance from the first row background's right edge to the sidebar edge. */
rowEdgeInset: number
/** Client-area right edge in viewport coordinates (`clientWidth` excludes the scrollbar band). */
clientRight: number
/** Border-box right edge in viewport coordinates. */
borderRight: number
/** Right edge of the first row's relative-time element, the content the unreserved bar covered. */
timeRight: number
/**
* Pixels of the relative time the scrollbar paints over: how far its right
* edge reaches into the band the bar occupies, `[borderRight - barWidth,
* borderRight]`. This is the reported symptom as a number, and it is the one
* geometric signal that separates the two states in this environment — see
* the file header on why `clientWidth` comparisons cannot.
* Pixels of relative time under the scrollbar, measured against the bar's
* width because an overlay scrollbar does not move the client edge.
*/
timeCoveredBy: number
}
@@ -145,13 +61,8 @@ function measureList(page: Page): Promise<ListMetrics> {
if (time === null) throw new Error('no row relative-time element in the sidebar list')
const row = list.querySelector<HTMLElement>('[role="treeitem"]')
if (row === null) throw new Error('no row in the sidebar list')
// Each indirection variable is resolved through its own throwaway probe
// appended to the list: `var()` substitution then happens where the list
// sits in the cascade, which is the claim, and `color` normalizes whatever
// notation the palette sheet chose into one comparable serialization. A
// REUSED probe would report only the last value read — `getComputedStyle`
// returns a live declaration, so reassigning `style.color` retroactively
// changes every earlier read.
// Use one probe per variable because computed style declarations are live;
// the color property also normalizes palette syntax.
const resolve = (name: string): string => {
const probe = document.createElement('span')
probe.style.color = `var(${name})`
@@ -160,11 +71,8 @@ function measureList(page: Page): Promise<ListMetrics> {
probe.remove()
return value
}
// The hover colour is read out of the cascade rather than computed:
// chromium reports the `:hover` background for the resting pseudo-element
// too (see the file header), so no computed query separates the states.
// Cross-origin sheets throw on `cssRules`; none is expected, and skipping
// them cannot mask the rule under test, which ships in the app's own CSS.
// Computed pseudo style folds in hover even at rest, so inspect the cascade.
// Cross-origin sheets may throw and cannot contain the app-owned rule.
const hoverRules = [...document.styleSheets]
.flatMap((sheet) => {
try {
@@ -233,9 +141,7 @@ function measureRowInset(page: Page): Promise<Pick<ListMetrics, 'overflows' | 'r
/** One palette's readings, taken at both pointer positions. */
interface PaletteMetrics {
/** Everything measured with the pointer over the list, which is when a thumb exists. */
hovered: ListMetrics
/** `--dsh-scrollbar-thumb` with the pointer parked outside the column. */
quietThumb: string
}
@@ -260,17 +166,8 @@ async function measurePalette(page: Page): Promise<PaletteMetrics> {
}
/**
* Render the golden body: the resolved scrollbar style of the list in each
* palette, plus the geometric relations the scrollbar-gutter/thin-scrollbar
* declarations establish.
*
* Absolute coordinates are deliberately absent. `timeRight`, `clientRight`, and
* `borderRight` depend on the sidebar's laid-out width and on font metrics, so
* committing them would make the golden fail on a machine whose fonts measure
* differently — a fixture that has to be re-recorded per platform documents the
* platform, not the behavior. What is recorded instead is the band, the overlap,
* and the two orderings, each of which is a difference or a comparison and so
* survives any layout that keeps the reservation.
* Render platform-neutral differences and comparisons instead of absolute
* coordinates that depend on sidebar width and font metrics.
* @param light - metrics measured under the light palette.
* @param dark - metrics measured under the dark palette.
* @returns the golden body, without a trailing newline.
@@ -416,27 +313,12 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
await expect.poll(async () => (await measureList(page)).overflows, { timeout: 10_000 }).toBe(true)
const metrics = await measureList(page)
expect(metrics.gutter).toBe('stable')
// The control. `band > 0` is the whole observable effect of the
// reservation: the scrollbar is taken out of the content area instead of
// drawn over it. Removing the declaration makes it exactly 0. The value
// itself is not pinned — it tracks `scrollbar-width` and the platform.
// Pin presence, not width, because the width is platform-dependent.
expect(metrics.band).toBeGreaterThan(0)
expect(metrics.scrollbarEdgeOffset).toBe(2)
expect(metrics.rowEdgeInset).toBe(12)
// The reported symptom, stated directly: no part of the row's relative time
// lies under the bar. Without either declaration it measures 7 — the `h`
// of `1h` is the covered part. Unlike the client-edge comparison below it
// does not go vacuous under an overlay scrollbar, because it measures
// against the bar's own width rather than against a content edge the
// overlay bar does not move. It is not a replacement for the band
// assertion above; see the file header for which regression each one
// catches.
// Measure against the bar because overlay scrollbars do not move the client edge.
expect(metrics.timeCoveredBy).toBe(0)
// Corollaries of the reservation, kept because they pin where the band sits
// rather than only that it exists: the time ends inside the content area,
// and the content area ends before the border box. Each holds in both
// states on its own (see the file header) and is meaningful only alongside
// the two assertions above.
expect(metrics.timeRight).toBeLessThanOrEqual(metrics.clientRight)
expect(metrics.clientRight).toBeLessThan(metrics.borderRight)
expect(tripwire.pageErrors).toEqual([])
+3 -13
View File
@@ -1,22 +1,12 @@
// Cold-boot RPC budget. The describe mirror (packages/client/ui-settings) is
// the one `settings.describe` reader in the browser, so startup describe
// traffic stays bounded no matter how many client plugins own a preference.
// A regression here means a consumer bypassed the mirror — grep for
// `settings.describe(` outside ui-settings' client sources.
//
// Zero model calls: the lane only boots chrome, so no replay fixture mounts.
// Cold boot may issue at most two settings.describe calls regardless of client
// plugin count. No model call or replay fixture is involved.
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
import { newEnglishPage } from './support.ts'
/**
* Both reads are the mirror's: once eagerly at bind time over HTTP, and once
* on the first-connection reset — that second read closes the window where a
* document commit lands between the eager read and the SSE subscription and
* its invalidation is lost. Every settings consumer derives from these two.
*/
/** One eager read plus one first-connection reset closes the pre-subscription commit window. */
const DESCRIBE_BUDGET = 2
let scaffold: WebScaffold
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: a41538ffc2fc18939e015e8c08676868f588d897
config-catalog.zh.md: 9481cb2b4db13d7d91c11e323e8d1118184f5bfe
config-catalog.md: 9c679db07b9922d49e5ddb5f64c6726c7443e4ff
config-catalog.zh.md: b9b9b4ae8243f71fb8c60aec314709d1da325dbb
+4 -4
View File
@@ -692,7 +692,7 @@ export type Config = LocalConfig
Depends on: [`LocalConfig`](#deepseek-aidsh-fs-local)
Source: [`packages/fs/fs-sandbox/src/index.ts:49`](../packages/fs/fs-sandbox/src/index.ts)
Source: [`packages/fs/fs-sandbox/src/index.ts:45`](../packages/fs/fs-sandbox/src/index.ts)
<a id="deepseek-aidsh-goal"></a>
@@ -3110,7 +3110,7 @@ export interface Config {
}
```
Source: [`packages/web/web-fetch-http/src/index.ts:34`](../packages/web/web-fetch-http/src/index.ts)
Source: [`packages/web/web-fetch-http/src/index.ts:32`](../packages/web/web-fetch-http/src/index.ts)
<a id="deepseek-aidsh-web-search-deepseek"></a>
@@ -3162,7 +3162,7 @@ export interface Config {
}
```
Source: [`packages/web/web-search-exa/src/index.ts:38`](../packages/web/web-search-exa/src/index.ts)
Source: [`packages/web/web-search-exa/src/index.ts:35`](../packages/web/web-search-exa/src/index.ts)
<a id="deepseek-aidsh-web-search-perplexity"></a>
@@ -3186,7 +3186,7 @@ export interface Config {
}
```
Source: [`packages/web/web-search-perplexity/src/index.ts:32`](../packages/web/web-search-perplexity/src/index.ts)
Source: [`packages/web/web-search-perplexity/src/index.ts:30`](../packages/web/web-search-perplexity/src/index.ts)
<a id="deepseek-aidsh-workflow-worker-thread"></a>
+4 -4
View File
@@ -694,7 +694,7 @@ export type Config = LocalConfig
依赖:[`LocalConfig`](#deepseek-aidsh-fs-local)
来源:[`packages/fs/fs-sandbox/src/index.ts:49`](../packages/fs/fs-sandbox/src/index.ts)
来源:[`packages/fs/fs-sandbox/src/index.ts:45`](../packages/fs/fs-sandbox/src/index.ts)
<a id="deepseek-aidsh-goal"></a>
@@ -3112,7 +3112,7 @@ export interface Config {
}
```
来源:[`packages/web/web-fetch-http/src/index.ts:34`](../packages/web/web-fetch-http/src/index.ts)
来源:[`packages/web/web-fetch-http/src/index.ts:32`](../packages/web/web-fetch-http/src/index.ts)
<a id="deepseek-aidsh-web-search-deepseek"></a>
@@ -3164,7 +3164,7 @@ export interface Config {
}
```
来源:[`packages/web/web-search-exa/src/index.ts:38`](../packages/web/web-search-exa/src/index.ts)
来源:[`packages/web/web-search-exa/src/index.ts:35`](../packages/web/web-search-exa/src/index.ts)
<a id="deepseek-aidsh-web-search-perplexity"></a>
@@ -3188,7 +3188,7 @@ export interface Config {
}
```
来源:[`packages/web/web-search-perplexity/src/index.ts:32`](../packages/web/web-search-perplexity/src/index.ts)
来源:[`packages/web/web-search-perplexity/src/index.ts:30`](../packages/web/web-search-perplexity/src/index.ts)
<a id="deepseek-aidsh-workflow-worker-thread"></a>
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
event-producer-consumer.md: 52a8003beb55c178f2ae7513f2b21d3a6c686b2b
event-producer-consumer.zh.md: c66e07657ec3be4bd4ce1c461782a04f1b1edf8f
event-producer-consumer.md: 43b6c2b13ce2c09b2e3c938e30651e5dcbca36b3
event-producer-consumer.zh.md: 7813f488a494cfed0ede01443d5bad31bd4774f1
+4 -4
View File
@@ -47,10 +47,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` |
| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:166`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:146`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:157`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:138`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:144`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:155`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
+4 -4
View File
@@ -49,10 +49,10 @@
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` |
| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:166`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:146`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:157`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:138`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:144`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:155`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session-telemetry.md
session-telemetry.md: 1158171c30228389317f708a35114bf4495a4e34
session-telemetry.zh.md: f6c2d1dccf5ef5e6dad8e6e11ea843310a53f2d1
session-telemetry.md: 718fe5dabfd1c059a6a02077407480e96bdd271a
session-telemetry.zh.md: d4081664a121877e36a2cba123e9c5840108b529
+2 -3
View File
@@ -64,9 +64,8 @@ The seam's acknowledgement contract (owned by the [Service Definition README's s
/**
* Deployment-selected session-sharing policy disclosed by a mounted
* {@link SessionTelemetryBackend} backend to human-facing acknowledgement surfaces (the
* `/feedback` command's confirmation text). The seam owns the vocabulary so
* any backend can disclose a policy without depending on the OTel package;
* the values mirror the OTel backend's serialized `SessionTelemetryMode` choices.
* `/feedback` command's confirmation text). The Service Definition owns the
* vocabulary so consumers and backends do not depend on a specific provider.
*/
type SessionTelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled'
```
+2 -3
View File
@@ -64,9 +64,8 @@ interface SessionTelemetryRecord {
/**
* Deployment-selected session-sharing policy disclosed by a mounted
* {@link SessionTelemetryBackend} backend to human-facing acknowledgement surfaces (the
* `/feedback` command's confirmation text). The seam owns the vocabulary so
* any backend can disclose a policy without depending on the OTel package;
* the values mirror the OTel backend's serialized `SessionTelemetryMode` choices.
* `/feedback` command's confirmation text). The Service Definition owns the
* vocabulary so consumers and backends do not depend on a specific provider.
*/
type SessionTelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled'
```
@@ -1,9 +1,5 @@
// FixtureApi: standalone UI development without a server. Real contract shape: unary takes
// RpcRequest<P> and returns RpcResponse<T> (echoing the rpcId); streams yield RpcRequest<frame>
// (the fixture IS the fake server, so it mints frame rpcIds); root respond takes ClientResponse
// and returns RpcReceipt. fx-alpha carries a hand-built history script (74 turns, pageable);
// prompt triggers a chunked streaming replay; cancel stops the replay; resident pending
// approval/question requests exercise replay and composer takeover with stable rpcIds.
// Standalone browser fixture. It models server-owned frame rpcIds and echoes
// unary request rpcIds through the production carrier types.
import {
createAssistantMessage,
@@ -177,13 +173,6 @@ const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; lin
},
]
/**
* The model-facing grep render text for the sample what a UI without a search
* card shows, attached as the view's `content`. Mirrors the real grep
* presenter's shape (see formatGrepOutput in dsh-tool-fs-search): a
* `Found X of Y matches` header, the matches grouped under file headers with
* `Line N:` rows, then a spill-recovery footer.
*/
const SEARCH_MATCHES_TEXT = [
'Found 9 of 42 matches',
'',
@@ -193,10 +182,6 @@ const SEARCH_MATCHES_TEXT = [
'(Full grep result stored at: fixture://spill/grep-66. Read it to see every match.)',
].join('\n')
/**
* Structured glob result for the search sample (turn 68): a flat path list,
* truncated with a larger `total` so the path card shows its capped indicator.
*/
const SEARCH_PATHS_FIXTURE = [
'packages/client/ui-primitives/src/SearchBlock.tsx',
'packages/client/ui-primitives/src/SearchBlock.module.css',
@@ -205,25 +190,12 @@ const SEARCH_PATHS_FIXTURE = [
'packages/client/ui-tool/tests/search-card.client.spec.tsx',
]
/**
* The model-facing glob render text the newline-joined path list plus a
* spill-recovery footer, mirroring the real glob presenter's shape (see
* formatGlobOutput in dsh-tool-fs-search).
*/
const SEARCH_PATHS_TEXT = [
...SEARCH_PATHS_FIXTURE,
'',
'(Showing 5 of 23 paths. Full sorted result stored at: fixture://spill/glob-67. Read it to see every path.)',
].join('\n')
/**
* Read-card sample for the read turn: a WINDOW past an offset, so the line
* numbers start above 1 (the card's gutter keeps the file's own numbering) and
* `totalLines` exceeds the window (the card shows a "showing N of M" note). The
* fixture is client-side and cannot import the read tool, so the structured
* window is authored inline exactly as the tool would project it through
* `presentationMeta`. `lang` is a `ts` hint so the shiki path highlights it.
*/
const READ_SAMPLE_FIRST_LINE = 41
const READ_SAMPLE_SOURCE = [
'export interface ReadBlockProps {',
@@ -373,8 +345,7 @@ function buildAlphaLog(): SessionEvent[] {
events.push({ seq, time: (time += 800), ...authored })
return seq
}
// This resident history represents completed model requests, so retain the
// route capacity that accompanied them just as the live prompt path does.
// Completed fixture requests retain the route capacity recorded with them.
push({
type: 'request/context',
data: { provider: 'deepseek-official', model: 'deepseek-v4-flash', contextWindow: 128_000 },
@@ -2021,7 +1992,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
/** At most one in-flight replay per session; cancel clears it. */
const replays = new Map<SessionId, { timer: ReturnType<typeof setTimeout>; finish(aborted: boolean): void }>()
/** history transit delay (timing hooks below); the page snapshot is taken at request time, like a real host. */
/** History transit delay; the page snapshot is taken at request time. */
let historyDelayMs = 0
/** One-shot history failure (timing hook: a pre-disconnect history request already doomed when reconnect lands). */
let failNextHistory = false
@@ -2032,10 +2003,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
/** The single opt-in browser stress producer; normal fixture journeys never start it. */
let activeReasoningChunkStorm: ReasoningChunkStormState | null = null
// Timing-acceptance hooks (browser test backdoor): the in-memory fixture is
// ideally timed. These let
// browser acceptance runs create slow-history, lost-frame, and reconnect
// windows a real host produces naturally.
// Browser-only timing hooks for slow history, lost frames, and reconnects.
const timingHooks = {
setHistoryDelay(ms: number): void {
historyDelayMs = ms
@@ -2357,7 +2325,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
modelSelections.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' })
attachedSessions += 1
const emitSession = (): void => {
// Mirrors the host: the frame fires at creation, so blank is constantly true.
// The creation frame precedes later workspace-attachment work.
emitHost({ type: 'host/session-added', sessionId: created.sessionId, blank: true, cwd })
}
if (workspace !== undefined && options.failWorkspaceAttach) {
@@ -2447,7 +2415,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
},
history: async (request) => {
const log = logs.get(request.payload.sessionId) ?? []
// Snapshot at request time, deliver after the transit delay (mirrors a real host under latency).
// Snapshot at request time, then deliver after the transit delay.
const page = pageOf(log, request.payload.beforeSeq, request.payload.maxMessages ?? 50)
// Tail page carries the projections block (host parallel: one consistent
// cut over the registered units; asOfSeq = window tail seq, -1 on an
@@ -1,9 +1,3 @@
/**
* Fixture impl semantics: the demo data source must honor the same contract
* shapes as the real host (paging boundaries, rpcId echo, replay lifecycle,
* baseline replay, timing hooks) this is the vitest-side drift detector for
* the hand-written fixture/host parallel implementations.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionId, WorkspaceId } from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts'
@@ -1,6 +1,4 @@
// @vitest-environment jsdom
/** LanguageRow behavior: selector pill shows the active locale, the menu
* opens/closes, and selection drives setLocale. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
@@ -13,7 +11,6 @@ afterEach(cleanup)
const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }]
/** Empty global standard-kit hooks (the row reads neither). */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
@@ -1,12 +1,4 @@
// @vitest-environment jsdom
/**
* ClientModuleSystem behavior: lazy CJS arrival (bundle execution only
* registers the factory), materialization on first import/require with
* memoization and recursive self-sequencing, the resolution branch order,
* shared in-flight arrival, invalidate-refetch (HMR), style claiming, the
* default transport hook, and the loud failure modes (duplicate
* registration, cycles, table misses, double boot).
*/
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
@@ -39,8 +39,6 @@ export type { RootOwnerProps } from './slots.ts'
export { SessionCreateError, SessionRuntime, scopeOf, workspaceTitleOf } from './sessions/service.ts'
export { indexSubagentDescendants } from './sessions/subagent-lineage.ts'
export type { SubagentDescendantSummary } from './sessions/subagent-lineage.ts'
// The provide channel is shared with the client test runtime (one
// materialization/projection implementation; no test-side mirror to drift).
export { SessionProvideChannel } from './sessions/provide.ts'
export type { SessionProvideChannelHost } from './sessions/provide.ts'
export { createScope } from './agents/scope.ts'
@@ -1,9 +1,6 @@
// ConversationSnapshot / ConversationNode: the only data shape the logic layer feeds the UI.
// Publication contract: every change swaps the top-level object; unchanged
// substructures keep their references (the React.memo premise). Chat node and
// Location stores are stable live readers, so old snapshots are not time-point
// views. callId/approvalId stay plain string here (narrow to real brands when
// convenient).
// Each publication replaces the top-level snapshot while preserving unchanged
// substructure references. Stable node and location stores make old snapshots
// live readers rather than time-point views.
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
@@ -1,16 +1,8 @@
// Notifier: subscription + batched notification primitive shared by Session and
// SessionManager. Semantics: N markDirty calls collapse into one microtask flush, while
// N markFrameDirty calls collapse into one animation-frame flush;
// the flush rebuilds the snapshot cache BEFORE notifying (useSyncExternalStore requires a stable
// getSnapshot reference). With no listeners the rebuild is skipped and only the dirty bit is set
// (keeps frame storms cheap); the next getSnapshot rebuilds lazily.
//
// Freshness and notification are SEPARATE bits: a pull (ensureFresh) between
// markDirty and the scheduled flush rebuilds the snapshot but must not
// swallow the notification — push subscribers (object-layer watchers) would
// otherwise starve whenever any reader pulls first.
/** Subscription + batched notification primitive (shared by Session and SessionManager). */
/**
* Batches structural updates in microtasks and stream updates by animation
* frame. Reads may rebuild a dirty snapshot without consuming the pending
* subscriber notification.
*/
export class Notifier {
private listeners = new Set<() => void>()
private dirty = false
@@ -33,7 +25,7 @@ export class Notifier {
}
}
/** State-change entry: mark dirty and schedule the batched flush. */
/** Mark the snapshot dirty and notify in a microtask. */
markDirty(): void {
this.dirty = true
this.notifyPending = true
@@ -41,7 +33,7 @@ export class Notifier {
this.schedule('microtask')
}
/** Stream-change entry: mark dirty and publish the cumulative state at most once per frame. */
/** Mark the snapshot dirty and publish cumulative state at most once per frame. */
markFrameDirty(): void {
this.dirty = true
this.notifyPending = true
@@ -1,7 +1,3 @@
// PartialAccumulator: assistant/chunk accumulator.
// Folds the six StreamChunk variants into AssistantBlock[] keyed by block index;
// block-level immutability (a delta only swaps that block's reference).
import type { StreamChunk } from '@deepseek-ai/dsh-llm/types'
import type { AssistantBlock, PartialAssistant } from './conversation.ts'
import { toAssistantBlock } from './conversation.ts'
@@ -369,7 +369,6 @@ export class SlotRegistry extends Service {
// there before this layer commits anything.
const dispose = (this._core as unknown as ErasedCore).register(erased, component)
if (store !== undefined) {
// Register succeeded, so the target's spec is on the ledger.
const scope = (this._core.specDynamic(options.name) as SlotSpec<SlotEntryDef>).scope
this._acquire(store, scope)
}
@@ -221,7 +221,6 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
props.close()
}}
>
{/* Same glyph as the Models page's add affordances. */}
<IconPlusOutline16 size={14} />
{t('creatorDraft')}
</button>
@@ -54,7 +54,7 @@ function fakeApi(
if (options.failWrite !== undefined) {
return Promise.resolve({ rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failWrite, details: {} } } })
}
// A committed write moves the roster's default, exactly as the host does.
// A committed write moves the roster's default.
for (const preset of presets) {
preset.isDefault = preset.id === (payload.patch as { default?: string }).default
}
@@ -1,7 +1,3 @@
/* Thumbnail geometry mirrors DeepSeek Chat's composer rail: 64px cards with a
16px radius, remove control fully inside the card, arrows overlaid at the
edges instead of a scrollbar. */
.root {
position: relative;
min-width: 0;
@@ -1,7 +1,5 @@
/* Full-viewport drop invitation (DeepSeek Chat DragMask). pointer-events:
none the layer is decoration; drag events must keep hitting the page so
the owner's enter/leave count stays balanced. The frosted sheet color is
the theme's drop-mask alias (dark override lives with the theme owner). */
/* Drag events must keep reaching the page so the owner's enter/leave count
stays balanced. */
.mask {
position: fixed;
inset: 0;
@@ -1,7 +1,4 @@
// @vitest-environment jsdom
// AttachmentRail behavior in the jsdom lane: item rendering and callbacks,
// arrow paging over stubbed scroll geometry (jsdom lays nothing out), the
// exclusive vertical-wheel pan, and the new-item end reveal.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
@@ -1,11 +1,3 @@
/**
* PopupSelectController behavior: one options load per
* open with local search filtering, filtered highlight movement,
* single-flight select with open-time context, consume-on-success (CAS miss
* benign), failure-keeps-open retry semantics for both options and onSelect,
* and binding-identity revocation of late settlements after
* dismiss/reopen/dispose.
*/
import { describe, expect, it, vi } from 'vitest'
import type { SelectOption } from '../src/client/contract.ts'
import type { PopupSpec, TokenSegment } from '../src/client/popup.ts'
@@ -1,8 +1,3 @@
/* Assistant flow body: full-width narration (figma 16/28), block gap 16.
IconActions sit below the body with an explicit 16px top margin (figma
43:32997) separate from the body's internal gap so the footer spacing
stays fixed when the body is a single block. */
.root {
display: flex;
flex-direction: column;
@@ -1,14 +1,3 @@
// AssistantMarkdown: renders assistant blocks in order — markdown text body,
// reasoning as the figma Think summary row (expand = indented gray text),
// other-block JSON fallback. Tool-call heads are NOT rendered here: the chat
// view groups them into tool rows through its keyed toolview slot (figma
// step-summary flow). Shared by finalized nodes and the streaming partial;
// the turn-level loading dots live in the chat view's tail, not here.
// Finalized content (text) nodes append IconActions once their turn ends
// (`time` is omitted for mid-turn narration and while the turn still runs);
// their branch action is enabled only when the node is also the completed
// turn's transcript tail. Think / tool-head-only nodes stay chrome-free.
import { Fragment, memo, useMemo } from 'react'
import type { ReactNode } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
@@ -1,8 +1,3 @@
/* Chat flow: one 16px rhythm everywhere through the column gap. Input
padding cap rides the skeleton. Under
`[data-conversation-scroll]` the column host owns overflow and this view
is ordinary flow (see ConversationRoot active-phase rules). */
.root {
position: relative;
display: flex;
@@ -1,17 +1,5 @@
// ChatView: the default conversation view — one stable keyed parent list over
// final business Nodes, plus paging, pending steering and bottom-follow.
// Each row dispatches through 'conversation.chat.node'; ui-tool owns the
// tool-call renderer and its recursive root/subcall composition. A Host
// open-path refusal from the injected opener is an in-page dialog here.
//
// Scroll: when nested under `[data-conversation-scroll]` (active conversation
// column), that host is the scrollport and this view is flow content; when
// mounted alone (unit tests), `.scroll` owns overflow. Bottom-follow and
// prepend anchoring always target the resolved scrollport.
//
// Render economics: order changes only when rows enter, leave or move. Each
// ChatNodeSeat subscribes to one Node key, so Assistant deltas and Tool
// lifecycle updates replace only their own row without remounting it.
// An enclosing `[data-conversation-scroll]` owns scrolling when present;
// otherwise this view owns it. Each row subscribes to one stable node key.
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
@@ -1,7 +1,5 @@
// CompactionCommandCard: the `/compact` command's running row and its
// successful checkpoint disclosure. Outcomes without a checkpoint keep the
// generic command card so no-history, cancellation, and failures retain their
// complete handler-authored text.
// Only a structured checkpoint uses the compaction marker; all other outcomes
// retain the command's complete settlement text.
import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts'
import { CompactionItem } from './CompactionItem.tsx'
@@ -1,10 +1,5 @@
// CompactionItem: the one row a landed compaction contributes to the flow.
// The conversation it shadowed on the model surface stays above it, so this
// marker reports where the model stopped seeing that history — it never
// replaces it. The framed checkpoint payload is written for the model and is
// not rendered; the disclosure shows the summary from the checkpoint's own
// cited `compaction/summary` event, and a window cut that left that event outside makes the row
// non-expandable rather than empty.
// A compaction marker does not replace shadowed transcript rows. It is
// expandable only when the current window includes its cited summary.
import { memo, useState } from 'react'
import type { CompactionSummaryNode } from '@deepseek-ai/dsh-client-runtime/client'
@@ -28,7 +23,7 @@ interface CompactionItemProps {
}
/**
* The collapsed-by-default compaction marker.
* Renders the model-history compaction marker.
* @param props - the marker node off the snapshot cache.
* @returns the marker row, with the summary disclosure when one is available.
*/
@@ -1,9 +1,3 @@
// GenericCommandCard: the default command row — a stripped-down
// GenericToolCard rendering the command name and its settlement text.
// Supplied by the chat view as the keyed commandview slot's render-site
// fallback (an unregistered command name lands here); registrants may compose
// it as a base, feeding the same owner payload through.
import { useState, type ReactNode } from 'react'
import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts'
import { DisclosureRow, IconApiOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -35,10 +29,8 @@ export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCa
const summary = node.outcome === null
? runningSummary ?? t('command.running')
: text ?? (node.outcome.kind === 'error' ? t('command.failed') : t('command.done'))
// Title is the bare command name: the row already reads `name · outcome`,
// and the dispatched line's own `/` and arguments only restate what the
// settlement text says (`permission · preset workspace-write`). A
// cross-window node whose run page fell out of the window has no name.
// The summary already carries the settlement text, so the title is the bare
// command name.
const title = node.name ?? t('command.title')
const state = stateOf(node.outcome)
const body = text !== undefined && text.includes('\n') ? text : null
@@ -1,8 +1,3 @@
// MessageItem: simple chat nodes — user and consumed-steering bubbles
// (right-aligned, with clock + copy IconActions; branch lives only under
// assistant answers), pending steering (copy only), context injection,
// compaction marker, retry disclosure, and unknown-surface JSON rows.
import { memo, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type {
@@ -1,8 +1,3 @@
// Queue dock entry: renders the authoritative transient inbox snapshot and
// addresses per-row mutations through the session-scoped conversation face.
//
// The 'conversation.input.dock' SlotMap declaration lives in
// ../contract/slots.ts beside the other input-region slots.
import type { Context } from '@deepseek-ai/cordis'
import { useEffect, useId, useMemo, useState } from 'react'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
@@ -212,17 +207,10 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
)
}
/**
* The dock entry as a plain registrant plugin. The conversation service is
* the action contract; the slot declaration has an independent lifecycle boundary.
*/
/** Registers queue actions backed by the session-scoped conversation service. */
export const queueDockEntry = {
name: 'conversation-queue-dock',
inject: ['slots', 'conversation', 'sessions'],
/**
* Register the queue strip as the terminal input-dock entry (order 20).
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
name: 'conversation.input.dock',
@@ -1,9 +1,3 @@
/* Composer-takeover approval panel (draft approval.png): the same floating
capsule footprint as the InputBar card, with an amber header band, the
justification headline, a muted command line, and right-aligned actions.
Warn semantics ride the alias state tokens; no hardcoded colors. */
/* Mirrors InputBar .root so the takeover is a content swap, not a layout jump. */
.root {
display: flex;
flex-direction: column;
@@ -1,17 +1,3 @@
// ApprovalPanel: the composer-takeover approval prompt (designer draft
// approval.png), registered as a selector-routed entry of the
// conversation-declared composer chain. While an approval question is
// pending, this panel occupies the composer slot in place of the InputBar:
// an amber "Waiting for approval" strip on the card top, the model's
// justification as the headline, the paired command in muted code text, and
// a right-aligned refuse/allow action row. Justification and command are
// unbounded model text, so they scroll inside the card at the shared composer
// cap (`data-approval-scroll`) and the action row stays outside it — the
// buttons must be reachable no matter how long the command is.
// One-shot: the buttons disable
// after a click and the panel leaves (the InputBar returns) on the broadcast
// resolved frame.
import { useMemo, useState } from 'react'
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
import type { RunningToolCall } from '@deepseek-ai/dsh-client-runtime/client'
@@ -26,15 +12,12 @@ export function commandOf(call: RunningToolCall | undefined): string | undefined
const args = JSON.parse(call.argsRaw) as Record<string, unknown>
return typeof args.command === 'string' ? args.command : undefined
} catch {
// Unparseable model args: the panel still renders, just without the command line.
return undefined
}
}
/**
* Composer takeover boundary: mints the domain face on the carrier's stable
* identity and remounts the flow per request key, so the one-shot answered
* latch never leaks to the next pending approval.
* Render one pending approval and remount local answer state per request.
* @param props - the selector-matched pending approval carrier plus the framework standard kit.
* @returns The approval prompt for this request.
*/
@@ -54,9 +37,8 @@ function ApprovalFlow({ pending, command, t }: {
command?: string
t: ApprovalComposerProps['t']
}) {
// Local one-shot latch: the panel leaves only when the resolved frame
// lands; until then the buttons must not re-fire. An answer failure
// (rejected receipt / transport) re-arms them for retry.
// Keep actions disabled until the resolved frame arrives; failed answers
// re-enable them for retry.
const [answered, setAnswered] = useState(false)
const answer = (outcome: 'allowed-once' | 'rejected'): void => {
setAnswered(true)
@@ -1,8 +1,3 @@
/* Conversation column skeleton: header (breadcrumb row only for subagents not fork + tabs) over the view
area, composer InputBar at the bottom. Column width/squeeze is layout's;
this fills its cell. Figma: Header 39:27730 (83px two-row), tabs 13px with
a 2px active bar. */
.root {
display: flex;
flex-direction: column;
@@ -1,11 +1,3 @@
// DetailsPanel: close button + the selected call's args and
// result — args as JSON, the result raw except for a terminal-card call, whose
// Output section is the command's terminal card. Reads the
// selection from the shared chat
// store (conversation writes, this panel reads — the cross-registration
// share the store seat exists for) and derives the call material from the
// session snapshot — no data of its own.
import { Fragment } from 'react'
import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
@@ -14,28 +6,19 @@ import type { DetailsSlotProps } from '../contract/slots.ts'
import { findToolCall } from '../chat/tool-node-reader.ts'
import css from './DetailsPanel.module.css'
/** Full props composed by reference from the contract (automatic shares & injected share). */
export type DetailsPanelProps = DetailsSlotProps
/**
* Selected call material: the call's display name and args plus the frozen
* block slice it came from. `block` is a snapshot-cached reference, so the
* wrapper stays shallow-equal across unrelated snapshot frames; the settled /
* running split is read off it with the `'kind' in block` discrimination
* instead of duplicated as flags.
*/
/** The snapshot-owned block reference must remain stable across unrelated frames. */
interface CallMaterial {
name: string
argsRaw: string | null
block: ToolCallBlock
}
/** Material of a settled result node (native call or run_code sub-dispatch). */
function settledMaterial(node: ToolResultNode, callId: string): CallMaterial {
return { name: node.call?.name ?? callId, argsRaw: node.call?.argsRaw ?? null, block: node }
}
/** Material of an in-flight call (native call or run_code sub-dispatch). */
function runningMaterial(call: RunningToolCall): CallMaterial {
return { name: call.name, argsRaw: call.argsRaw, block: call }
}
@@ -50,7 +33,6 @@ function pretty(raw: string): string {
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
// Not JSON (streaming fragment or plain text): show verbatim.
return raw
}
}
@@ -1,8 +1,5 @@
// Hero chrome for the blank-draft phase of ConversationRoot: fish headline,
// glow backdrop, and the workspace row. Pure presentation — the resident
// composer is NOT rendered here (it keeps its own stable tree position in
// ConversationRoot so the textarea survives the hero → composer flip); CSS
// positions it over this shell's glow area during the hero phase.
// The composer remains in ConversationRoot so switching out of the blank-draft
// phase does not remount its textarea.
import { useId } from 'react'
import type { ReactNode, RefObject } from 'react'
@@ -119,7 +116,6 @@ export function HeroShell({ t, renderSlot, children }: HeroShellProps) {
<div className={css.root}>
<div className={css.stack}>
<div className={css.headline}>
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */}
<span className={css.fishHitbox}>
{renderSlot('conversation.hero.brand.mark', { size: 34, className: css.fish }, {
fallback: <FishLogo size={34} className={css.fish} />,
@@ -129,10 +125,7 @@ export function HeroShell({ t, renderSlot, children }: HeroShellProps) {
<span className={css.previewBadge}>{t('hero.preview')}</span>
</div>
<div className={css.body}>
{/* The resident composer (ConversationRoot's root-owned scrollport;
the workspace row rides the stack above the card) is CSS-centered
in that scroll body during hero see
ConversationRoot.module.css [data-phase='hero']. */}
{/* The composer remains mounted outside this component. */}
</div>
</div>
{children}
@@ -1,13 +1,4 @@
/* Floating capsule input (figma Input_Bottom 75:8208): card floats above the
viewport bottom inside the centered message column; textarea on top, action
row below, primary action controls bottom-right. Input width rides the
column (--dsh-composer-card-max-width = chat content + 32px, 16px per side,
is a cap, not a fixed size layout rule: the box shrinks with the center
column keeping its clearance). Hero variant = the same card centered in the
empty state; the transition between the two is a position move of one
component. */
.root {
display: flex;
flex-direction: column;
@@ -147,7 +147,6 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
<span className={css.triggerIcon} aria-hidden>{permissionGlyph(currentValue)}</span>
)}
<span className={css.triggerLabel}>{current === undefined ? displayName(currentValue) : optionLabel(current)}</span>
{/* Same glyph + open rotation as the sibling ModelSelect trigger. */}
<span className={clsx(css.chevron, open && css.chevronOpen)} aria-hidden>
<IconChevronDownOutline14 />
</span>
@@ -1,8 +1,3 @@
/* Todo strip in the composer context stack (Figma 1236:32276): tip surface,
status icons + secondary item labels. Its visible card aligns with the
GoalBar and the Queue panel inside their shared dock column: 12px radius,
36px collapsed row, 12px side padding, 14px tertiary leading glyph. */
.root {
box-sizing: border-box;
flex: none;
@@ -1,10 +1,3 @@
// TodoPanel: plan strip above the composer (the web counterpart of the TUI
// plan panel). Renders the standing todo/write whole-list snapshot (cleared on
// the next turn/start) — no data of its own, hidden while the list is empty.
// Mounted through the 'conversation.input.dock' slot (QueueDock posture): the
// dock adapter does the selecting, so the panel takes the plain list and stays
// framework-free. Visual: figma 772:51905 / 772:52972 / 772:53419.
import { useId, useState } from 'react'
import type { Context } from '@deepseek-ai/cordis'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
@@ -127,26 +120,19 @@ export function TodoPanel({ todos, t }: TodoPanelProps) {
)
}
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat + the locale seat. */
/** Props for the projected todo dock. */
export type TodoDockProps = PropsRuntime<'conversation.input.dock'> & PropsLocale<'conversation'>
/** Dock adapter: reads the host-computed 'todos' projection (whole list; absent or null renders nothing). */
/** Renders the current todo projection, or nothing when it is absent. */
export function TodoDock({ useProjection, t }: TodoDockProps) {
const todos = useProjection('todos')
return <TodoPanel todos={todos ?? []} t={t} />
}
/**
* The plan strip as a plain registrant plugin (QueueDock posture), following
* the input-dock declaration across independent activation and reload.
*/
/** Registers the projected todo dock. */
export const todoDockEntry = {
name: 'conversation-todo-dock',
inject: ['slots'],
/**
* Register the plan strip before the goal and queue entries (order 0).
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.inject('conversation.input.dock', () =>
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock))
@@ -1,18 +1,4 @@
// @vitest-environment jsdom
// apply inject factories exercised end to end against the terminal thin
// API: the strict session API (views triple, draft mirror), the
// provide-channel input face (machine-sink submit choreography incl.
// transactional clear + failure retention), the resident API (selectWorkspace
// draft carrying), the composer-bar stop face, openDetails = select action +
// layout orchestration, and the closeDetails details API. Complements
// chat-apply.spec.tsx (registration) and selection-survival.spec.tsx (store
// axis). History opening is NOT an inject concern — the runtime sessions
// service opens on watch (sessions-service.spec.ts owns that behavior).
//
// The inject APIs are read off the ledger entries deliberately (typed at
// this spec's own contract): these cases pin factory choreography the UI
// guards would mask. Rendering-path acceptance lives in
// chat-toolview-slot.spec.tsx.
import { describe, expect, it, vi } from 'vitest'
import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
@@ -1,9 +1,4 @@
// @vitest-environment jsdom
// apply wiring: the conversation service provided, the chat view registered
// as the first 'conversation.view' ring entry declaring the whole-Tool seat,
// the slot registrations land against a root entry's children declarations
// (the AppFrame role), and the shared store handle rides all strict session
// entries. Tool composition belongs to ui-tool and its machinery spec.
import { describe, expect, it, vi } from 'vitest'
import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
@@ -1,9 +1,4 @@
// @vitest-environment jsdom
// Remaining chat branch tails: MessageItem context/unknown arms,
// user IconActions, StatsLine no-cache join,
// AssistantMarkdown single-line reasoning. (Tool-row dispatch tails live
// with the keyed-slot machinery specs since the tool ring dissolved into
// renderSlot.)
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
@@ -40,7 +35,6 @@ afterEach(() => {
vi.unstubAllGlobals()
})
// Mirrors the real lookup chain (conversation namespace, then common).
const t: ChatNodeViewProps['t'] = makeTranslate(zh, commonZh)
const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null
const RETRY_ID = 'retry-fixture' as Extract<ConversationNode, { kind: 'model-retry' }>['retryId']
@@ -1,6 +1,4 @@
// @vitest-environment jsdom
// StatsLine (composer.dock entry): totals derivation + the RFC hard
// acceptance — zero renders during streaming.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
@@ -16,7 +14,6 @@ import { StatsLine, contextOccupancy, deriveStats, formatDuration, formatTokens,
import { en, zh } from '../src/client/locales.ts'
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
// Mirrors the real lookup chain (conversation namespace, then common).
const t: StatsLineProps['t'] = makeTranslate(zh, commonZh)
const tEn: StatsLineProps['t'] = makeTranslate(en, commonEn)
@@ -1,7 +1,4 @@
// @vitest-environment jsdom
// ChatView behavior: flow derivation, streaming isolation (Profiler counts),
// Tool seat ownership and selection handoff — driven through a scripted
// ObservableSnapshot fake, no wire or Tool presentation plugin.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
@@ -162,8 +159,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
read: () => savedScroll,
}
const forkAt = vi.fn()
// Selection rides the REAL chat store (same construction path as
// production; the view reads it through the PropsStore useStore share).
// Rows and the harness must observe the same chat-store instance.
const chat = createChatStore().create()
const t = makeTranslate(zh, commonZh)
const toolOwners: Array<{
@@ -289,7 +285,6 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
forkAt,
// Absent-service default; mention tests override with a real resolver.
fileMentions: () => undefined,
// Mirrors the real lookup chain (conversation namespace, then common).
t,
}
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
@@ -343,7 +338,6 @@ describe('Chat node rendering', () => {
],
turnEnds: new Map([[1, 4]]),
})
// Stub provider mirroring the real service: only produced files resolve.
h.props.fileMentions = owner => ({
resolve: (value) => {
if (value !== 'report.html') return undefined
@@ -1,6 +1,4 @@
// @vitest-environment jsdom
// ContextMeter (composer trailing control): occupancy ring gating, the
// click-open breakdown panel, and its close gestures.
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
@@ -12,7 +10,6 @@ import { en, zh } from '../src/client/locales.ts'
afterEach(cleanup)
// Mirrors the real lookup chain (conversation namespace, then common).
const t = makeTranslate(zh, commonZh) as ContextMeterProps['t']
const tEn = makeTranslate(en, commonEn) as ContextMeterProps['t']
@@ -21,7 +18,6 @@ const BREAKDOWN = { systemTokens: 120, toolsTokens: 21_500, messageTokens: 477_0
const segmentClass = css.segment
if (segmentClass === undefined) throw new Error('segment class missing from ContextMeter.module.css')
/** Stub the projection seat: a key-addressed table of whole values. */
function projections(values: Record<string, unknown>): ContextMeterProps['useProjection'] {
return (key: string) => values[key]
}
@@ -1,6 +1,4 @@
// @vitest-environment jsdom
// Branch tails the acceptance specs do not reach: the node-half apply
// without a settings service and AssistantMarkdown reasoning/unknown block arms.
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
@@ -11,7 +9,6 @@ import { apply as nodeApply } from '../src/index.ts'
import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
import { zh } from '../src/client/locales.ts'
// Mirrors the real lookup chain (conversation namespace, then common).
const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh)
const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null
@@ -19,7 +19,6 @@ import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { zh } from '../src/client/locales.ts'
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
// Mirrors the real lookup chain (conversation namespace, then common).
const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh)
const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null
@@ -1,5 +1,4 @@
// @vitest-environment jsdom
// Conversation-owned attachment errors and the message-image slot handoff.
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render } from '@testing-library/react'
@@ -1,8 +1,4 @@
// @vitest-environment jsdom
// InputBar behavior over the machine wiring: Enter-send semantics (IME guard,
// Shift newline, busy Enter policy, Ctrl/Meta steering, repeat suppression), running
// semantics (input stays free; continuable children keep Send beside Stop), the machine pending lock,
// decoration backdrop, error banners, status strips, and the focus-keeping mousedown.
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
@@ -187,7 +183,6 @@ function bench(over?: BenchOptions) {
useMenuLauncher: bindSnapshotSelector(menuLauncher),
stop,
command: over?.command ?? (() => Promise.resolve(true)),
// Mirrors the real lookup chain (conversation namespace, then common).
t: over?.t ?? makeTranslate(zh, commonZh),
renderSlot,
variant: over?.variant ?? 'composer',
@@ -67,7 +67,6 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
renderSlot: (() => null) as InputBarProps['renderSlot'],
stop: vi.fn(),
command: () => Promise.resolve(true),
// Mirrors the real lookup chain (conversation namespace, then common).
t: makeTranslate(zh, commonZh),
variant: 'composer',
}
@@ -176,7 +176,6 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
renderSlot: (() => null) as InputBarProps['renderSlot'],
stop: vi.fn(),
command: () => Promise.resolve(true),
// Mirrors the real lookup chain (conversation namespace, then common).
t: makeTranslate(zh, commonZh),
variant: 'composer',
}
@@ -63,10 +63,8 @@ function liveSession(initial: ConversationSnapshot) {
}
}
/** InputZone owner stub (the dock reads useSession only; the zone fields satisfy the owner share). */
const INPUT_STATE: InputState = { draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [] }
// Standard locale seat stub mirroring the real ns → common → key chain.
const t: QueueDockProps['t'] = makeTranslate(zh, commonZh)
function kitFor(snapshot: ConversationSnapshot, injected: Partial<QueueDockInjected> = {}) {
@@ -1,7 +1,4 @@
// @vitest-environment jsdom
// ConversationRoot skeleton behavior: the ONE resident composer across the
// hero (blank session) and active phases — same textarea DOM node, machine-
// owned draft, and the hero workspace picker (switching = retargetWorkspace).
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ReactNode } from 'react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
@@ -31,7 +28,6 @@ import type {
} from '../src/client/contract/slots.ts'
import type { ViewTab } from '../src/client/contract/views.ts'
/** Machine-backed wiring over a sink spy. */
function fakeWiring() {
const sink = vi.fn(() => Promise.resolve({ kind: 'success' as const }))
const shell = new SessionInputShell({ actx: {} as ClientContext, defaultSink: sink, commandImages: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` } })
@@ -54,7 +50,6 @@ beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
})
// Mirrors the real lookup chain (conversation namespace, then common).
const t: ConversationRootProps['t'] = makeTranslate(zh, commonZh)
const sid = (id: string) => id as SessionId
@@ -15,7 +15,6 @@ import type { TodoDockProps } from '../src/client/skeleton/TodoPanel.tsx'
import { TodoDock, TodoPanel, todoDockEntry } from '../src/client/skeleton/TodoPanel.tsx'
import { NS, zh } from '../src/client/locales.ts'
// Mirrors the real lookup chain (conversation namespace, then common).
const t: TodoDockProps['t'] = makeTranslate(zh, commonZh)
afterEach(cleanup)
@@ -1,9 +1,3 @@
// ProducedFiles: the produced-file row a finished turn ends with. The paths
// come pre-matched by the turn-tail chain from the mutation tools'
// follow-along locations, never from the closing prose. Clicking one goes
// through the same openFile the tool rows use — the Host's own opener, on the
// Host machine.
import { useLayoutEffect, useRef, useState } from 'react'
import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client'
import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
@@ -1,9 +1,3 @@
/* Directory-browser dialog (figma 813-23126 family). The shared Modal renders
* headless here mask, card, Escape only and this module owns the figma
* frame: 680×500 card (viewport-clamped; upsized from the figma 600×420),
* header (title + crumbs, l3 separator),
* the one-or-two-column Miller content, and the bordered footer. */
/* Doubled class beats Modal's own .dialog regardless of stylesheet order. */
/* Short viewports clamp the card: header/footer are flex-none and the
* columns scroll, so shrinking the height keeps Open/Cancel reachable
@@ -279,7 +279,6 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
const [pathDraft, setPathDraft] = useState<string | null>(null)
// Show-hidden toggle state (pure client-side filter, reset on each open).
const [showHidden, setShowHidden] = useState(false)
// Create-folder state: null = closed; a string = the nested dialog's draft.
const [folderDraft, setFolderDraft] = useState<string | null>(null)
const [creatingFolder, setCreatingFolder] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
@@ -1329,7 +1329,6 @@ describe('DirectoryBrowser', () => {
fireEvent.compositionEnd(pathInput)
fireEvent.keyDown(pathInput, { key: 'Enter' })
await waitFor(() => { expect(b.listDirectory).toHaveBeenLastCalledWith(DOCS, expect.any(AbortSignal)) })
// Create dialog: same guard.
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
const nameInput = screen.getByLabelText('browser.folderName')
fireEvent.change(nameInput, { target: { value: '新建' } })
@@ -1,7 +1,4 @@
// @vitest-environment jsdom
// GoalBar behavior: the docked strip above the composer — phase labels,
// inline edit form, and resume/clear icon actions — driven purely through
// props, no wire. Loading, absent, and complete goals render nothing.
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -12,7 +9,6 @@ import { GoalBar } from '../src/client/GoalBar.tsx'
import type { GoalActionResult, GoalBarActions } from '../src/client/slots.ts'
import { zh } from '../src/client/locales.ts'
// The framework-injected t seat, stubbed over the zh dictionaries (the default locale).
const t: Parameters<typeof GoalBar>[0]['t'] = makeTranslate(zh, commonZh)
afterEach(cleanup)
@@ -1,9 +1,3 @@
/* Trigger candidate menu (figma SLASH 39:26572 MenuDropdown): menu surface,
* r12, hairline border, shadow-lv3, 4px inset padding; anchored to the
* composer top edge, left-aligned with the input text. Cells follow
* .Menu_cell (min-h 40, r10, pad 10/8, gap 8, 14/22 primary label) with a
* trailing dimmed description. */
.menu {
position: absolute;
bottom: calc(100% + 4px);
@@ -1,5 +1,3 @@
// menuReduce generation gating, auto-close, silent group removal, cyclic
// highlight movement, stale/no-op reference identity; exactMatch lookup.
import { describe, expect, it } from 'vitest'
import type { MenuState, TriggerHit } from '../src/core/contract.ts'
import { exactMatch, MENU_CLOSED, menuReduce, seedGroups } from '../src/core/menu.ts'
@@ -12,7 +10,6 @@ const hit = (query = ''): TriggerHit => ({
span: { start: 0, end: 1 + query.length, draftRev: 1 },
})
/** Seed sources onto the closed state and open a first generation. */
function open(sources: readonly string[], h: TriggerHit = hit()): MenuState {
return menuReduce(seedGroups(MENU_CLOSED, sources.map(name => ({ name }))), { type: 'hit', hit: h })
}
@@ -1,10 +1,4 @@
// @vitest-environment jsdom
// Client apply wiring under the terminal register form: ctx.layout provided,
// ONE register() call declares the three child slots + seats the store factory
// + wires the panel actions through the inject hook; teardown cascades
// (service unprovided + declarations gone + registration cleared). Node half
// and the invariant companion ride along — one line exposes the aggregate
// coverage gate still requires exercised.
import { Context } from '@deepseek-ai/cordis'
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
@@ -1,9 +1,3 @@
/**
* LayoutController behavior: the cross-plugin panel-action face. Geometry
* lives in the entry store (layout-store.spec.ts) here we assert the
* delegation contract: attachPanels wiring, the three actions forwarding, the
* unwired fail-loud, and re-attach overwriting a stale action set.
*/
import { describe, expect, it, vi } from 'vitest'
import { LayoutController } from '@deepseek-ai/dsh-client-ui-layout/src/client/service.ts'
import type { PanelActions } from '@deepseek-ai/dsh-client-ui-layout/src/client/service.ts'
@@ -1,8 +1,4 @@
// @vitest-environment jsdom
// ThemePresenter behavior account: root color-scheme and the palette attribute
// follow active.colorScheme only, token variables replace the previous apply's
// set, theme-color metadata follows the rendered body background, and dispose
// retracts everything the presenter wrote.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
@@ -1,10 +1,3 @@
/* Per-message feedback controls. The rating buttons mirror the shared message
IconActions chrome so the strip reads as one row. The note editor is a
popover portaled to document.body and fixed from the note trigger's rect,
so it neither competes with the row for inline width nor gets cropped by the
conversation column's overflow clip. Surface recipe follows the Menu card:
r12, inverted hairline border, shadow-lv3. */
.action {
display: inline-flex;
align-items: center;
@@ -111,8 +111,7 @@ export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register('settings.permission', { zh, en }), 'ui-permission: settings row dictionaries')
const connection = ctx.get('connection') as ConnectionHandle
// The row follows the shared describe mirror, whose owning plugin already
// refreshes it on document commits and reconnects.
// The shared SettingsScope mirror updates after document commits and reconnects.
const controller = new PermissionPresetSettingsController(
ctx.settingsScope.describe(), connection.api, ctx.settingsSchema)
const load = (): Promise<void> => controller.load()
@@ -1,8 +1,3 @@
// DeepSeek Harness brand wordmark (figma 356:14644, exact extract): whale +
// "deepseek-official" letterforms + HARNESS badge plate in one svg. Native 182x24.
// Ink rides currentColor; the badge text is knocked out in the inverted
// label color so the plate stays legible in both themes.
import type { IconProps } from './icons/props.ts'
/** Display options for the official brand wordmark. */
@@ -1,8 +1,3 @@
// ConnectionBanner: top strip surfacing connection loss. The atom is pure:
// the owner subscribes to connection state and passes `reconnecting` down.
// A null/connecting state upstream should stay quiet too — only an actual
// outage (reconnect backoff in progress) shows the strip.
import css from './ConnectionBanner.module.css'
/**
@@ -1,9 +1,3 @@
/* Geometry mirrors CodeBlock/TerminalBlock (12px radius, code-block surface +
banner row, markdown code-block font) so a diff card reads as one family with
a fenced block and a terminal card. The deliberate divergence, shared with
TerminalBlock: the body keeps `white-space: pre` and scrolls horizontally,
because folding a source line destroys the indentation a diff is read by. */
.block {
--dsl-diff-radius: 12px;
--dsl-diff-line-height: 22px;
@@ -1,29 +1,14 @@
// DiffBlock: the inline-diff surface for a file mutation (write/edit) — a copy
// control over one or more per-file hunks, each a bold path header followed by
// the removed block (`-`, error color) and the added block (`+`, success
// color), with a dim `└ +A -R · N file(s)` footer. Unlike the TUI's exact
// changed-row comparison, this block renders the old and new sides in full.
// Both front ends share the line-terminator rule and distinct-path file count.
// Output never soft-wraps — an aligned source line keeps its indentation and
// scrolls horizontally instead of folding. Colors resolve through --dsw-*
// tokens; geometry mirrors CodeBlock.
import { useCallback, useMemo, useState } from 'react'
import clsx from 'clsx'
import { writeClipboard } from './clipboard.ts'
import css from './DiffBlock.module.css'
/**
* Output lines shown before the height cap collapses the middle. Matches
* {@link DEFAULT_TERMINAL_MAX_LINES} so a diff card and a terminal card cut a
* long body at the same place.
*/
/** Output lines shown before the height cap collapses the middle. */
export const DEFAULT_DIFF_MAX_LINES = 16
/**
* One file's change, in the shape {@link DiffBlock} draws. Structurally the
* render-intent contract's `FileDiff`, redeclared here so this primitive stays
* free of the tool contract (the terminal card's decoupling, applied to diffs).
* One file change in the form {@link DiffBlock} renders. It is declared here
* so this primitive stays independent of the tool contract.
*/
export interface DiffHunk {
/** The changed file's path, drawn verbatim as the hunk's header (the tool's model-facing path). */
@@ -1,8 +1,3 @@
// DeepSeek fish logo (figma I39:24057;88:8943 fillGeometry, exact extract;
// the full-canvas rectangle path from the raw dump is a background plate and
// is intentionally dropped). Native 23.16x17.04, rendered 24x18 by default;
// hero usage scales to 34x25. Color rides currentColor (wordmark ink).
import type { IconProps } from './icons/props.ts'
/**
@@ -1,13 +1,3 @@
// HoverCard: delayed hover-preview card portaled to document.body.
// Same portal mechanics as Menu: the wrapper span supplies the anchor rect,
// the card is fixed-positioned at its right edge and repositions on
// scroll/resize while open. The card is reachable: it takes pointer events,
// and leaving the anchor only arms a grace-delayed close, so the pointer can
// cross the 8px gap and settle on the card to read a clipped path or title.
// The portaled card is a React child of the wrapper, so React's enter/leave
// traversal already treats it as inside — one pair of wrapper handlers covers
// anchor and card alike.
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { createPortal } from 'react-dom'
@@ -1,13 +1,3 @@
// Menu: minimal controlled dropdown (group-by pickers, project selectors).
// Default: pure CSS positioning relative to the anchor wrapper — no popper.
// Opt-in `portal` renders the list into document.body, fixed-positioned from
// the anchor rect, for anchors inside overflow-clipping containers (sidebar).
// The owner controls `open`; outside-click closing uses one document listener
// active only while open. Submenus open on hover/focus inside the same root.
// Entries also cover non-interactive `label` headings and `danger` rows.
// Lists keep 12px clearance to the viewport's top/bottom edges and scroll
// internally past that; submenu-bearing menus are exempt (see .scrollable).
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import { createPortal } from 'react-dom'
+3 -11
View File
@@ -1,8 +1,3 @@
// Modal: controlled full-viewport dialog (create-workspace and similar).
// The overlay portals to this document's body so ancestor stacking contexts
// cannot leave sticky page controls above the mask. This is still an in-page
// WebUI dialog; it never creates or targets another browser/native window.
import { useEffect } from 'react'
import type { ReactNode } from 'react'
import { createPortal } from 'react-dom'
@@ -11,20 +6,17 @@ import { IconCloseOutline16 } from './icons/index.tsx'
import css from './Modal.module.css'
/**
* Render a centered modal over a blurred page mask.
* Render a centered, body-portaled modal over a blurred page mask.
* @param props.open - whether the dialog is showing.
* @param props.onClose - Escape or mask click.
* @param props.title - dialog heading (aria-label in every mode).
* @param props.closeLabel - accessible close-button label.
* @param props.closeLabel - localized accessible close-button label.
* @param props.description - optional supporting sentence under the title.
* @param props.children - body (inputs, etc.).
* @param props.footer - action row (Cancel / Create).
* @param props.contentClassName - optional class for a scrollable content region.
* @param props.headless - render children directly in the card (no default
* header/close/body chrome) for dialogs whose figma frame owns its own
* header structure; mask, card, Escape, and aria-label remain.
* @param props.closeLabel - close-button aria label; the owner passes
* localized copy (this package is cordis-free, so copy arrives via props).
* header/close/body chrome); mask, card, Escape, and aria-label remain.
* @returns null when closed; otherwise the overlay tree.
*/
export function Modal({
@@ -1,19 +1,11 @@
// OnboardingSurface: the full-viewport first-run takeover an onboarding step
// wraps its visible content in. The overlay portals to this document's body
// (the Modal precedent: ancestor stacking contexts cannot leave sticky page
// controls above the mask), and the surface holds `#root` inert for exactly
// its own lifetime — a step that renders null paints nothing and blocks
// nothing, so "should onboarding show right now" stays a plain render
// decision inside the step component.
import { useEffect } from 'react'
import type { ReactNode } from 'react'
import { createPortal } from 'react-dom'
import css from './OnboardingSurface.module.css'
/**
* Render the onboarding takeover chrome (mask + opaque stage) around one
* step's content and keep the application root inert while mounted.
* Render a body-portaled onboarding stage and keep the application root inert
* while mounted.
* @param props.children - the step's page content, centered on the stage.
* @returns the body-portaled overlay tree.
*/
@@ -1,9 +1,3 @@
/* Geometry mirrors CodeBlock (12px radius, code-block surface + banner row,
markdown code-block font) so a read card and a fenced code block read as one
family. Content keeps `white-space: pre` and scrolls horizontally rather than
folding, because a source line's indentation is part of what a reader is
reading. */
.block {
--dsl-read-radius: 12px;
--dsl-read-line-height: 22px;
@@ -1,14 +1,3 @@
// ReadBlock: the file surface for a read tool result — a banner (label +
// language + a "showing N of M" note when the read is a window + a copy
// control) over line-numbered, syntax-highlighted source. Each row carries the
// file's OWN line number in a gutter, so a windowed read past an offset keeps
// its file numbering rather than re-counting from 1. Highlighting reuses the
// CodeBlock shiki path (highlight.ts) at the per-line granularity a gutter
// needs; an unknown or absent language renders plain monospace. Long content is
// height-capped with the same head/tail arithmetic TerminalBlock uses, so the
// two cards collapse a long body at the same place. Colors resolve through
// --shiki-*/--dsw-* tokens.
import { useCallback, useMemo, useState, useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { writeClipboard } from './clipboard.ts'
@@ -50,13 +39,6 @@ export interface ReadBlockProps {
className?: string | undefined
}
/**
* Render one line's highlighted runs. The css-variables theme colors every run,
* so each run is a styled span; a line with no highlighting at all takes the
* bare-text path in the caller instead (an unknown or absent language).
* @param spans - the line's styled runs.
* @returns the line's children.
*/
function renderSpans(spans: readonly HighlightSpan[]) {
return spans.map((span, index) => <span key={index} style={span.style}>{span.text}</span>)
}
@@ -75,26 +57,19 @@ export function ReadBlock({
maxLines = DEFAULT_READ_MAX_LINES,
className,
}: ReadBlockProps) {
// The raw text the copy control writes and the highlighter tokenizes: the
// window's lines joined by newlines, without the file numbers or any chrome.
// Highlighting the whole window in one call (not line by line) keeps grammar
// context across lines — a multi-line string or comment stays one construct.
// Whole-window highlighting preserves multiline grammar context; copy uses
// the same text without gutter or banner chrome.
const raw = useMemo(() => lines.map(line => line.text).join('\n'), [lines])
// Re-render when a lazy grammar finishes loading, so a read card that showed
// plain text while its language's grammar imported picks up highlighting. The
// snapshot value is opaque; only its change across renders drives the memo.
const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount)
// Per-line highlighted runs aligned 1:1 with `lines`; undefined for an
// unknown/absent (or not-yet-loaded) language, when every line renders as
// bare text.
const highlighted = useMemo(() => highlightLines(raw, lang), [raw, lang, loaded])
const [expanded, setExpanded] = useState(false)
const [copied, setCopied] = useState(false)
const onCopy = useCallback(() => {
if (copied) return
// The window's raw text, never the rendered tree: the gutter numbers and the
// banner are chrome the file does not contain.
void writeClipboard(raw).then((ok) => {
if (!ok) return
setCopied(true)
@@ -106,19 +81,12 @@ export function ReadBlock({
const hidden = lines.length - maxLines
const capped = hidden > 0 && !expanded
// Same split arithmetic as TerminalBlock's height cap, so a long read and a
// long command output slice their head and tail at the same place.
const headLines = Math.ceil(maxLines / 2)
const tailLines = maxLines - headLines
// A read is a window when its returned lines are fewer than the file's total;
// the note states that so a reader is not misled that the file ends here.
const windowed = lines.length < totalLines
/**
* Render a slice of the line array as gutter-numbered rows.
* @param slice - the lines to draw, each with its aligned run array.
* @returns the row elements.
*/
const rows = (slice: readonly (readonly [ReadBlockLine, readonly HighlightSpan[] | undefined])[]) =>
slice.map(([line, spans]) => (
<div key={line.number} className={css.line}>
@@ -127,8 +95,6 @@ export function ReadBlock({
</div>
))
// Pair each line with its aligned run array up front, so head/tail slicing
// keeps the two in step without re-indexing.
const paired = lines.map((line, index): readonly [ReadBlockLine, readonly HighlightSpan[] | undefined] =>
[line, highlighted?.[index]])
@@ -141,10 +107,7 @@ export function ReadBlock({
<span className={css.count}>{`显示 ${lines.length} / ${totalLines}`}</span>
)}
<span className={css.lang}>{lang ?? ''}</span>
{/* Hide copy on an empty window, matching TerminalBlock's empty-output
guard: a successful read of an empty file returns lines: [] with
card:'read', so this branch is reachable, and copying then would
wipe the clipboard with an empty string. */}
{/* Empty files omit Copy to avoid replacing the clipboard with an empty string. */}
{lines.length > 0 && (
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
@@ -1,9 +1,3 @@
/* Geometry mirrors CodeBlock and TerminalBlock (12px radius, code-block
surface + banner row, markdown code-block font) so a search card reads as one
family with them. The deliberate divergence they share: the result rows keep
`white-space: pre` and scroll horizontally, because folding a long match line
or path destroys the alignment a reader scans by. */
.block {
--dsl-search-radius: 12px;
--dsl-search-line-height: 22px;
@@ -15,8 +9,6 @@
border-radius: var(--dsl-search-radius);
}
/* The banner: result summary on the left, the copy control holding its
intrinsic width on the right. */
.header {
display: flex;
align-items: center;
@@ -1,13 +1,3 @@
// SearchBlock: the search surface for a completed content or path search — a
// banner (result summary that folds the pre-cap total in when the tool capped
// the result, plus a copy control), then either grep matches grouped by file
// (each file a bold
// path header with its `lineNumber: line` rows, the group collapsible) or a
// flat glob path list. Both shapes flatten to one list of rows the height cap
// slices head/tail over, and neither soft-wraps: a long match line or path
// scrolls horizontally instead of folding. Geometry mirrors CodeBlock and
// TerminalBlock so a search card reads as one family with them.
import { useCallback, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import { headTailCap } from './head-tail-cap.ts'
@@ -1,8 +1,3 @@
// StateDot: session state indicator (figma nodes 14:3303/3305/3312, 122:9182).
// done/warning/error: 10x10 halo (same color, 10% opacity) around a 6x6 solid
// core. ongoing: a pixel-art chase — the 8 outer cells of a 3x3 matrix light
// up clockwise with a stepped trail. Colors resolve through --dsw-* tokens only.
import clsx from 'clsx'
import css from './StateDot.module.css'
@@ -1,9 +1,3 @@
/* Geometry mirrors CodeBlock (12px radius, code-block surface + banner rows,
markdown code-block font) so a terminal card and a fenced code block read as
one family. The one deliberate divergence: output keeps `white-space: pre`
and scrolls horizontally, because folding a column-aligned command's output
destroys its alignment. */
.block {
--dsl-terminal-radius: 12px;
--dsl-terminal-line-height: 22px;
@@ -1,10 +1,3 @@
// TerminalBlock: the terminal surface for a shell command and its output —
// prompt line (run-state dot + shortened cwd + command), ANSI-colored output,
// settled exit status, and a copy control for the raw output. Output never soft-wraps:
// column-aligned output (ls, tables, box drawing) keeps its alignment and
// scrolls horizontally instead of folding. Colors resolve through --dsw-*
// tokens; ANSI parsing lives in ansi.ts.
import { useCallback, useMemo, useState } from 'react'
import clsx from 'clsx'
import { parseAnsiLines, type AnsiLine } from './ansi.ts'
@@ -14,11 +7,7 @@ import { Pill } from './Pill.tsx'
import { StateDot, type StateDotState } from './StateDot.tsx'
import css from './TerminalBlock.module.css'
/**
* Output lines shown before the height cap collapses the middle. Matches the
* TUI transcript's default tool-output budget so both front ends cut a long
* command's output at the same place.
*/
/** Output lines shown before the height cap collapses the middle. */
export const DEFAULT_TERMINAL_MAX_LINES = 16
/**

Some files were not shown because too many files have changed in this diff Show More