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:
@@ -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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user