feat(web): add plan mode controls

This commit is contained in:
fz
2026-07-24 12:55:23 +08:00
parent bc63b5fe00
commit 9a69feeec0
41 changed files with 1047 additions and 27 deletions

View File

@@ -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;
}

View 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>
)
}

View 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)
}

View File

@@ -0,0 +1,4 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}

View 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 })
}

View 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 */