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

View File

@@ -1,5 +1,5 @@
/* Read-only plan status badge: quiet chip; the × affordance appears on /* Plan-mode toggle chip: quiet while off; the pressed state takes the
hover/focus and the whole chip is the /plan off button. */ business accent pair (same token pairing as the trajectory user badge). */
.wrap { .wrap {
display: inline-flex; display: inline-flex;
@@ -10,8 +10,7 @@
.chip { .chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; padding: 4px 8px;
padding: 6px 8px;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
background: transparent; background: transparent;
@@ -25,6 +24,14 @@
background: var(--dsw-alias-interactive-bg-hover); 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 { .chip:focus-visible {
outline: 2px solid var(--dsw-alias-label-secondary); outline: 2px solid var(--dsw-alias-label-secondary);
outline-offset: 2px; outline-offset: 2px;
@@ -35,17 +42,6 @@
cursor: default; 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 { .error {
color: var(--dsw-alias-state-error-primary); color: var(--dsw-alias-state-error-primary);
font-size: 12px; font-size: 12px;

View File

@@ -11,16 +11,16 @@ export type PlanChipProps =
PropsRuntime<'conversation.input.plan'> & InjectFace<PlanChipInjected> PropsRuntime<'conversation.input.plan'> & InjectFace<PlanChipInjected>
/** /**
* Read-only status badge over the host-computed `plan` projection. Plan mode * Plan-mode toggle over the host-computed `plan` projection. The chip renders
* is entered through the /plan command only; the chip appears while the * whenever the capability is present and reflects the effective target as its
* effective target is plan mode and its hover × executes /plan off. The * pressed state (`pending ? !active : active` — a folded host value, not
* displayed state follows the target (`pending ? !active : active`) — a * client optimism, so an arriving frame corrects it). Clicking executes
* folded host value, not client optimism, so an arriving frame corrects it. * /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 plan = useProjection('plan')
const [leaving, setLeaving] = useState(false) const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<{ text: string; detail: string } | null>(null)
const aliveRef = useRef(true) const aliveRef = useRef(true)
useEffect(() => { useEffect(() => {
@@ -30,24 +30,25 @@ export function PlanChip({ useProjection, locked, exitPlanMode }: PlanChipProps)
} }
}, []) }, [])
// Absent capability (no plan-mode host plugin / no session yet) or the // Absent capability (no plan-mode host plugin / no session yet): no seat
// default mode: no seat content. // content — without the capability there is nothing to toggle.
if (plan === undefined) return null if (plan === undefined) return null
const target = plan.pending ? !plan.active : plan.active const target = plan.pending ? !plan.active : plan.active
if (!target) return null
const off = (): void => { const toggle = (): void => {
// No leaving/locked guard: both disable the button, so no click arrives. // No busy/locked guard: both disable the button, so no click arrives.
setLeaving(true) const on = !target
const failText = on ? '进入 plan mode 失败' : '退出 plan mode 失败'
setBusy(true)
setError(null) setError(null)
void exitPlanMode().then((failure) => { void setPlanMode(on).then((failure) => {
if (!aliveRef.current) return if (!aliveRef.current) return
setLeaving(false) setBusy(false)
setError(failure) setError(failure === null ? null : { text: failText, detail: failure })
}, (reason: unknown) => { }, (reason: unknown) => {
if (!aliveRef.current) return if (!aliveRef.current) return
setLeaving(false) setBusy(false)
setError(reason instanceof Error ? reason.message : String(reason)) setError({ text: failText, detail: reason instanceof Error ? reason.message : String(reason) })
}) })
} }
@@ -56,19 +57,17 @@ export function PlanChip({ useProjection, locked, exitPlanMode }: PlanChipProps)
<button <button
type="button" type="button"
className={css.chip} className={css.chip}
aria-label="Plan mode on, press to turn off" aria-pressed={target}
title="Plan mode on — click × to turn off (/plan off)" aria-label={target ? 'Plan mode on, press to turn off' : 'Plan mode off, press to turn on'}
disabled={locked || leaving} title={target
onClick={off} ? 'Plan mode on — click to turn off (/plan off)'
: 'Plan mode off — click to turn on (/plan)'}
disabled={locked || busy}
onClick={toggle}
> >
Plan Plan { target ? 'on' : 'off' }
<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>
</button> </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> </span>
) )
} }

View File

@@ -1,11 +1,11 @@
/** /**
* Plan control plugin, browser half: occupies the composer's named * Plan control plugin, browser half: occupies the composer's named
* `conversation.input.plan` seat with a read-only status chip. Plan mode is * `conversation.input.plan` seat with a plan-mode toggle chip. While the
* entered through the /plan command only; while the projection's effective * `plan` projection is present the chip renders in both states and executes
* target is plan mode the chip renders (hover × executes /plan off through * /plan or /plan off through `command.execute` toward the opposite target;
* `command.execute`), otherwise the seat stays empty. Reads ride the generic * an absent projection (no capability) leaves the seat empty. Reads ride the
* projection pair through the standard-kit `useProjection` (an absent key is * generic projection pair through the standard-kit `useProjection` (an absent
* capability absence); zero client-side plan state. * key is capability absence); zero client-side plan state.
*/ */
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/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. */ /** Injected business face of the composer plan seat. */
export interface PlanChipInjected { 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. * @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({ ctx.effect(() => ctx.slots.register({
name: 'conversation.input.plan', name: 'conversation.input.plan',
inject: (sessionId: SessionId): PlanChipInjected => ({ inject: (sessionId: SessionId): PlanChipInjected => ({
exitPlanMode: async () => { setPlanMode: async (on) => {
const line = on ? '/plan' : '/plan off'
const connection = ctx.get('connection') as ConnectionHandle 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.ok) return `${result.error.message}${result.error.code}`
if (!result.value.matched) return '未知命令:/plan off' if (!result.value.matched) return `未知命令:${line}`
return null return null
}, },
}), }),

View File

@@ -1,9 +1,9 @@
/** /**
* ui-plan browser half on a real SlotsService: the plugin occupies the * ui-plan browser half on a real SlotsService: the plugin occupies the
* conversation-declared `conversation.input.plan` single seat with the plan * conversation-declared `conversation.input.plan` single seat with the plan
* status chip; the injected face executes /plan off and folds admission * toggle chip; the injected face executes /plan or /plan off by direction and
* outcomes into null (admitted) or a user-visible failure line; teardown * folds admission outcomes into null (admitted) or a user-visible failure
* empties the seat (HMR safety). * line; teardown empties the seat (HMR safety).
*/ */
import { Context } from 'cordis' import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
@@ -49,7 +49,7 @@ describe('ui-plan browser apply', () => {
.rejects.toThrow(/slot "conversation.input.plan" is not declared/) .rejects.toThrow(/slot "conversation.input.plan" is not declared/)
}) })
it('registers the chip, executes /plan off, and unregisters on teardown', async () => { it('registers the chip, executes /plan by direction, and unregisters on teardown', async () => {
const b = await bench() const b = await bench()
const fiber = b.ctx.plugin({ inject: [...inject], apply }) const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await() await fiber.await()
@@ -57,20 +57,22 @@ describe('ui-plan browser apply', () => {
expect(entry.component).toBe(PlanChip) expect(entry.component).toBe(PlanChip)
const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID) const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID)
await expect(injected.exitPlanMode()).resolves.toBeNull() await expect(injected.setPlanMode(false)).resolves.toBeNull()
expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan off' }) expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan off' })
await expect(injected.setPlanMode(true)).resolves.toBeNull()
expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan' })
// Business failure folds to the composer-visible line. // Business failure folds to the composer-visible line.
b.execute.mockResolvedValueOnce({ b.execute.mockResolvedValueOnce({
result: { ok: false as const, error: { code: 'session-not-found', message: 'gone', details: {} } }, result: { ok: false as const, error: { code: 'session-not-found', message: 'gone', details: {} } },
} as never) } as never)
await expect(injected.exitPlanMode()).resolves.toBe('gonesession-not-found') await expect(injected.setPlanMode(false)).resolves.toBe('gonesession-not-found')
// Unmatched admission (plan-mode not composed host-side) is also a failure line. // Unmatched admission (plan-mode not composed host-side) is also a failure line.
b.execute.mockResolvedValueOnce({ b.execute.mockResolvedValueOnce({
result: { ok: true as const, value: { matched: false as const } }, result: { ok: true as const, value: { matched: false as const } },
} as never) } as never)
await expect(injected.exitPlanMode()).resolves.toBe('未知命令:/plan off') await expect(injected.setPlanMode(true)).resolves.toBe('未知命令:/plan')
await fiber.dispose() await fiber.dispose()
expect(b.slots.entries('conversation.input.plan')).toHaveLength(0) expect(b.slots.entries('conversation.input.plan')).toHaveLength(0)

View File

@@ -1,10 +1,11 @@
// @vitest-environment jsdom // @vitest-environment jsdom
/** /**
* PlanChip over the `plan` projection: nothing renders while the capability * PlanChip over the `plan` projection: nothing renders while the capability
* is absent or the effective target is the default mode; the chip renders * is absent; with the capability present the chip renders in both states with
* while the target is plan mode (pending follows the target — /plan shows it * aria-pressed following the effective target (pending folds — /plan shows
* immediately, /plan off hides it immediately); the chip button executes * pressed immediately, /plan off unpressed immediately); clicking executes
* /plan off and surfaces failures without hiding until the projection says so. * the command toward the opposite target and surfaces direction-specific
* failures while the projection still owns the displayed state.
*/ */
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
@@ -17,78 +18,98 @@ afterEach(cleanup)
function setup( function setup(
plan: PlanProjection | undefined, plan: PlanProjection | undefined,
exitPlanMode = vi.fn(() => Promise.resolve<string | null>(null)), setPlanMode = vi.fn((_on: boolean) => Promise.resolve<string | null>(null)),
locked = false, locked = false,
) { ) {
const store = createSnapshotStore<{ value: PlanProjection | undefined }>({ value: plan }) const store = createSnapshotStore<{ value: PlanProjection | undefined }>({ value: plan })
const useProjection = (_key: string, selector?: (v: unknown) => unknown) => const useProjection = (_key: string, selector?: (v: unknown) => unknown) =>
bindSnapshotSelector(store)(s => (selector ?? (v => v))(s.value)) bindSnapshotSelector(store)(s => (selector ?? (v => v))(s.value))
const props = { useProjection, locked, exitPlanMode } as unknown as PlanChipProps const props = { useProjection, locked, setPlanMode } as unknown as PlanChipProps
const view = render(<PlanChip {...props} />) const view = render(<PlanChip {...props} />)
return { store, exitPlanMode, view } return { store, setPlanMode, view }
} }
const chip = () => screen.getByRole('button', { name: 'Plan mode on, press to turn off' }) const onChip = () => screen.getByRole('button', { name: 'Plan mode on, press to turn off' })
const offChip = () => screen.getByRole('button', { name: 'Plan mode off, press to turn on' })
describe('PlanChip', () => { describe('PlanChip', () => {
it('renders nothing for absent capability or the default mode', () => { it('renders nothing while the capability is absent', () => {
const absent = setup(undefined) const absent = setup(undefined)
expect(absent.view.container.innerHTML).toBe('') expect(absent.view.container.innerHTML).toBe('')
cleanup()
const inactive = setup({ active: false, pending: false })
expect(inactive.view.container.innerHTML).toBe('')
cleanup()
// 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('renders while the effective target is plan mode, including the pending entry window', () => { it('reflects the effective target as the pressed state, folding pending', () => {
setup({ active: false, pending: false })
expect(offChip().getAttribute('aria-pressed')).toBe('false')
cleanup()
setup({ active: true, pending: false }) setup({ active: true, pending: false })
expect(chip()).toBeTruthy() expect(onChip().getAttribute('aria-pressed')).toBe('true')
cleanup() cleanup()
// /plan just ran (command/run folded, plan/mode not yet): target is plan. // /plan just ran (command/run folded, plan/mode not yet): target is plan.
setup({ active: false, pending: true }) setup({ active: false, pending: true })
expect(chip()).toBeTruthy() expect(onChip().getAttribute('aria-pressed')).toBe('true')
cleanup()
// Active with a pending exit: the target is default — already unpressed.
setup({ active: true, pending: true })
expect(offChip().getAttribute('aria-pressed')).toBe('false')
}) })
it('the chip executes /plan off once and follows the projection down', async () => { it('unpressed chip executes /plan (on) once and follows the projection up', async () => {
let resolve!: (value: string | null) => void let resolve!: (value: string | null) => void
const exitPlanMode = vi.fn(() => new Promise<string | null>((done) => { resolve = done })) const setPlanMode = vi.fn((_on: boolean) => new Promise<string | null>((done) => { resolve = done }))
const { store } = setup({ active: true, pending: false }, exitPlanMode) const { store } = setup({ active: false, pending: false }, setPlanMode)
fireEvent.click(chip()) fireEvent.click(offChip())
expect(exitPlanMode).toHaveBeenCalledTimes(1) expect(setPlanMode).toHaveBeenCalledTimes(1)
expect(setPlanMode).toHaveBeenLastCalledWith(true)
// Busy while its own call is in flight. // Busy while its own call is in flight.
fireEvent.click(chip()) fireEvent.click(offChip())
expect(exitPlanMode).toHaveBeenCalledTimes(1) expect(setPlanMode).toHaveBeenCalledTimes(1)
resolve(null) resolve(null)
// The off command's run record folds: target flips, the chip unmounts. // The command's run record folds: target flips, the chip presses.
store.set({ value: { active: false, pending: true } })
await waitFor(() => {
expect(onChip().getAttribute('aria-pressed')).toBe('true')
})
})
it('pressed chip executes /plan off and follows the projection down', async () => {
const setPlanMode = vi.fn((_on: boolean) => Promise.resolve<string | null>(null))
const { store } = setup({ active: true, pending: false }, setPlanMode)
fireEvent.click(onChip())
expect(setPlanMode).toHaveBeenLastCalledWith(false)
store.set({ value: { active: true, pending: true } }) store.set({ value: { active: true, pending: true } })
await waitFor(() => { await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Plan mode on, press to turn off' })).toBeNull() expect(offChip().getAttribute('aria-pressed')).toBe('false')
}) })
}) })
it('disables under the locked owner prop', () => { it('disables under the locked owner prop', () => {
setup({ active: true, pending: false }, vi.fn(), true) setup({ active: true, pending: false }, vi.fn(), true)
expect((chip() as HTMLButtonElement).disabled).toBe(true) expect((onChip() as HTMLButtonElement).disabled).toBe(true)
}) })
it('surfaces admission and transport failures while staying visible', async () => { it('surfaces direction-specific admission and transport failures while staying visible', async () => {
const exitPlanMode = vi.fn() const exitFailing = vi.fn()
.mockResolvedValueOnce('host said no') .mockResolvedValueOnce('host said no')
.mockRejectedValueOnce(new Error('network down')) .mockRejectedValueOnce(new Error('network down'))
.mockRejectedValueOnce('socket closed') .mockRejectedValueOnce('socket closed')
setup({ active: true, pending: false }, exitPlanMode) setup({ active: true, pending: false }, exitFailing)
fireEvent.click(chip()) fireEvent.click(onChip())
expect((await screen.findByText('退出 plan mode 失败')).getAttribute('title')).toBe('host said no') expect((await screen.findByText('退出 plan mode 失败')).getAttribute('title')).toBe('host said no')
expect(chip()).toBeTruthy() expect(onChip()).toBeTruthy()
fireEvent.click(chip()) fireEvent.click(onChip())
expect(await screen.findByTitle('network down')).toBeTruthy() expect(await screen.findByTitle('network down')).toBeTruthy()
fireEvent.click(chip()) fireEvent.click(onChip())
expect(await screen.findByTitle('socket closed')).toBeTruthy() expect(await screen.findByTitle('socket closed')).toBeTruthy()
cleanup()
const enterFailing = vi.fn().mockResolvedValueOnce('agent busy')
setup({ active: false, pending: false }, enterFailing)
fireEvent.click(offChip())
expect((await screen.findByText('进入 plan mode 失败')).getAttribute('title')).toBe('agent busy')
expect(offChip()).toBeTruthy()
}) })
it('ignores in-flight fulfillment and rejection after unmount', () => { it('ignores in-flight fulfillment and rejection after unmount', () => {
@@ -97,14 +118,14 @@ describe('PlanChip', () => {
{ active: true, pending: false }, { active: true, pending: false },
vi.fn(() => new Promise<string | null>((done) => { resolve = done })), vi.fn(() => new Promise<string | null>((done) => { resolve = done })),
) )
fireEvent.click(chip()) fireEvent.click(onChip())
successful.view.unmount() successful.view.unmount()
expect(() => { resolve(null) }).not.toThrow() expect(() => { resolve(null) }).not.toThrow()
let reject!: (reason: unknown) => void let reject!: (reason: unknown) => void
const exitPlanMode = vi.fn(() => new Promise<string | null>((_done, fail) => { reject = fail })) const setPlanMode = vi.fn(() => new Promise<string | null>((_done, fail) => { reject = fail }))
const { view } = setup({ active: true, pending: false }, exitPlanMode) const { view } = setup({ active: true, pending: false }, setPlanMode)
fireEvent.click(chip()) fireEvent.click(onChip())
view.unmount() view.unmount()
expect(() => { reject(new Error('late')) }).not.toThrow() expect(() => { reject(new Error('late')) }).not.toThrow()
}) })