feat(web): plan chip as an always-visible pressed-state toggle

fix: plan button add label
This commit is contained in:
imccyu
2026-07-30 13:12:10 +08:00
committed by imccyu
parent 245aeb414c
commit 925cb0b315
5 changed files with 123 additions and 103 deletions
@@ -1,5 +1,5 @@
/* Read-only plan status badge: quiet chip; the × affordance appears on
hover/focus and the whole chip is the /plan off button. */
/* Plan-mode toggle chip: quiet while off; the pressed state takes the
business accent pair (same token pairing as the trajectory user badge). */
.wrap {
display: inline-flex;
@@ -10,8 +10,7 @@
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
padding: 4px 8px;
border: none;
border-radius: 8px;
background: transparent;
@@ -25,6 +24,14 @@
background: var(--dsw-alias-interactive-bg-hover);
}
/* Hovering keeps the pressed accent: the higher-specificity hover rule above
would otherwise swap it back to the neutral hover wash. */
.chip[aria-pressed='true'],
.chip[aria-pressed='true']:hover:not(:disabled) {
color: var(--dsw-alias-state-business-primary);
background: var(--dsw-alias-state-business-tertiary);
}
.chip:focus-visible {
outline: 2px solid var(--dsw-alias-label-secondary);
outline-offset: 2px;
@@ -35,17 +42,6 @@
cursor: default;
}
.close {
display: inline-flex;
align-items: center;
color: var(--dsw-alias-label-caption);
}
.chip:hover .close,
.chip:focus-visible .close {
color: var(--dsw-alias-label-secondary);
}
.error {
color: var(--dsw-alias-state-error-primary);
font-size: 12px;
@@ -11,16 +11,16 @@ export type PlanChipProps =
PropsRuntime<'conversation.input.plan'> & InjectFace<PlanChipInjected>
/**
* 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.
* Plan-mode toggle over the host-computed `plan` projection. The chip renders
* whenever the capability is present and reflects the effective target as its
* pressed state (`pending ? !active : active` — a folded host value, not
* client optimism, so an arriving frame corrects it). Clicking executes
* /plan or /plan off toward the opposite target.
*/
export function PlanChip({ useProjection, locked, exitPlanMode }: PlanChipProps) {
export function PlanChip({ useProjection, locked, setPlanMode }: PlanChipProps) {
const plan = useProjection('plan')
const [leaving, setLeaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<{ text: string; detail: string } | null>(null)
const aliveRef = useRef(true)
useEffect(() => {
@@ -30,24 +30,25 @@ export function PlanChip({ useProjection, locked, exitPlanMode }: PlanChipProps)
}
}, [])
// Absent capability (no plan-mode host plugin / no session yet) or the
// default mode: no seat content.
// Absent capability (no plan-mode host plugin / no session yet): no seat
// content — without the capability there is nothing to toggle.
if (plan === undefined) return null
const target = plan.pending ? !plan.active : plan.active
if (!target) return null
const off = (): void => {
// No leaving/locked guard: both disable the button, so no click arrives.
setLeaving(true)
const toggle = (): void => {
// No busy/locked guard: both disable the button, so no click arrives.
const on = !target
const failText = on ? '进入 plan mode 失败' : '退出 plan mode 失败'
setBusy(true)
setError(null)
void exitPlanMode().then((failure) => {
void setPlanMode(on).then((failure) => {
if (!aliveRef.current) return
setLeaving(false)
setError(failure)
setBusy(false)
setError(failure === null ? null : { text: failText, detail: failure })
}, (reason: unknown) => {
if (!aliveRef.current) return
setLeaving(false)
setError(reason instanceof Error ? reason.message : String(reason))
setBusy(false)
setError({ text: failText, detail: reason instanceof Error ? reason.message : String(reason) })
})
}
@@ -56,19 +57,17 @@ export function PlanChip({ useProjection, locked, exitPlanMode }: PlanChipProps)
<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}
aria-pressed={target}
aria-label={target ? 'Plan mode on, press to turn off' : 'Plan mode off, press to turn on'}
title={target
? 'Plan mode on — click to turn off (/plan off)'
: 'Plan mode off — click to turn on (/plan)'}
disabled={locked || busy}
onClick={toggle}
>
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>
Plan { target ? 'on' : 'off' }
</button>
{error !== null && <span className={css.error} role="status" title={error}>退 plan mode </span>}
{error !== null && <span className={css.error} role="status" title={error.detail}>{error.text}</span>}
</span>
)
}
+13 -11
View File
@@ -1,11 +1,11 @@
/**
* Plan control plugin, browser half: occupies the composer's named
* `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.
* `conversation.input.plan` seat with a plan-mode toggle chip. While the
* `plan` projection is present the chip renders in both states and executes
* /plan or /plan off through `command.execute` toward the opposite target;
* an absent projection (no capability) leaves the seat 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'
@@ -18,10 +18,11 @@ import { PlanChip } from './PlanModeControl.tsx'
/** Injected business face of the composer plan seat. */
export interface PlanChipInjected {
/**
* Leave plan mode by executing /plan off.
* Switch plan mode by executing /plan (on) or /plan off.
* @param on - desired target: true enters plan mode, false leaves it.
* @returns null on admitted execution; a user-visible failure line otherwise.
*/
exitPlanMode: () => Promise<string | null>
setPlanMode: (on: boolean) => Promise<string | null>
}
/**
@@ -38,11 +39,12 @@ export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.slots.register({
name: 'conversation.input.plan',
inject: (sessionId: SessionId): PlanChipInjected => ({
exitPlanMode: async () => {
setPlanMode: async (on) => {
const line = on ? '/plan' : '/plan off'
const connection = ctx.get('connection') as ConnectionHandle
const { result } = await connection.api.commands.execute({ sessionId, line: '/plan off' })
const { result } = await connection.api.commands.execute({ sessionId, line })
if (!result.ok) return `${result.error.message}${result.error.code}`
if (!result.value.matched) return '未知命令:/plan off'
if (!result.value.matched) return `未知命令:${line}`
return null
},
}),