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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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} />)
|
||||
|
||||
@@ -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} />)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../../plan/plan-mode"
|
||||
},
|
||||
{
|
||||
"path": "../../goal/goal"
|
||||
},
|
||||
{
|
||||
"path": "../../todo/tool-todo"
|
||||
},
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } : {}),
|
||||
}))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'
|
||||
|
||||
38
packages/client/ui-primitives/src/useAnchoredMaxHeight.ts
Normal file
38
packages/client/ui-primitives/src/useAnchoredMaxHeight.ts
Normal 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
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -11,9 +11,15 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user