feat(client): localize composer hints and rework input command interaction

Unify the /plan claimed hint with the plan placeholder through a locale
namespace, localize slash menu group titles, replace the PermissionSelect
native select with the Menu primitive, add a goal pause verb chain, clamp
anchored popups to the viewport with scroll-into-view and outside-dismiss,
and fix onPasteUpgrade insertedRange to account for the chip trailing gap.
This commit is contained in:
Yif
2026-07-29 19:46:48 +08:00
parent c451b63016
commit 45c9205caf
46 changed files with 623 additions and 225 deletions

View File

@@ -13,8 +13,10 @@
display: flex;
flex-direction: column;
min-width: 220px;
/* Height cap: the 320px design maximum, clamped at runtime to the space
* above the composer (inline max-height set in PopupSelectView.tsx). */
max-height: 320px;
overflow-y: auto;
overflow: hidden;
/* Elevated surface: the scrollbar thumb takes the l2 elevation tokens
(see ui-theme styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
@@ -26,6 +28,13 @@
outline: none;
}
.viewport {
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
}
.row {
display: flex;
align-items: center;
@@ -34,11 +43,11 @@
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--dsw-alias-text-primary);
color: var(--dsw-alias-label-primary);
}
.rowActive {
background: var(--dsw-alias-fill-hover);
background: var(--dsw-alias-interactive-bg-hover);
}
.label {
@@ -50,19 +59,20 @@
.detail {
font-size: 12px;
color: var(--dsw-alias-text-tertiary);
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
}
.check {
display: inline-flex;
color: var(--dsw-alias-text-secondary);
flex: none;
color: var(--dsw-alias-label-primary);
}
.status {
padding: 8px;
font-size: 12px;
color: var(--dsw-alias-text-tertiary);
padding: 8px 10px;
font-size: 13px;
color: var(--dsw-alias-label-tertiary);
}
.search {
@@ -72,7 +82,7 @@
border-radius: 8px;
background: transparent;
font-size: 13px;
color: var(--dsw-alias-text-primary);
color: var(--dsw-alias-label-primary);
outline: none;
}
@@ -97,6 +107,6 @@
border-radius: 6px;
background: transparent;
font-size: 12px;
color: var(--dsw-alias-text-primary);
color: var(--dsw-alias-label-primary);
cursor: pointer;
}

View File

@@ -3,19 +3,23 @@
* store into the conversation.input.overlay anchor. Unlike the slash menu
* (combobox — textarea keeps focus), this shell HOLDS focus while open: the
* inner search input takes focus, plain typing filters the loaded options
* locally, Enter/↑↓ drive the filtered highlight, Escape dismisses back to
* the composer, and ←→ keep the search input's native caret. Any pointer
* interaction outside the box dismisses (the click's own target takes
* focus). Closed state renders null; the overlay slot stays mounted.
* locally, Enter/↑↓ drive the filtered highlight (scrolled into view), Escape
* dismisses back to the composer, and ←→ keep the search input's native
* caret. Any pointer interaction outside the box dismisses (the click's own
* target takes focus). Closed state renders null; the overlay slot stays
* mounted. The card height clamps to the space above the composer.
*/
import { useEffect, useRef } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { IconCheckOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconCheckOutline16, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
import { filterOptions } from './popup.ts'
import type { PopupSelectController } from './popup.ts'
import css from './PopupSelectView.module.css'
/** Design cap on the card height (same MenuDropdown family as the slash menu). */
const MAX_HEIGHT = 320
/** Injected business face of the popupSelect overlay entry. */
export interface PopupSelectInjected {
/** The session's shell controller (state store + verbs; the view never touches the open-context type). */
@@ -34,6 +38,17 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
)
const cardRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
// The card is bottom-anchored above the composer; clamp the design cap to
// the space above it, re-measured on every store update.
const maxHeight = useAnchoredMaxHeight(cardRef, MAX_HEIGHT, state)
const active = state.open ? state.active : null
// The search input keeps focus while arrows move a virtual highlight, so
// the browser never scrolls the active row into view — do it here.
useEffect(() => {
if (active === null) return
cardRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' })
}, [active])
// Focus ownership: the search input grabs on open (the design's
// transient-layer rule), and ANY outside pointer interaction dismisses —
@@ -42,7 +57,6 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
// takes focus naturally, so no focusComposer here.
useEffect(() => {
if (!state.open) return
searchRef.current?.focus()
const onPointerDown = (ev: PointerEvent): void => {
if (cardRef.current !== null && ev.target instanceof Node && cardRef.current.contains(ev.target)) return
popup.dismiss()
@@ -51,6 +65,11 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
return () => { document.removeEventListener('pointerdown', onPointerDown, true) }
}, [state.open, popup])
// Focus the search input after it mounts (separate effect so the ref is populated).
useEffect(() => {
if (state.open) searchRef.current?.focus()
}, [state.open])
if (!state.open) return null
const rows = filterOptions(state.options, state.search)
@@ -83,6 +102,7 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
<div
ref={cardRef}
className={css.card}
style={{ maxHeight }}
aria-label={`/${String(state.command)} options`}
onKeyDown={onKeyDown}
>
@@ -108,7 +128,7 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
{state.submitting && <div className={css.status}>Applying</div>}
{state.status === 'ready' && rows.length === 0 && <div className={css.status}>No options</div>}
{state.status === 'ready' && (
<div role="listbox" aria-label={`/${String(state.command)} matches`}>
<div role="listbox" aria-label={`/${String(state.command)} matches`} className={css.viewport}>
{rows.map((option, index) => (
<div
key={option.id}

View File

@@ -4,17 +4,28 @@
* focus on open and plain typing filters locally, ↑↓ move the filtered
* highlight while ←→ stay native to the input, Enter selects single-flight,
* Escape dismisses back through focusComposer, outside pointerdown dismisses
* plainly, and the submitting/failed states render pending text and a
* working retry button.
* plainly, the submitting/failed states render pending text and a working
* retry button, the highlighted row scrolls into view, and the card height
* clamps to the space above the composer.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { SelectOption } from '../src/client/contract.ts'
import type { PopupSpec, TokenSegment } from '../src/client/popup.ts'
import { PopupSelectController } from '../src/client/popup.ts'
import { PopupSelectView } from '../src/client/PopupSelectView.tsx'
afterEach(cleanup)
// jsdom has no scrollIntoView; the view calls it on the highlighted row.
const scrollIntoView = vi.fn()
beforeEach(() => {
Element.prototype.scrollIntoView = scrollIntoView
scrollIntoView.mockClear()
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
const OPTIONS: SelectOption[] = [
{ id: 'dark', label: 'Dark' },
@@ -87,6 +98,27 @@ describe('PopupSelectView', () => {
expect(fireEvent.keyDown(search, { key: 'ArrowRight' })).toBe(true)
})
it('scrolls the highlighted row into view when the highlight moves', async () => {
const { search } = await mountOpen()
scrollIntoView.mockClear()
act(() => { fireEvent.keyDown(search, { key: 'ArrowDown' }) })
const options = screen.getAllByRole('option')
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
expect(scrollIntoView.mock.instances.at(-1)).toBe(options[1])
})
it('caps the card height at the design maximum when the composer sits low enough', async () => {
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 800 } as DOMRect)
await mountOpen()
expect(screen.getByLabelText('/theme options').style.maxHeight).toBe('320px')
})
it('clamps the card height to the space above the composer minus the safe margin', async () => {
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 200 } as DOMRect)
await mountOpen()
expect(screen.getByLabelText('/theme options').style.maxHeight).toBe('188px')
})
it('Enter selects the highlighted row: onSelect, consume, close, focusComposer', async () => {
const seen: Array<{ option: SelectOption; context: string }> = []
const { view, search, consume, focusComposer } = await mountOpen({

View File

@@ -39,6 +39,7 @@
"clsx": "^2.0.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
@@ -48,10 +49,10 @@
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",

View File

@@ -3,6 +3,8 @@ import type { Context } from 'cordis'
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { ViewTab } from './contract/views.ts'
import type {
ApprovalWait, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected,
@@ -25,7 +27,7 @@ import { ConversationSession } from './skeleton/ConversationSession.tsx'
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
/** Services required by the conversation plugin. */
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale']
/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
@@ -50,6 +52,33 @@ export function apply(ctx: Context): void {
const layout = ctx.layout
const slots = ctx.slots
// Command hint locale: friendly placeholder text for claimed commands. The
// claimed /plan hint and the plan-mode textarea placeholder share one
// string: both describe the same next action.
const HINT_NS = 'command.hint'
const PLAN_HINT_ZH = '描述你的任务以生成计划'
const PLAN_HINT_EN = 'describe your task to generate plan'
ctx.effect(() => {
const disposers = [
ctx.locale.register(HINT_NS, 'zh', {
plan: PLAN_HINT_ZH,
goal: '输入目标,智能体将持续执行',
'goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除',
'placeholder.plan': PLAN_HINT_ZH,
'placeholder.default': '给智能体发消息',
}),
ctx.locale.register(HINT_NS, 'en', {
plan: PLAN_HINT_EN,
goal: 'describe the objective for a long-running task',
'goal.active': 'goal active — edit / pause / resume / clear',
'placeholder.plan': PLAN_HINT_EN,
'placeholder.default': 'Message the agent',
}),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-conversation: command hint dictionaries')
const translateHint = ctx.locale.bind(HINT_NS)
// Apply-time construction keeps store identity bound to this fiber.
const chatStore = createChatStore()
@@ -159,6 +188,7 @@ export function apply(ctx: Context): void {
const result = await session.command(line)
return result.ok && result.value.matched
},
translateHint,
hooks: { notices: shell.notices, lexicon: shell.lexicon },
}
},

View File

@@ -257,6 +257,8 @@ export interface ComposerBarInjected {
* Resolves admission: false = rejected/unmatched/transport failure.
*/
command: (line: string) => Promise<boolean>
/** Locale-aware hint translator for claimed command placeholders. */
translateHint: (key: string) => string
/** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */
hooks: {
/** Latest surfaced notice (null after none; seq keys re-render of repeats). */

View File

@@ -297,14 +297,23 @@ export class InputMachine {
return []
}
/** Shared chip-insertion transaction: replace [span) with one placeholder occurrence (insert-ref and paste-upgrade both land here). */
private replaceSpanWithChip(reference: ReferenceInsert, span: TokenSpan): void {
/**
* Shared chip-insertion transaction: replace [span) with one placeholder
* occurrence (insert-ref and paste-upgrade both land here). A separating
* space follows the chip unless one is already next.
* @returns the inserted length (placeholder plus optional gap).
*/
private replaceSpanWithChip(reference: ReferenceInsert, span: TokenSpan): number {
this.pushTxn()
this.typingRun = undefined
this.reconcile({ start: span.start, end: span.end, insertedLength: 1 })
const tail = this.draft.slice(span.end)
const gap = tail.length === 0 || tail[0] !== ' ' ? ' ' : ''
const inserted = PLACEHOLDER + gap
this.reconcile({ start: span.start, end: span.end, insertedLength: inserted.length })
this.withMinted([this.mint(reference, span.start)])
this.adopt(this.draft.slice(0, span.start) + PLACEHOLDER + this.draft.slice(span.end))
this.adopt(this.draft.slice(0, span.start) + inserted + tail)
this.watchClaim()
return inserted.length
}
/**
@@ -442,10 +451,10 @@ export class InputMachine {
if (attempt === undefined || attempt.attemptId !== attemptId) return []
if (this.phase !== 'plain' && this.phase !== 'claimed') return []
if (!this.casOk(span) || span.start === span.end) return []
this.replaceSpanWithChip(reference, span)
const insertedLength = this.replaceSpanWithChip(reference, span)
this.paste = {
...attempt,
insertedRange: { start: attempt.insertedRange.start, end: attempt.insertedRange.end + 1 - (span.end - span.start) },
insertedRange: { start: attempt.insertedRange.start, end: attempt.insertedRange.end + insertedLength - (span.end - span.start) },
}
return []
}

View File

@@ -125,20 +125,18 @@
position: absolute;
inset: 0;
overflow: hidden;
color: transparent;
color: var(--dsw-alias-label-primary);
pointer-events: none;
}
.hlToken {
border-radius: 4px;
/* GOAL 稿 amber token emphasis (state warn pair; glyphs stay the textarea's). */
background: var(--dsw-alias-state-warn-tertiary);
color: transparent;
background-color: transparent;
color: var(--dsw-alias-state-warn-label);
}
.hlSegment {
border-radius: 4px;
background: var(--dsw-alias-interactive-bg-hover);
background-color: transparent;
color: transparent;
}
@@ -170,7 +168,7 @@
border: none;
outline: none;
background: transparent;
color: var(--dsw-alias-label-primary);
color: transparent;
/* Business blue, not brand-primary: that token resolves to ink in this sheet. */
caret-color: var(--dsw-alias-state-business-primary);
}
@@ -348,25 +346,13 @@
draft's own glyphs — advance untouched, so the two layers cannot drift.
Chip family colors; clone keeps rounded ends on soft-wrap fragments. */
.textRef {
color: transparent;
background-color: transparent;
color: var(--dsw-alias-state-business-primary);
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
position: relative;
}
.textRef:after {
content: "";
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
border-radius: 6px;
background: rgba(97, 135, 216, 0.22);
transform: translate(-2px, -1px);
padding: 2px 4px;
display: none;
}
/* Reference chip: rendered in the backdrop at the placeholder offset. Hard

View File

@@ -13,6 +13,8 @@ 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'
// Type-only: the `goal` projection key merge (hint disambiguation).
import type {} from '@deepseek-ai/dsh-goal/client'
import type { ComposerBarProps } from '../contract/slots.ts'
import { deriveDecorations } from '../input/decorations.ts'
import { PermissionSelect } from './PermissionSelect.tsx'
@@ -27,7 +29,7 @@ export interface InputBarError {
export type InputBarProps = ComposerBarProps
export function InputBar({
useSession, useInput, inputActions, keyboard, stop, command, renderSlot, useNotices, useLexicon, useProjection,
useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon, useProjection,
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
}: InputBarProps) {
const input = useInput(s => s)
@@ -39,6 +41,8 @@ export function InputBar({
// 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))
// Absent (undefined: no frame yet) and cleared (null) both mean no goal.
const hasGoal = useProjection('goal', goal => goal != null)
// 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.
@@ -296,7 +300,12 @@ export function InputBar({
}
pushPlain(draft.length)
if (deco.hint !== null) {
backdrop.push(<span key="hint" className={css.hint} data-decoration="hint">{deco.hint}</span>)
// Claim tokens are shaped `/name ` (trailing space); trim to the bare name.
const commandName = input.claim?.token.slice(1).trim() ?? ''
const hintKey = commandName === 'goal' && hasGoal ? 'goal.active' : commandName
const translated = translateHint(hintKey)
const displayHint = translated !== hintKey ? translated : deco.hint
backdrop.push(<span key="hint" className={css.hint} data-decoration="hint">{displayHint}</span>)
}
}
@@ -312,7 +321,7 @@ export function InputBar({
{notice.text}
</div>
)}
<div className={css.card}>
<div className={css.card} data-composer-card>
{overlay !== undefined && <div className={css.overlayAnchor}>{overlay}</div>}
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
{/* Mirror-div auto-grow: the hidden mirror renders draft+'\n' and stretches the wrapper
@@ -329,7 +338,7 @@ export function InputBar({
data-phase={input.phase}
placeholder={placeholder ?? (disabled
? 'Session unavailable'
: planActive ? 'describe your task to generate plan' : 'Message the agent')}
: planActive ? translateHint('placeholder.plan') : translateHint('placeholder.default'))}
rows={2}
onChange={onChange}
onKeyDown={onKeyDown}

View File

@@ -1,49 +1,43 @@
/* Composer bottom-row permission chip (draft start.jpeg `Read-only `): a
quiet text chip with a chevron; hover paints the standard interactive pill.
The native select is stretched invisibly over the chip so the platform
dropdown does the menu work — keyboard/AT semantics come free. */
.root {
position: relative;
display: inline-flex;
align-items: center;
}
.chip {
.trigger {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
border-radius: 8px;
color: var(--dsw-alias-label-secondary);
font-size: 14px;
line-height: 20px;
pointer-events: none; /* the overlaid select owns the interaction */
}
.root:hover .chip {
background: var(--dsw-alias-interactive-bg-hover);
}
.chevron {
color: var(--dsw-alias-label-caption);
}
/* Invisible native select stretched over the chip: real menu, zero drawing. */
.select {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
min-width: 0;
max-width: 220px;
height: 28px;
padding: 0 4px 0 8px;
border: none;
border-radius: 8px;
outline: none;
background: transparent;
color: var(--dsw-alias-label-secondary);
font-size: 13px;
line-height: 20px;
font-weight: 500;
cursor: pointer;
}
.select:disabled {
.trigger:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.trigger:focus-visible {
box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);
}
.trigger:disabled {
color: var(--dsw-alias-label-dimmed);
cursor: default;
}
.root:has(.select:disabled) .chip {
opacity: 0.5;
.triggerLabel {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
flex: 0 0 auto;
color: var(--dsw-alias-label-caption);
}

View File

@@ -1,27 +1,14 @@
// PermissionSelect: the composer bottom-row permission chip (draft
// start.jpeg's `Read-only ` control), the Access seat's wired occupant.
// Options and the current value read from the host-computed `permissions`
// projection (baseline block + push frames — no fetch, no mount timing);
// key absence (a permission-less composition, or a Draft with no host
// session yet) renders nothing. The visible chip is presentation only — an
// invisible native select stretched over it owns the menu and interaction.
// A switch submits the `/permission <preset>` command line (the one write
// path); the control shows the picked value optimistically and disables
// until the admission result, then re-follows the projection — the pushed
// frame confirms the switch, and a failed/unmatched submit falls back to
// the still-authoritative projection value (`custom` is shown as the
// current value but never offered as a target — the host omits it from
// switchable options).
import { useState } from 'react'
import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client'
import { Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './PermissionSelect.module.css'
/**
* Display transform: kebab-case machine names render as title-case labels
* (`workspace-write` → `Workspace Write`). Presentation-only — the wire
* vocabulary and the host's advertised names are untouched; a host-configured
* name that is not kebab-case (contains spaces or uppercase) passes through.
* (`workspace-write` → `Workspace Write`); non-kebab host-configured names
* pass through. Twin of the /permission popup's (client ui-permission) — the
* two permission surfaces must show the same text.
*/
function displayName(name: string): string {
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name
@@ -29,52 +16,57 @@ function displayName(name: string): string {
}
export interface PermissionSelectProps {
/** The host-computed select, or undefined while the capability is absent. */
value: PermissionSelectValue | undefined
/** Session-removed lock (the bar's chrome disable state). */
locked: boolean
/** Submit one slash-command line; resolves admission (false = rejected/unmatched). */
command: (line: string) => Promise<boolean>
}
export function PermissionSelect({ value, locked, command }: PermissionSelectProps) {
// Optimistic pick, shown while the admission round-trip runs; null follows
// the projection (the pushed frame lands the confirmed value there).
const [pick, setPick] = useState<string | null>(null)
const [open, setOpen] = useState(false)
if (value === undefined) return null
const currentValue = pick ?? value.currentValue
const current = value.options.find(option => option.value === currentValue)
const busy = pick !== null
const onChange = (next: string): void => {
if (next === value.currentValue) return
setPick(next)
void command(`/permission ${next}`)
const items: MenuEntry[] = value.options
.filter(o => o.value !== 'custom')
.map(option => ({ id: option.value, label: displayName(option.name) }))
const choose = (id: string): void => {
setOpen(false)
if (id === value.currentValue) return
setPick(id)
void command(`/permission ${id}`)
.catch(() => false)
.then(() => { setPick(null) })
}
return (
<label className={css.root} title={current?.description}>
<span className={css.chip}>
{displayName(current?.name ?? currentValue)}
<svg className={css.chevron} viewBox="0 0 12 12" width="12" height="12" aria-hidden>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
</span>
<select
className={css.select}
aria-label="Access mode"
value={currentValue}
disabled={locked || pick !== null}
onChange={(e) => { onChange(e.target.value) }}
>
{value.options.map(option => (
<option key={option.value} value={option.value} disabled={option.value === 'custom'}>
{displayName(option.name)}
</option>
))}
</select>
</label>
<Menu
open={open}
items={items}
selectedId={currentValue}
onSelect={choose}
onClose={() => { setOpen(false) }}
side="top"
anchor={
<button
type="button"
className={css.trigger}
aria-label={`Access mode, current: ${displayName(current?.name ?? currentValue)}`}
title={current?.description}
disabled={locked || busy}
onClick={() => { setOpen(!open) }}
>
<span className={css.triggerLabel}>{displayName(current?.name ?? currentValue)}</span>
<svg className={css.chevron} viewBox="0 0 12 12" width="12" height="12" aria-hidden>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
</button>
}
/>
)
}

View File

@@ -17,6 +17,7 @@
import { describe, expect, it, vi } from 'vitest'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
@@ -49,6 +50,7 @@ async function bench() {
})
const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
runtime.provide('layout', layoutFake)
runtime.provide('locale', new LocaleService(runtime.ctx))
// The AppFrame role: the conversation-package slots must be declared by a
// live entry before apply can contribute into them.

View File

@@ -10,6 +10,7 @@
import { describe, expect, it, vi } from 'vitest'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -22,6 +23,7 @@ async function bench() {
await runtime.sessions.add(
{ id: CHILD, summary: { title: 'C', displayTitle: 'C', parentId: ROOT } }, { current: false })
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
runtime.provide('locale', new LocaleService(runtime.ctx))
// Declared by ui-layout's root entry in production; the test root declares
// them here so the contributions land.

View File

@@ -17,6 +17,7 @@ import type {
ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -125,7 +126,7 @@ async function bench(snapshot: ConversationSnapshot) {
}
ctx.provide('workspaces', workspaces)
ctx.provide('layout', layout)
ctx.provide('i18n', { bind: () => (key: string) => key })
ctx.provide('locale', new LocaleService(ctx))
slots.install(createSlotRenderer())
slots.register({

View File

@@ -15,6 +15,7 @@ import { cleanup, fireEvent } from '@testing-library/react'
import type { ISession, SessionId, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -53,6 +54,7 @@ async function bench(nodes: ToolResultNode[]) {
const runtime = await SlotTestRuntime.create()
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
runtime.provide('layout', layout)
runtime.provide('locale', new LocaleService(runtime.ctx))
await runtime.sessions.add({
id: SID,
summary: { title: 'S', displayTitle: 'S' },
@@ -180,6 +182,7 @@ describe('registrant load-order seam', () => {
it("suspends a registrant on inject: ['slots', 'conversation'] until the service (and the hole) exists", async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
runtime.provide('locale', new LocaleService(runtime.ctx))
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject

View File

@@ -42,6 +42,7 @@ interface BenchOptions {
promptError?: ConversationSnapshot['promptError']
variant?: 'hero' | 'composer'
placeholder?: string
translateHint?: (key: string) => string
accessory?: React.ReactNode
overlay?: React.ReactNode
leftItems?: React.ReactNode
@@ -100,6 +101,11 @@ function bench(over?: BenchOptions) {
useLexicon: bindSnapshotSelector(shell.lexicon),
stop,
command: () => Promise.resolve(true),
// Mirrors the en 'command.hint' locale entries the production apply wires in.
translateHint: over?.translateHint ?? ((key: string) => ({
'placeholder.default': 'Message the agent',
'placeholder.plan': 'describe your task to generate plan',
} as Record<string, string>)[key] ?? key),
renderSlot,
variant: over?.variant ?? 'composer',
...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}),
@@ -292,6 +298,19 @@ describe('decorations', () => {
expect(view.container.querySelector('[data-decoration="token"]')).not.toBeNull()
})
it('a locale entry for the claimed command overrides the raw claim hint (trailing-space token)', () => {
const dict: Record<string, string> = { goal: '输入目标,智能体将持续执行' }
const { view, shell } = bench({ translateHint: key => dict[key] ?? key })
act(() => {
shell.setDraft('/goal ')
shell.beginCommand(
{ token: '/goal ', hint: '[<objective>|clear|edit <objective>|pause|resume]', submit: () => Promise.resolve({ kind: 'success' as const }) },
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
)
})
expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('输入目标,智能体将持续执行')
})
it('an inserted reference renders as a chip at its placeholder offset', () => {
const { view, shell } = bench()
act(() => {
@@ -374,7 +393,7 @@ describe('placeholder chrome and control seats', () => {
const { view, slotCalls } = bench()
expect(view.getByLabelText('Add attachment')).toBeTruthy()
// Capability absent (no projection value): the chip renders nothing.
expect(view.queryByLabelText('Access mode')).toBeNull()
expect(view.queryByLabelText(/^Access mode/)).toBeNull()
// Both seats dispatched, nothing rendered.
expect(slotCalls.map(c => c.key)).toEqual(['conversation.input.plan', 'conversation.input.model'])
expect(view.queryByLabelText('Plan mode')).toBeNull()
@@ -390,15 +409,19 @@ describe('placeholder chrome and control seats', () => {
currentValue: 'workspace-write',
}
const { view } = bench({ permissions })
const select = view.getByLabelText('Access mode') as HTMLSelectElement
expect(select.value).toBe('workspace-write')
// Title-case display is presentation only; the option values stay machine names.
expect([...select.options].map(o => o.textContent)).toEqual(['Workspace Write', 'Danger Full Access'])
fireEvent.change(select, { target: { value: 'danger-full-access' } })
const trigger = view.getByLabelText(/^Access mode/) as HTMLButtonElement
// Title-case display is presentation only; the menu ids stay machine names.
expect(trigger.textContent).toBe('Workspace Write')
fireEvent.click(trigger)
const items = view.getAllByRole('menuitem')
expect(items.map(o => o.textContent)).toEqual(['Workspace Write', 'Danger Full Access'])
fireEvent.click(items[1]!)
// Optimistic pick + disable until admission resolves (command stub resolves true).
expect(select.disabled).toBe(true)
const busy = view.getByLabelText(/^Access mode/) as HTMLButtonElement
expect(busy.textContent).toBe('Danger Full Access')
expect(busy.disabled).toBe(true)
await act(async () => {})
expect(select.disabled).toBe(false)
expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(false)
})
it('a registered entry fills its seat and receives the locked owner prop', () => {
@@ -420,9 +443,9 @@ describe('placeholder chrome and control seats', () => {
const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' }
const { view } = bench({ disabled: true, permissions })
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText('Access mode') as HTMLSelectElement).disabled).toBe(true)
expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(true)
cleanup()
const live = bench({ running: true, permissions })
expect((live.view.getByLabelText('Access mode') as HTMLSelectElement).disabled).toBe(false)
expect((live.view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(false)
})
})

View File

@@ -260,10 +260,10 @@ describe('input-machine: insert-ref and the occurrence table', () => {
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
m.dispatch({ type: 'draft-changed', draft: `${P} and /alp`, editRange: { start: 1, end: 1, insertedLength: 9 } })
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 6, 10) })
expect(m.state.draft).toBe(`${P} and ${P}`)
expect(m.state.draft).toBe(`${P} and ${P} `)
expect(m.state.occurrences.map(o => o.occurrenceId)).toEqual([1, 2])
// Delete the first chip whole; the second survives with its own identity.
m.dispatch({ type: 'draft-changed', draft: ` and ${P}`, editRange: { start: 0, end: 1, insertedLength: 0 } })
m.dispatch({ type: 'draft-changed', draft: ` and ${P} `, editRange: { start: 0, end: 1, insertedLength: 0 } })
expect(m.state.occurrences).toEqual([expect.objectContaining({ occurrenceId: 2, offset: 5 })])
})
@@ -273,7 +273,7 @@ describe('input-machine: insert-ref and the occurrence table', () => {
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
m.dispatch({ type: 'draft-changed', draft: '/goal ask @wor' })
m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 10, 14) })
expect(m.state.draft).toBe(`/goal ask ${P}`)
expect(m.state.draft).toBe(`/goal ask ${P} `)
expect(m.state.phase).toBe('claimed')
expect(m.state.occurrences).toHaveLength(1)
})
@@ -350,10 +350,10 @@ describe('input-machine: newline transaction (F1)', () => {
m.dispatch({ type: 'draft-changed', draft: 'ab @wor' })
m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 3, 7) })
m.dispatch({ type: 'newline', selection: { start: 2, end: 2 } })
expect(m.state.draft).toBe(`ab\n ${P}`)
expect(m.state.draft).toBe(`ab\n ${P} `)
expect(m.state.occurrences[0]?.offset).toBe(4)
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe(`ab ${P}`)
expect(m.state.draft).toBe(`ab ${P} `)
})
it('replaces a selection, breaks the claim prefix when leading, and rejects out-of-bounds', () => {
@@ -410,7 +410,7 @@ describe('input-machine: consume-token guards', () => {
m.dispatch({ type: 'draft-changed', draft: '/model @wor' })
m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 7, 11) })
m.dispatch({ type: 'consume-token', guard: { kind: 'span', span: spanOf(m, 0, 7) } })
expect(m.state.draft).toBe(P)
expect(m.state.draft).toBe(`${P} `)
expect(m.state.occurrences[0]?.offset).toBe(0)
})
})
@@ -490,7 +490,7 @@ describe('input-machine: undo / redo', () => {
m.dispatch({ type: 'draft-changed', draft: '', editRange: { start: 0, end: 1, insertedLength: 0 } })
expect(m.state.occurrences).toEqual([])
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe(P)
expect(m.state.draft).toBe(`${P} `)
expect(m.state.occurrences).toHaveLength(1)
})
@@ -555,9 +555,9 @@ describe('input-machine: paste plane', () => {
m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })
expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: 7 })
m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 2, 7), reference: refOf('beta') })
expect(m.state.draft).toBe(`${P} ${P}`)
expect(m.state.draft).toBe(`${P} ${P} `)
expect(m.state.occurrences.map(o => o.ref)).toEqual(['alpha', 'beta'])
expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: 3 })
expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: 4 })
})
it('a stale span CAS drops one upgrade without ending the attempt', () => {
@@ -634,8 +634,8 @@ describe('input-machine: projectClipboard', () => {
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 4, 8) })
m.dispatch({ type: 'draft-changed', draft: `use ${P} then /bet`, editRange: { start: 5, end: 5, insertedLength: 10 } })
m.dispatch({ type: 'insert-ref', reference: refOf('beta'), span: spanOf(m, 11, 15) })
expect(m.state.draft).toBe(`use ${P} then ${P}`)
expect(projectClipboard(m.state)).toBe('use /alpha then /beta')
expect(m.state.draft).toBe(`use ${P} then ${P} `)
expect(projectClipboard(m.state)).toBe('use /alpha then /beta ')
})
it('is the identity on a chip-free draft', () => {

View File

@@ -48,6 +48,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
renderSlot: (() => null) as InputBarProps['renderSlot'],
stop: vi.fn(),
command: () => Promise.resolve(true),
translateHint: (key: string) => key,
variant: 'composer',
}
return render(<InputBar {...props} />)

View File

@@ -134,6 +134,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
renderSlot: (() => null) as InputBarProps['renderSlot'],
stop: vi.fn(),
command: () => Promise.resolve(true),
translateHint: (key: string) => key,
variant: 'composer',
}
const view = render(<InputBar {...barProps} />)

View File

@@ -124,6 +124,7 @@ function mount(
useLexicon={bindSnapshotSelector(wiring.lexicon)}
stop={stop}
command={() => Promise.resolve(true)}
translateHint={(key: string) => key}
renderSlot={(() => null) as InputBarProps['renderSlot']}
{...bar}
/>

View File

@@ -29,6 +29,9 @@
{
"path": "../../plan/plan-mode"
},
{
"path": "../../goal/goal"
},
{
"path": "../../todo/tool-todo"
},

View File

@@ -91,7 +91,7 @@
.actions {
display: flex;
align-items: center;
gap: 2px;
gap: 8px;
flex: none;
}

View File

@@ -11,7 +11,7 @@
import { useCallback, useEffect, useState } from 'react'
import type { GoalSnapshot } from '@deepseek-ai/dsh-goal/client'
import {
IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconPlayOutline16, IconSparkle16, IconTrashOutline16,
IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconPauseOutline16, IconPlayOutline16, IconSparkle16, IconTrashOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { GoalActionResult, GoalBarActions } from './slots.ts'
import css from './GoalBar.module.css'
@@ -28,7 +28,7 @@ const PHASE_LABELS = {
blocked: 'Blocked Goal',
} as const
export function GoalBar({ goal, onEdit, onResume, onClear }: GoalBarProps) {
export function GoalBar({ goal, onEdit, onPause, onResume, onClear }: GoalBarProps) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState('')
const [pending, setPending] = useState(false)
@@ -120,6 +120,11 @@ export function GoalBar({ goal, onEdit, onResume, onClear }: GoalBarProps) {
<span className={css.objective}>{goal.objective}</span>
{actionError !== null && <span className={css.error} role="alert">{actionError}</span>}
<div className={css.actions}>
{goal.phase === 'active' && (
<button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void runAction(onPause) }} title="Pause goal" aria-label="Pause goal">
<IconPauseOutline16 />
</button>
)}
{goal.phase === 'paused' && (
<button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void runAction(onResume) }} title="Resume goal" aria-label="Resume goal">
<IconPlayOutline16 />
@@ -148,12 +153,13 @@ export function GoalBar({ goal, onEdit, onResume, onClear }: GoalBarProps) {
export type GoalDockProps = import('@deepseek-ai/dsh-client-ui-slots').PropsRuntime<'conversation.input.dock'> & GoalBarActions
/** Dock adapter: reads the host-computed 'goal' projection (whole value; absent or null renders nothing). */
export function GoalDock({ useProjection, onEdit, onResume, onClear }: GoalDockProps) {
export function GoalDock({ useProjection, onEdit, onPause, onResume, onClear }: GoalDockProps) {
const projection = useProjection('goal')
return (
<GoalBar
goal={projection === undefined ? undefined : projection === null ? null : projection.goal}
onEdit={onEdit}
onPause={onPause}
onResume={onResume}
onClear={onClear}
/>

View File

@@ -66,6 +66,11 @@ export function apply(ctx: ClientContext): void {
if (ref === undefined) return noCurrentGoal
return settle((await goals.edit({ sessionId, ref, objective })).result)
},
onPause: async () => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle((await goals.pause({ sessionId, ref })).result)
},
onResume: async () => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal

View File

@@ -19,6 +19,8 @@ export interface GoalBarActions {
* @param objective - replacement objective text.
*/
onEdit: (objective: string) => Promise<GoalActionResult>
/** Pause an active goal. */
onPause: () => Promise<GoalActionResult>
/** Resume a paused goal. */
onResume: () => Promise<GoalActionResult>
/** Clear the current goal (tombstone). */

View File

@@ -57,6 +57,7 @@ function bench(options: { projection?: GoalProjection | null | undefined; failWi
const ref = { id: 'g-1', revision: 3 }
ctx.provide('connection', { api: { goals: {
edit: answer('goal.edit', { ref }),
pause: answer('goal.pause', { ref }),
resume: answer('goal.resume', { ref }),
clear: answer('goal.clear', { cleared: true as const }),
} } })
@@ -100,13 +101,15 @@ describe('ui-goal browser plugin', () => {
await b.fiber.await()
const verbs = b.entry()!.inject!(sid('s1'))
expect(await verbs.onEdit('New objective')).toEqual({ ok: true })
expect(await verbs.onPause()).toEqual({ ok: true })
expect(await verbs.onResume()).toEqual({ ok: true })
expect(await verbs.onClear()).toEqual({ ok: true })
expect(b.calls.map(c => c.method)).toEqual(['goal.edit', 'goal.resume', 'goal.clear'])
expect(b.calls.map(c => c.method)).toEqual(['goal.edit', 'goal.pause', 'goal.resume', 'goal.clear'])
const ref = { id: 'g-1', revision: 5 }
expect(b.calls[0]?.payload).toEqual({ sessionId: 's1', ref, objective: 'New objective' })
expect(b.calls[1]?.payload).toEqual({ sessionId: 's1', ref })
expect(b.calls[2]?.payload).toEqual({ sessionId: 's1', ref })
expect(b.calls[3]?.payload).toEqual({ sessionId: 's1', ref })
})
it('a null or absent projection short-circuits every verb without touching the wire', async () => {
@@ -114,7 +117,7 @@ describe('ui-goal browser plugin', () => {
const b = bench({ projection })
await b.fiber.await()
const verbs = b.entry()!.inject!(sid('s1'))
for (const result of [await verbs.onEdit('x'), await verbs.onResume(), await verbs.onClear()]) {
for (const result of [await verbs.onEdit('x'), await verbs.onPause(), await verbs.onResume(), await verbs.onClear()]) {
expect(result).toEqual({ ok: false, error: { code: 'no-current-goal', message: 'no current goal to mutate' } })
}
expect(b.calls).toHaveLength(0)
@@ -143,6 +146,7 @@ describe('GoalDock adapter', () => {
const useProjection = vi.fn(() => projection)
const actions: GoalBarActions = {
onEdit: () => Promise.resolve({ ok: true }),
onPause: () => Promise.resolve({ ok: true }),
onResume: () => Promise.resolve({ ok: true }),
onClear: () => Promise.resolve({ ok: true }),
}

View File

@@ -25,6 +25,7 @@ function makeGoal(over: Partial<GoalSnapshot> = {}): GoalSnapshot {
function makeActions() {
return {
onEdit: vi.fn<GoalBarActions['onEdit']>(() => Promise.resolve({ ok: true })),
onPause: vi.fn<GoalBarActions['onPause']>(() => Promise.resolve({ ok: true })),
onResume: vi.fn<GoalBarActions['onResume']>(() => Promise.resolve({ ok: true })),
onClear: vi.fn<GoalBarActions['onClear']>(() => Promise.resolve({ ok: true })),
} satisfies GoalBarActions

View File

@@ -23,13 +23,24 @@ function selectOf(session: SessionFace | undefined): PermissionSelect | undefine
return session?.projections.faceOf('permissions').getSnapshot() as PermissionSelect | undefined
}
/**
* Display transform twin of the composer chip's (ui-conversation
* PermissionSelect): kebab-case machine names render as title-case labels
* (`workspace-write` → `Workspace Write`) so both permission surfaces show
* the same text; non-kebab host-configured names pass through.
*/
function displayName(name: string): string {
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name
return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
/** Flatten the projection select into popup rows; `custom` is display state, never a target. */
function optionsOf(value: PermissionSelect): SelectOption[] {
return value.options
.filter(option => option.value !== 'custom')
.map(option => ({
id: option.value,
label: option.name,
label: displayName(option.name),
...(option.description !== undefined ? { detail: option.description } : {}),
...(option.value === value.currentValue ? { active: true } : {}),
}))

View File

@@ -39,13 +39,10 @@
display: inline-flex;
align-items: center;
color: var(--dsw-alias-label-caption);
opacity: 0;
transition: opacity 0.12s ease;
}
.chip:hover .close,
.chip:focus-visible .close {
opacity: 1;
color: var(--dsw-alias-label-secondary);
}

View File

@@ -512,6 +512,18 @@ export const IconPlayOutline16 = ({ size = 16, className }: IconProps) => (
</svg>
)
/** ic_ds_pause_outline_16 */
export const IconPauseOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M14.1448 8.00024C14.1448 4.60644 11.394 1.85563 8.00024 1.85563C4.60644 1.85563 1.85563 4.60644 1.85563 8.00024C1.85563 11.394 4.60644 14.1448 8.00024 14.1448C11.394 14.1448 14.1448 11.394 14.1448 8.00024ZM15.5112 8.00024C15.5112 12.1482 12.1482 15.5112 8.00024 15.5112C3.85226 15.5112 0.489258 12.1482 0.489258 8.00024C0.489258 3.85226 3.85226 0.489258 8.00024 0.489258C12.1482 0.489258 15.5112 3.85226 15.5112 8.00024Z"
fill="currentColor"
/>
<path d="M7.14244 5.14258V10.8569H5.71387V5.14258H7.14244Z" fill="currentColor" />
<path d="M10.286 5.14258V10.8569H8.85742V5.14258H10.286Z" fill="currentColor" />
</svg>
)
/** ic_ds_fullscreen_outline_16 */
export const IconFullscreenOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">

View File

@@ -10,6 +10,7 @@ export { Pill } from './Pill.tsx'
export { Input } from './Input.tsx'
export { Menu } from './Menu.tsx'
export type { MenuEntry, MenuItem, MenuSeparator, MenuLabel } from './Menu.tsx'
export { useAnchoredMaxHeight } from './useAnchoredMaxHeight.ts'
export { HoverCard } from './HoverCard.tsx'
export { Modal } from './Modal.tsx'
export { ConnectionBanner } from './ConnectionBanner.tsx'

View File

@@ -0,0 +1,38 @@
/**
* Viewport-fit hook for bottom-anchored overlays (slash menu, popupSelect):
* the element's bottom edge is laid out independent of its height, so it
* grows upward and only the top edge can collide with the viewport — clamp
* the design cap to the space between that edge and the viewport top.
*/
import { useLayoutEffect, useState } from 'react'
import type { RefObject } from 'react'
/** Safe distance kept between the overlay and the viewport top edge (mirrors the Menu portal margin). */
const MARGIN = 12
/**
* Clamp a bottom-anchored overlay's max-height to the viewport.
* @param ref - the overlay element; a null current (overlay closed) skips measuring.
* @param cap - design max-height in px (the clamp never exceeds it).
* @param signal - re-measure trigger: pass the overlay's render state so anchor
* moves (composer growth) re-fit; resize/scroll re-fit while mounted.
* @returns the max-height to apply inline, in px.
*/
export function useAnchoredMaxHeight(ref: RefObject<HTMLElement>, cap: number, signal: unknown): number {
const [maxHeight, setMaxHeight] = useState(cap)
useLayoutEffect(() => {
const el = ref.current
if (el === null) return
const fit = () => {
setMaxHeight(Math.min(cap, Math.max(0, el.getBoundingClientRect().bottom - MARGIN)))
}
fit()
window.addEventListener('resize', fit)
window.addEventListener('scroll', fit, true)
return () => {
window.removeEventListener('resize', fit)
window.removeEventListener('scroll', fit, true)
}
}, [ref, cap, signal])
return maxHeight
}

View File

@@ -14,8 +14,8 @@ const icons = Object.fromEntries(
const iconNames = Object.keys(icons)
describe('ic_ds_ icon set', () => {
it('exports the full P-I set (43 deepsuite + 13 figma extracts + the hand-authored sparkle)', () => {
expect(iconNames.length).toBe(57)
it('exports the full P-I set (44 deepsuite + 13 figma extracts + the hand-authored sparkle)', () => {
expect(iconNames.length).toBe(58)
})
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {

View File

@@ -100,6 +100,7 @@ export function apply(ctx: ClientContext): void {
const source: SlashSource = {
trigger: '/',
name: 'skill',
order: 2,
async candidates(session, { query, signal }) {
const skills = await fetchCatalog(session.sessionId)
// Superseded keystroke: the shared fetch stays warm, this caller yields.

View File

@@ -24,7 +24,8 @@
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime"
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale"
],
"platform": "web"
},
@@ -37,14 +38,18 @@
"clsx": "^2.0.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",

View File

@@ -11,8 +11,10 @@
z-index: 100;
min-width: 260px;
max-width: 537px;
/* Height cap: the 320px design maximum, clamped at runtime to the space
* above the composer (inline max-height set in MenuView.tsx). */
max-height: 320px;
overflow-y: auto;
overflow: hidden;
/* Elevated surface: the scrollbar thumb takes the l2 elevation tokens
(see ui-theme styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
@@ -26,6 +28,13 @@
box-shadow: var(--dsw-shadow-lv3);
}
.viewport {
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
}
.item {
display: flex;
align-items: center;
@@ -75,6 +84,15 @@
color: var(--dsw-alias-label-tertiary);
}
/* Heading row above a source group: non-interactive small grey text,
* padding aligned with items (mirrors ui-primitives Menu .label). */
.groupTitle {
padding: 8px 10px;
font-size: 12px;
line-height: 16px;
color: var(--dsw-alias-label-tertiary);
}
/* Pending-source row: same cell metrics, dimmed label. */
.loading {
display: flex;

View File

@@ -1,16 +1,21 @@
/**
* Trigger candidate menu: renders the SlashService menu store into the
* conversation.input.overlay anchor. Closed state renders null (the overlay
* slot stays mounted); groups render in roster order, pending groups as a
* loading row; pointer picks route back through the service (combobox
* pattern — focus never leaves the textarea, so rows are mousedown-handled
* and the highlight is exposed via aria-activedescendant on the listbox).
* slot stays mounted); groups render in roster order under localized title
* rows, pending groups as a loading row; pointer picks route back through
* the service (combobox pattern — focus never leaves the textarea, so rows
* are mousedown-handled and the highlight is exposed via
* aria-activedescendant on the listbox).
*/
import { useSyncExternalStore } from 'react'
import { Fragment, useEffect, useRef, useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './MenuView.module.css'
import type { MenuViewInjected } from './slots.ts'
/** Design cap on the list height (figma SLASH 39:26572 MenuDropdown). */
const MAX_HEIGHT = 320
/** DOM id of one option row (the aria-activedescendant target). */
function optionId(source: string, index: number): string {
return `dsh-slash-option-${source}-${index}`
@@ -18,49 +23,86 @@ function optionId(source: string, index: number): string {
/**
* Render the candidate menu overlay entry.
* @param props - injected face: the menu store and the pick route.
* @param props - injected face: the menu store, the pick route, and the menu-namespace translator.
* @returns the dropdown while open; null while closed.
*/
export function MenuView({ menu, onPick }: MenuViewInjected) {
export function MenuView({ menu, onPick, onDismiss, t }: MenuViewInjected) {
const state = useSyncExternalStore(
fn => menu.subscribe(fn),
() => menu.getSnapshot(),
)
const listRef = useRef<HTMLDivElement>(null)
// The list is bottom-anchored above the composer; clamp the design cap to
// the space above it, re-measured on every store update (the anchor moves
// when the composer grows).
const maxHeight = useAnchoredMaxHeight(listRef, MAX_HEIGHT, state)
const highlight = state.open ? state.highlight : null
// Focus stays in the textarea (combobox pattern), so the browser never
// scrolls the active option into view on keyboard moves — do it here.
useEffect(() => {
if (highlight === null) return
document.getElementById(optionId(highlight.source, highlight.index))
?.scrollIntoView({ block: 'nearest' })
}, [highlight])
// Dismiss on pointer outside the menu AND outside the composer card
// (clicking the textarea or bottom bar must not close the menu).
useEffect(() => {
if (!state.open) return
const onPointerDown = (ev: PointerEvent): void => {
if (!(ev.target instanceof Node)) return
if (listRef.current?.contains(ev.target)) return
const composerCard = listRef.current?.closest('[data-composer-card]')
if (composerCard?.contains(ev.target)) return
onDismiss()
}
document.addEventListener('pointerdown', onPointerDown, true)
return () => { document.removeEventListener('pointerdown', onPointerDown, true) }
}, [state.open, onDismiss])
if (!state.open) return null
const { highlight } = state
return (
<div
ref={listRef}
className={css.menu}
style={{ maxHeight }}
role="listbox"
aria-label="Trigger suggestions"
aria-activedescendant={highlight !== null ? optionId(highlight.source, highlight.index) : undefined}
>
{state.groups.map(group => group.status === 'pending'
? <div key={group.source} className={css.loading} data-source={group.source}>Loading {group.source}</div>
: group.items.map((item, index) => {
const active = highlight !== null && highlight.source === group.source && highlight.index === index
return (
<button
key={`${group.source}:${item.name}`}
id={optionId(group.source, index)}
type="button"
role="option"
aria-selected={active}
className={clsx(css.item, active && css.active)}
// mousedown, not click: the textarea keeps focus (combobox
// pattern) — preventing default stops the focus steal, and the
// pick runs before any blur-driven teardown.
onMouseDown={(ev) => {
ev.preventDefault()
onPick(group.source, index)
}}
>
{item.icon !== undefined && <span className={css.itemIcon} aria-hidden>{item.icon}</span>}
<span className={css.itemName}>{item.name}</span>
{item.description !== undefined && <span className={css.itemDescription}>{item.description}</span>}
</button>
)
}))}
<div className={css.viewport}>
{state.groups.map(group => (group.status === 'ready' && group.items.length === 0)
? null
: (
<Fragment key={group.source}>
<div className={css.groupTitle} role="presentation" data-source={group.source}>{t(group.source)}</div>
{group.status === 'pending'
? <div className={css.loading} data-source={group.source}>{t('loading')}</div>
: group.items.map((item, index) => {
const active = highlight !== null && highlight.source === group.source && highlight.index === index
return (
<button
key={`${group.source}:${item.name}`}
id={optionId(group.source, index)}
type="button"
role="option"
aria-selected={active}
className={clsx(css.item, active && css.active)}
// mousedown, not click: the textarea keeps focus (combobox
// pattern) — preventing default stops the focus steal, and the
// pick runs before any blur-driven teardown.
onMouseDown={(ev) => {
ev.preventDefault()
onPick(group.source, index)
}}
>
{item.icon !== undefined && <span className={css.itemIcon} aria-hidden>{item.icon}</span>}
<span className={css.itemName}>{item.name}</span>
{item.description !== undefined && <span className={css.itemDescription}>{item.description}</span>}
</button>
)
})}
</Fragment>
))}
</div>
</div>
)
}

View File

@@ -256,6 +256,13 @@ export class SlashController {
this.refreshLexicon()
}
/** External dismiss (e.g. pointer outside the composer area). */
dismiss(): void {
if (this.disposed) return
this.stopFetch()
this.reduce({ type: 'close' })
}
/** Scope teardown: close and abort (the service deletes the map entry). */
dispose(): void {
this.disposed = true

View File

@@ -4,6 +4,8 @@
* self-registers into the conversation.input.overlay slot. Frozen pipeline
* contract in ./contract.ts; sources register through ctx.slash alone.
*/
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { SlashService } from './service.ts'
import type { MenuViewInjected } from './slots.ts'
@@ -29,8 +31,11 @@ declare module 'cordis' {
}
}
/** Required services: controller resolution reads the session scope tree. */
export const inject = ['sessions']
/** Namespace owning the candidate-menu copy: group titles keyed by source name plus the pending row. */
const MENU_NS = 'slash.menu'
/** Required services: controller resolution reads the session scope tree; the menu copy is localized. */
export const inject = ['sessions', 'locale']
/**
* Client plugin body: mount the service, then register MenuView into the
@@ -39,6 +44,13 @@ export const inject = ['sessions']
*/
export function apply(ctx: ClientContext): void {
ctx.plugin(SlashService)
ctx.effect(() => {
const disposers = [
ctx.locale.register(MENU_NS, 'zh', { command: '命令', skill: '技能', subagent: '子智能体', loading: '正在加载…' }),
ctx.locale.register(MENU_NS, 'en', { command: 'Commands', skill: 'Skills', subagent: 'Subagents', loading: 'Loading…' }),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-slash: menu dictionaries')
// Conditional mount: 'conversation.input.overlay' is declared by the
// conversation composer entry, and the conversation service is mounted
// after that declaration lands on the ledger — its presence is the
@@ -59,6 +71,8 @@ export function apply(ctx: ClientContext): void {
return {
menu: controller.menu,
onPick: (source, index) => { controller.pick(source, index) },
onDismiss: () => { controller.dismiss() },
t: scope.locale.bind(MENU_NS),
}
},
}, MenuView), 'ui-slash: MenuView overlay registration')

View File

@@ -87,7 +87,7 @@ export class SlashService extends Service implements SlashServiceContract {
actx,
sessionId: id,
roster: {
sources: trigger => live.sources.filter(s => s.trigger === trigger),
sources: trigger => live.sources.filter(s => s.trigger === trigger).sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
all: () => live.sources,
},
})

View File

@@ -9,6 +9,7 @@
*/
// Type-only edge: the SlotMap augmentation below merges into this package's interface.
import type {} from '@deepseek-ai/dsh-client-ui-slots'
import type { Translate } from '@deepseek-ai/dsh-client-locale/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { MenuState } from '../core/contract.ts'
@@ -35,4 +36,12 @@ export interface MenuViewInjected {
* @param index - candidate index within the group.
*/
onPick: (source: string, index: number) => void
/** Dismiss the menu (external pointer outside the composer area). */
onDismiss: () => void
/**
* Bound translator for the menu namespace: group titles keyed by source
* name (the locale fallback chain returns the key itself, so an unknown
* source shows its raw name) plus the pending-row text.
*/
t: Translate
}

View File

@@ -138,6 +138,8 @@ export interface SlashSource {
readonly trigger: TriggerChar
/** Menu group label; unique per trigger — duplicate registration throws. */
readonly name: string
/** Menu group display order (lower = higher in the list; default 0). */
readonly order?: number
candidates(session: ClientSessionContext, req: CandidateRequest): Promise<readonly SlashCandidate[]>
/** Every pick lands here; claim/insert outcomes are executed by the pipeline via the scoped input events. */
onPick(pick: SlashPick): PickOutcome

View File

@@ -7,6 +7,7 @@
*/
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { createScope, scopeOf, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject, SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
@@ -31,12 +32,25 @@ async function bench() {
scope: (id: SessionId) => (id === sid('a') ? scope.ctx : undefined),
scopeOf: (c: Context) => scopeOf(c),
})
return { ctx, slots }
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
return { ctx, slots, locale }
}
describe('apply', () => {
it('declares the sessions dependency (controller resolution reads the scope tree)', () => {
expect(inject).toEqual(['sessions'])
it('declares the sessions and locale dependencies (scope tree + localized menu copy)', () => {
expect(inject).toEqual(['sessions', 'locale'])
})
it('registers the bilingual menu dictionaries (group titles by source name + the pending row)', async () => {
const { ctx, locale } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
const t = locale.bind('slash.menu')
expect(t('command')).toBe('命令')
locale.setLocale('en')
expect(t('skill')).toBe('Skills')
expect(t('subagent')).toBe('Subagents')
expect(t('loading')).toBe('Loading…')
})
it('mounts ctx.slash once sessions is up, before any conversation service exists', async () => {
@@ -65,6 +79,8 @@ describe('apply', () => {
(ctx.get('sessions') as { scope(id: SessionId): Context }).scope(sid('a')),
)
expect(injected.menu).toBe(controller.menu)
// The injected translator is the menu-namespace binding.
expect(injected.t('command')).toBe('命令')
// The pick face routes into the controller pipeline (closed menu → no-op).
injected.onPick('command', 0)
expect(controller.menu.getSnapshot().open).toBe(false)

View File

@@ -1,11 +1,13 @@
// @vitest-environment jsdom
/**
* MenuView rendering spec, props-direct (slot-parity doctrine): closed store
* renders null, groups render in roster order with pending rows as loading,
* pointer picks route (source, index) back without stealing focus, and the
* highlight is exposed through aria-activedescendant + aria-selected.
* renders null, groups render in roster order under localized title rows
* (unknown sources fall back to the raw name) with pending rows as loading,
* pointer picks route (source, index) back without stealing focus, the
* highlight is exposed through aria-activedescendant + aria-selected, and
* the list height clamps to the space above the composer.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { MenuState, TriggerHit } from '@deepseek-ai/dsh-client-ui-slash/client'
@@ -34,13 +36,35 @@ function openState(partial?: Partial<MenuState>): MenuState {
}
}
afterEach(cleanup)
// jsdom has no scrollIntoView; the view calls it on the highlighted option.
const scrollIntoView = vi.fn()
beforeEach(() => {
Element.prototype.scrollIntoView = scrollIntoView
scrollIntoView.mockClear()
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
// Dictionary-backed fake mirroring the LocaleService key fallback (an
// unknown key comes back verbatim, so unknown sources show their raw name).
const DICT: Record<string, string> = { command: 'Commands', skill: 'Skills', loading: 'Loading…' }
const t = (key: string) => DICT[key] ?? key
function mount(state: MenuState) {
const menu = createSnapshotStore<MenuState>(state)
const onPick = vi.fn()
const view = render(<MenuView menu={menu} onPick={onPick} />)
return { menu, onPick, view }
const onDismiss = vi.fn()
const view = render(<MenuView menu={menu} onPick={onPick} onDismiss={onDismiss} t={t} />)
return { menu, onPick, onDismiss, view }
}
/** The non-interactive group title rows (role=presentation), in document order. */
function titles(container: HTMLElement): string[] {
return [...container.querySelectorAll('div[role="presentation"][data-source]')]
.map(el => el.textContent ?? '')
}
describe('MenuView', () => {
@@ -57,7 +81,19 @@ describe('MenuView', () => {
mount(openState())
const options = screen.getAllByRole('option')
expect(options.map(o => o.textContent)).toEqual(['⚑goalSet up a goal', 'plan'])
expect(screen.queryByText('Loading skill…')).not.toBeNull()
expect(screen.queryByText('Loading…')).not.toBeNull()
})
it('titles each group with the localized source name, raw name for unknown sources, none for empty ready groups', () => {
const { view } = mount(openState({
groups: [
{ source: 'command', status: 'ready', items: [{ name: 'goal' }] },
{ source: 'hollow', status: 'ready', items: [] },
{ source: 'mystery', status: 'ready', items: [{ name: 'x' }] },
{ source: 'skill', status: 'pending', items: [] },
],
}))
expect(titles(view.container)).toEqual(['Commands', 'mystery', 'Skills'])
})
it('exposes the highlight via aria-activedescendant and aria-selected', () => {
@@ -75,6 +111,37 @@ describe('MenuView', () => {
expect(screen.getByRole('listbox').getAttribute('aria-activedescendant')).toBeNull()
})
it('scrolls the highlighted option into view when the highlight moves', () => {
const { menu } = mount(openState())
scrollIntoView.mockClear()
act(() => { menu.set(openState({ highlight: { source: 'command', index: 1 } })) })
const options = screen.getAllByRole('option')
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
expect(scrollIntoView.mock.instances.at(-1)).toBe(options[1])
})
it('caps the list height at the design maximum when the composer sits low enough', () => {
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 800 } as DOMRect)
mount(openState())
expect(screen.getByRole('listbox').style.maxHeight).toBe('320px')
})
it('clamps the list height to the space above the composer minus the safe margin', () => {
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 200 } as DOMRect)
mount(openState())
expect(screen.getByRole('listbox').style.maxHeight).toBe('188px')
})
it('re-fits the height when the window resizes', () => {
const rect = vi.spyOn(Element.prototype, 'getBoundingClientRect')
rect.mockReturnValue({ bottom: 800 } as DOMRect)
mount(openState())
expect(screen.getByRole('listbox').style.maxHeight).toBe('320px')
rect.mockReturnValue({ bottom: 100 } as DOMRect)
act(() => { window.dispatchEvent(new Event('resize')) })
expect(screen.getByRole('listbox').style.maxHeight).toBe('88px')
})
it('mousedown on a row picks (source, index) and prevents the focus steal', () => {
const { onPick } = mount(openState())
const options = screen.getAllByRole('option')

View File

@@ -11,9 +11,15 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../locale"
},
{
"path": "../runtime"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slots"
},