Merge origin/master into feature/shared-cli-config-foundation

This commit is contained in:
Turtle
2026-07-30 10:11:38 +08:00
253 changed files with 18294 additions and 3194 deletions

View File

@@ -86,7 +86,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore
Bringing up a new `packages/client/<name>` plugin package (ui-workspace is the latest walked example; ui-sidebar/ui-question are good skeletons to copy):
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section.
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/web.cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/config/web.cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else.
4. **Registering into another package's slot**: if the declaring host provides no waitable service, your apply's order relative to the host's is unconstrained — a bare `slots.register` into its slot races boot (intermittent `slot "..." is not declared` page failures). Register with declaration-aware deferral: check `ctx.slots.spec(name)`, otherwise `ctx.slots.subscribe(name)` and register on the declaration event (SlotCore supports subscribing ahead of declaration); make the registration idempotent, and unsubscribe + dispose in the effect disposer. Only take a service edge in `inject` when the host actually provides one (ui-question → `'conversation'` is that case).
5. Rebuild the bundle (`pnpm --filter <pkg> bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources.

View File

@@ -601,7 +601,7 @@ function backscanGoal(log: readonly SessionEvent[]): FxGoalProjection | null {
const source = event.data?.source
if (source?.kind !== 'goal' || source.round !== 0) continue
const change = source.change
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
// oxlint-disable-next-line typescript/no-unnecessary-condition
if (change === undefined || change.kind !== 'goal/change') continue
if (change.operation === 'clear') return null
return { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt }

View File

@@ -32,7 +32,7 @@ const install: InvariantInstaller = (ctx, fail) => {
const baselines = new WeakMap<Fiber, number>()
// Async listener by design: emitPluginDisposed awaits-and-logs returned
// promises, so a violation surfaces loudly instead of unhandled.
// eslint-disable-next-line @typescript-eslint/no-misused-promises
// oxlint-disable-next-line typescript/no-misused-promises
ctx.on('internal/plugin', async (fiber) => {
if (fiber.name !== 'client-hmr') return
if (fiber.uid !== null) {

View File

@@ -9,7 +9,7 @@
* with the last holding entry, session instances cleared (with persisted
* state) on scope death.
*/
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
/* oxlint-disable typescript/no-redundant-type-constituents --
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap only
* holds this package's 'root' row in this compilation unit, but consumers
* merge keys in; the rule fires on the narrow-map view, not on real
@@ -310,6 +310,6 @@ export class SlotsService extends Service {
// The core's overloads proved the shares; the implementation works on
// the erased view (same pattern as the core's own implementation arm).
const options = rawOptions as ErasedRegisterOptions
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return this.ctx.effect(() => this['_register'](options, component), 'slots.register()')
}

View File

@@ -4,7 +4,7 @@
*/
/* jscpd:ignore-start */
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
/* oxlint-disable typescript/no-redundant-type-constituents --
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty
* in this compilation unit (intersection reads `never`) but consumers merge
* keys in; the rule fires on the empty-map view, not on real redundancy. */

View File

@@ -286,3 +286,78 @@ describe('WorkspacesService', () => {
await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
})
})
describe('startInitialSelection', () => {
function bench() {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
return { api, sessions, workspaces }
}
it('connects the recent Workspace blank session once baselines are ready and opens it', async () => {
const b = bench()
const stop = b.workspaces.startInitialSelection()
// Nothing happens before both baselines land.
expect(b.api.callsOf('session.create')).toHaveLength(0)
b.api.onWorkspaceList = () => Promise.resolve(ok({
items: [workspace('recent', [], '2026-01-02T00:00:00.000Z')] as never[],
}))
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-new') }))
await b.workspaces.refresh()
await b.sessions.refresh()
// Store notifications and the connect round trip are microtask-batched.
await new Promise(resolve => setTimeout(resolve, 0))
expect(b.api.callsOf('session.create')).toEqual([{ workspaceId: 'recent' }])
expect(b.sessions.list.getSnapshot().current).toBe('s-new')
stop()
})
it('stays idle when a session is already current or no recent Workspace exists', async () => {
const withCurrent = bench()
withCurrent.api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }] as never[],
}))
await withCurrent.sessions.refresh()
withCurrent.sessions.open(sid('s1'))
withCurrent.api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('w1', [sid('s1')])] as never[] }))
const stopCurrent = withCurrent.workspaces.startInitialSelection()
await withCurrent.workspaces.refresh()
await new Promise(resolve => setTimeout(resolve, 0))
expect(withCurrent.api.callsOf('session.create')).toHaveLength(0)
stopCurrent()
const noRecent = bench()
const stopEmpty = noRecent.workspaces.startInitialSelection()
await noRecent.workspaces.refresh()
await noRecent.sessions.refresh()
await new Promise(resolve => setTimeout(resolve, 0))
expect(noRecent.api.callsOf('session.create')).toHaveLength(0)
expect(() => noRecent.workspaces.startInitialSelection()).toThrow(/already started/)
stopEmpty()
})
it('a failed connect returns to waiting and retries on the next list change', async () => {
const b = bench()
b.api.onWorkspaceList = () => Promise.resolve(ok({
items: [workspace('recent', [], '2026-01-02T00:00:00.000Z')] as never[],
}))
b.api.onCreate = () => Promise.resolve(err({ code: 'internal', message: 'attach exploded', details: {} }))
const stop = b.workspaces.startInitialSelection()
await b.workspaces.refresh()
await b.sessions.refresh()
await new Promise(resolve => setTimeout(resolve, 0))
expect(b.api.callsOf('session.create')).toHaveLength(1)
expect(b.sessions.list.getSnapshot().current).toBeUndefined()
// Recovery: the next workspace-list change re-runs the reconcile.
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-retry') }))
await b.workspaces.refresh()
await new Promise(resolve => setTimeout(resolve, 0))
expect(b.api.callsOf('session.create')).toHaveLength(2)
expect(b.sessions.list.getSnapshot().current).toBe('s-retry')
stop()
})
})

View File

@@ -10,7 +10,7 @@
* machinery — everything mounts the production implementations.
* @module @deepseek-ai/dsh-client-test-runtime
*/
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
/* oxlint-disable typescript/no-redundant-type-constituents --
* `keyof SlotMap & string` is the declare-merge key pattern (see ui-slots):
* this compilation unit sees only the runtime's 'root' row, but consumer
* programs merge their own keys in; the rule fires on the narrow-map view. */

View File

@@ -237,6 +237,20 @@ export class TestSessions implements ISessions {
await this.stabilize(() => { record.snapshot.update(mutate) })
}
/**
* Update a session's list row (the wire-echo stand-in: title settles,
* running flips — components subscribed via useSessions re-render).
* @param id - session id.
* @param patch - summary fields to merge over the row.
*/
async updateSummary(id: string, patch: Partial<Omit<SessionSummary, 'id'>>): Promise<void> {
const record = this.require(id)
record.summary = { ...record.summary, ...patch }
await this.stabilize(() => {
this.list.update((draft) => { draft.byId[id as SessionId] = record.summary })
})
}
/**
* Switch the current selection (undefined = the no-session empty state).
* @param id - session id to select, or undefined to clear.

View File

@@ -8,7 +8,7 @@
export async function writeClipboard(text: string): Promise<void> {
// lib.dom types clipboard non-optional, but insecure contexts omit it —
// that runtime gap is exactly what this guard detects.
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
/* oxlint-disable-next-line typescript/no-unnecessary-condition */
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
@@ -19,7 +19,7 @@ export async function writeClipboard(text: string): Promise<void> {
}
// execCommand('copy') is the only clipboard fallback where the async API
// is missing (insecure contexts); deprecated but deliberately retained.
/* eslint-disable @typescript-eslint/no-deprecated */
/* oxlint-disable typescript/no-deprecated */
const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document)
: undefined
@@ -36,7 +36,7 @@ export async function writeClipboard(text: string): Promise<void> {
} catch {
// Clipboard unavailable; the button stays idle.
}
/* eslint-enable @typescript-eslint/no-deprecated */
/* oxlint-enable typescript/no-deprecated */
el.remove()
}

View File

@@ -105,7 +105,7 @@ export function InputBar({
// IME guard so a composition-closing Shift+Enter still breaks the line.
if (e.key === 'Enter' && e.shiftKey) return
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
// eslint-disable-next-line @typescript-eslint/no-deprecated
// oxlint-disable-next-line typescript/no-deprecated
const composing = composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
if (keyboard.arbitrate(e.key === 'ArrowUp' ? 'up' : 'down', composing) === 'consumed') e.preventDefault()
@@ -165,8 +165,8 @@ export function InputBar({
if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock
const next = e.target.value
keyboard.setDraft(next)
// selectionStart is number|null in lib.dom; the eslint program narrows it.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
// selectionStart is number|null in lib.dom; the type-aware lint program narrows it.
// oxlint-disable-next-line typescript/no-unnecessary-condition
keyboard.track(next, e.target.selectionStart ?? next.length)
}
@@ -178,13 +178,13 @@ export function InputBar({
// too (one char = one step). Mouse selection of a chip is handled in the
// backdrop click handler below. Undo/redo must NOT reach the browser: the
// machine owns the transaction log.
// selectionStart/End are number|null in lib.dom; the eslint program narrows them.
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
// selectionStart/End are number|null in lib.dom; the type-aware lint program narrows them.
/* oxlint-disable typescript/no-unnecessary-condition */
const selectionOf = (el: HTMLTextAreaElement) => ({
start: el.selectionStart ?? 0,
end: el.selectionEnd ?? el.selectionStart ?? 0,
})
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
/* oxlint-enable typescript/no-unnecessary-condition */
const onCopyOrCut = (e: React.ClipboardEvent<HTMLTextAreaElement>, cut: boolean): void => {
const el = e.currentTarget

View File

@@ -0,0 +1,245 @@
// @vitest-environment jsdom
/**
* Assembly-level acceptance on SlotTestRuntime (real apply, real slot
* machinery, real renderer; data fed as fixtures) for surfaces that were
* previously pinned only by the assembled-app jsdom snapshots
* (apps/web/tests/{todo-display,terminal-card,slash-flow}.snapshot.ts):
*
* - the todo_write turn reaches BOTH surfaces through the product
* registrations (keyed toolview row in the flow, plan strip in the input
* dock via the 'todos' projection) and the strip follows projection
* retirement;
* - the bash keyed row carries its resident terminal card, and the fallback
* row reaches the same card through its expand control;
* - the resident composer textarea survives the blank→active conversion as
* the SAME DOM node (focus/IME continuity rides React reconciliation:
* component identity + tree position, which this assembled tree pins).
*
* Component-level behavior (collapse interaction, card model arms, summary
* derivations) lives in todo-panel.spec.tsx / terminal-card.spec.tsx; this
* suite only proves the assembled wiring.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { ISession, SessionId, TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
const SID = 's1' as SessionId
afterEach(cleanup)
beforeEach(() => {
localStorage.clear()
})
const TODOS: TodoItem[] = [
{ content: '梳理需求', status: 'completed' },
{ content: '实现 fixture 样本', status: 'in_progress' },
{ content: '浏览器验收', status: 'pending' },
]
const todoResult = (seq: number): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId: `todo-${seq}`,
call: { name: 'todo_write', argsRaw: JSON.stringify({ todos: TODOS }) },
callTime: seq * 1_000 - 500,
content: [], isError: false, callView: null, resultView: null,
})
const bashResult = (seq: number, callId: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' },
callTime: seq * 1_000 - 500,
content: [{ type: 'text', text: 'total 2\ndemo.txt\n' }], isError: false,
callView: { card: 'terminal', title: 'ls -la', description: 'List files' },
resultView: { card: 'terminal', output: 'total 2\ndemo.txt\n', exitCode: 0 },
...over,
})
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
type AppRootProps = PropsRenderSlots<'conversation' | 'details'>
function AppRoot({ renderSlot }: AppRootProps) {
return <>{renderSlot('conversation', {})}</>
}
const LAYOUT_CHILDREN = {
'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' },
} as const
async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) {
const runtime = await SlotTestRuntime.create()
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
runtime.provide('locale', new LocaleService(runtime.ctx))
await runtime.sessions.add({
id: SID,
summary: { title: 'S', displayTitle: 'S', cwd: '/proj' },
snapshot: {
nodes,
...(opts?.blank === true ? { blank: true, composerPhase: 'blank' as const } : {}),
},
session: {
loadOlder: vi.fn<ISession['loadOlder']>(),
prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),
},
})
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
await runtime.mount({ inject: [...inject], apply })
return runtime
}
describe('todo_write assembly (product registrations, no outlet twins)', () => {
it('reaches the keyed toolview row and the dock plan strip, and the strip follows projection retirement', async () => {
const runtime = await bench([todoResult(3)])
// The dock strip reads the host-computed 'todos' projection.
runtime.sessions.behavior(SID).projections.set('todos', TODOS)
const view = runtime.renderRoot()
// Keyed toolview registration took the row (summary derived from args).
const row = view.container.querySelector('[data-sample="todo-row"]')
expect(row).not.toBeNull()
expect(row!.textContent).toContain('1/3 已完成 · 实现 fixture 样本')
// The plan strip sits in the input dock, fed by the projection
// (default-collapsed: the header summary shows; rows appear on expand).
const panel = view.container.querySelector('[data-testid="todo-panel"]')
expect(panel).not.toBeNull()
expect(panel!.textContent).toContain('1/3 tasks · 1 in progress')
fireEvent.click(panel!.querySelector('button')!)
expect([...panel!.querySelectorAll('li')].map(li => li.getAttribute('data-status')))
.toEqual(['completed', 'in_progress', 'pending'])
// Next turn retires the standing plan (host pushes null): the strip
// clears while the historical row stays in the flow.
await runtime.flush()
runtime.sessions.behavior(SID).projections.set('todos', null)
await waitFor(() => {
expect(view.container.querySelector('[data-testid="todo-panel"]')).toBeNull()
})
expect(view.container.querySelector('[data-sample="todo-row"]')).not.toBeNull()
await runtime.dispose()
})
})
describe('terminal card assembly', () => {
it('the keyed bash row carries a resident terminal card; the fallback row reaches one through expand', async () => {
const runtime = await bench([
bashResult(3, 'c-keyed'),
// An unregistered tool with terminal views: GenericToolCard fallback.
bashResult(4, 'c-fallback', { call: { name: 'fx-bash', argsRaw: '{"command":"ls -la"}' } }),
])
const view = runtime.renderRoot()
// Keyed BashRow renders the card residently (no expand gesture).
const keyed = view.container.querySelector('[data-sample="bash-global"]')?.parentElement
expect(keyed?.querySelector('[data-terminal]')).not.toBeNull()
// Fallback row: card appears only after its expand control.
const fallback = view.container.querySelector('[data-tool="fx-bash"]')
expect(fallback).not.toBeNull()
expect(fallback!.querySelector('[data-terminal]')).toBeNull()
fireEvent.click(fallback!.querySelector('button[aria-expanded]')!)
await waitFor(() => {
expect(fallback!.querySelector('[data-terminal]')).not.toBeNull()
})
await runtime.dispose()
})
})
describe('resident composer', () => {
it('renders the locked view state while no session exists at all', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
runtime.provide('locale', new LocaleService(runtime.ctx))
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
await runtime.mount({ inject: [...inject], apply })
const view = runtime.renderRoot()
// No session entity: the inert twin renders (disabled textarea), and the
// workspace picker chip is the only live control.
const textarea = view.container.querySelector('textarea')
expect(textarea).not.toBeNull()
expect(textarea!.disabled).toBe(true)
expect(view.getByRole('button', { name: 'Choose workspace' })).toBeTruthy()
await runtime.dispose()
})
it('the textarea survives the blank→active conversion as the same DOM node', async () => {
const runtime = await bench([], { blank: true })
// The hero renders the LIVE composer only when the blank session's
// workspace resolves a chip title; an ownerless blank session shows the
// disabled twin instead (deleted-workspace semantics).
await runtime.workspaces.update((draft) => {
draft.items = [{ workspaceId: 'w1', title: 'Proj', path: '/proj', sessionIds: [SID] }] as never
})
const view = runtime.renderRoot()
const hero = view.container.querySelector('textarea')
expect(hero).not.toBeNull()
expect(hero!.disabled).toBe(false)
// First acceptance: the session leaves blank and the composer docks.
await runtime.sessions.updateSnapshot(SID, (draft) => {
draft.blank = false
draft.composerPhase = 'active'
})
const docked = view.container.querySelector('textarea')
expect(docked).toBe(hero)
await runtime.dispose()
})
})
describe('prompt rejection through the assembled composer', () => {
it('renders the promptError alert strip and keeps the draft in the machine', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
runtime.provide('locale', new LocaleService(runtime.ctx))
const prompt = vi.fn<ISession['prompt']>(async () => ({
ok: false, error: { code: 'agent-busy', message: 'prompt rejected before acceptance', details: { reason: 'busy' } },
}))
await runtime.sessions.add({
id: SID,
summary: { title: 'S', displayTitle: 'S', cwd: '/proj' },
session: { prompt, loadOlder: vi.fn<ISession['loadOlder']>() },
})
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
await runtime.mount({ inject: [...inject], apply })
const view = runtime.renderRoot()
const composer = view.container.querySelector('textarea')!
fireEvent.change(composer, { target: { value: 'do not lose this' } })
fireEvent.keyDown(composer, { key: 'Enter' })
await waitFor(() => { expect(prompt).toHaveBeenCalledOnce() })
// The rejection lands in snapshot.promptError (the Session's own path);
// the fixture mirrors that hop — the assembled InputBar renders it.
await runtime.sessions.updateSnapshot(SID, (draft) => {
draft.promptError = {
op: 'send',
error: { code: 'agent-busy', message: 'prompt rejected before acceptance', details: { reason: 'busy' } },
}
})
const alert = await view.findByRole('alert')
expect(alert.textContent).toContain('prompt rejected before acceptance (agent-busy)')
// Failure restore: the machine returned the draft to the same textarea.
await waitFor(() => {
expect((view.container.querySelector('textarea'))!.value).toBe('do not lose this')
})
await runtime.dispose()
})
})
describe('title projection across assembled surfaces', () => {
it('one summary update re-labels the breadcrumb and document.title consumers together', async () => {
const runtime = await bench([])
const view = runtime.renderRoot()
// The strict session header breadcrumb reads useSessions ancestry.
const crumb = within(view.container.querySelector('[aria-label="Session hierarchy"]') as HTMLElement)
expect(crumb.getByText('S')).toBeTruthy()
await runtime.sessions.updateSummary(SID, { displayTitle: '修订标题', title: '修订标题' })
await waitFor(() => { expect(crumb.getByText('修订标题')).toBeTruthy() })
expect(crumb.queryByText('S')).toBeNull()
await runtime.dispose()
})
})

View File

@@ -12,7 +12,7 @@
export async function writeClipboard(text: string): Promise<boolean> {
// lib.dom types clipboard non-optional, but insecure contexts omit it —
// that runtime gap is exactly what this guard detects.
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
/* oxlint-disable-next-line typescript/no-unnecessary-condition */
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
@@ -25,7 +25,7 @@ export async function writeClipboard(text: string): Promise<boolean> {
// jsdom and older hosts: best-effort execCommand path when present.
// execCommand('copy') is the only clipboard fallback where the async API
// is missing; deprecated but deliberately retained.
/* eslint-disable @typescript-eslint/no-deprecated */
/* oxlint-disable typescript/no-deprecated */
const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document)
: undefined
@@ -44,5 +44,5 @@ export async function writeClipboard(text: string): Promise<boolean> {
} finally {
el.remove()
}
/* eslint-enable @typescript-eslint/no-deprecated */
/* oxlint-enable typescript/no-deprecated */
}

View File

@@ -16,7 +16,7 @@ export function JsonBlock({ label, payload, defaultOpen = false }: {
let s: string
try {
// lib typing hides stringify's undefined arm (undefined/function/symbol payloads).
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
// oxlint-disable-next-line typescript/no-unnecessary-condition
s = JSON.stringify(payload, null, 2) ?? String(payload)
} catch {
s = String(payload)

View File

@@ -38,7 +38,7 @@ export function parseQuestionTitle(title: string): string {
/** Return whether a textarea key event belongs to an active IME composition. */
function isComposing(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
// eslint-disable-next-line @typescript-eslint/no-deprecated
// oxlint-disable-next-line typescript/no-deprecated
return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229
}
@@ -64,9 +64,9 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
const [error, setError] = useState<string | null>(null)
// index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
const question = questions[index]!
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
const draft = drafts[index]!
const hasOptions = (question.options?.length ?? 0) > 0

View File

@@ -8,7 +8,7 @@
* consumer `declare module` augmentation merges with declarations lexically in
* the augmented module, not with re-exports.
*/
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
/* oxlint-disable typescript/no-redundant-type-constituents --
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty
* in THIS compilation unit (so the intersection reads as `never`), but every
* consumer merges keys in and the intersection is what keeps them string-typed.
@@ -350,7 +350,7 @@ interface ErasedOptions {
priority?: number | undefined
children?: Record<string, SlotSpec<SlotEntryDef>> | undefined
store?: StoreDecl | undefined
/* eslint-disable-next-line @typescript-eslint/no-explicit-any --
/* oxlint-disable-next-line typescript/no-explicit-any --
* implementation-signature position only (both public overloads type inject
* exactly); `never[]` would fail overload-to-implementation compatibility
* against the per-declaration InjectParams tuples. */

View File

@@ -21,7 +21,7 @@ export type MaybeSnapshotSelectorHook<T> =
* declared as the store's complete write set (the audit face — components can
* only write through these).
*/
/* eslint-disable-next-line @typescript-eslint/no-explicit-any --
/* oxlint-disable-next-line typescript/no-explicit-any --
* any[] (not unknown[]): each action carries its own parameter list, and
* unknown[] would reject every concrete signature under strict parameter
* contravariance. Params are re-inferred per action by BakedActions. */
@@ -95,14 +95,14 @@ export interface StoreHandle<T, A extends ActionsDecl<T>> {
* Exclusive-store registration form: the registrant passes the factory itself
* and the framework calls it per entry x scope (no shared identity exists).
*/
/* eslint-disable-next-line @typescript-eslint/no-explicit-any --
/* oxlint-disable-next-line typescript/no-explicit-any --
* erased position accepting every StoreHandle instantiation; T/A are
* recovered per use site by conditional inference (HandleOf/BoundActions/
* PropsStore). */
export type StoreFactory = () => StoreHandle<any, any>
/** The register `store` option position: a shared handle or an exclusive factory. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- same erased-constraint position as StoreFactory (see above).
// oxlint-disable-next-line typescript/no-explicit-any -- same erased-constraint position as StoreFactory (see above).
export type StoreDecl = StoreHandle<any, any> | StoreFactory
/** Normalize a store declaration to its handle type (factories yield their return). */

View File

@@ -41,7 +41,7 @@ describe('tsdown client artifact', () => {
// Same execution form the loader uses (inline script eval, window scope) —
// the implied-eval ban targets accidental string execution, not this
// deliberate bundle-execution fixture.
// eslint-disable-next-line @typescript-eslint/no-implied-eval, @typescript-eslint/no-unsafe-call
// oxlint-disable-next-line typescript/no-implied-eval, typescript/no-unsafe-call
new Function(code!)()
expect(handoff).toBeDefined()
const modules = new Map<string, unknown>([

View File

@@ -0,0 +1,118 @@
// @vitest-environment jsdom
/**
* The session-rename assembly chain on SlotTestRuntime (real apply, real
* WorkspaceBrowser occupying the sidebar hole): row menu → rename dialog →
* the injected renameSession hop (sessions.binding → ISession.rename) → on
* the accepted unary response the dialog closes and the row re-labels from
* the list state — no push-frame wait. Previously pinned only by the
* assembled-app snapshot (apps/web/tests/session-actions.snapshot.ts); the
* verb's wire behavior stays with the runtime package
* (session.spec.ts#rename), the dialog's own arms with rows.spec /
* workspace-browser.spec.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
import type { ISession, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
const SID = 's1' as SessionId
afterEach(cleanup)
beforeEach(() => { localStorage.clear() })
/** Test-owned sidebar shell role: declares and renders the browsing region. */
type FrameProps = PropsRenderSlots<'sidebar.workspaces'>
function SidebarFrame({ renderSlot }: FrameProps) {
return <>{renderSlot('sidebar.workspaces', { wide: true, expandSidebar: () => {} })}</>
}
describe('session rename through the assembled browser', () => {
it('renames via the row menu: binding.session.rename fires, the dialog closes, the row re-labels from the list', async () => {
const runtime = await SlotTestRuntime.create()
const rename = vi.fn<ISession['rename']>(async title => ({
ok: true, value: { title: title.trim().replace(/\s+/g, ' '), seq: 7 },
}))
await runtime.sessions.add({
id: SID,
summary: { title: '旧标题', displayTitle: '旧标题', cwd: '/w/alpha' },
session: { rename },
})
await runtime.workspaces.update((draft) => {
draft.items = [{
workspaceId: 'w1' as WorkspaceId, title: 'alpha', path: '/w/alpha',
sessionIds: [SID], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
}] as never
})
await runtime.root.declare(
{ 'sidebar.workspaces': { kind: 'single', scope: 'root' } } as never,
SidebarFrame as never,
)
await runtime.mount({ inject: [...inject], apply })
const view = runtime.renderRoot()
// The current session's group auto-expands; open the row's action menu.
const row = (await view.findByText('旧标题')).closest('[role="treeitem"]')!
fireEvent.click(within(row as HTMLElement).getByLabelText('Session actions for 旧标题'))
fireEvent.click(view.getByRole('menuitem', { name: 'Rename', hidden: true }))
// The dialog seeds from the current title; submit a padded value.
const input = await view.findByLabelText('Session name') as HTMLInputElement
expect(input.value).toBe('旧标题')
fireEvent.change(input, { target: { value: ' 分叉 实验记录 ' } })
fireEvent.click(view.getByRole('button', { name: 'Rename' }))
// The injected hop reached the session face with the edge-trimmed draft
// (the dialog trims edges; interior normalization is host-side).
await waitFor(() => { expect(rename).toHaveBeenCalledWith('分叉 实验记录') })
// Acceptance closes the dialog without any push-frame wait.
await waitFor(() => { expect(view.queryByLabelText('Session name')).toBeNull() })
// The manager lands the unary echo in the list store (its own package
// tests own that hop); the row re-labels from list state alone.
await runtime.sessions.updateSummary(SID, { displayTitle: '分叉 实验记录', title: '分叉 实验记录' })
await view.findByText('分叉 实验记录')
expect(view.queryByText('旧标题')).toBeNull()
await runtime.dispose()
})
it('a rejected rename keeps the dialog open with the error surfaced', async () => {
const runtime = await SlotTestRuntime.create()
const rename = vi.fn<ISession['rename']>(async () => ({
ok: false, error: { code: 'internal', message: 'title write failed', details: {} },
}))
await runtime.sessions.add({
id: SID,
summary: { title: '旧标题', displayTitle: '旧标题', cwd: '/w/alpha' },
session: { rename },
})
await runtime.workspaces.update((draft) => {
draft.items = [{
workspaceId: 'w1' as WorkspaceId, title: 'alpha', path: '/w/alpha',
sessionIds: [SID], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
}] as never
})
await runtime.root.declare(
{ 'sidebar.workspaces': { kind: 'single', scope: 'root' } } as never,
SidebarFrame as never,
)
await runtime.mount({ inject: [...inject], apply })
const view = runtime.renderRoot()
await runtime.flush()
const row = (await view.findByText('旧标题')).closest('[role="treeitem"]')!
fireEvent.click(within(row as HTMLElement).getByLabelText('Session actions for 旧标题'))
fireEvent.click(view.getByRole('menuitem', { name: 'Rename', hidden: true }))
const input = await view.findByLabelText('Session name')
fireEvent.change(input, { target: { value: '新名' } })
fireEvent.click(view.getByRole('button', { name: 'Rename' }))
// Failure: the injected hop rethrows the business error; the dialog
// stays open with the alert and the row keeps its title.
const alert = await view.findByRole('alert')
expect(alert.textContent).toContain('title write failed')
expect(view.getByLabelText('Session name')).toBeTruthy()
expect(view.getByText('旧标题')).toBeTruthy()
await runtime.dispose()
})
})