From 45c9205cafa5f37b3f7ee9a21db04d9ee2505297 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Wed, 29 Jul 2026 19:46:48 +0800 Subject: [PATCH] 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. --- .../src/client/PopupSelectView.module.css | 30 +++-- .../ui-command/src/client/PopupSelectView.tsx | 34 ++++-- .../ui-command/tests/popup-view.spec.tsx | 40 ++++++- packages/client/ui-conversation/package.json | 5 +- .../ui-conversation/src/client/apply.ts | 32 ++++- .../src/client/contract/slots.ts | 2 + .../src/client/input/machine.ts | 21 +++- .../src/client/skeleton/InputBar.module.css | 28 ++--- .../src/client/skeleton/InputBar.tsx | 17 ++- .../skeleton/PermissionSelect.module.css | 70 +++++------ .../src/client/skeleton/PermissionSelect.tsx | 88 +++++++------- .../tests/apply-inject.spec.tsx | 2 + .../ui-conversation/tests/chat-apply.spec.tsx | 2 + .../tests/chat-code-subcalls.spec.tsx | 3 +- .../tests/chat-toolview-slot.spec.tsx | 3 + .../ui-conversation/tests/input-bar.spec.tsx | 43 +++++-- .../tests/input-machine.spec.ts | 22 ++-- .../tests/input-matrix.spec.tsx | 1 + .../tests/input-scenarios.spec.tsx | 1 + .../ui-conversation/tests/skeleton.spec.tsx | 1 + packages/client/ui-conversation/tsconfig.json | 3 + .../ui-goal/src/client/GoalBar.module.css | 2 +- .../client/ui-goal/src/client/GoalBar.tsx | 12 +- packages/client/ui-goal/src/client/index.ts | 5 + packages/client/ui-goal/src/client/slots.ts | 2 + .../ui-goal/tests/browser-plugin.spec.tsx | 8 +- .../client/ui-goal/tests/goalbar.spec.tsx | 1 + .../client/ui-permission/src/client/index.ts | 13 ++- .../src/client/PlanModeControl.module.css | 3 - .../client/ui-primitives/src/icons/index.tsx | 12 ++ packages/client/ui-primitives/src/index.ts | 1 + .../ui-primitives/src/useAnchoredMaxHeight.ts | 38 ++++++ .../client/ui-primitives/tests/icons.spec.tsx | 4 +- packages/client/ui-skill/src/client/index.ts | 1 + packages/client/ui-slash/package.json | 7 +- .../ui-slash/src/client/MenuView.module.css | 20 +++- .../client/ui-slash/src/client/MenuView.tsx | 110 ++++++++++++------ .../client/ui-slash/src/client/controller.ts | 7 ++ packages/client/ui-slash/src/client/index.ts | 18 ++- .../client/ui-slash/src/client/service.ts | 2 +- packages/client/ui-slash/src/client/slots.ts | 9 ++ packages/client/ui-slash/src/types.ts | 2 + packages/client/ui-slash/tests/apply.spec.ts | 22 +++- .../client/ui-slash/tests/menu-view.spec.tsx | 83 +++++++++++-- packages/client/ui-slash/tsconfig.json | 6 + pnpm-lock.yaml | 12 ++ 46 files changed, 623 insertions(+), 225 deletions(-) create mode 100644 packages/client/ui-primitives/src/useAnchoredMaxHeight.ts diff --git a/packages/client/ui-command/src/client/PopupSelectView.module.css b/packages/client/ui-command/src/client/PopupSelectView.module.css index 14cf581e13..16e1e5c11d 100644 --- a/packages/client/ui-command/src/client/PopupSelectView.module.css +++ b/packages/client/ui-command/src/client/PopupSelectView.module.css @@ -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; } diff --git a/packages/client/ui-command/src/client/PopupSelectView.tsx b/packages/client/ui-command/src/client/PopupSelectView.tsx index 9d2807ded1..ec0bbdd2bb 100644 --- a/packages/client/ui-command/src/client/PopupSelectView.tsx +++ b/packages/client/ui-command/src/client/PopupSelectView.tsx @@ -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(null) const searchRef = useRef(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) {
@@ -108,7 +128,7 @@ export function PopupSelectView({ popup }: PopupSelectInjected) { {state.submitting &&
Applying…
} {state.status === 'ready' && rows.length === 0 &&
No options
} {state.status === 'ready' && ( -
+
{rows.map((option, index) => (
{ + 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({ diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 42812dce24..8a9173982b 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -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:^", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 2b845e6c83..f5aa3caa23 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -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 }, } }, diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 9f3f3edf02..6b3d5c2fae 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -257,6 +257,8 @@ export interface ComposerBarInjected { * Resolves admission: false = rejected/unmatched/transport failure. */ command: (line: string) => Promise + /** 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). */ diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index f9c5a479a4..48f6ddd872 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -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 [] } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 1bada4391d..8837752830 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -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 diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index a98ee09114..331e600bff 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -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({deco.hint}) + // 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({displayHint}) } } @@ -312,7 +321,7 @@ export function InputBar({ {notice.text}
)} -
+
{overlay !== undefined &&
{overlay}
} {accessory !== undefined &&
{accessory}
} {/* 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} diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css index dd5986992c..50dce3913f 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css @@ -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); } diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx index 0622e64500..873c8c11c4 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx @@ -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 ` 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 } 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(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 ( - + { setOpen(false) }} + side="top" + anchor={ + + } + /> ) } diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 416f3fa4ef..e73f959341 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -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. diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 4d6dc99f4a..bc10aedb05 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -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. diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 1b4d1ee158..6b75f940d4 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -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({ diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 3d2e9ea0e6..02bb6b92dc 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -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 diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 23d853dd1a..495258f7ab 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -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)[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 = { goal: '输入目标,智能体将持续执行' } + const { view, shell } = bench({ translateHint: key => dict[key] ?? key }) + act(() => { + shell.setDraft('/goal ') + shell.beginCommand( + { token: '/goal ', hint: '[|clear|edit |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) }) }) diff --git a/packages/client/ui-conversation/tests/input-machine.spec.ts b/packages/client/ui-conversation/tests/input-machine.spec.ts index 206a66e4c6..9ce23c4a10 100644 --- a/packages/client/ui-conversation/tests/input-machine.spec.ts +++ b/packages/client/ui-conversation/tests/input-machine.spec.ts @@ -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', () => { diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index f6694f8cb4..a9c00b0748 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -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() diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 9d9ace032c..1c7bbe50ec 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -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() diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 0d32e2edea..870d5c113b 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -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} /> diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 14ae91598a..1aa28c9c62 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../plan/plan-mode" }, + { + "path": "../../goal/goal" + }, { "path": "../../todo/tool-todo" }, diff --git a/packages/client/ui-goal/src/client/GoalBar.module.css b/packages/client/ui-goal/src/client/GoalBar.module.css index fe07bace1b..80c87be57b 100644 --- a/packages/client/ui-goal/src/client/GoalBar.module.css +++ b/packages/client/ui-goal/src/client/GoalBar.module.css @@ -91,7 +91,7 @@ .actions { display: flex; align-items: center; - gap: 2px; + gap: 8px; flex: none; } diff --git a/packages/client/ui-goal/src/client/GoalBar.tsx b/packages/client/ui-goal/src/client/GoalBar.tsx index 76308734fc..b1b0fd7398 100644 --- a/packages/client/ui-goal/src/client/GoalBar.tsx +++ b/packages/client/ui-goal/src/client/GoalBar.tsx @@ -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) { {goal.objective} {actionError !== null && {actionError}}
+ {goal.phase === 'active' && ( + + )} {goal.phase === 'paused' && ( - ) - }))} +
+ {state.groups.map(group => (group.status === 'ready' && group.items.length === 0) + ? null + : ( + +
{t(group.source)}
+ {group.status === 'pending' + ?
{t('loading')}
+ : group.items.map((item, index) => { + const active = highlight !== null && highlight.source === group.source && highlight.index === index + return ( + + ) + })} +
+ ))} +
) } diff --git a/packages/client/ui-slash/src/client/controller.ts b/packages/client/ui-slash/src/client/controller.ts index ab0b26da54..3a8e85afc3 100644 --- a/packages/client/ui-slash/src/client/controller.ts +++ b/packages/client/ui-slash/src/client/controller.ts @@ -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 diff --git a/packages/client/ui-slash/src/client/index.ts b/packages/client/ui-slash/src/client/index.ts index f1c1d1953e..0ef751462a 100644 --- a/packages/client/ui-slash/src/client/index.ts +++ b/packages/client/ui-slash/src/client/index.ts @@ -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') diff --git a/packages/client/ui-slash/src/client/service.ts b/packages/client/ui-slash/src/client/service.ts index c5448a3d50..d9af10e887 100644 --- a/packages/client/ui-slash/src/client/service.ts +++ b/packages/client/ui-slash/src/client/service.ts @@ -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, }, }) diff --git a/packages/client/ui-slash/src/client/slots.ts b/packages/client/ui-slash/src/client/slots.ts index f74ec29457..f69be9f28a 100644 --- a/packages/client/ui-slash/src/client/slots.ts +++ b/packages/client/ui-slash/src/client/slots.ts @@ -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 } diff --git a/packages/client/ui-slash/src/types.ts b/packages/client/ui-slash/src/types.ts index 4b9bd64408..2b63381efb 100644 --- a/packages/client/ui-slash/src/types.ts +++ b/packages/client/ui-slash/src/types.ts @@ -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 /** Every pick lands here; claim/insert outcomes are executed by the pipeline via the scoped input events. */ onPick(pick: SlashPick): PickOutcome diff --git a/packages/client/ui-slash/tests/apply.spec.ts b/packages/client/ui-slash/tests/apply.spec.ts index 637f18f102..5a8b2b6c37 100644 --- a/packages/client/ui-slash/tests/apply.spec.ts +++ b/packages/client/ui-slash/tests/apply.spec.ts @@ -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) diff --git a/packages/client/ui-slash/tests/menu-view.spec.tsx b/packages/client/ui-slash/tests/menu-view.spec.tsx index d9b6a08a88..b18bbd6b30 100644 --- a/packages/client/ui-slash/tests/menu-view.spec.tsx +++ b/packages/client/ui-slash/tests/menu-view.spec.tsx @@ -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 { } } -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 = { command: 'Commands', skill: 'Skills', loading: 'Loading…' } +const t = (key: string) => DICT[key] ?? key function mount(state: MenuState) { const menu = createSnapshotStore(state) const onPick = vi.fn() - const view = render() - return { menu, onPick, view } + const onDismiss = vi.fn() + const view = render() + 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') diff --git a/packages/client/ui-slash/tsconfig.json b/packages/client/ui-slash/tsconfig.json index a3002d4981..deca328a0a 100644 --- a/packages/client/ui-slash/tsconfig.json +++ b/packages/client/ui-slash/tsconfig.json @@ -11,9 +11,15 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../locale" + }, { "path": "../runtime" }, + { + "path": "../ui-primitives" + }, { "path": "../ui-slots" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66ea5bea26..998ad4cc4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1049,6 +1049,9 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -1064,6 +1067,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../goal/goal '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -1483,9 +1489,15 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots