diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index 0dbd6c839e..564fb8cc58 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -320,6 +320,10 @@ - id: ui-model name: '@deepseek-ai/dsh-client-ui-model' +# Plan control: the composer plan seat over the plan projection + /plan channel. +- id: ui-plan + name: '@deepseek-ai/dsh-client-ui-plan' + - id: ui-question name: '@deepseek-ai/dsh-client-ui-question' diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index 2a2a4478cd..8fd1f1ca16 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -1,21 +1,19 @@ # @deepseek-ai/dsh-client-ui-plan -Web plan-mode feature with two lifecycle-coupled halves. The node entry mounts `@deepseek-ai/dsh-plan-mode` with the Web product policy; the browser entry contributes a session-scoped selector to `conversation.composer.controls`. +Plan-mode composer control, a pure browser surface plugin. The browser half occupies the conversation-declared `conversation.input.plan` single seat with a pending-aware mode selector; the node half is an empty apply (the roster row). Plan behavior itself — the `/plan` command, the boundary-committed `plan/mode` state, the `plan` projection unit, and the policy section — is owned by [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md), composed independently on the host roster. -The selector distinguishes unavailable capability (`planMode === null`), committed mode (`active`), and the target queued for the next model-request boundary (`pending`, including `pending: false`). Selecting a mode never cancels a running turn. It remains available while generation is running, disables only during its own RPC, and displays the host-confirmed pending target until a logged `plan/mode` event commits it. The transparent native select mirrors keyboard focus onto the visible chip and carries a dynamic accessible description of the committed and pending modes. +Reads ride the generic projection pair: the control renders the host-computed `plan` projection (`{ active, pending }`) through the standard-kit `useProjection`; an absent key is capability absence and hides the control, so a host without plan-mode (or a Draft with no session) shows no seat content. Writes ride the standard command channel: selecting a mode executes `/plan` or `/plan off` through `command.execute`, whose logged `command/run` immediately folds into a pending projection frame and whose request-boundary `plan/mode` commit resolves it — the control never holds client-side plan state, displays only host-confirmed values, and stays available while generation runs (switching never cancels a turn; the pending target applies at the next model-request boundary). -The model exits plan mode through the stable `exit_plan_mode` tool. Its plan review uses the composed Web question channel: approval schedules default mode for the next step, while rejection or custom feedback keeps plan mode active and returns the feedback to the model. +The transparent native select mirrors keyboard focus onto the visible chip and carries a dynamic accessible description of the committed and pending modes. Admission failures (`matched: false`, business errors, transport faults) surface as an inline error without mutating the displayed mode. + +The model exits plan mode through the stable `exit_plan_mode` tool; its plan review uses the composed Web question channel. ## Model Experience -Indirectly, through `@deepseek-ai/dsh-plan-mode`; that package owns policy activation, the exit-tool schema and rendering, logged state, and request-boundary transitions, while this package supplies the Web composition's section text. - -#### KV Cache effect - -Entering or leaving plan mode changes the active system-prompt section and therefore the request prefix. The stable exit-tool registration avoids an additional tool-catalog shape change across the same transition. +None directly. Model-visible plan behavior (policy activation, the exit-tool schema, logged state) is owned by `@deepseek-ai/dsh-plan-mode`; this package only renders the projection and dispatches `/plan` lines a user could equally type. ## Known Limitations and Deferred Work - **Plan mode is guidance, not an execution sandbox** — deployments that require enforced read-only planning must compose the independent sandbox and approval policies. - **The control belongs to the default composer** — a pending whole-composer interaction such as plan review temporarily replaces the InputBar and its mode control. -- **An idle pending target is process-local until the next boundary** — a process exit before another prompt loses that uncommitted intent; the committed mode remains durable in the session log. +- **No Draft-time selection** — before a session exists there is no projection and the seat stays empty; plan mode is selected after the first prompt creates the session. diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index 709e88677d..a2aec47985 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-plan", - "description": "Web plan-mode feature: host policy mount plus a pending-aware composer control", + "description": "Plan-mode composer control: the conversation.input.plan seat over the plan projection and the /plan command channel", "version": "0.0.1", "private": true, "type": "module", @@ -24,6 +24,7 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-ui-conversation" ], "platform": "web" @@ -33,26 +34,27 @@ "watch": "tsdown --watch" }, "license": "BSD-3-Clause", - "dependencies": { - "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-plan-mode": "workspace:^", - "react": "^18.2.0" - }, "peerDependencies": { + "@deepseek-ai/dsh-client-connection": "^0.0.1", + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-plan-mode": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-user-interaction": "workspace:^", + "@deepseek-ai/dsh-plan-mode": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7" + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-plan/src/client/PlanModeControl.tsx b/packages/client/ui-plan/src/client/PlanModeControl.tsx index d48b1f323e..6338255415 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.tsx +++ b/packages/client/ui-plan/src/client/PlanModeControl.tsx @@ -1,15 +1,23 @@ import { useEffect, useId, useRef, useState } from 'react' -import type { PlanModeControlProps } from './index.ts' +import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +// Type-only: pulls the ui-conversation SlotMap merge (the input.plan seat and +// its {locked} owner share). +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { PlanModeControlInjected } from './index.ts' import css from './PlanModeControl.module.css' +/** Full plan-seat component props: runtime share (standard kit + locked owner prop) & injected share. */ +export type PlanModeControlProps = + PropsRuntime<'conversation.input.plan'> & InjectFace + const labels = { default: '默认', plan: '计划', } as const -/** Composer control for the host-confirmed plan target. */ -export function PlanModeControl({ useSession, setPlanMode }: PlanModeControlProps) { - const planMode = useSession(snapshot => snapshot.planMode) +/** Composer control over the host-computed `plan` projection. */ +export function PlanModeControl({ useProjection, locked, setPlanMode }: PlanModeControlProps) { + const plan = useProjection('plan') const [switching, setSwitching] = useState(false) const [error, setError] = useState(null) const aliveRef = useRef(true) @@ -22,15 +30,16 @@ export function PlanModeControl({ useSession, setPlanMode }: PlanModeControlProp } }, []) - if (planMode === null) return null + // Capability absence: the host composed no plan-mode plugin (or no + // baseline has arrived yet) — the seat stays empty. + if (plan === undefined) return null - const pending = planMode.pending !== undefined - const target = planMode.pending ?? planMode.active + const target = plan.pending ? !plan.active : plan.active const value = target ? 'plan' : 'default' - const currentLabel = labels[planMode.active ? 'plan' : 'default'] + const currentLabel = labels[plan.active ? 'plan' : 'default'] const targetLabel = labels[value] - const label = `${targetLabel}${pending ? ' · 待生效' : ''}` - const title = pending + const label = `${targetLabel}${plan.pending ? ' · 待生效' : ''}` + const title = plan.pending ? `当前为${currentLabel}模式;${targetLabel}模式将在下一次模型请求时生效` : `当前为${currentLabel}模式` @@ -64,7 +73,7 @@ export function PlanModeControl({ useSession, setPlanMode }: PlanModeControlProp aria-label="协作模式" aria-describedby={descriptionId} value={value} - disabled={switching} + disabled={locked || switching} onChange={(event) => { select(event.target.value === 'plan') }} > diff --git a/packages/client/ui-plan/src/client/index.ts b/packages/client/ui-plan/src/client/index.ts index 0f1c2b75c5..4165a130db 100644 --- a/packages/client/ui-plan/src/client/index.ts +++ b/packages/client/ui-plan/src/client/index.ts @@ -1,46 +1,53 @@ /** - * Web plan plugin, browser half: contributes one pending-aware selector to - * the default composer's additive controls slot. + * Plan control plugin, browser half: occupies the composer's named + * `conversation.input.plan` seat with a pending-aware mode selector. Reads + * ride the generic projection pair — the control renders the `plan` + * projection through the standard-kit `useProjection` (an absent key is + * capability absence and hides the control); writes ride the standard + * command channel — selecting a mode executes `/plan` / `/plan off` through + * `command.execute`, whose logged lifecycle plus the boundary `plan/mode` + * commit come back as projection frames. Zero client-side plan state. */ -import type { - ClientContext, SessionId, SessionsService, -} from '@deepseek-ai/dsh-client-runtime/client' -import type { ComposerControlProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the ui-conversation SlotMap merge (the input.plan seat). +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +// Type-only: pulls the `plan` SessionProjectionMap merge for useProjection. +import type {} from '@deepseek-ai/dsh-plan-mode/client' import { PlanModeControl } from './PlanModeControl.tsx' -/** Callback share injected into the pure control component. */ +/** Injected business face of the composer plan seat. */ export interface PlanModeControlInjected { - /** Select the target mode; null means success, a string is user-visible failure detail. */ + /** + * Select the target mode by executing the corresponding /plan line. + * @param active - whether plan mode should be active from the next boundary. + * @returns null on admitted execution; a user-visible failure line otherwise. + */ setPlanMode(active: boolean): Promise } -/** Complete props assembled for the composer-control entry. */ -export type PlanModeControlProps = ComposerControlProps & PlanModeControlInjected - /** - * Required services. `conversation` is the ordering edge that guarantees the - * composer-controls slot has been declared before this plugin registers. + * Required services: the seat's slot registry, the transport, and the + * conversation service whose presence guarantees the seat is declared. */ -export const inject = ['slots', 'sessions', 'conversation'] +export const inject = ['slots', 'connection', 'conversation'] /** - * Register the plan selector and bridge its callback to the session object. - * @param ctx - Client root context. + * Client plugin body: register the plan seat occupant over the command channel. + * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - // This dual-half package also imports the host plan service, whose program - // carries the host-side `sessions` merge. Resolve and narrow the browser - // service at the client entry seam instead of relying on that shared key. - const sessions = ctx.get('sessions') as unknown as SessionsService - ctx.slots.register({ - name: 'conversation.composer.controls', - id: 'plan-mode', - order: 10, + ctx.effect(() => ctx.slots.register({ + name: 'conversation.input.plan', inject: (sessionId: SessionId): PlanModeControlInjected => ({ setPlanMode: async (active) => { - const result = await sessions.manager.get(sessionId).setPlanMode(active) - return result.ok ? null : `${result.error.message}(${result.error.code})` + const connection = ctx.get('connection') as ConnectionHandle + const line = active ? '/plan' : '/plan off' + const { result } = await connection.api.commands.execute({ sessionId, line }) + if (!result.ok) return `${result.error.message}(${result.error.code})` + if (!result.value.matched) return `未知命令:${line}` + return null }, }), - }, PlanModeControl) + }, PlanModeControl), 'ui-plan: composer plan seat registration') } diff --git a/packages/client/ui-plan/src/index.ts b/packages/client/ui-plan/src/index.ts index 7face1a04d..8d63cb2102 100644 --- a/packages/client/ui-plan/src/index.ts +++ b/packages/client/ui-plan/src/index.ts @@ -1,30 +1,11 @@ /** - * Web plan plugin, node half: selecting this UI feature also mounts the - * logged plan-mode service with the Web product's planning policy. + * Plan control plugin, node half. Pure UI plugin: the empty apply exists so + * the plugin appears in the host cordis.yml / Loader; the browser half ships + * via exports["./client"], discovered through the package.json dshClient + * declaration. Plan behavior itself (the /plan command, the plan projection + * unit, the policy section) is owned by `@deepseek-ai/dsh-plan-mode`, + * composed independently on the host roster. */ -import type { Context } from 'cordis' -import PlanModeService from '@deepseek-ai/dsh-plan-mode' -/** Host services required by plan mode. */ -export const inject = ['tools', 'systemPrompt'] - -/** Web product-owned policy rendered while plan mode is active. */ -export const WEB_PLAN_SECTION = `You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. - -Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. - -The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. - -Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. - -Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. - -When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation.` - -/** - * Mount plan mode for hosts that selected the Web plan plugin. - * @param ctx - Host context carrying tools and systemPrompt. - */ -export function apply(ctx: Context): void { - ctx.plugin(PlanModeService, { section: WEB_PLAN_SECTION }) -} +/** Host plugin body — no host-side behavior for this surface plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-plan/tests/browser-plugin.spec.ts b/packages/client/ui-plan/tests/browser-plugin.spec.ts index 5b8f72af22..c16321aa87 100644 --- a/packages/client/ui-plan/tests/browser-plugin.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.spec.ts @@ -1,3 +1,10 @@ +/** + * ui-plan browser half on a real SlotsService: the plugin occupies the + * conversation-declared `conversation.input.plan` single seat; the injected + * face maps mode selections onto /plan command lines and folds admission + * outcomes into null (admitted) or a user-visible failure line; teardown + * empties the seat (HMR safety). + */ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' @@ -14,45 +21,55 @@ async function bench() { const slots = ctx.get('slots') as SlotsService slots.register({ name: 'root', - children: { 'conversation.composer.controls': { kind: 'list', scope: 'session' } }, + children: { 'conversation.input.plan': { kind: 'single', scope: 'session' } }, } as never, () => null) - const setPlanMode = vi.fn(() => Promise.resolve({ ok: true, value: { active: false, pending: true } })) - ctx.provide('sessions', { manager: { get: () => ({ setPlanMode }) } }) + const execute = vi.fn((_payload: { sessionId: SessionId; line: string }) => + Promise.resolve({ result: { ok: true as const, value: { matched: true as const, commandId: 'c1' } } })) + ctx.provide('connection', { api: { commands: { execute } } }) ctx.provide('conversation', {}) - return { ctx, slots, setPlanMode } + return { ctx, slots, execute } } describe('ui-plan browser apply', () => { it('declares every service it binds', () => { - expect(inject).toEqual(['slots', 'sessions', 'conversation']) + expect(inject).toEqual(['slots', 'connection', 'conversation']) }) - it('fails loud when conversation did not declare the controls slot', async () => { + it('fails loud when conversation did not declare the plan seat', async () => { const ctx = new Context() await ctx.plugin(SlotsService).await() - ctx.provide('sessions', {}) + ctx.provide('connection', {}) ctx.provide('conversation', {}) await expect(ctx.plugin({ inject: [...inject], apply })) - .rejects.toThrow(/slot "conversation.composer.controls" is not declared/) + .rejects.toThrow(/slot "conversation.input.plan" is not declared/) }) - it('registers the control, bridges host results, and unregisters on teardown', async () => { + it('registers the control, maps selections to /plan lines, and unregisters on teardown', async () => { const b = await bench() const fiber = b.ctx.plugin({ inject: [...inject], apply }) await fiber.await() - const entry = b.slots.entries('conversation.composer.controls')[0]! + const entry = b.slots.entries('conversation.input.plan')[0]! expect(entry.component).toBe(PlanModeControl) - expect(entry.options).toMatchObject({ id: 'plan-mode', order: 10 }) const injected = (entry.inject as unknown as (id: SessionId) => PlanModeControlInjected)(SID) - await expect(injected.setPlanMode(true)).resolves.toBeNull() - expect(b.setPlanMode).toHaveBeenCalledWith(true) - b.setPlanMode.mockResolvedValueOnce({ - ok: false, error: { code: 'session-not-found', message: 'gone', details: {} }, + await expect(injected.setPlanMode(true)).resolves.toBeNull() + expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan' }) + await expect(injected.setPlanMode(false)).resolves.toBeNull() + expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan off' }) + + // Business failure folds to the composer-visible line. + b.execute.mockResolvedValueOnce({ + result: { ok: false as const, error: { code: 'session-not-found', message: 'gone', details: {} } }, } as never) - await expect(injected.setPlanMode(false)).resolves.toBe('gone(session-not-found)') + await expect(injected.setPlanMode(true)).resolves.toBe('gone(session-not-found)') + + // Unmatched admission (plan-mode not composed host-side) is also a failure line. + b.execute.mockResolvedValueOnce({ + result: { ok: true as const, value: { matched: false as const } }, + } as never) + await expect(injected.setPlanMode(true)).resolves.toBe('未知命令:/plan') await fiber.dispose() - expect(b.slots.entries('conversation.composer.controls')).toHaveLength(0) + expect(b.slots.entries('conversation.input.plan')).toHaveLength(0) }) }) diff --git a/packages/client/ui-plan/tests/node-plugin.spec.ts b/packages/client/ui-plan/tests/node-plugin.spec.ts deleted file mode 100644 index 3669d61195..0000000000 --- a/packages/client/ui-plan/tests/node-plugin.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Context } from 'cordis' -import { afterEach, describe, expect, it } from 'vitest' -import ToolRegistry from '@deepseek-ai/dsh-tools' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import { EXIT_PLAN_MODE } from '@deepseek-ai/dsh-plan-mode' -import { WEB_PLAN_SECTION, apply, inject } from '../src/index.ts' - -let ctx: Context | undefined - -afterEach(async () => { - await ctx?.fiber.dispose() - ctx = undefined -}) - -describe('ui-plan node plugin', () => { - it('mounts the Web policy and stable exit tool for the selected feature lifecycle', async () => { - ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(UserInteractionService) - const feature = ctx.plugin({ inject: [...inject], apply }) - await feature.await() - - expect(ctx.get('planMode')).toBeDefined() - expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeDefined() - expect(WEB_PLAN_SECTION).toContain('Stay in plan mode until exit_plan_mode succeeds') - expect(WEB_PLAN_SECTION).toContain('Do not edit or write files') - expect(WEB_PLAN_SECTION).toContain('Make exit_plan_mode the only and final tool call') - expect((await ctx.systemPrompt.assemble()).sections) - .toEqual(expect.arrayContaining([expect.objectContaining({ name: 'plan:policy', text: '' })])) - - await feature.dispose() - expect(ctx.get('planMode')).toBeUndefined() - expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeUndefined() - expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)) - .not.toContain('plan:policy') - }) -}) diff --git a/packages/client/ui-plan/tests/plan-mode-control.spec.tsx b/packages/client/ui-plan/tests/plan-mode-control.spec.tsx index 36baf559d7..9c5fd1b2b6 100644 --- a/packages/client/ui-plan/tests/plan-mode-control.spec.tsx +++ b/packages/client/ui-plan/tests/plan-mode-control.spec.tsx @@ -1,44 +1,38 @@ // @vitest-environment jsdom +/** + * PlanModeControl over the `plan` projection: an absent key (capability + * absence) hides the control; {active, pending} renders committed and + * pending-target labels; selection maps from the effective target and + * surfaces failures without mutating the host-confirmed state. + */ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import type { - ConversationSnapshot, SessionId, SessionListState, -} from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' -import type { PlanModeState } from '@deepseek-ai/dsh-client-connection/client' -import { PlanModeControl } from '../src/client/PlanModeControl.tsx' +import type { PlanProjection } from '@deepseek-ai/dsh-plan-mode/client' +import { PlanModeControl, type PlanModeControlProps } from '../src/client/PlanModeControl.tsx' afterEach(cleanup) -const SID = 's-plan' as SessionId - function setup( - planMode: PlanModeState | null, + plan: PlanProjection | undefined, setPlanMode = vi.fn(() => Promise.resolve(null)), - running = false, + locked = false, ) { - const store = createSnapshotStore({ planMode, running }) - const useSession = bindSnapshotSelector(store) as unknown as SnapshotSelectorHook - const useSessions = (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook - const view = render( - , - ) + const store = createSnapshotStore<{ value: PlanProjection | undefined }>({ value: plan }) + const useProjection = (_key: string, selector?: (v: unknown) => unknown) => + bindSnapshotSelector(store)(s => (selector ?? (v => v))(s.value)) + const props = { useProjection, locked, setPlanMode } as unknown as PlanModeControlProps + const view = render() return { store, setPlanMode, view } } describe('PlanModeControl', () => { - it('hides an unavailable capability and renders committed modes', () => { - const unavailable = setup(null) - expect(unavailable.view.container.innerHTML).toBe('') + it('hides an absent capability and renders committed modes', () => { + const absent = setup(undefined) + expect(absent.view.container.innerHTML).toBe('') cleanup() - setup({ active: false }) + setup({ active: false, pending: false }) expect(screen.getByTitle('当前为默认模式')).toBeTruthy() const select = screen.getByRole('combobox', { name: '协作模式' }) as HTMLSelectElement expect(select.value).toBe('default') @@ -46,15 +40,14 @@ describe('PlanModeControl', () => { .toBe('当前为默认模式') }) - it('treats pending field presence as the target, including pending false', () => { + it('renders the pending target as the opposite of the committed state', () => { setup({ active: false, pending: true }) expect(screen.getByText('计划 · 待生效')).toBeTruthy() - expect(screen.getByTitle(/当前为默认模式/)).toBeTruthy() const planSelect = screen.getByRole('combobox') expect(document.getElementById(planSelect.getAttribute('aria-describedby') ?? '')?.textContent) .toBe('当前为默认模式;计划模式将在下一次模型请求时生效') cleanup() - setup({ active: true, pending: false }) + setup({ active: true, pending: true }) expect(screen.getByText('默认 · 待生效')).toBeTruthy() const defaultSelect = screen.getByRole('combobox') as HTMLSelectElement expect(defaultSelect.value).toBe('default') @@ -62,32 +55,39 @@ describe('PlanModeControl', () => { .toBe('当前为计划模式;默认模式将在下一次模型请求时生效') }) - it('switches from the effective target and remains available while a turn runs', async () => { + it('switches from the effective target, disables during its own call, and follows the pushed projection', async () => { let resolve!: (value: string | null) => void const setPlanMode = vi.fn(() => new Promise((done) => { resolve = done })) - const { store } = setup({ active: false }, setPlanMode, true) + const { store } = setup({ active: false, pending: false }, setPlanMode) const select = screen.getByRole('combobox', { name: '协作模式' }) as HTMLSelectElement expect(select.disabled).toBe(false) fireEvent.change(select, { target: { value: 'plan' } }) expect(setPlanMode).toHaveBeenCalledWith(true) expect(select.disabled).toBe(true) - store.set({ planMode: { active: false, pending: true }, running: true }) + // The projection frame lands (command/run folded host-side). + store.set({ value: { active: false, pending: true } }) resolve(null) await waitFor(() => { expect((screen.getByRole('combobox') as HTMLSelectElement).disabled).toBe(false) }) expect(screen.getByText('计划 · 待生效')).toBeTruthy() + // Re-selecting the effective target is a no-op. fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } }) expect(setPlanMode).toHaveBeenCalledTimes(1) }) - it('surfaces host and transport failures without changing the confirmed mode', async () => { + it('disables under the locked owner prop', () => { + setup({ active: false, pending: false }, vi.fn(), true) + expect((screen.getByRole('combobox') as HTMLSelectElement).disabled).toBe(true) + }) + + it('surfaces admission and transport failures without changing the confirmed mode', async () => { const setPlanMode = vi.fn() .mockResolvedValueOnce('host said no') .mockRejectedValueOnce(new Error('network down')) .mockRejectedValueOnce('socket closed') - setup({ active: false }, setPlanMode) + setup({ active: false, pending: false }, setPlanMode) fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } }) expect((await screen.findByText('模式切换失败')).getAttribute('title')).toBe('host said no') expect(screen.getByTitle('当前为默认模式')).toBeTruthy() @@ -103,7 +103,7 @@ describe('PlanModeControl', () => { it('ignores in-flight fulfillment and rejection after unmount', () => { let resolve!: (value: string | null) => void const successful = setup( - { active: false }, + { active: false, pending: false }, vi.fn(() => new Promise((done) => { resolve = done })), ) fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } }) @@ -112,7 +112,7 @@ describe('PlanModeControl', () => { let reject!: (reason: unknown) => void const setPlanMode = vi.fn(() => new Promise((_done, fail) => { reject = fail })) - const { view } = setup({ active: false }, setPlanMode) + const { view } = setup({ active: false, pending: false }, setPlanMode) fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } }) view.unmount() expect(() => { reject(new Error('late')) }).not.toThrow() diff --git a/packages/client/ui-plan/tsconfig.json b/packages/client/ui-plan/tsconfig.json index 2d71f5bc1e..4ab13662f2 100644 --- a/packages/client/ui-plan/tsconfig.json +++ b/packages/client/ui-plan/tsconfig.json @@ -29,15 +29,6 @@ { "path": "../../plan/plan-mode" }, - { - "path": "../../core/system-prompt" - }, - { - "path": "../../core/tools" - }, - { - "path": "../../ui/user-interaction" - }, { "path": "../../support/invariants" } diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index a9c027530d..c681d44aaf 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -13,7 +13,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 905f0fbe77..5ea949dfdd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1095,7 +1095,10 @@ importers: version: 18.3.1 packages/client/ui-plan: - dependencies: + devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -1105,37 +1108,24 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots - '@deepseek-ai/dsh-plan-mode': - specifier: workspace:^ - version: link:../../plan/plan-mode - react: - specifier: ^18.2.0 - version: 18.3.1 - devDependencies: - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection '@deepseek-ai/dsh-client-web-react': specifier: workspace:^ version: link:../web-react '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - '@deepseek-ai/dsh-system-prompt': + '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - '@deepseek-ai/dsh-user-interaction': - specifier: workspace:^ - version: link:../../ui/user-interaction + version: link:../../plan/plan-mode '@types/react': specifier: ~18.3.1 version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 packages/client/ui-primitives: dependencies: