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:
imccyu
2026-07-28 23:39:50 +08:00
parent d804c9c94d
commit a39bd9035e
19 changed files with 207 additions and 218 deletions

View File

@@ -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 {

View File

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

View File

@@ -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')
}