feat(web): plan chip as an always-visible pressed-state toggle
fix: plan button add label
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user