feat(web): replace the plan select with a status chip and placeholder swap
Plan mode is entered through /plan only — the select control is retired. The conversation.input.plan seat (now right of the access-mode control) renders a read-only Plan chip while the projection's effective target is plan mode; its hover x executes /plan off, and the chip follows the folded target (appears on /plan immediately, disappears on /plan off) with frames correcting either way. While plan mode is targeted the composer textarea's placeholder switches to the plan-task wording — InputBar reads the same projection through the standard-kit useProjection (the TodoDock posture: a type-only key merge, no domain service edge), and owner placeholders still win.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-plan/README.md
|
||||
README.md: a8472c82ed1a5d6e5ebe4ae46b030ee140aa07c1
|
||||
README.zh.md: 77db21f515fd80d06719aec54170d451fbb67ad6
|
||||
README.md: de43ce66d17498d31e05f8c64092ea0843103054
|
||||
README.zh.md: b4d2f4fd1a6d45f814d4a20195434f34d207e9c8
|
||||
|
||||
@@ -2,24 +2,24 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
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.
|
||||
Plan-mode status chip, a pure browser surface plugin. The browser half occupies the conversation-declared `conversation.input.plan` single seat (to the right of the access-mode control); the node half is an empty apply (the roster row). Plan behavior itself — the `/plan` command, the boundary-or-idle-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.
|
||||
|
||||
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).
|
||||
Plan mode is entered through the `/plan` command only; there is no UI control that turns it on. While the host-computed `plan` projection's effective target is plan mode (`pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way), the seat renders a read-only "Plan" chip whose hover × executes `/plan off` through `command.execute`; otherwise the seat stays empty — a host without plan-mode (or a Draft with no session) shows nothing. While plan mode is the effective target, the composer textarea's placeholder switches to "describe your task to generate plan" (rendered by the composer from the same projection; owner-supplied placeholders win).
|
||||
|
||||
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 chip carries the accessible description "Plan mode on, press to turn off". Admission failures (`matched: false`, business errors, transport faults) surface as an inline error and the chip stays until the projection confirms the exit.
|
||||
|
||||
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 the `/plan` command lines the control dispatches: `@deepseek-ai/dsh-plan-mode` owns the model-visible policy section, the exit-tool schema, and the logged state those lines drive, while this package only renders the projection and sends what a user could equally type.
|
||||
Indirectly, through the `/plan off` command line the chip dispatches: `@deepseek-ai/dsh-plan-mode` owns the model-visible policy section, the exit-tool schema, and the logged state that line drives, while this package only renders the projection and sends what a user could equally type.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Entering or leaving plan mode changes the active `plan:policy` system-prompt section and therefore the request prefix; the control itself adds no prompt content.
|
||||
Entering or leaving plan mode changes the active `plan:policy` system-prompt section and therefore the request prefix; the chip itself adds no prompt content.
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
- **The chip belongs to the default composer** — a pending whole-composer interaction such as plan review temporarily replaces the InputBar and its chip.
|
||||
- **No UI entry point** — plan mode is entered by typing `/plan`; a session with the capability but inactive mode shows no affordance in the tool row.
|
||||
|
||||
@@ -2,24 +2,24 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Plan mode 编辑器控件,纯浏览器 surface 插件。浏览器侧以一个感知待生效态的模式选择器占据会话声明的 `conversation.input.plan` 单座;node 侧是空 apply(roster 行)。plan 行为本身——`/plan` 命令、边界提交的 `plan/mode` 状态、`plan` 投影单元与 policy 段——归 [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md) 所有,由 host roster 独立组合。
|
||||
Plan mode 状态徽章,纯浏览器 surface 插件。浏览器侧占据会话声明的 `conversation.input.plan` 单座(位于 access 模式控件右侧);node 侧是空 apply(roster 行)。plan 行为本身——`/plan` 命令、边界或空闲即时提交的 `plan/mode` 状态、`plan` 投影单元与 policy 段——归 [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md) 所有,由 host roster 独立组合。
|
||||
|
||||
读取走通用投影对:控件经标准套件的 `useProjection` 渲染 host 计算的 `plan` 投影(`{ active, pending }`);key 缺席即能力缺席并隐藏控件,因此未组合 plan-mode 的 host(或尚无会话的 Draft)不展示座位内容。写入走标准命令通道:选择模式即经 `command.execute` 执行 `/plan` 或 `/plan off`,其入日志的 `command/run` 立即折叠为 pending 投影帧,请求边界的 `plan/mode` 提交将其兑现——控件不持有任何客户端侧 plan 状态,只显示 host 确认的值,且在生成期间保持可用(切换绝不取消轮次;待生效目标在下一次模型请求边界生效)。
|
||||
plan mode 只经 `/plan` 命令进入;UI 上没有打开它的控件。当 host 计算的 `plan` 投影有效目标为 plan mode 时(`pending ? !active : active`——折叠的 host 值而非客户端乐观态,帧到达即自动纠正),座位渲染一个只读 "Plan" chip,hover 出现的 × 经 `command.execute` 执行 `/plan off`;否则座位保持为空——未组合 plan-mode 的 host(或尚无会话的 Draft)不显示任何内容。plan mode 为有效目标期间,composer 文本框的 placeholder 切换为 "describe your task to generate plan"(由 composer 从同一投影渲染;owner 提供的 placeholder 优先)。
|
||||
|
||||
透明的原生 select 把键盘焦点镜像到可见 chip 上,并携带区分已提交与待生效模式的动态无障碍描述。准入失败(`matched: false`、业务错误、传输故障)以内联错误呈现,不改变显示的模式。
|
||||
chip 携带无障碍描述 "Plan mode on, press to turn off"。准入失败(`matched: false`、业务错误、传输故障)以内联错误呈现,chip 保持显示直至投影确认退出。
|
||||
|
||||
模型通过稳定的 `exit_plan_mode` 工具退出 plan mode;其 plan 评审走已组合的 Web question 通道。
|
||||
|
||||
## 模型体验
|
||||
|
||||
间接地,通过控件派发的 `/plan` 命令行:`@deepseek-ai/dsh-plan-mode` 拥有这些命令行驱动的模型可见 policy 段、退出工具 schema 与已记录状态,本包只渲染投影并发送用户同样可以手敲的内容。
|
||||
间接地,通过 chip 派发的 `/plan off` 命令行:`@deepseek-ai/dsh-plan-mode` 拥有该命令行驱动的模型可见 policy 段、退出工具 schema 与已记录状态,本包只渲染投影并发送用户同样可以手敲的内容。
|
||||
|
||||
#### KV 缓存效应
|
||||
|
||||
进入或离开 plan mode 会改变活跃的 `plan:policy` 系统提示词段,因此改变请求前缀;控件本身不添加任何提示词内容。
|
||||
进入或离开 plan mode 会改变活跃的 `plan:policy` 系统提示词段,因此改变请求前缀;chip 本身不添加任何提示词内容。
|
||||
|
||||
## 已知局限与延后工作
|
||||
|
||||
- **Plan mode 是引导而非执行沙箱**——需要强制只读规划的部署必须组合独立的沙箱与审批策略。
|
||||
- **控件属于默认编辑器**——待处理的整编辑器交互(如 plan 评审)会临时取代 InputBar 及其模式控件。
|
||||
- **无 Draft 期选择**——会话存在之前没有投影,座位保持为空;plan mode 在首个 prompt 创建会话后选择。
|
||||
- **chip 属于默认编辑器**——待处理的整编辑器交互(如 plan 评审)会临时取代 InputBar 及其 chip。
|
||||
- **无 UI 进入点**——plan mode 靠敲 `/plan` 进入;有能力但未激活的会话在工具行不显示任何入口。
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Quiet composer chip: an invisible native select owns interaction and
|
||||
accessibility while the visible layer reports committed/pending state. */
|
||||
/* Read-only plan status badge: quiet chip; the × affordance appears on
|
||||
hover/focus and the whole chip is the /plan off button. */
|
||||
|
||||
.wrap {
|
||||
display: inline-flex;
|
||||
@@ -7,65 +7,46 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
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);
|
||||
}
|
||||
|
||||
.root:focus-within .chip {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.description {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.select {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select:disabled {
|
||||
.chip:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.chip:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-label-secondary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.chip:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.root:has(.select:disabled) .chip {
|
||||
opacity: 0.5;
|
||||
.close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
|
||||
.chip:hover .close,
|
||||
.chip:focus-visible .close {
|
||||
opacity: 1;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.error {
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
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 type { PlanChipInjected } 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<PlanModeControlInjected>
|
||||
export type PlanChipProps =
|
||||
PropsRuntime<'conversation.input.plan'> & InjectFace<PlanChipInjected>
|
||||
|
||||
const labels = {
|
||||
default: '默认',
|
||||
plan: '计划',
|
||||
} as const
|
||||
|
||||
/** Composer control over the host-computed `plan` projection. */
|
||||
export function PlanModeControl({ useProjection, locked, setPlanMode }: PlanModeControlProps) {
|
||||
/**
|
||||
* Read-only status badge over the host-computed `plan` projection. Plan mode
|
||||
* is entered through the /plan command only; the chip appears while the
|
||||
* effective target is plan mode and its hover × executes /plan off. The
|
||||
* displayed state follows the target (`pending ? !active : active`) — a
|
||||
* folded host value, not client optimism, so an arriving frame corrects it.
|
||||
*/
|
||||
export function PlanChip({ useProjection, locked, exitPlanMode }: PlanChipProps) {
|
||||
const plan = useProjection('plan')
|
||||
const [switching, setSwitching] = useState(false)
|
||||
const [leaving, setLeaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const aliveRef = useRef(true)
|
||||
const descriptionId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
aliveRef.current = true
|
||||
@@ -30,57 +30,45 @@ export function PlanModeControl({ useProjection, locked, setPlanMode }: PlanMode
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Capability absence: the host composed no plan-mode plugin (or no
|
||||
// baseline has arrived yet) — the seat stays empty.
|
||||
// Absent capability (no plan-mode host plugin / no session yet) or the
|
||||
// default mode: no seat content.
|
||||
if (plan === undefined) return null
|
||||
|
||||
const target = plan.pending ? !plan.active : plan.active
|
||||
const value = target ? 'plan' : 'default'
|
||||
const currentLabel = labels[plan.active ? 'plan' : 'default']
|
||||
const targetLabel = labels[value]
|
||||
const label = `${targetLabel}${plan.pending ? ' · 待生效' : ''}`
|
||||
const title = plan.pending
|
||||
? `当前为${currentLabel}模式;${targetLabel}模式将在下一次模型请求时生效`
|
||||
: `当前为${currentLabel}模式`
|
||||
if (!target) return null
|
||||
|
||||
const select = (active: boolean): void => {
|
||||
if (active === target || switching) return
|
||||
setSwitching(true)
|
||||
const off = (): void => {
|
||||
if (leaving || locked) return
|
||||
setLeaving(true)
|
||||
setError(null)
|
||||
void setPlanMode(active).then((failure) => {
|
||||
void exitPlanMode().then((failure) => {
|
||||
if (!aliveRef.current) return
|
||||
setSwitching(false)
|
||||
setLeaving(false)
|
||||
setError(failure)
|
||||
}, (reason: unknown) => {
|
||||
if (!aliveRef.current) return
|
||||
setSwitching(false)
|
||||
setLeaving(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" />
|
||||
<button
|
||||
type="button"
|
||||
className={css.chip}
|
||||
aria-label="Plan mode on, press to turn off"
|
||||
title="Plan mode on — click × to turn off (/plan off)"
|
||||
disabled={locked || leaving}
|
||||
onClick={off}
|
||||
>
|
||||
Plan
|
||||
<span className={css.close} aria-hidden>
|
||||
<svg viewBox="0 0 12 12" width="10" height="10">
|
||||
<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" fill="none" />
|
||||
</svg>
|
||||
</span>
|
||||
<span id={descriptionId} className={css.description}>{title}</span>
|
||||
<select
|
||||
className={css.select}
|
||||
aria-label="协作模式"
|
||||
aria-describedby={descriptionId}
|
||||
value={value}
|
||||
disabled={locked || 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>}
|
||||
</button>
|
||||
{error !== null && <span className={css.error} role="status" title={error}>退出 plan mode 失败</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/**
|
||||
* 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.
|
||||
* `conversation.input.plan` seat with a read-only status chip. Plan mode is
|
||||
* entered through the /plan command only; while the projection's effective
|
||||
* target is plan mode the chip renders (hover × executes /plan off through
|
||||
* `command.execute`), otherwise the seat stays empty. Reads ride the generic
|
||||
* projection pair through the standard-kit `useProjection` (an absent key is
|
||||
* capability absence); zero client-side plan state.
|
||||
*/
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -14,16 +13,15 @@ import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/c
|
||||
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'
|
||||
import { PlanChip } from './PlanModeControl.tsx'
|
||||
|
||||
/** Injected business face of the composer plan seat. */
|
||||
export interface PlanModeControlInjected {
|
||||
export interface PlanChipInjected {
|
||||
/**
|
||||
* Select the target mode by executing the corresponding /plan line.
|
||||
* @param active - whether plan mode should be active from the next boundary.
|
||||
* Leave plan mode by executing /plan off.
|
||||
* @returns null on admitted execution; a user-visible failure line otherwise.
|
||||
*/
|
||||
setPlanMode: (active: boolean) => Promise<string | null>
|
||||
exitPlanMode: () => Promise<string | null>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,21 +31,20 @@ export interface PlanModeControlInjected {
|
||||
export const inject = ['slots', 'connection', 'conversation']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the plan seat occupant over the command channel.
|
||||
* Client plugin body: register the plan chip over the command channel.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.slots.register({
|
||||
name: 'conversation.input.plan',
|
||||
inject: (sessionId: SessionId): PlanModeControlInjected => ({
|
||||
setPlanMode: async (active) => {
|
||||
inject: (sessionId: SessionId): PlanChipInjected => ({
|
||||
exitPlanMode: async () => {
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const line = active ? '/plan' : '/plan off'
|
||||
const { result } = await connection.api.commands.execute({ sessionId, line })
|
||||
const { result } = await connection.api.commands.execute({ sessionId, line: '/plan off' })
|
||||
if (!result.ok) return `${result.error.message}(${result.error.code})`
|
||||
if (!result.value.matched) return `未知命令:${line}`
|
||||
if (!result.value.matched) return '未知命令:/plan off'
|
||||
return null
|
||||
},
|
||||
}),
|
||||
}, PlanModeControl), 'ui-plan: composer plan seat registration')
|
||||
}, PlanChip), 'ui-plan: composer plan chip registration')
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 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
|
||||
* conversation-declared `conversation.input.plan` single seat with the plan
|
||||
* status chip; the injected face executes /plan off and folds admission
|
||||
* outcomes into null (admitted) or a user-visible failure line; teardown
|
||||
* empties the seat (HMR safety).
|
||||
*/
|
||||
@@ -9,8 +9,8 @@ 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 { PlanChip } from '../src/client/PlanModeControl.tsx'
|
||||
import type { PlanChipInjected } from '../src/client/index.ts'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { apply as nodeApply } from '../src/index.ts'
|
||||
|
||||
@@ -49,30 +49,28 @@ describe('ui-plan browser apply', () => {
|
||||
.rejects.toThrow(/slot "conversation.input.plan" is not declared/)
|
||||
})
|
||||
|
||||
it('registers the control, maps selections to /plan lines, and unregisters on teardown', async () => {
|
||||
it('registers the chip, executes /plan off, 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.input.plan')[0]!
|
||||
expect(entry.component).toBe(PlanModeControl)
|
||||
const injected = (entry.inject as unknown as (id: SessionId) => PlanModeControlInjected)(SID)
|
||||
expect(entry.component).toBe(PlanChip)
|
||||
const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID)
|
||||
|
||||
await expect(injected.setPlanMode(true)).resolves.toBeNull()
|
||||
expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan' })
|
||||
await expect(injected.setPlanMode(false)).resolves.toBeNull()
|
||||
await expect(injected.exitPlanMode()).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(true)).resolves.toBe('gone(session-not-found)')
|
||||
await expect(injected.exitPlanMode()).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 expect(injected.exitPlanMode()).resolves.toBe('未知命令:/plan off')
|
||||
|
||||
await fiber.dispose()
|
||||
expect(b.slots.entries('conversation.input.plan')).toHaveLength(0)
|
||||
|
||||
@@ -1,119 +1,110 @@
|
||||
// @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.
|
||||
* PlanChip over the `plan` projection: nothing renders while the capability
|
||||
* is absent or the effective target is the default mode; the chip renders
|
||||
* while the target is plan mode (pending follows the target — /plan shows it
|
||||
* immediately, /plan off hides it immediately); the chip button executes
|
||||
* /plan off and surfaces failures without hiding until the projection says so.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { PlanProjection } from '@deepseek-ai/dsh-plan-mode/client'
|
||||
import { PlanModeControl, type PlanModeControlProps } from '../src/client/PlanModeControl.tsx'
|
||||
import { PlanChip, type PlanChipProps } from '../src/client/PlanModeControl.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function setup(
|
||||
plan: PlanProjection | undefined,
|
||||
setPlanMode = vi.fn(() => Promise.resolve<string | null>(null)),
|
||||
exitPlanMode = vi.fn(() => Promise.resolve<string | null>(null)),
|
||||
locked = false,
|
||||
) {
|
||||
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(<PlanModeControl {...props} />)
|
||||
return { store, setPlanMode, view }
|
||||
const props = { useProjection, locked, exitPlanMode } as unknown as PlanChipProps
|
||||
const view = render(<PlanChip {...props} />)
|
||||
return { store, exitPlanMode, view }
|
||||
}
|
||||
|
||||
describe('PlanModeControl', () => {
|
||||
it('hides an absent capability and renders committed modes', () => {
|
||||
const chip = () => screen.getByRole('button', { name: 'Plan mode on, press to turn off' })
|
||||
|
||||
describe('PlanChip', () => {
|
||||
it('renders nothing for absent capability or the default mode', () => {
|
||||
const absent = setup(undefined)
|
||||
expect(absent.view.container.innerHTML).toBe('')
|
||||
cleanup()
|
||||
setup({ active: false, pending: false })
|
||||
expect(screen.getByTitle('当前为默认模式')).toBeTruthy()
|
||||
const select = screen.getByRole<HTMLSelectElement>('combobox', { name: '协作模式' })
|
||||
expect(select.value).toBe('default')
|
||||
expect(document.getElementById(select.getAttribute('aria-describedby') ?? '')?.textContent)
|
||||
.toBe('当前为默认模式')
|
||||
})
|
||||
|
||||
it('renders the pending target as the opposite of the committed state', () => {
|
||||
setup({ active: false, pending: true })
|
||||
expect(screen.getByText('计划 · 待生效')).toBeTruthy()
|
||||
const planSelect = screen.getByRole('combobox')
|
||||
expect(document.getElementById(planSelect.getAttribute('aria-describedby') ?? '')?.textContent)
|
||||
.toBe('当前为默认模式;计划模式将在下一次模型请求时生效')
|
||||
const inactive = setup({ active: false, pending: false })
|
||||
expect(inactive.view.container.innerHTML).toBe('')
|
||||
cleanup()
|
||||
setup({ active: true, pending: true })
|
||||
expect(screen.getByText('默认 · 待生效')).toBeTruthy()
|
||||
const defaultSelect = screen.getByRole<HTMLSelectElement>('combobox')
|
||||
expect(defaultSelect.value).toBe('default')
|
||||
expect(document.getElementById(defaultSelect.getAttribute('aria-describedby') ?? '')?.textContent)
|
||||
.toBe('当前为计划模式;默认模式将在下一次模型请求时生效')
|
||||
// Active with a pending exit: the target is default — chip already gone.
|
||||
const leaving = setup({ active: true, pending: true })
|
||||
expect(leaving.view.container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
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<string | null>((done) => { resolve = done }))
|
||||
const { store } = setup({ active: false, pending: false }, setPlanMode)
|
||||
const select = screen.getByRole<HTMLSelectElement>('combobox', { name: '协作模式' })
|
||||
expect(select.disabled).toBe(false)
|
||||
fireEvent.change(select, { target: { value: 'plan' } })
|
||||
expect(setPlanMode).toHaveBeenCalledWith(true)
|
||||
expect(select.disabled).toBe(true)
|
||||
it('renders while the effective target is plan mode, including the pending entry window', () => {
|
||||
setup({ active: true, pending: false })
|
||||
expect(chip()).toBeTruthy()
|
||||
cleanup()
|
||||
// /plan just ran (command/run folded, plan/mode not yet): target is plan.
|
||||
setup({ active: false, pending: true })
|
||||
expect(chip()).toBeTruthy()
|
||||
})
|
||||
|
||||
// The projection frame lands (command/run folded host-side).
|
||||
store.set({ value: { active: false, pending: true } })
|
||||
it('the chip executes /plan off once and follows the projection down', async () => {
|
||||
let resolve!: (value: string | null) => void
|
||||
const exitPlanMode = vi.fn(() => new Promise<string | null>((done) => { resolve = done }))
|
||||
const { store } = setup({ active: true, pending: false }, exitPlanMode)
|
||||
fireEvent.click(chip())
|
||||
expect(exitPlanMode).toHaveBeenCalledTimes(1)
|
||||
// Busy while its own call is in flight.
|
||||
fireEvent.click(chip())
|
||||
expect(exitPlanMode).toHaveBeenCalledTimes(1)
|
||||
resolve(null)
|
||||
// The off command's run record folds: target flips, the chip unmounts.
|
||||
store.set({ value: { active: true, pending: true } })
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole<HTMLSelectElement>('combobox').disabled).toBe(false)
|
||||
expect(screen.queryByRole('button', { name: 'Plan mode on, press to turn off' })).toBeNull()
|
||||
})
|
||||
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('disables under the locked owner prop', () => {
|
||||
setup({ active: false, pending: false }, vi.fn(), true)
|
||||
expect(screen.getByRole<HTMLSelectElement>('combobox').disabled).toBe(true)
|
||||
setup({ active: true, pending: false }, vi.fn(), true)
|
||||
expect((chip() as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('surfaces admission and transport failures without changing the confirmed mode', async () => {
|
||||
const setPlanMode = vi.fn()
|
||||
it('surfaces admission and transport failures while staying visible', async () => {
|
||||
const exitPlanMode = vi.fn()
|
||||
.mockResolvedValueOnce('host said no')
|
||||
.mockRejectedValueOnce(new Error('network down'))
|
||||
.mockRejectedValueOnce('socket closed')
|
||||
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()
|
||||
setup({ active: true, pending: false }, exitPlanMode)
|
||||
fireEvent.click(chip())
|
||||
expect((await screen.findByText('退出 plan mode 失败')).getAttribute('title')).toBe('host said no')
|
||||
expect(chip()).toBeTruthy()
|
||||
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } })
|
||||
fireEvent.click(chip())
|
||||
expect(await screen.findByTitle('network down')).toBeTruthy()
|
||||
expect(screen.getByRole<HTMLSelectElement>('combobox').disabled).toBe(false)
|
||||
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } })
|
||||
fireEvent.click(chip())
|
||||
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, pending: false },
|
||||
{ active: true, pending: false },
|
||||
vi.fn(() => new Promise<string | null>((done) => { resolve = done })),
|
||||
)
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } })
|
||||
fireEvent.click(chip())
|
||||
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, pending: false }, setPlanMode)
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } })
|
||||
const exitPlanMode = vi.fn(() => new Promise<string | null>((_done, fail) => { reject = fail }))
|
||||
const { view } = setup({ active: true, pending: false }, exitPlanMode)
|
||||
fireEvent.click(chip())
|
||||
view.unmount()
|
||||
expect(() => { reject(new Error('late')) }).not.toThrow()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user