feat(web): add plan mode controls
This commit is contained in:
21
packages/client/ui-plan/README.md
Normal file
21
packages/client/ui-plan/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# @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`.
|
||||
|
||||
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 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
65
packages/client/ui-plan/package.json
Normal file
65
packages/client/ui-plan/package.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-plan",
|
||||
"description": "Web plan-mode feature: host policy mount plus a pending-aware composer control",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"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-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "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:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/* Quiet composer chip: an invisible native select owns interaction and
|
||||
accessibility while the visible layer reports committed/pending state. */
|
||||
|
||||
.wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.root:hover .chip {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.select {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.root:has(.select:disabled) .chip {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
74
packages/client/ui-plan/src/client/PlanModeControl.tsx
Normal file
74
packages/client/ui-plan/src/client/PlanModeControl.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { PlanModeControlProps } from './index.ts'
|
||||
import css from './PlanModeControl.module.css'
|
||||
|
||||
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)
|
||||
const [switching, setSwitching] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const aliveRef = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
aliveRef.current = true
|
||||
return () => {
|
||||
aliveRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (planMode === null) return null
|
||||
|
||||
const pending = planMode.pending !== undefined
|
||||
const target = planMode.pending ?? planMode.active
|
||||
const value = target ? 'plan' : 'default'
|
||||
const currentLabel = labels[planMode.active ? 'plan' : 'default']
|
||||
const targetLabel = labels[value]
|
||||
const label = `${targetLabel}${pending ? ' · 待生效' : ''}`
|
||||
const title = pending
|
||||
? `当前为${currentLabel}模式;${targetLabel}模式将在下一次模型请求时生效`
|
||||
: `当前为${currentLabel}模式`
|
||||
|
||||
const select = (active: boolean): void => {
|
||||
if (active === target || switching) return
|
||||
setSwitching(true)
|
||||
setError(null)
|
||||
void setPlanMode(active).then((failure) => {
|
||||
if (!aliveRef.current) return
|
||||
setSwitching(false)
|
||||
setError(failure)
|
||||
}, (reason: unknown) => {
|
||||
if (!aliveRef.current) return
|
||||
setSwitching(false)
|
||||
setError(reason instanceof Error ? reason.message : String(reason))
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={css.wrap}>
|
||||
<label className={css.root} title={title}>
|
||||
<span className={css.chip}>
|
||||
{label}
|
||||
<svg className={css.chevron} viewBox="0 0 12 12" width="12" height="12" aria-hidden>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</svg>
|
||||
</span>
|
||||
<select
|
||||
className={css.select}
|
||||
aria-label="协作模式"
|
||||
value={value}
|
||||
disabled={switching}
|
||||
onChange={(event) => { select(event.target.value === 'plan') }}
|
||||
>
|
||||
<option value="default">默认</option>
|
||||
<option value="plan">计划</option>
|
||||
</select>
|
||||
</label>
|
||||
{error !== null && <span className={css.error} role="status" title={error}>模式切换失败</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
46
packages/client/ui-plan/src/client/index.ts
Normal file
46
packages/client/ui-plan/src/client/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Web plan plugin, browser half: contributes one pending-aware selector to
|
||||
* the default composer's additive controls slot.
|
||||
*/
|
||||
import type {
|
||||
ClientContext, SessionId, SessionsService,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ComposerControlProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { PlanModeControl } from './PlanModeControl.tsx'
|
||||
|
||||
/** Callback share injected into the pure control component. */
|
||||
export interface PlanModeControlInjected {
|
||||
/** Select the target mode; null means success, a string is user-visible failure detail. */
|
||||
setPlanMode(active: boolean): Promise<string | null>
|
||||
}
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
export const inject = ['slots', 'sessions', 'conversation']
|
||||
|
||||
/**
|
||||
* Register the plan selector and bridge its callback to the session object.
|
||||
* @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,
|
||||
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})`
|
||||
},
|
||||
}),
|
||||
}, PlanModeControl)
|
||||
}
|
||||
4
packages/client/ui-plan/src/css-modules.d.ts
vendored
Normal file
4
packages/client/ui-plan/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
30
packages/client/ui-plan/src/index.ts
Normal file
30
packages/client/ui-plan/src/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Web plan plugin, node half: selecting this UI feature also mounts the
|
||||
* logged plan-mode service with the Web product's planning policy.
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
31
packages/client/ui-plan/src/invariant.ts
Normal file
31
packages/client/ui-plan/src/invariant.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-plan`.
|
||||
* @module @deepseek-ai/dsh-client-ui-plan/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-plan'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-plan-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: plan state and boundary ownership are
|
||||
* audited by dsh-plan-mode, while the control is a slot effect whose
|
||||
* declaration, registration, and teardown are exercised by this package.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns The installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
58
packages/client/ui-plan/tests/browser-plugin.spec.ts
Normal file
58
packages/client/ui-plan/tests/browser-plugin.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { PlanModeControl } from '../src/client/PlanModeControl.tsx'
|
||||
import type { PlanModeControlInjected } from '../src/client/index.ts'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
|
||||
const SID = 's-plan' as SessionId
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: { 'conversation.composer.controls': { kind: 'list', scope: 'session' } },
|
||||
} as never, () => null)
|
||||
const setPlanMode = vi.fn(() => Promise.resolve({ ok: true, value: { active: false, pending: true } }))
|
||||
ctx.provide('sessions', { manager: { get: () => ({ setPlanMode }) } })
|
||||
ctx.provide('conversation', {})
|
||||
return { ctx, slots, setPlanMode }
|
||||
}
|
||||
|
||||
describe('ui-plan browser apply', () => {
|
||||
it('declares every service it binds', () => {
|
||||
expect(inject).toEqual(['slots', 'sessions', 'conversation'])
|
||||
})
|
||||
|
||||
it('fails loud when conversation did not declare the controls slot', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
ctx.provide('sessions', {})
|
||||
ctx.provide('conversation', {})
|
||||
await expect(ctx.plugin({ inject: [...inject], apply }))
|
||||
.rejects.toThrow(/slot "conversation.composer.controls" is not declared/)
|
||||
})
|
||||
|
||||
it('registers the control, bridges host results, 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]!
|
||||
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: {} },
|
||||
} as never)
|
||||
await expect(injected.setPlanMode(false)).resolves.toBe('gone(session-not-found)')
|
||||
|
||||
await fiber.dispose()
|
||||
expect(b.slots.entries('conversation.composer.controls')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
39
packages/client/ui-plan/tests/node-plugin.spec.ts
Normal file
39
packages/client/ui-plan/tests/node-plugin.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
111
packages/client/ui-plan/tests/plan-mode-control.spec.tsx
Normal file
111
packages/client/ui-plan/tests/plan-mode-control.spec.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
// @vitest-environment jsdom
|
||||
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'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const SID = 's-plan' as SessionId
|
||||
|
||||
function setup(
|
||||
planMode: PlanModeState | null,
|
||||
setPlanMode = vi.fn(() => Promise.resolve<string | null>(null)),
|
||||
running = false,
|
||||
) {
|
||||
const store = createSnapshotStore({ planMode, running })
|
||||
const useSession = bindSnapshotSelector(store) as unknown as SnapshotSelectorHook<ConversationSnapshot>
|
||||
const useSessions = (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>
|
||||
const view = render(
|
||||
<PlanModeControl
|
||||
sessionId={SID}
|
||||
useSession={useSession}
|
||||
useSessions={useSessions}
|
||||
setPlanMode={setPlanMode}
|
||||
/>,
|
||||
)
|
||||
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('')
|
||||
cleanup()
|
||||
setup({ active: false })
|
||||
expect(screen.getByTitle('当前为默认模式')).toBeTruthy()
|
||||
expect((screen.getByRole('combobox', { name: '协作模式' }) as HTMLSelectElement).value).toBe('default')
|
||||
})
|
||||
|
||||
it('treats pending field presence as the target, including pending false', () => {
|
||||
setup({ active: false, pending: true })
|
||||
expect(screen.getByText('计划 · 待生效')).toBeTruthy()
|
||||
expect(screen.getByTitle(/当前为默认模式/)).toBeTruthy()
|
||||
cleanup()
|
||||
setup({ active: true, pending: false })
|
||||
expect(screen.getByText('默认 · 待生效')).toBeTruthy()
|
||||
expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe('default')
|
||||
})
|
||||
|
||||
it('switches from the effective target and remains available while a turn runs', async () => {
|
||||
let resolve!: (value: string | null) => void
|
||||
const setPlanMode = vi.fn(() => new Promise<string | null>((done) => { resolve = done }))
|
||||
const { store } = setup({ active: false }, setPlanMode, true)
|
||||
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 })
|
||||
resolve(null)
|
||||
await waitFor(() => {
|
||||
expect((screen.getByRole('combobox') as HTMLSelectElement).disabled).toBe(false)
|
||||
})
|
||||
expect(screen.getByText('计划 · 待生效')).toBeTruthy()
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } })
|
||||
expect(setPlanMode).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('surfaces host 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)
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } })
|
||||
expect((await screen.findByText('模式切换失败')).getAttribute('title')).toBe('host said no')
|
||||
expect(screen.getByTitle('当前为默认模式')).toBeTruthy()
|
||||
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } })
|
||||
expect(await screen.findByTitle('network down')).toBeTruthy()
|
||||
expect((screen.getByRole('combobox') as HTMLSelectElement).disabled).toBe(false)
|
||||
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } })
|
||||
expect(await screen.findByTitle('socket closed')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('ignores in-flight fulfillment and rejection after unmount', () => {
|
||||
let resolve!: (value: string | null) => void
|
||||
const successful = setup(
|
||||
{ active: false },
|
||||
vi.fn(() => new Promise<string | null>((done) => { resolve = done })),
|
||||
)
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } })
|
||||
successful.view.unmount()
|
||||
expect(() => { resolve(null) }).not.toThrow()
|
||||
|
||||
let reject!: (reason: unknown) => void
|
||||
const setPlanMode = vi.fn(() => new Promise<string | null>((_done, fail) => { reject = fail }))
|
||||
const { view } = setup({ active: false }, setPlanMode)
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } })
|
||||
view.unmount()
|
||||
expect(() => { reject(new Error('late')) }).not.toThrow()
|
||||
})
|
||||
})
|
||||
45
packages/client/ui-plan/tsconfig.json
Normal file
45
packages/client/ui-plan/tsconfig.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../web-react"
|
||||
},
|
||||
{
|
||||
"path": "../../plan/plan-mode"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-plan/tsdown.config.ts
Normal file
3
packages/client/ui-plan/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-plan', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
Reference in New Issue
Block a user