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:
imccyu
2026-07-28 23:39:50 +08:00
parent d804c9c94d
commit a39bd9035e
19 changed files with 207 additions and 218 deletions
@@ -129,8 +129,9 @@ export function apply(ctx: Context): void {
// verbs ride this inject (package-internal — hub and bar are one plugin).
slots.register({
name: 'conversation.composer.bar',
// The two named control seats in the bar's tool row (plan left, model
// right); empty until their owning plugins register (B ruling).
// The two named control seats in the bar's tool row (plan beside the
// access control, model right); empty until their owning plugins
// register (B ruling).
children: {
'conversation.input.plan': { kind: 'single', scope: 'session' },
'conversation.input.model': { kind: 'single', scope: 'session' },
@@ -83,9 +83,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
*/
'conversation.composer.bar': { kind: 'single'; scope: 'session'; owner: ComposerBarOwnerProps }
/**
* The Plan-mode control seat in the composer tool row (left group).
* Declared by the composer-bar entry; empty until a plan plugin
* registers (B ruling: no placeholder fallback).
* The Plan-mode status seat in the composer tool row (left group,
* right of the access-mode control). Declared by the composer-bar
* entry; empty until a plan plugin registers (B ruling: no placeholder
* fallback).
*/
'conversation.input.plan': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps }
/**
@@ -10,6 +10,9 @@ import { useEffect, useRef, useState } from 'react'
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
// Type-only: the `plan` projection key merge (the TodoDock posture — the
// composer reads a host-computed value; the domain owns the key).
import type {} from '@deepseek-ai/dsh-plan-mode/client'
import type { ComposerBarProps } from '../contract/slots.ts'
import { deriveDecorations } from '../input/decorations.ts'
import css from './InputBar.module.css'
@@ -28,7 +31,7 @@ const READONLY_OPTIONS: readonly { id: string; label: string }[] = [
]
export function InputBar({
useSession, useInput, inputActions, keyboard, stop, renderSlot, useNotices, useLexicon,
useSession, useInput, inputActions, keyboard, stop, renderSlot, useNotices, useLexicon, useProjection,
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
}: InputBarProps) {
const input = useInput(s => s)
@@ -37,6 +40,9 @@ export function InputBar({
const promptError = useSession(s => s.promptError)
const running = useSession(s => s.running)
const disabled = useSession(s => s.removed)
// Plan mode swaps the textarea placeholder (the projection is the folded
// host value; owner-prop placeholders — hero, session-unavailable — win).
const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active))
// Prompt failures are ordinary failures (no create/attach transaction
// exists anymore): the strip renders promptError, the draft stays in the
// machine, and the user resubmits.
@@ -334,7 +340,9 @@ export function InputBar({
disabled={locked}
readOnly={machineBusy}
data-phase={input.phase}
placeholder={placeholder ?? (disabled ? 'Session unavailable' : 'Message the agent')}
placeholder={placeholder ?? (disabled
? 'Session unavailable'
: planActive ? 'describe your task to generate plan' : 'Message the agent')}
rows={2}
onChange={onChange}
onKeyDown={onKeyDown}
@@ -361,8 +369,8 @@ export function InputBar({
<IconPlusOutline16 size={14} />
</button>
<div className={css.modes}>
{renderSlot('conversation.input.plan', { locked })}
{accessSelect}
{renderSlot('conversation.input.plan', { locked })}
</div>
{leftItems}
</div>