Ver código fonte

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

fix: plan button add label
imccyu 1 mês atrás
pai
commit
925cb0b315

+ 11 - 15
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;

+ 29 - 30
packages/client/ui-plan/src/client/PlanModeControl.tsx

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

+ 9 - 7
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)

+ 61 - 40
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<string | null>(null)),
+  setPlanMode = vi.fn((_on: boolean) => Promise.resolve<string | null>(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(<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', () => {
-  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<string | null>((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<string | null>((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<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 } })
     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<string | null>((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<string | null>((_done, fail) => { reject = fail }))
-    const { view } = setup({ active: true, pending: false }, exitPlanMode)
-    fireEvent.click(chip())
+    const setPlanMode = vi.fn(() => new Promise<string | null>((_done, fail) => { reject = fail }))
+    const { view } = setup({ active: true, pending: false }, setPlanMode)
+    fireEvent.click(onChip())
     view.unmount()
     expect(() => { reject(new Error('late')) }).not.toThrow()
   })