Merge origin/master (plan line #590) into web-permission-sandbox-merge-master

Shared-surface conflicts resolve as unions: the fixture serves all five
projection keys (title/todos/permissions/plan/goal) with the /permission
and /plan command mirrors side by side, the connection specs assert the
five-key baseline and the shifted approval/question replay indices, and
the cli roster/deps, tsconfig aggregate, and README allowlist carry both
lines' rows. Plan-side content lands verbatim from master.

One end-state consolidation both branches half-did: with questions
(ui-question) and approvals (ApprovalPanel) each owning a composer
takeover, PendingCard retires outright — ChatView renders no pending
placeholder, the card component and its specs go, and both README halves
state the takeover-only contract.
This commit is contained in:
imccyu
2026-07-29 03:52:55 +08:00
70 changed files with 1453 additions and 289 deletions

View File

@@ -1,18 +1,14 @@
// @vitest-environment jsdom
// Remaining chat branch tails: MessageItem context/unknown/steering arms,
// user IconActions, StatsLine no-cache join, PendingCard reason strip,
// 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, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { MessageItem } from '../src/client/chat/MessageItem.tsx'
import { PendingCard } from '../src/client/chat/PendingCard.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
@@ -114,13 +110,6 @@ describe('MessageItem arms', () => {
})
describe('small branch tails', () => {
it('PendingCard renders the question count', () => {
const view = render(
<PendingCard item={new PendingWait('question', RpcId('r1'), 's1' as SessionId, { questions: [{ id: 'q1', question: '选择' }] }, vi.fn())} />,
)
expect(view.getByText(/等待回答(1 题)/)).toBeTruthy()
})
it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => {
const view = render(
<AssistantMarkdown blocks={[{ kind: 'reasoning', text: 'one-liner' }]} streaming={false} />,

View File

@@ -392,7 +392,7 @@ describe('ChatView', () => {
expect(lv.getByText('载入历史…')).toBeTruthy()
})
it('question waits render placeholder cards; approvals leave the flow (composer takeover)', () => {
it('pending waits leave the flow entirely — questions and approvals both take over the composer', () => {
const h = makeHarness({
pending: [
new PendingWait('approval', RpcId('r1'), SID,
@@ -402,7 +402,7 @@ describe('ChatView', () => {
],
})
const view = render(<h.ChatView {...h.props} />)
expect(view.getByText(/等待回答(1 题)/)).toBeTruthy()
expect(view.queryByText(/等待回答/)).toBeNull()
expect(view.queryByText(/等待审批/)).toBeNull()
})

View File

@@ -1,6 +1,6 @@
// @vitest-environment jsdom
// Branch tails the acceptance specs do not reach: ToolRow stopped-state dot,
// PendingCard question arm, bash sample state dots, the node-half empty
// bash sample state dots, the node-half empty
// apply, and AssistantMarkdown reasoning/unknown block arms.
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -8,13 +8,10 @@ import { cleanup, render } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { RunningToolCall, SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { apply as nodeApply } from '../src/index.ts'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
import { PendingCard } from '../src/client/chat/PendingCard.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
@@ -33,13 +30,6 @@ describe('tails', () => {
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
})
it('PendingCard renders the question arm with its count', () => {
const view = render(
<PendingCard item={new PendingWait('question', RpcId('r1'), 's1' as SessionId, { questions: [{}, {}] } as PendingWait<'question'>['payload'], vi.fn())} />,
)
expect(view.getByText(/等待回答(2 题)/)).toBeTruthy()
})
it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => {
const view = render(
<AssistantMarkdown

View File

@@ -30,6 +30,8 @@ function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): Conversation
interface BenchOptions {
planEntry?: React.ReactNode
/** The `plan` projection value the standard-kit useProjection serves. */
plan?: { active: boolean; pending: boolean }
modelEntry?: React.ReactNode
/** Hot text-ref lexicon (injects a minimal slash stub exposing only lexicon()). */
lexicon?: ReadonlyMap<'/' | '@', readonly string[]>
@@ -89,7 +91,8 @@ function bench(over?: BenchOptions) {
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})),
useProjection: ((key: string) => (key === 'permissions' ? over?.permissions : undefined)),
useProjection: ((key: string, selector?: (v: unknown) => unknown) =>
(selector ?? (v => v))(key === 'permissions' ? over?.permissions : key === 'plan' ? over?.plan : undefined)),
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
@@ -232,6 +235,20 @@ describe('running and lock semantics (queue cut 1)', () => {
const custom = bench({ placeholder: 'Custom placeholder' })
expect(custom.textarea.placeholder).toBe('Custom placeholder')
})
it('the plan projection swaps the placeholder while its effective target is plan mode', () => {
const active = bench({ plan: { active: true, pending: false } })
expect(active.textarea.placeholder).toBe('describe your task to generate plan')
// /plan just ran: pending entry already reads as the plan target.
const entering = bench({ plan: { active: false, pending: true } })
expect(entering.textarea.placeholder).toBe('describe your task to generate plan')
// Pending exit: target is default again.
const leaving = bench({ plan: { active: true, pending: true } })
expect(leaving.textarea.placeholder).toBe('Message the agent')
// Owner placeholder outranks the plan swap.
const custom = bench({ plan: { active: true, pending: false }, placeholder: 'Custom placeholder' })
expect(custom.textarea.placeholder).toBe('Custom placeholder')
})
})
describe('machine pending lock', () => {
@@ -252,7 +269,6 @@ describe('machine pending lock', () => {
expect(shell.snapshot.phase).toBe('submitting')
const textarea = view.container.querySelector('textarea')!
expect(textarea.readOnly).toBe(true)
expect(view.container.querySelector('[data-input-pending]')).not.toBeNull()
expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="Send message"]')!.disabled).toBe(true)
})
})

View File

@@ -125,13 +125,12 @@ describe('matrix row: claimed', () => {
describe('matrix row: submitting', () => {
it('locks enter, renders pending + read-only, keeps the claim snapshot on the currency', async () => {
const submit = vi.fn(() => new Promise<SubmitOutcome>(() => {})) // never settles
const { view, textarea, shell, sink, claim } = bench({ submit })
const { textarea, shell, sink, claim } = bench({ submit })
claim()
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(shell.snapshot.phase).toBe('submitting')
expect(shell.snapshot.claim).toBeDefined()
expect((textarea).readOnly).toBe(true)
expect(view.container.querySelector('[data-input-pending]')).not.toBeNull()
// Enter is dead inside the lock (submit dispatch is microtask-deferred).
await vi.waitFor(() => { expect(submit).toHaveBeenCalledTimes(1) })
fireEvent.keyDown(textarea, { key: 'Enter' })