From 925cb0b315d6e11ca25d119643a982e2cccb64fc Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:12:10 +0800 Subject: [PATCH] feat(web): plan chip as an always-visible pressed-state toggle fix: plan button add label --- .../src/client/PlanModeControl.module.css | 26 ++--- .../ui-plan/src/client/PlanModeControl.tsx | 59 +++++----- packages/client/ui-plan/src/client/index.ts | 24 +++-- .../ui-plan/tests/browser-plugin.spec.ts | 16 +-- .../ui-plan/tests/plan-mode-control.spec.tsx | 101 +++++++++++------- 5 files changed, 123 insertions(+), 103 deletions(-) diff --git a/packages/client/ui-plan/src/client/PlanModeControl.module.css b/packages/client/ui-plan/src/client/PlanModeControl.module.css index 9e7e11d828..f79e9073db 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.module.css +++ b/packages/client/ui-plan/src/client/PlanModeControl.module.css @@ -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; diff --git a/packages/client/ui-plan/src/client/PlanModeControl.tsx b/packages/client/ui-plan/src/client/PlanModeControl.tsx index b7945789a0..961d7435ef 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.tsx +++ b/packages/client/ui-plan/src/client/PlanModeControl.tsx @@ -11,16 +11,16 @@ export type PlanChipProps = PropsRuntime<'conversation.input.plan'> & InjectFace /** - * 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(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) - {error !== null && 退出 plan mode 失败} + {error !== null && {error.text}} ) } diff --git a/packages/client/ui-plan/src/client/index.ts b/packages/client/ui-plan/src/client/index.ts index 2780d66a09..77779efb02 100644 --- a/packages/client/ui-plan/src/client/index.ts +++ b/packages/client/ui-plan/src/client/index.ts @@ -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 + setPlanMode: (on: boolean) => Promise } /** @@ -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 }, }), diff --git a/packages/client/ui-plan/tests/browser-plugin.spec.ts b/packages/client/ui-plan/tests/browser-plugin.spec.ts index 865ff67042..1051d52777 100644 --- a/packages/client/ui-plan/tests/browser-plugin.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.spec.ts @@ -1,9 +1,9 @@ /** * ui-plan browser half on a real SlotsService: the plugin occupies the * 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). + * toggle chip; the injected face executes /plan or /plan off by direction and + * folds admission outcomes into null (admitted) or a user-visible failure + * line; teardown empties the seat (HMR safety). */ import { Context } from 'cordis' 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/) }) - 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 fiber = b.ctx.plugin({ inject: [...inject], apply }) await fiber.await() @@ -57,20 +57,22 @@ describe('ui-plan browser apply', () => { expect(entry.component).toBe(PlanChip) 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' }) + await expect(injected.setPlanMode(true)).resolves.toBeNull() + expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan' }) // 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.exitPlanMode()).resolves.toBe('gone(session-not-found)') + await expect(injected.setPlanMode(false)).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.exitPlanMode()).resolves.toBe('未知命令:/plan off') + await expect(injected.setPlanMode(true)).resolves.toBe('未知命令:/plan') await fiber.dispose() expect(b.slots.entries('conversation.input.plan')).toHaveLength(0) diff --git a/packages/client/ui-plan/tests/plan-mode-control.spec.tsx b/packages/client/ui-plan/tests/plan-mode-control.spec.tsx index 44c17e5837..63f6bd7a6e 100644 --- a/packages/client/ui-plan/tests/plan-mode-control.spec.tsx +++ b/packages/client/ui-plan/tests/plan-mode-control.spec.tsx @@ -1,10 +1,11 @@ // @vitest-environment jsdom /** * 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. + * is absent; with the capability present the chip renders in both states with + * aria-pressed following the effective target (pending folds — /plan shows + * pressed immediately, /plan off unpressed immediately); clicking executes + * 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 { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' @@ -17,78 +18,98 @@ afterEach(cleanup) function setup( plan: PlanProjection | undefined, - exitPlanMode = vi.fn(() => Promise.resolve(null)), + setPlanMode = vi.fn((_on: boolean) => Promise.resolve(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, exitPlanMode } as unknown as PlanChipProps + const props = { useProjection, locked, setPlanMode } as unknown as PlanChipProps const view = render() - 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', () => { - it('renders nothing for absent capability or the default mode', () => { + it('renders nothing while the capability is absent', () => { const absent = setup(undefined) 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 }) - expect(chip()).toBeTruthy() + expect(onChip().getAttribute('aria-pressed')).toBe('true') cleanup() // /plan just ran (command/run folded, plan/mode not yet): target is plan. 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 - const exitPlanMode = vi.fn(() => new Promise((done) => { resolve = done })) - const { store } = setup({ active: true, pending: false }, exitPlanMode) - fireEvent.click(chip()) - expect(exitPlanMode).toHaveBeenCalledTimes(1) + const setPlanMode = vi.fn((_on: boolean) => new Promise((done) => { resolve = done })) + const { store } = setup({ active: false, pending: false }, setPlanMode) + fireEvent.click(offChip()) + expect(setPlanMode).toHaveBeenCalledTimes(1) + expect(setPlanMode).toHaveBeenLastCalledWith(true) // Busy while its own call is in flight. - fireEvent.click(chip()) - expect(exitPlanMode).toHaveBeenCalledTimes(1) + fireEvent.click(offChip()) + expect(setPlanMode).toHaveBeenCalledTimes(1) 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(null)) + const { store } = setup({ active: true, pending: false }, setPlanMode) + fireEvent.click(onChip()) + expect(setPlanMode).toHaveBeenLastCalledWith(false) store.set({ value: { active: true, pending: true } }) 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', () => { 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 () => { - const exitPlanMode = vi.fn() + it('surfaces direction-specific admission and transport failures while staying visible', async () => { + const exitFailing = vi.fn() .mockResolvedValueOnce('host said no') .mockRejectedValueOnce(new Error('network down')) .mockRejectedValueOnce('socket closed') - setup({ active: true, pending: false }, exitPlanMode) - fireEvent.click(chip()) + setup({ active: true, pending: false }, exitFailing) + fireEvent.click(onChip()) 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() - fireEvent.click(chip()) + fireEvent.click(onChip()) 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', () => { @@ -97,14 +118,14 @@ describe('PlanChip', () => { { active: true, pending: false }, vi.fn(() => new Promise((done) => { resolve = done })), ) - fireEvent.click(chip()) + fireEvent.click(onChip()) successful.view.unmount() expect(() => { resolve(null) }).not.toThrow() let reject!: (reason: unknown) => void - const exitPlanMode = vi.fn(() => new Promise((_done, fail) => { reject = fail })) - const { view } = setup({ active: true, pending: false }, exitPlanMode) - fireEvent.click(chip()) + const setPlanMode = vi.fn(() => new Promise((_done, fail) => { reject = fail })) + const { view } = setup({ active: true, pending: false }, setPlanMode) + fireEvent.click(onChip()) view.unmount() expect(() => { reject(new Error('late')) }).not.toThrow() })