wip
fix: docs
This commit is contained in:
@@ -37,6 +37,7 @@ export { FixtureSession, TestSessions } from './sessions.ts'
|
||||
export { TestWorkspaces } from './workspaces.ts'
|
||||
export { conversationSnapshot, workspaceListState } from './fixtures.ts'
|
||||
export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts'
|
||||
export { makeTranslate } from './translate.ts'
|
||||
|
||||
/** Erased register face for the internal root call (the public declare seam holds the typing). */
|
||||
type ErasedRegister = (options: object, component: unknown) => () => void
|
||||
|
||||
32
packages/client/test-runtime/src/translate.ts
Normal file
32
packages/client/test-runtime/src/translate.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Test double of the locale lookup chain: a translate stub over plain
|
||||
* dictionaries, mirroring LocaleService's resolution order (first dictionary
|
||||
* that owns the key wins, then the key itself stays visible) and its
|
||||
* `{name}` template interpolation. Specs stub the framework-injected `t`
|
||||
* seat with `makeTranslate(zh, commonZh)` instead of re-implementing the
|
||||
* chain per suite.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build a translate stub resolving through `dicts` in order (namespace
|
||||
* first, then the shared common vocabulary), falling back to the key.
|
||||
* @param dicts - dictionaries consulted in order.
|
||||
* @returns the translate function (assignable to any `XxxProps['t']` seat).
|
||||
*/
|
||||
export function makeTranslate(
|
||||
...dicts: readonly Record<string, string>[]
|
||||
): (key: string, params?: Record<string, unknown>) => string {
|
||||
return (key, params) => {
|
||||
let template = key
|
||||
for (const dict of dicts) {
|
||||
const hit = dict[key]
|
||||
if (hit !== undefined) {
|
||||
template = hit
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!params) return template
|
||||
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
|
||||
name in params ? String(params[name]) : match)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-slash",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
@@ -40,6 +41,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
@@ -51,7 +53,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useEffect, useRef } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCheckOutline16, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { filterOptions } from './popup.ts'
|
||||
import type { PopupSelectController } from './popup.ts'
|
||||
import css from './PopupSelectView.module.css'
|
||||
@@ -26,12 +27,15 @@ export interface PopupSelectInjected {
|
||||
popup: PopupSelectController
|
||||
}
|
||||
|
||||
/** Full shell props: injected face + the locale seat. */
|
||||
export type PopupSelectViewProps = PopupSelectInjected & PropsLocale<'command'>
|
||||
|
||||
/**
|
||||
* Render the popupSelect shell overlay entry.
|
||||
* @param props - injected face: the session's shell controller.
|
||||
* @param props - injected face: the session's shell controller; `t` rides the standard locale seat.
|
||||
* @returns the select card while open; null while closed.
|
||||
*/
|
||||
export function PopupSelectView({ popup }: PopupSelectInjected) {
|
||||
export function PopupSelectView({ popup, t }: PopupSelectViewProps) {
|
||||
const state = useSyncExternalStore(
|
||||
fn => popup.state.subscribe(fn),
|
||||
() => popup.state.getSnapshot(),
|
||||
@@ -103,15 +107,15 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
|
||||
ref={cardRef}
|
||||
className={css.card}
|
||||
style={{ maxHeight }}
|
||||
aria-label={`/${String(state.command)} options`}
|
||||
aria-label={t('overlay.aria', { command: String(state.command) })}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<input
|
||||
ref={searchRef}
|
||||
className={css.search}
|
||||
type="text"
|
||||
placeholder="Search…"
|
||||
aria-label="Filter options"
|
||||
placeholder={t('search.placeholder')}
|
||||
aria-label={t('search.aria')}
|
||||
value={state.search}
|
||||
readOnly={state.submitting}
|
||||
onChange={(ev) => { popup.setSearch(ev.currentTarget.value) }}
|
||||
@@ -120,15 +124,15 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
|
||||
<div className={css.error} role="alert">
|
||||
<span className={css.errorText}>{state.error}</span>
|
||||
{state.status === 'failed' && (
|
||||
<button type="button" className={css.retry} onClick={() => { popup.retry() }}>Retry</button>
|
||||
<button type="button" className={css.retry} onClick={() => { popup.retry() }}>{t('retry')}</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{state.status === 'pending' && <div className={css.status}>Loading options…</div>}
|
||||
{state.submitting && <div className={css.status}>Applying…</div>}
|
||||
{state.status === 'ready' && rows.length === 0 && <div className={css.status}>No options</div>}
|
||||
{state.status === 'pending' && <div className={css.status}>{t('status.loading')}</div>}
|
||||
{state.submitting && <div className={css.status}>{t('status.applying')}</div>}
|
||||
{state.status === 'ready' && rows.length === 0 && <div className={css.status}>{t('status.empty')}</div>}
|
||||
{state.status === 'ready' && (
|
||||
<div role="listbox" aria-label={`/${String(state.command)} matches`} className={css.viewport}>
|
||||
<div role="listbox" aria-label={t('listbox.aria', { command: String(state.command) })} className={css.viewport}>
|
||||
{rows.map((option, index) => (
|
||||
<div
|
||||
key={option.id}
|
||||
|
||||
@@ -10,19 +10,23 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// key's owner) into this program so the overlay registration below typechecks
|
||||
// against the real declaration — no runtime edge to ui-conversation.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { CommandService } from './service.ts'
|
||||
import type { PopupSelectInjected } from './PopupSelectView.tsx'
|
||||
import { PopupSelectView } from './PopupSelectView.tsx'
|
||||
import { en, zh, type CommandKey } from './locales.ts'
|
||||
|
||||
export { CommandService } from './service.ts'
|
||||
export { CommandDirectory } from './directory.ts'
|
||||
export type { CommandDescriptor, DirectoryStatus } from './directory.ts'
|
||||
export { filterOptions, PopupSelectController } from './popup.ts'
|
||||
export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts'
|
||||
export type { PopupSelectInjected } from './PopupSelectView.tsx'
|
||||
export type { PopupSelectInjected, PopupSelectViewProps } from './PopupSelectView.tsx'
|
||||
export type {
|
||||
CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectOption,
|
||||
} from './contract.ts'
|
||||
export type { CommandKey } from './locales.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
@@ -30,8 +34,18 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services: the '/' source registry plus the scope + wire faces the service reads. */
|
||||
export const inject = ['slash', 'sessions', 'connection']
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** The popupSelect shell's copy. */
|
||||
command: CommandKey
|
||||
}
|
||||
}
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
const NS = 'command'
|
||||
|
||||
/** Required services: the '/' source registry plus the scope + wire faces the service reads, and the copy's locale registry. */
|
||||
export const inject = ['slash', 'sessions', 'connection', 'locale']
|
||||
|
||||
/**
|
||||
* Client plugin body: mount the service, then register the popupSelect shell
|
||||
@@ -39,6 +53,7 @@ export const inject = ['slash', 'sessions', 'connection']
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-command: dictionaries')
|
||||
ctx.plugin(CommandService)
|
||||
// Conditional mount, same seam as ui-slash's MenuView registration:
|
||||
// 'conversation.input.overlay' is declared by the conversation composer
|
||||
@@ -51,6 +66,7 @@ export function apply(ctx: ClientContext): void {
|
||||
name: 'conversation.input.overlay',
|
||||
id: 'command-popup',
|
||||
order: 1,
|
||||
locale: NS,
|
||||
inject: (sessionId): PopupSelectInjected => {
|
||||
const actx = sessions.scope(sessionId)
|
||||
if (actx === undefined) throw new Error(`ui-command: session "${String(sessionId)}" resolved no scope`)
|
||||
|
||||
26
packages/client/ui-command/src/client/locales.ts
Normal file
26
packages/client/ui-command/src/client/locales.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/** `command` namespace dictionaries (the popupSelect shell's copy). */
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'search.placeholder': '搜索…',
|
||||
'search.aria': '筛选选项',
|
||||
'status.loading': '正在加载选项…',
|
||||
'status.applying': '正在应用…',
|
||||
'status.empty': '无选项',
|
||||
'overlay.aria': '/{command} 选项',
|
||||
'listbox.aria': '/{command} 匹配项',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The command namespace key union. */
|
||||
export type CommandKey = keyof typeof zh
|
||||
|
||||
/** English dictionary, checked complete against the zh key set. */
|
||||
export const en = {
|
||||
'search.placeholder': 'Search…',
|
||||
'search.aria': 'Filter options',
|
||||
'status.loading': 'Loading options…',
|
||||
'status.applying': 'Applying…',
|
||||
'status.empty': 'No options',
|
||||
'overlay.aria': '/{command} options',
|
||||
'listbox.aria': '/{command} matches',
|
||||
} satisfies Record<CommandKey, string>
|
||||
@@ -13,6 +13,7 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { CommandServiceContract } from '../src/client/contract.ts'
|
||||
import type { PopupSelectInjected } from '../src/client/PopupSelectView.tsx'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, CommandService, inject } from '../src/client/index.ts'
|
||||
|
||||
const sid = (k: string): SessionId => k as SessionId
|
||||
@@ -41,6 +42,7 @@ async function bench() {
|
||||
},
|
||||
})
|
||||
ctx.provide('conversation', {})
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const mint = (key: string) => {
|
||||
@@ -53,7 +55,7 @@ async function bench() {
|
||||
|
||||
describe('apply', () => {
|
||||
it('declares the services it binds', () => {
|
||||
expect(inject).toEqual(['slash', 'sessions', 'connection'])
|
||||
expect(inject).toEqual(['slash', 'sessions', 'connection', 'locale'])
|
||||
})
|
||||
|
||||
it('mounts ctx.command, registers the source and the overlay entry, and folds up on disposal', async () => {
|
||||
|
||||
@@ -14,6 +14,12 @@ 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'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
// The framework-injected t seat, stubbed over the zh dictionaries (the default locale).
|
||||
const t: Parameters<typeof PopupSelectView>[0]['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
// jsdom has no scrollIntoView; the view calls it on the highlighted row.
|
||||
const scrollIntoView = vi.fn()
|
||||
@@ -47,12 +53,12 @@ async function mountOpen(overrides: Partial<PopupSpec<string>> = {}, consumeResu
|
||||
const consume = vi.fn((_segment: TokenSegment) => consumeResult)
|
||||
const focusComposer = vi.fn()
|
||||
const popup = new PopupSelectController<string>({ consume, focusComposer })
|
||||
const view = render(<PopupSelectView popup={popup} />)
|
||||
const view = render(<PopupSelectView popup={popup} t={t} />)
|
||||
await act(async () => {
|
||||
popup.open('theme', spec(overrides), 'ctx-A', SEGMENT)
|
||||
await Promise.resolve()
|
||||
})
|
||||
return { popup, view, consume, focusComposer, search: screen.getByRole('textbox', { name: 'Filter options' }) }
|
||||
return { popup, view, consume, focusComposer, search: screen.getByRole('textbox', { name: '筛选选项' }) }
|
||||
}
|
||||
|
||||
function rowLabels(): string[] {
|
||||
@@ -62,13 +68,13 @@ function rowLabels(): string[] {
|
||||
describe('PopupSelectView', () => {
|
||||
it('renders null while closed, opens with focus in the search input', async () => {
|
||||
const popup = new PopupSelectController<string>({ consume: () => true, focusComposer: () => {} })
|
||||
const view = render(<PopupSelectView popup={popup} />)
|
||||
const view = render(<PopupSelectView popup={popup} t={t} />)
|
||||
expect(view.container.childElementCount).toBe(0)
|
||||
await act(async () => {
|
||||
popup.open('theme', spec(), 'ctx-A', SEGMENT)
|
||||
await Promise.resolve()
|
||||
})
|
||||
const search = screen.getByRole('textbox', { name: 'Filter options' })
|
||||
const search = screen.getByRole('textbox', { name: '筛选选项' })
|
||||
expect(document.activeElement).toBe(search)
|
||||
expect(rowLabels()).toEqual(['Dark', 'Light', 'Sepia'])
|
||||
})
|
||||
@@ -82,7 +88,7 @@ describe('PopupSelectView', () => {
|
||||
expect(options).toHaveBeenCalledTimes(1)
|
||||
act(() => { fireEvent.change(search, { target: { value: 'zzz' } }) })
|
||||
expect(screen.queryByRole('option')).toBeNull()
|
||||
expect(screen.queryByText('No options')).not.toBeNull()
|
||||
expect(screen.queryByText('无选项')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('ArrowUp/Down move the filtered highlight; ArrowLeft/Right are left to the native caret', async () => {
|
||||
@@ -110,13 +116,13 @@ describe('PopupSelectView', () => {
|
||||
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')
|
||||
expect(screen.getByLabelText('/theme 选项').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')
|
||||
expect(screen.getByLabelText('/theme 选项').style.maxHeight).toBe('188px')
|
||||
})
|
||||
|
||||
it('Enter selects the highlighted row: onSelect, consume, close, focusComposer', async () => {
|
||||
@@ -148,7 +154,7 @@ describe('PopupSelectView', () => {
|
||||
const onSelect = vi.fn(() => new Promise<void>((resolve) => { release = resolve }))
|
||||
const { search, consume } = await mountOpen({ onSelect })
|
||||
await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) })
|
||||
expect(screen.queryByText('Applying…')).not.toBeNull()
|
||||
expect(screen.queryByText('正在应用…')).not.toBeNull()
|
||||
expect((search as HTMLInputElement).readOnly).toBe(true)
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(search, { key: 'Enter' })
|
||||
@@ -162,7 +168,7 @@ describe('PopupSelectView', () => {
|
||||
expect(consume).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a failed options load shows the error with a Retry button that reloads', async () => {
|
||||
it('a failed options load shows the error with a retry button that reloads', async () => {
|
||||
let attempts = 0
|
||||
await mountOpen({
|
||||
options: () => {
|
||||
@@ -172,7 +178,7 @@ describe('PopupSelectView', () => {
|
||||
})
|
||||
expect(screen.getByRole('alert').textContent).toContain('directory down')
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(attempts).toBe(2)
|
||||
@@ -183,7 +189,7 @@ describe('PopupSelectView', () => {
|
||||
const { search, consume } = await mountOpen({ onSelect: () => Promise.reject(new Error('host rejected')) })
|
||||
await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) })
|
||||
expect(screen.getByRole('alert').textContent).toContain('host rejected')
|
||||
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: '重试' })).toBeNull()
|
||||
expect(consume).not.toHaveBeenCalled()
|
||||
expect(screen.getAllByRole('option').length).toBe(3)
|
||||
})
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: bfb56dc52406e1377cd84c87866a644ade10293a
|
||||
README.zh.md: e09bdcc4bebd8176a3061b221e07ae73a7a8941c
|
||||
README.md: b4b1e5653705c76bac3e0227e6df77143a11cbbe
|
||||
README.zh.md: 74e0f3dc0ebaf74e2e065c6b88f3a30fce94b391
|
||||
|
||||
@@ -24,7 +24,7 @@ The todo surfaces are two registrations over that shape, both plain registrant p
|
||||
|
||||
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
|
||||
|
||||
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `command.hint` locale namespace this package registers and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists.
|
||||
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists.
|
||||
|
||||
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
|
||||
|
||||
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。
|
||||
|
||||
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
|
||||
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
|
||||
|
||||
`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Registers the conversation components, shared store, and service callbacks. */
|
||||
import type { Context } from 'cordis'
|
||||
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { resolveSlotLabel, 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).
|
||||
@@ -28,6 +28,14 @@ import { queueDockEntry } from './queue/QueueDock.tsx'
|
||||
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
|
||||
import { ConversationSession } from './skeleton/ConversationSession.tsx'
|
||||
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
|
||||
import { en, NS, zh, type ConversationKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** The conversation surfaces' copy (skeleton, chat view, toolviews, docks). */
|
||||
conversation: ConversationKey
|
||||
}
|
||||
}
|
||||
|
||||
/** Services required by the conversation plugin. */
|
||||
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale']
|
||||
@@ -68,32 +76,12 @@ 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)
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-conversation: dictionaries')
|
||||
|
||||
// Registration-time text (the view tab label) reads through the bound
|
||||
// translate as a thunk, so it follows the active locale without
|
||||
// re-registration; components read the standard `t` seat instead.
|
||||
const t = ctx.locale.bind(NS)
|
||||
|
||||
// Apply-time construction keeps store identity bound to this fiber.
|
||||
const chatStore = createChatStore()
|
||||
@@ -103,7 +91,7 @@ export function apply(ctx: Context): void {
|
||||
for (const entry of slots.entries('conversation.view')) {
|
||||
/* v8 ignore next -- unreachable: list registration validates id at load. */
|
||||
if (entry.options.id === undefined) continue
|
||||
tabs.push({ id: entry.options.id, label: entry.options.label ?? entry.options.id })
|
||||
tabs.push({ id: entry.options.id, label: resolveSlotLabel(entry.options.label) ?? entry.options.id })
|
||||
}
|
||||
return tabs
|
||||
}
|
||||
@@ -132,6 +120,7 @@ export function apply(ctx: Context): void {
|
||||
// frame while strict session slots fill only their session-bound regions.
|
||||
slots.register({
|
||||
name: 'conversation',
|
||||
locale: NS,
|
||||
children: {
|
||||
'conversation.session': { kind: 'single', scope: 'session' },
|
||||
'conversation.composer': { kind: 'chain', scope: 'session' },
|
||||
@@ -163,6 +152,7 @@ export function apply(ctx: Context): void {
|
||||
// the resident parent keeps Hero and composer layout identity stable.
|
||||
slots.register({
|
||||
name: 'conversation.session',
|
||||
locale: NS,
|
||||
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
|
||||
store: chatStore,
|
||||
inject: (sessionId: SessionId, _actions: BoundActions<typeof chatStore>): ConversationSessionInjected => ({
|
||||
@@ -185,6 +175,7 @@ export function apply(ctx: Context): void {
|
||||
// observableHook caching and hook order stay stable across transitions).
|
||||
slots.register({
|
||||
name: 'conversation.composer.bar',
|
||||
locale: NS,
|
||||
// The two named control seats in the bar's tool row (plan beside the
|
||||
// access control, model right); empty until their owning plugins
|
||||
// register (B ruling).
|
||||
@@ -198,7 +189,6 @@ export function apply(ctx: Context): void {
|
||||
keyboard: undefined,
|
||||
stop: undefined,
|
||||
command: undefined,
|
||||
translateHint,
|
||||
hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON },
|
||||
}
|
||||
}
|
||||
@@ -216,7 +206,6 @@ 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 },
|
||||
}
|
||||
},
|
||||
@@ -230,7 +219,7 @@ export function apply(ctx: Context): void {
|
||||
// pending — a question is a conversation the model is waiting on, while an
|
||||
// approval only blocks one tool call; answering the question first cannot
|
||||
// strand the approval (it re-elects the moment the question resolves).
|
||||
slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1 }, ApprovalPanel)
|
||||
slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1, locale: NS }, ApprovalPanel)
|
||||
|
||||
// The chat view: first entry of the ring this package just declared.
|
||||
// Declaring the keyed toolview hole here is claiming it: ChatView is the
|
||||
@@ -241,7 +230,8 @@ export function apply(ctx: Context): void {
|
||||
name: 'conversation.view',
|
||||
id: 'chat',
|
||||
order: 0,
|
||||
label: 'Chat',
|
||||
label: () => t('view.chat'),
|
||||
locale: NS,
|
||||
children: {
|
||||
'conversation.chat.toolview': { kind: 'keyed', scope: 'session' },
|
||||
'conversation.chat.commandview': { kind: 'keyed', scope: 'session' },
|
||||
@@ -303,6 +293,7 @@ export function apply(ctx: Context): void {
|
||||
|
||||
slots.register({
|
||||
name: 'details',
|
||||
locale: NS,
|
||||
store: chatStore,
|
||||
inject: (): DetailsInjected => ({
|
||||
closeDetails: () => { layout.closeDetails() },
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
// ends (`time` is omitted for mid-turn narration); Think / tool-head-only
|
||||
// nodes stay chrome-free.
|
||||
|
||||
import { memo } from 'react'
|
||||
import { memo, useMemo } from 'react'
|
||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
IconThinkOutline14, JsonBlock, MarkdownText,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
import css from './AssistantMarkdown.module.css'
|
||||
@@ -20,7 +21,7 @@ import css from './AssistantMarkdown.module.css'
|
||||
export interface AssistantMarkdownProps {
|
||||
blocks: readonly AssistantBlock[]
|
||||
streaming: boolean
|
||||
/** Frozen partial of an aborted turn: rendered with a 已停止 marker. */
|
||||
/** Frozen partial of an aborted turn: rendered with a stopped marker. */
|
||||
interrupted?: boolean | undefined
|
||||
/** Unix epoch ms for the IconActions clock; omitted while streaming or when
|
||||
* the parent withholds chrome (mid-turn content assistants). */
|
||||
@@ -29,6 +30,8 @@ export interface AssistantMarkdownProps {
|
||||
seq?: number | undefined
|
||||
/** Fork the session through the turn containing this finalized message. */
|
||||
onFork?: ((seq: number) => void) | undefined
|
||||
/** The owning view's locale seat, passed down as a plain prop. */
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
|
||||
function firstLine(text: string): string {
|
||||
@@ -51,9 +54,10 @@ function hasContentText(blocks: readonly AssistantBlock[]): boolean {
|
||||
}
|
||||
|
||||
/** Reasoning block as the Think variant summary row (figma 39:28304). */
|
||||
function ThinkRow({ text, running }: { text: string; running: boolean }) {
|
||||
function ThinkRow({ text, running, t }: { text: string; running: boolean; t: AssistantMarkdownProps['t'] }) {
|
||||
return (
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant="think"
|
||||
icon={<IconThinkOutline14 size={14} />}
|
||||
title="Think"
|
||||
@@ -66,8 +70,11 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
|
||||
}
|
||||
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
blocks, streaming, interrupted, time, seq, onFork,
|
||||
blocks, streaming, interrupted, time, seq, onFork, t,
|
||||
}: AssistantMarkdownProps) {
|
||||
// Stable per locale revision (t identity changes on switch): a fresh object
|
||||
// per render would rebuild MarkdownText's component table every chunk.
|
||||
const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t])
|
||||
const last = blocks.length - 1
|
||||
// Tool-call heads render as tool rows in the chat view's grouping pass, so
|
||||
// a node that is only those heads (or empty) would paint an empty root
|
||||
@@ -83,14 +90,23 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
<div className={css.body}>
|
||||
{blocks.map((block, i) => {
|
||||
switch (block.kind) {
|
||||
case 'text': return <MarkdownText key={i} text={block.text} streaming={streaming} />
|
||||
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
|
||||
case 'text': return (
|
||||
<MarkdownText key={i} text={block.text} streaming={streaming} codeLabels={codeLabels} />
|
||||
)
|
||||
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} t={t} />
|
||||
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
|
||||
case 'tool-call': return null
|
||||
default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
|
||||
default: return (
|
||||
<JsonBlock
|
||||
key={i}
|
||||
label={t('message.unknownBlock')}
|
||||
payload={block.block}
|
||||
truncatedLabel={total => t('json.truncated', { total })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})}
|
||||
{interrupted && <span className={css.stopped}>已停止</span>}
|
||||
{interrupted && <span className={css.stopped}>{t('message.stopped')}</span>}
|
||||
</div>
|
||||
{showActions && (
|
||||
<MessageIconActions
|
||||
@@ -99,6 +115,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
clock="end"
|
||||
onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }}
|
||||
className={css.actions}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -57,12 +57,13 @@ type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
|
||||
* top-level call (same registrations, same fallback), nested by the parent.
|
||||
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and
|
||||
* renders the running state exactly as a native in-flight row. */
|
||||
const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd }: {
|
||||
const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd, t }: {
|
||||
renderSlot: RenderToolRow
|
||||
node: CodeSubCall
|
||||
openFile: OpenFile
|
||||
selected: boolean
|
||||
cwd: string | undefined
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const settled = 'kind' in node
|
||||
const toolName = settled ? node.call?.name ?? '' : node.name
|
||||
@@ -73,7 +74,7 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, select
|
||||
<div className={css.callRow} data-selected={selected || undefined}>
|
||||
{renderSlot('conversation.chat.toolview', owner, {
|
||||
entryKey: toolName,
|
||||
fallback: <GenericToolCard {...owner} />,
|
||||
fallback: <GenericToolCard {...owner} t={t} />,
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
@@ -85,7 +86,7 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, select
|
||||
* renders its logged sub-dispatches as always-visible indented rows —
|
||||
* each one the same keyed-slot dispatch as a native top-level call. */
|
||||
const CallRow = memo(function CallRow({
|
||||
renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd,
|
||||
renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd, t,
|
||||
}: {
|
||||
renderSlot: RenderToolRow
|
||||
callId: string
|
||||
@@ -100,6 +101,7 @@ const CallRow = memo(function CallRow({
|
||||
selectedCallId?: string | undefined
|
||||
/** Session workspace root for path-relative summaries. */
|
||||
cwd: string | undefined
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const owner = useMemo(() => ({
|
||||
callId, toolName, block, openFile, cwd,
|
||||
@@ -108,7 +110,7 @@ const CallRow = memo(function CallRow({
|
||||
<div className={css.callRow} data-selected={selected || undefined}>
|
||||
{renderSlot('conversation.chat.toolview', owner, {
|
||||
entryKey: toolName,
|
||||
fallback: <GenericToolCard {...owner} />,
|
||||
fallback: <GenericToolCard {...owner} t={t} />,
|
||||
})}
|
||||
{subCalls !== undefined && subCalls.length > 0 && (
|
||||
<div className={css.subCalls} data-subcalls>
|
||||
@@ -120,6 +122,7 @@ const CallRow = memo(function CallRow({
|
||||
openFile={openFile}
|
||||
selected={node.callId === selectedCallId}
|
||||
cwd={cwd}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -129,7 +132,7 @@ const CallRow = memo(function CallRow({
|
||||
})
|
||||
|
||||
/** Consecutive tool results as one step-run group (uniform 16px rhythm). */
|
||||
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd }: {
|
||||
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd, t }: {
|
||||
renderSlot: RenderToolRow
|
||||
results: readonly ToolResultNode[]
|
||||
openFile: OpenFile
|
||||
@@ -139,6 +142,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
/** Session workspace root for path-relative summaries. */
|
||||
cwd: string | undefined
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
return (
|
||||
<div className={css.toolGroup}>
|
||||
@@ -154,6 +158,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
|
||||
subCalls={codeDispatches.get(node.callId)}
|
||||
selectedCallId={selectedCallId}
|
||||
cwd={cwd}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -163,16 +168,17 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
|
||||
/** One command lifecycle row: keyed dispatch on the command name with the
|
||||
* generic card as the render-site fallback (zero registration required). A
|
||||
* run-less cross-window node has no name and always lands on the fallback. */
|
||||
const CommandRow = memo(function CommandRow({ renderSlot, node }: {
|
||||
const CommandRow = memo(function CommandRow({ renderSlot, node, t }: {
|
||||
renderSlot: RenderToolRow
|
||||
node: CommandNode
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const owner = useMemo(() => ({ node }), [node])
|
||||
return (
|
||||
<div className={css.callRow}>
|
||||
{renderSlot('conversation.chat.commandview', owner, {
|
||||
entryKey: node.name ?? '',
|
||||
fallback: <GenericCommandCard {...owner} />,
|
||||
fallback: <GenericCommandCard {...owner} t={t} />,
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
@@ -214,23 +220,24 @@ function TurnDots() {
|
||||
|
||||
/** The streaming partial, isolated so chunk batches re-render only this tail.
|
||||
* onGrow lets the scroll owner follow content the parent never re-renders for. */
|
||||
function StreamingTail({ useSession, onGrow }: {
|
||||
function StreamingTail({ useSession, onGrow, t }: {
|
||||
useSession: UseConversation
|
||||
onGrow: () => void
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const partial = useSession(s => s.partial)
|
||||
useLayoutEffect(() => {
|
||||
onGrow()
|
||||
})
|
||||
if (partial === null) return null
|
||||
return <AssistantMarkdown blocks={partial.blocks} streaming />
|
||||
return <AssistantMarkdown blocks={partial.blocks} streaming t={t} />
|
||||
}
|
||||
|
||||
/**
|
||||
* The chat view slot entry: pure component over the composed props (tool rows
|
||||
* render through the declared keyed hole's renderSlot share).
|
||||
*/
|
||||
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, forkAt }: ChatViewSlotProps) {
|
||||
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, forkAt, t }: ChatViewSlotProps) {
|
||||
const nodes = useSession(s => s.nodes)
|
||||
// Workspace root off the session list row: path summaries display relative to it.
|
||||
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
|
||||
@@ -238,7 +245,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
const runningCalls = useSession(s => s.runningCalls)
|
||||
const codeDispatches = useSession(s => s.codeDispatches)
|
||||
const openState = useSession(s => s.openState)
|
||||
const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`)
|
||||
const openError = useSession(s => s.openError)
|
||||
const hasMore = useSession(s => s.hasMore)
|
||||
const loadingOlder = useSession(s => s.loadingOlder)
|
||||
const selectedCallId = useStore(s => s.selection?.callId)
|
||||
@@ -368,6 +375,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
selectedCallId={inGroup ? selectedCallId : undefined}
|
||||
codeDispatches={codeDispatches}
|
||||
cwd={cwd}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -382,32 +390,37 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
time={actionSeqs.has(node.seq) ? node.time : undefined}
|
||||
seq={node.seq}
|
||||
onFork={forkAt}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (node.kind === 'command') {
|
||||
return <CommandRow key={item.key} renderSlot={renderSlot} node={node} />
|
||||
return <CommandRow key={item.key} renderSlot={renderSlot} node={node} t={t} />
|
||||
}
|
||||
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
|
||||
if (node.kind === 'tool-result') return null
|
||||
return <MessageItem key={item.key} node={node} onFork={forkAt} />
|
||||
return <MessageItem key={item.key} node={node} onFork={forkAt} t={t} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<div ref={listRef} className={css.scroll}>
|
||||
<div className={css.column}>
|
||||
{openState === 'loading' && <div className={css.hint}>载入历史…</div>}
|
||||
{openState === 'error' && <div className={css.openError}>历史加载失败:{openErrorMessage}</div>}
|
||||
{openState === 'loading' && <div className={css.hint}>{t('chat.loadingHistory')}</div>}
|
||||
{openState === 'error' && openError !== null && (
|
||||
<div className={css.openError}>
|
||||
{t('chat.loadError', { message: openError.message, code: openError.code })}
|
||||
</div>
|
||||
)}
|
||||
{hasMore && (
|
||||
<div className={css.older}>
|
||||
<button type="button" disabled={loadingOlder} onClick={loadOlderAnchored}>
|
||||
{loadingOlder ? '加载中…' : '加载更早'}
|
||||
{loadingOlder ? t('loading') : t('chat.loadOlder')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{items.map(renderItem)}
|
||||
<StreamingTail useSession={useSession} onGrow={onGrow} />
|
||||
<StreamingTail useSession={useSession} onGrow={onGrow} t={t} />
|
||||
{runningCalls.length > 0 && (
|
||||
<div className={css.toolGroup}>
|
||||
{runningCalls.map(call => (
|
||||
@@ -422,6 +435,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
subCalls={codeDispatches.get(call.callId)}
|
||||
selectedCallId={selectedCallId}
|
||||
cwd={cwd}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -438,7 +452,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
<button
|
||||
type="button"
|
||||
className={css.toBottom}
|
||||
aria-label="回到底部"
|
||||
aria-label={t('chat.toBottom')}
|
||||
onClick={() => {
|
||||
const local = listRef.current
|
||||
/* v8 ignore next -- ref-null guard: the button only renders alongside the mounted list. */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { DisclosureRow } from './DisclosureRow.tsx'
|
||||
import css from './ContextInjectionRow.module.css'
|
||||
@@ -47,6 +48,8 @@ function inlineJson(payload: unknown): string {
|
||||
export interface ContextInjectionRowProps {
|
||||
content: ContextMessageNode['content']
|
||||
source: ContextMessageNode['source']
|
||||
/** The owning view's locale seat, passed down as a plain prop. */
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,22 +57,22 @@ export interface ContextInjectionRowProps {
|
||||
* @param props - Durable content and source provenance.
|
||||
* @returns A collapsed context row with a bounded JSON body.
|
||||
*/
|
||||
export function ContextInjectionRow({ content, source }: ContextInjectionRowProps) {
|
||||
export function ContextInjectionRow({ content, source, t }: ContextInjectionRowProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const body = useMemo(() => {
|
||||
if (!open) return ''
|
||||
const text = inlineJson({ content, source })
|
||||
return text.length > MAX_CHARS
|
||||
? `${text.slice(0, MAX_CHARS)}\n… 已截断,共 ${text.length} 字符`
|
||||
? `${text.slice(0, MAX_CHARS)}\n${t('json.truncated', { total: text.length })}`
|
||||
: text
|
||||
}, [content, open, source])
|
||||
}, [content, open, source, t])
|
||||
|
||||
return (
|
||||
<DisclosureRow
|
||||
className={css.root}
|
||||
icon={<IconBrowseOutline16 size={14} />}
|
||||
chevronClassName={css.chevron}
|
||||
title="上下文注入"
|
||||
title={t('message.contextInjection')}
|
||||
open={open}
|
||||
expandable
|
||||
expandOnRowClick
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
import type { ToolRowState } from '../contract/tool-call-model.ts'
|
||||
import type { CommandRowOwnerProps } from '../contract/slots.ts'
|
||||
import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts'
|
||||
import { IconApiOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
/** Node state → row state semantic (running while unsettled; outcome kind after). */
|
||||
@@ -15,18 +15,24 @@ function stateOf(outcome: CommandRowOwnerProps['node']['outcome']): ToolRowState
|
||||
return outcome.kind === 'error' ? 'error' : 'ok'
|
||||
}
|
||||
|
||||
export function GenericCommandCard({ node }: CommandRowOwnerProps) {
|
||||
/** Card props: the owner payload plus the render site's locale seat (plain prop). */
|
||||
export interface GenericCommandCardProps extends CommandRowOwnerProps {
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
|
||||
export function GenericCommandCard({ node, t }: GenericCommandCardProps) {
|
||||
const text = node.outcome?.text
|
||||
const summary = node.outcome === null
|
||||
? '执行中…'
|
||||
: text ?? (node.outcome.kind === 'error' ? '命令失败' : '已完成')
|
||||
? t('command.running')
|
||||
: text ?? (node.outcome.kind === 'error' ? t('command.failed') : t('command.done'))
|
||||
// Title is the bare command name: the row already reads `name · outcome`,
|
||||
// and the dispatched line's own `/` and arguments only restate what the
|
||||
// settlement text says (`permission · preset workspace-write`). A
|
||||
// cross-window node whose run page fell out of the window has no name.
|
||||
const title = node.name ?? '命令'
|
||||
const title = node.name ?? t('command.title')
|
||||
return (
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant="others"
|
||||
icon={<IconApiOutline14 size={16} />}
|
||||
title={title}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16,
|
||||
IconThinkOutline14,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowOwnerProps } from '../contract/slots.ts'
|
||||
import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts'
|
||||
import { terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
@@ -26,12 +26,18 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
|
||||
others: <IconSparkle16 size={14} />,
|
||||
}
|
||||
|
||||
export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
|
||||
/** Card props: the owner payload plus the render site's locale seat (plain prop). */
|
||||
export interface GenericToolCardProps extends ToolRowOwnerProps {
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
|
||||
export function GenericToolCard({ toolName, block, cwd, openFile, t }: GenericToolCardProps) {
|
||||
const model = toolRowModel(toolName, block, cwd)
|
||||
const terminal = terminalCardModel(block, cwd)
|
||||
const singleFile = model.filePath !== undefined
|
||||
return (
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant={model.variant}
|
||||
toolName={toolName}
|
||||
icon={VARIANT_ICONS[model.variant]}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useCallback } from 'react'
|
||||
import {
|
||||
IconBranchOutline16, IconCopyOutline16, IconEditOutline16, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { formatMessageClock, writeClipboard } from './message-chrome.ts'
|
||||
import { useCalendarDay } from './use-calendar-day.ts'
|
||||
import css from './MessageIconActions.module.css'
|
||||
@@ -23,6 +24,8 @@ export interface MessageIconActionsProps {
|
||||
onBranch?: (() => void) | undefined
|
||||
/** Parent layout class composed onto the actions row. */
|
||||
className?: string | undefined
|
||||
/** The owning view's locale seat, passed down as a plain prop. */
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,7 +34,7 @@ export interface MessageIconActionsProps {
|
||||
* @returns The actions row element.
|
||||
*/
|
||||
export function MessageIconActions({
|
||||
text, time, clock, edit, onBranch, className,
|
||||
text, time, clock, edit, onBranch, className, t,
|
||||
}: MessageIconActionsProps) {
|
||||
const day = useCalendarDay()
|
||||
const onCopy = useCallback(() => {
|
||||
@@ -39,25 +42,25 @@ export function MessageIconActions({
|
||||
}, [text])
|
||||
const clockEl = (
|
||||
<span className={clock === 'start' ? css.timeStart : css.timeEnd}>
|
||||
{formatMessageClock(time, day)}
|
||||
{formatMessageClock(time, t, day)}
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
<div className={className === undefined ? css.actions : `${css.actions} ${className}`}>
|
||||
{clock === 'start' ? clockEl : null}
|
||||
<Tooltip label="复制" side="bottom">
|
||||
<button type="button" className={css.action} aria-label="复制" onClick={onCopy}>
|
||||
<Tooltip label={t('copy')} side="bottom">
|
||||
<button type="button" className={css.action} aria-label={t('copy')} onClick={onCopy}>
|
||||
<IconCopyOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="在新对话中分支" side="bottom">
|
||||
<button type="button" className={css.action} aria-label="在新对话中分支" onClick={onBranch}>
|
||||
<Tooltip label={t('message.branch')} side="bottom">
|
||||
<button type="button" className={css.action} aria-label={t('message.branch')} onClick={onBranch}>
|
||||
<IconBranchOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{edit === true && (
|
||||
<Tooltip label="编辑" side="bottom">
|
||||
<button type="button" className={css.action} aria-label="编辑">
|
||||
<Tooltip label={t('edit')} side="bottom">
|
||||
<button type="button" className={css.action} aria-label={t('edit')}>
|
||||
<IconEditOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { ContextInjectionRow } from './ContextInjectionRow.tsx'
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import css from './MessageItem.module.css'
|
||||
@@ -18,6 +19,8 @@ export interface MessageItemProps {
|
||||
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode
|
||||
/** Fork the session through the turn containing this message (user-bubble branch action). */
|
||||
onFork?: (seq: number) => void
|
||||
/** The owning view's locale seat, passed down as a plain prop. */
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
|
||||
function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } {
|
||||
@@ -63,7 +66,8 @@ function projectUserText(text: string): ReactNode {
|
||||
return <>{parts}</>
|
||||
}
|
||||
|
||||
export const MessageItem = memo(function MessageItem({ node, onFork }: MessageItemProps) {
|
||||
export const MessageItem = memo(function MessageItem({ node, onFork, t }: MessageItemProps) {
|
||||
const truncated = (total: number): string => t('json.truncated', { total })
|
||||
switch (node.kind) {
|
||||
case 'user': {
|
||||
const { text, rest } = contentText(node.content)
|
||||
@@ -71,7 +75,7 @@ export const MessageItem = memo(function MessageItem({ node, onFork }: MessageIt
|
||||
<div className={css.userRow}>
|
||||
<div className={css.bubble}>
|
||||
{projectUserText(text)}
|
||||
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
|
||||
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
|
||||
</div>
|
||||
<MessageIconActions
|
||||
text={text}
|
||||
@@ -80,6 +84,7 @@ export const MessageItem = memo(function MessageItem({ node, onFork }: MessageIt
|
||||
edit
|
||||
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
|
||||
className={css.actions}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -89,21 +94,21 @@ export const MessageItem = memo(function MessageItem({ node, onFork }: MessageIt
|
||||
return (
|
||||
<div className={css.userRow}>
|
||||
<div className={css.bubble}>
|
||||
<span className={css.badge}>插话</span>
|
||||
<span className={css.badge}>{t('message.steering')}</span>
|
||||
{projectUserText(text)}
|
||||
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
|
||||
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case 'context':
|
||||
return (
|
||||
<ContextInjectionRow content={node.content} source={node.source} />
|
||||
<ContextInjectionRow content={node.content} source={node.source} t={t} />
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<div className={css.contextRow}>
|
||||
<JsonBlock label={`未知 surface 事件:${node.type}`} payload={node.data} />
|
||||
<JsonBlock label={t('message.unknownSurface', { type: node.type })} payload={node.data} truncatedLabel={truncated} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,12 +10,15 @@
|
||||
|
||||
import { useState, type MouseEvent, type ReactNode } from 'react'
|
||||
import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { CHAT_TERMINAL_MAX_LINES, terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import { DisclosureRow } from './DisclosureRow.tsx'
|
||||
import css from './ToolRow.module.css'
|
||||
|
||||
export interface ToolRowProps {
|
||||
/** The render site's conversation locale seat (terminal/code body copy). */
|
||||
t: TranslateNS<'conversation'>
|
||||
variant: ToolRowVariant
|
||||
/** Wire tool name for tool-owned styling layered over the generic variant. */
|
||||
toolName?: string | undefined
|
||||
@@ -56,6 +59,7 @@ function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode {
|
||||
}
|
||||
|
||||
export function ToolRow({
|
||||
t,
|
||||
variant,
|
||||
toolName,
|
||||
icon,
|
||||
@@ -125,9 +129,16 @@ export function ToolRow({
|
||||
<div className={css.terminalDescription}>{terminalBody.description}</div>
|
||||
)}
|
||||
{terminalBody !== null
|
||||
? <TerminalBlock {...terminalBody.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminalBody} />
|
||||
? (
|
||||
<TerminalBlock
|
||||
{...terminalBody.card}
|
||||
maxLines={CHAT_TERMINAL_MAX_LINES}
|
||||
labels={terminalBlockLabels(t)}
|
||||
className={css.terminalBody}
|
||||
/>
|
||||
)
|
||||
: variant === 'code'
|
||||
? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
|
||||
? <CodeBlock code={text} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
|
||||
: <div className={css.body}>{text}</div>}
|
||||
</DisclosureRow>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
// Shared chrome helpers for user/assistant IconActions rows: clipboard write
|
||||
// and the compact date+clock label from a session-event epoch.
|
||||
|
||||
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
/** The date-template share of the conversation dictionary the clock consumes. */
|
||||
export type ClockTranslate = Translate<'clock.md' | 'clock.ymd'>
|
||||
|
||||
/**
|
||||
* Best-effort clipboard write; rejections stay swallowed (no success chrome).
|
||||
* @param text - Plain text to place on the clipboard.
|
||||
@@ -67,14 +72,16 @@ export function msUntilNextLocalMidnight(ms: number): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact local timestamp for message IconActions.
|
||||
* Same calendar day → `HH:mm`; earlier this year → `M月D日 HH:mm`;
|
||||
* other years → `YYYY年M月D日 HH:mm`.
|
||||
* Compact local timestamp for message IconActions. Same calendar day →
|
||||
* `HH:mm`; earlier this year → the `clock.md` date template + clock; other
|
||||
* years → the `clock.ymd` template + clock. Pure: the date templates arrive
|
||||
* through the caller's locale seat.
|
||||
* @param time - Unix epoch ms from the source session event.
|
||||
* @param t - translate seat supplying the `clock.md` / `clock.ymd` templates.
|
||||
* @param now - Reference instant for the day/year cut (defaults to wall clock).
|
||||
* @returns Date-aware clock string (24-hour, zero-padded time).
|
||||
*/
|
||||
export function formatMessageClock(time: number, now: number = Date.now()): string {
|
||||
export function formatMessageClock(time: number, t: ClockTranslate, now: number = Date.now()): string {
|
||||
const d = new Date(time)
|
||||
const n = new Date(now)
|
||||
const clock = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
|
||||
@@ -85,7 +92,7 @@ export function formatMessageClock(time: number, now: number = Date.now()): stri
|
||||
) {
|
||||
return clock
|
||||
}
|
||||
const md = `${d.getMonth() + 1}月${d.getDate()}日`
|
||||
if (d.getFullYear() === n.getFullYear()) return `${md} ${clock}`
|
||||
return `${d.getFullYear()}年${md} ${clock}`
|
||||
const params = { y: d.getFullYear(), m: d.getMonth() + 1, d: d.getDate() }
|
||||
const md = d.getFullYear() === n.getFullYear() ? t('clock.md', params) : t('clock.ymd', params)
|
||||
return `${md} ${clock}`
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Conversation slot declarations and their composed component props. */
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
import type {
|
||||
InjectFace, MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
|
||||
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
@@ -282,8 +282,6 @@ export interface ComposerBarInjected {
|
||||
* Resolves admission: false = rejected/unmatched/transport failure.
|
||||
*/
|
||||
command: ((line: string) => Promise<boolean>) | undefined
|
||||
/** Locale-aware hint translator for claimed command placeholders (session-independent — always present). */
|
||||
translateHint: (key: string) => string
|
||||
/**
|
||||
* Registrant hooks compartment: the renderer binds these to
|
||||
* useNotices/useLexicon (static absent sources without a session — hook
|
||||
@@ -306,11 +304,12 @@ export interface InputControlOwnerProps {
|
||||
locked: boolean
|
||||
}
|
||||
|
||||
/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share (hooks compartment bound). */
|
||||
/** Full composer-bar props: standard kit & owner share & control-seat render share & injected share (hooks bound) & locale seat. */
|
||||
export type ComposerBarProps =
|
||||
PropsRuntime<'conversation.composer.bar'>
|
||||
& PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'>
|
||||
& InjectFace<ComposerBarInjected>
|
||||
& PropsLocale<'conversation'>
|
||||
|
||||
/**
|
||||
* Composer chain currency: what ConversationRoot dispatches at its
|
||||
@@ -325,7 +324,8 @@ export interface ComposerChainProps {
|
||||
|
||||
/**
|
||||
* Full conversation-slot component props: runtime & child-render (view ring
|
||||
* + composer chain/bar + input-region + hero picker slots) & store & injected shares.
|
||||
* + composer chain/bar + input-region + hero picker slots) & store & injected
|
||||
* shares & the locale seat.
|
||||
*/
|
||||
export type ConversationSlotProps =
|
||||
PropsRuntime<'conversation'> & PropsRenderSlots<
|
||||
@@ -336,13 +336,15 @@ export type ConversationSlotProps =
|
||||
| 'conversation.hero.workspace'
|
||||
>
|
||||
& ConversationInjected
|
||||
& PropsLocale<'conversation'>
|
||||
|
||||
/** Full strict-session content props: per-session store, view ring, and callbacks. */
|
||||
/** Full strict-session content props: per-session store, view ring, callbacks, and the locale seat. */
|
||||
export type ConversationSessionSlotProps =
|
||||
PropsRuntime<'conversation.session'>
|
||||
& PropsRenderSlots<'conversation.view'>
|
||||
& PropsStore<ChatStore>
|
||||
& ConversationSessionInjected
|
||||
& PropsLocale<'conversation'>
|
||||
|
||||
/** The pending approval carrier the owner dispatches into the composer chain. */
|
||||
export type ApprovalWait = PendingWait<'approval'>
|
||||
@@ -400,11 +402,13 @@ export class PendingApproval {
|
||||
/**
|
||||
* Full approval-composer props: the framework runtime share (chain currency +
|
||||
* session/global standard kit) plus the chain `matched` share — the entry's
|
||||
* selector result, already narrowed to the approval carrier. No injected
|
||||
* share: the carrier plus the domain face above carry the whole behavior
|
||||
* surface; the paired command line derives from useSession in-component.
|
||||
* selector result, already narrowed to the approval carrier — plus the
|
||||
* standard locale seat. No injected share: the carrier plus the domain face
|
||||
* above carry the whole behavior surface; the paired command line derives
|
||||
* from useSession in-component.
|
||||
*/
|
||||
export type ApprovalComposerProps = PropsRuntime<'conversation.composer'> & { matched: ApprovalWait }
|
||||
export type ApprovalComposerProps =
|
||||
PropsRuntime<'conversation.composer'> & { matched: ApprovalWait } & PropsLocale<'conversation'>
|
||||
|
||||
/**
|
||||
* Injected share of the chat view entry: the two callbacks whose targets live
|
||||
@@ -423,10 +427,10 @@ export interface ChatViewInjected {
|
||||
forkAt: (seq: number) => void
|
||||
}
|
||||
|
||||
/** Full chat-view component props: runtime share & the declared toolview/commandview holes' render share & store share & injected share. */
|
||||
/** Full chat-view component props: runtime & the declared toolview/commandview holes' render share & store & injected & locale seat. */
|
||||
export type ChatViewSlotProps =
|
||||
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview' | 'conversation.chat.commandview'>
|
||||
& PropsStore<ChatStore> & ChatViewInjected
|
||||
& PropsStore<ChatStore> & ChatViewInjected & PropsLocale<'conversation'>
|
||||
|
||||
/**
|
||||
* Injected share of the details slot: the panel is otherwise a pure reader of
|
||||
@@ -437,8 +441,8 @@ export interface DetailsInjected {
|
||||
closeDetails: () => void
|
||||
}
|
||||
|
||||
/** Full details-slot component props: selection arrives through the shared store, call material through useSession. */
|
||||
export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> & DetailsInjected
|
||||
/** Full details-slot component props: selection rides the shared store, call material useSession; copy the locale seat. */
|
||||
export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> & DetailsInjected & PropsLocale<'conversation'>
|
||||
|
||||
/** Owner share common to the hero / New-Session Workspace pickers. */
|
||||
export interface EmptyWorkspaceOwnerProps {
|
||||
|
||||
@@ -8,9 +8,35 @@
|
||||
* are derived once.
|
||||
* @module
|
||||
*/
|
||||
import type { TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { TerminalBlockLabels, TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { resolveToolPath, type ToolCallBlock } from './tool-call-model.ts'
|
||||
|
||||
/**
|
||||
* Build the TerminalBlock display copy from the conversation locale seat —
|
||||
* the one place the primitive's label surface pairs with this package's
|
||||
* dictionary, shared by every terminal render site (chat row, bash row,
|
||||
* details panel).
|
||||
* @param t - the render site's conversation locale seat.
|
||||
* @returns the full label set for {@link TerminalBlockProps}'s `labels`.
|
||||
*/
|
||||
export function terminalBlockLabels(t: TranslateNS<'conversation'>): TerminalBlockLabels {
|
||||
return {
|
||||
signal: signal => t('terminal.signal', { signal }),
|
||||
exitCode: code => t('terminal.exitCode', { code }),
|
||||
running: t('terminal.running'),
|
||||
failed: t('terminal.failed'),
|
||||
done: t('terminal.done'),
|
||||
copy: t('copy'),
|
||||
copied: t('copied'),
|
||||
noOutput: t('terminal.noOutput'),
|
||||
collapseAria: t('terminal.collapseAria'),
|
||||
collapse: t('collapse'),
|
||||
expandAria: hidden => t('terminal.expandAria', { n: hidden }),
|
||||
expand: hidden => t('terminal.expandRest', { n: hidden }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Output lines the chat row's expanded terminal body shows before collapsing
|
||||
* the middle — half the primitive's own default, which the details panel
|
||||
|
||||
@@ -11,6 +11,7 @@ export type {
|
||||
CallId, ChatStoreState, SelectionTarget, ViewTab,
|
||||
} from './contract/views.ts'
|
||||
export type { ToolCallBlock } from './contract/tool-call-model.ts'
|
||||
export type { ConversationKey } from './locales.ts'
|
||||
export type {
|
||||
ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected,
|
||||
ComposerChainProps, ConversationInjected,
|
||||
|
||||
170
packages/client/ui-conversation/src/client/locales.ts
Normal file
170
packages/client/ui-conversation/src/client/locales.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/** `conversation` namespace dictionaries. */
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
export const NS = 'conversation'
|
||||
|
||||
// The claimed /plan hint and the plan-mode textarea placeholder share one
|
||||
// string: both describe the same next action.
|
||||
const PLAN_NEXT_ACTION_ZH = '描述你的任务以生成计划'
|
||||
const PLAN_NEXT_ACTION_EN = 'describe your task to generate plan'
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'view.chat': '对话',
|
||||
'hint.plan': PLAN_NEXT_ACTION_ZH,
|
||||
'hint.goal': '输入目标,智能体将持续执行',
|
||||
'hint.goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除',
|
||||
'placeholder.plan': PLAN_NEXT_ACTION_ZH,
|
||||
'placeholder.default': '给智能体发消息',
|
||||
'placeholder.unavailable': '会话不可用',
|
||||
'placeholder.hero': '描述你想要构建的内容',
|
||||
'placeholder.workspace': '选择一个工作区开始',
|
||||
'input.addAttachment': '添加附件',
|
||||
'input.stop': '停止生成',
|
||||
'input.send': '发送消息',
|
||||
'input.accessMode': '访问模式,当前:{name}',
|
||||
'hero.headline': '开始构建吧',
|
||||
'hero.chooseWorkspace': '选择工作区',
|
||||
'session.hierarchy': '会话层级',
|
||||
'details.title': '详情',
|
||||
'details.close': '关闭详情',
|
||||
'details.empty': '点击消息流中的工具行查看详情',
|
||||
'details.notInWindow': '该调用不在当前窗口内',
|
||||
'details.input': '输入',
|
||||
'details.output': '输出',
|
||||
'details.running': '运行中…',
|
||||
'todo.title': '任务清单',
|
||||
'todo.progress': '{done}/{total} 项任务 · {active} 项进行中',
|
||||
'todo.rowTitle': '更新任务清单',
|
||||
'todo.completed': '{done}/{total} 已完成',
|
||||
'chat.loadingHistory': '载入历史…',
|
||||
'chat.loadError': '历史加载失败:{message}({code})',
|
||||
'chat.loadOlder': '加载更早',
|
||||
'chat.toBottom': '回到底部',
|
||||
'message.extraBlock': '附加内容块',
|
||||
'message.steering': '插话',
|
||||
'message.contextInjection': '上下文注入',
|
||||
'message.unknownSurface': '未知 surface 事件:{type}',
|
||||
'message.unknownBlock': '未知内容块',
|
||||
'message.stopped': '已停止',
|
||||
'message.branch': '在新对话中分支',
|
||||
'command.running': '执行中…',
|
||||
'command.failed': '命令失败',
|
||||
'command.done': '已完成',
|
||||
'command.title': '命令',
|
||||
'approval.waiting': '等待审批',
|
||||
'approval.detail.aria': '审批详情',
|
||||
'approval.escalation': '工具 {toolName} 请求越权执行',
|
||||
'approval.reject': '拒绝',
|
||||
'approval.allowOnce': '允许一次',
|
||||
'ask.rowTitle': '提问',
|
||||
'ask.waiting': '等待回答',
|
||||
'ask.cancelled': '已取消',
|
||||
'ask.interrupted': '已中断',
|
||||
'ask.answered': '{answered}/{total} 已回答',
|
||||
'bash.running': '运行中',
|
||||
'bash.failed': '失败',
|
||||
'bash.stopped': '已停止',
|
||||
'queue.count': '{n} 条排队消息',
|
||||
'queue.edit': '编辑排队消息',
|
||||
'queue.edit.unsupported': '包含非文本内容,暂不支持编辑',
|
||||
'queue.save': '保存排队消息',
|
||||
'queue.cancelEdit': '取消编辑',
|
||||
'queue.remove': '删除排队消息',
|
||||
'queue.editFailed': '编辑失败:这条消息可能已经开始发送。',
|
||||
'queue.removeFailed': '删除失败:这条消息可能已经开始发送。',
|
||||
'terminal.signal': '信号 {signal}',
|
||||
'terminal.exitCode': '退出码 {code}',
|
||||
'terminal.running': '运行中',
|
||||
'terminal.failed': '失败',
|
||||
'terminal.done': '已完成',
|
||||
'terminal.noOutput': '无输出',
|
||||
'terminal.collapseAria': '收起输出',
|
||||
'terminal.expandAria': '展开其余 {n} 行输出',
|
||||
'terminal.expandRest': '… 其余 {n} 行',
|
||||
'json.truncated': '… 已截断,共 {total} 字符',
|
||||
'clock.md': '{m}月{d}日',
|
||||
'clock.ymd': '{y}年{m}月{d}日',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The conversation namespace key union. */
|
||||
export type ConversationKey = keyof typeof zh
|
||||
|
||||
/** English dictionary, checked complete against the zh key set. */
|
||||
export const en = {
|
||||
'view.chat': 'Chat',
|
||||
'hint.plan': PLAN_NEXT_ACTION_EN,
|
||||
'hint.goal': 'describe the objective for a long-running task',
|
||||
'hint.goal.active': 'goal active — edit / pause / resume / clear',
|
||||
'placeholder.plan': PLAN_NEXT_ACTION_EN,
|
||||
'placeholder.default': 'Message the agent',
|
||||
'placeholder.unavailable': 'Session unavailable',
|
||||
'placeholder.hero': 'Describe what you want to build',
|
||||
'placeholder.workspace': 'Choose a workspace to start',
|
||||
'input.addAttachment': 'Add attachment',
|
||||
'input.stop': 'Stop generating',
|
||||
'input.send': 'Send message',
|
||||
'input.accessMode': 'Access mode, current: {name}',
|
||||
'hero.headline': 'Let\'s start building',
|
||||
'hero.chooseWorkspace': 'Choose workspace',
|
||||
'session.hierarchy': 'Session hierarchy',
|
||||
'details.title': 'Details',
|
||||
'details.close': 'Close details',
|
||||
'details.empty': 'Click a tool row in the message flow to view its details',
|
||||
'details.notInWindow': 'This call is outside the current window',
|
||||
'details.input': 'Input',
|
||||
'details.output': 'Output',
|
||||
'details.running': 'Running…',
|
||||
'todo.title': 'To-dos',
|
||||
'todo.progress': '{done}/{total} tasks · {active} in progress',
|
||||
'todo.rowTitle': 'Update to-do list',
|
||||
'todo.completed': '{done}/{total} completed',
|
||||
'chat.loadingHistory': 'Loading history…',
|
||||
'chat.loadError': 'Failed to load history: {message} ({code})',
|
||||
'chat.loadOlder': 'Load earlier',
|
||||
'chat.toBottom': 'Back to bottom',
|
||||
'message.extraBlock': 'Extra content block',
|
||||
'message.steering': 'Interjection',
|
||||
'message.contextInjection': 'Context injection',
|
||||
'message.unknownSurface': 'Unknown surface event: {type}',
|
||||
'message.unknownBlock': 'Unknown content block',
|
||||
'message.stopped': 'Stopped',
|
||||
'message.branch': 'Branch into a new conversation',
|
||||
'command.running': 'Running…',
|
||||
'command.failed': 'Command failed',
|
||||
'command.done': 'Completed',
|
||||
'command.title': 'Command',
|
||||
'approval.waiting': 'Waiting for approval',
|
||||
'approval.detail.aria': 'Approval details',
|
||||
'approval.escalation': 'Tool {toolName} requests privileged execution',
|
||||
'approval.reject': 'Reject',
|
||||
'approval.allowOnce': 'Allow once',
|
||||
'ask.rowTitle': 'Ask question',
|
||||
'ask.waiting': 'waiting',
|
||||
'ask.cancelled': 'cancelled',
|
||||
'ask.interrupted': 'interrupted',
|
||||
'ask.answered': '{answered}/{total} answered',
|
||||
'bash.running': 'Running',
|
||||
'bash.failed': 'Failed',
|
||||
'bash.stopped': 'Stopped',
|
||||
'queue.count': '{n} queued messages',
|
||||
'queue.edit': 'Edit queued message',
|
||||
'queue.edit.unsupported': 'Contains non-text content; editing is not supported yet',
|
||||
'queue.save': 'Save queued message',
|
||||
'queue.cancelEdit': 'Cancel editing',
|
||||
'queue.remove': 'Remove queued message',
|
||||
'queue.editFailed': 'Edit failed: this message may have already started sending.',
|
||||
'queue.removeFailed': 'Removal failed: this message may have already started sending.',
|
||||
'terminal.signal': 'signal {signal}',
|
||||
'terminal.exitCode': 'exit code {code}',
|
||||
'terminal.running': 'Running',
|
||||
'terminal.failed': 'Failed',
|
||||
'terminal.done': 'Done',
|
||||
'terminal.noOutput': 'No output',
|
||||
'terminal.collapseAria': 'Collapse output',
|
||||
'terminal.expandAria': 'Expand the remaining {n} output lines',
|
||||
'terminal.expandRest': '… {n} more lines',
|
||||
'json.truncated': '… truncated, {total} characters total',
|
||||
'clock.md': '{m}/{d}',
|
||||
'clock.ymd': '{y}-{m}-{d}',
|
||||
} satisfies Record<ConversationKey, string>
|
||||
@@ -5,13 +5,14 @@
|
||||
// ../contract/slots.ts beside the other input-region slots.
|
||||
import type { Context } from 'cordis'
|
||||
import { useEffect, useId, useState } from 'react'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14,
|
||||
IconCloseOutline16, IconEditOutline16, IconTrashOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { QueueAction, QueueItemId } from '../contract/queue.ts'
|
||||
import { NS } from '../locales.ts'
|
||||
import css from './QueueDock.module.css'
|
||||
|
||||
/** Queue operations injected by the session-scoped registration. */
|
||||
@@ -20,14 +21,14 @@ export interface QueueDockInjected {
|
||||
notify: (level: 'info' | 'error', text: string) => void
|
||||
}
|
||||
|
||||
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */
|
||||
export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected
|
||||
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat + the locale seat. */
|
||||
export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected & PropsLocale<'conversation'>
|
||||
|
||||
/**
|
||||
* Queue strip: one item renders directly; multiple items default to a
|
||||
* collapsible count header; an empty queue renders nothing.
|
||||
*/
|
||||
export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
|
||||
export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps) {
|
||||
const queue = useSession(s => s.queue)
|
||||
const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null)
|
||||
const [busy, setBusy] = useState<QueueItemId | null>(null)
|
||||
@@ -67,7 +68,7 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
|
||||
if (await applyAction(
|
||||
editing.id,
|
||||
{ kind: 'edit', content: [{ type: 'text', text: editing.text }] },
|
||||
'编辑失败:这条消息可能已经开始发送。',
|
||||
t('queue.editFailed'),
|
||||
)) setEditing(null)
|
||||
}
|
||||
|
||||
@@ -83,7 +84,7 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
|
||||
disabled={interactionActive}
|
||||
onClick={() => { setCollapsed(value => !value) }}
|
||||
>
|
||||
<span className={css.count}>{queue.length} 条排队消息</span>
|
||||
<span className={css.count}>{t('queue.count', { n: queue.length })}</span>
|
||||
<span className={css.chevron} aria-hidden>
|
||||
{expanded ? <IconChevronDownOutline14 /> : <IconChevronUpOutline14 />}
|
||||
</span>
|
||||
@@ -97,7 +98,7 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
|
||||
<input
|
||||
autoFocus
|
||||
className={css.editor}
|
||||
aria-label="编辑排队消息"
|
||||
aria-label={t('queue.edit')}
|
||||
value={editing.text}
|
||||
onChange={(event) => { setEditing({ id: row.id, text: event.currentTarget.value }) }}
|
||||
onKeyDown={(event) => {
|
||||
@@ -120,8 +121,8 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label="保存排队消息"
|
||||
title="保存排队消息"
|
||||
aria-label={t('queue.save')}
|
||||
title={t('queue.save')}
|
||||
disabled={busy !== null || editing.text.trim() === ''}
|
||||
onClick={() => { void saveEdit() }}
|
||||
>
|
||||
@@ -130,8 +131,8 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label="取消编辑"
|
||||
title="取消编辑"
|
||||
aria-label={t('queue.cancelEdit')}
|
||||
title={t('queue.cancelEdit')}
|
||||
disabled={busy !== null}
|
||||
onClick={() => { setEditing(null) }}
|
||||
>
|
||||
@@ -144,8 +145,8 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label="编辑排队消息"
|
||||
title={row.text === null ? '包含非文本内容,暂不支持编辑' : '编辑排队消息'}
|
||||
aria-label={t('queue.edit')}
|
||||
title={row.text === null ? t('queue.edit.unsupported') : t('queue.edit')}
|
||||
disabled={busy !== null || row.text === null}
|
||||
onClick={() => {
|
||||
if (row.text !== null) setEditing({ id: row.id, text: row.text })
|
||||
@@ -156,14 +157,14 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label="删除排队消息"
|
||||
title="删除排队消息"
|
||||
aria-label={t('queue.remove')}
|
||||
title={t('queue.remove')}
|
||||
disabled={busy !== null}
|
||||
onClick={() => {
|
||||
void applyAction(
|
||||
row.id,
|
||||
{ kind: 'remove' },
|
||||
'删除失败:这条消息可能已经开始发送。',
|
||||
t('queue.removeFailed'),
|
||||
)
|
||||
}}
|
||||
>
|
||||
@@ -196,6 +197,7 @@ export const queueDockEntry = {
|
||||
name: 'conversation.input.dock',
|
||||
id: 'queue',
|
||||
order: 20,
|
||||
locale: NS,
|
||||
inject: (sessionId: SessionId): QueueDockInjected => {
|
||||
const actx = ctx.sessions.scope(sessionId)
|
||||
if (actx === undefined) throw new Error(`queue dock: session "${sessionId}" resolved no scope`)
|
||||
|
||||
@@ -41,10 +41,14 @@ export function ApprovalPanel(props: ApprovalComposerProps) {
|
||||
const approval = useMemo(() => new PendingApproval(props.matched), [props.matched])
|
||||
const command = props.useSession(s => commandOf(
|
||||
approval.callId === undefined ? undefined : s.runningCalls.find(call => call.callId === approval.callId)))
|
||||
return <ApprovalFlow key={approval.key} pending={approval} {...command === undefined ? {} : { command }} />
|
||||
return <ApprovalFlow key={approval.key} pending={approval} t={props.t} {...command === undefined ? {} : { command }} />
|
||||
}
|
||||
|
||||
function ApprovalFlow({ pending, command }: { pending: PendingApproval; command?: string }) {
|
||||
function ApprovalFlow({ pending, command, t }: {
|
||||
pending: PendingApproval
|
||||
command?: string
|
||||
t: ApprovalComposerProps['t']
|
||||
}) {
|
||||
// Local one-shot latch: the panel leaves only when the resolved frame
|
||||
// lands; until then the buttons must not re-fire. An answer failure
|
||||
// (rejected receipt / transport) re-arms them for retry.
|
||||
@@ -56,20 +60,20 @@ function ApprovalFlow({ pending, command }: { pending: PendingApproval; command?
|
||||
return (
|
||||
<div className={css.root} data-approval-key={pending.key}>
|
||||
<div className={css.card}>
|
||||
<div className={css.strip}><span className={css.dot} />等待审批</div>
|
||||
<div className={css.strip}><span className={css.dot} />{t('approval.waiting')}</div>
|
||||
{/* Tab stop: the region scrolls once the command passes the cap and
|
||||
holds nothing focusable of its own, so without one a keyboard-only
|
||||
user cannot reach the command's tail before answering. */}
|
||||
<div className={css.body} data-approval-scroll="" tabIndex={0} role="group" aria-label="审批详情">
|
||||
<div className={css.headline}>{pending.reason ?? `工具 ${pending.toolName} 请求越权执行`}</div>
|
||||
<div className={css.body} data-approval-scroll="" tabIndex={0} role="group" aria-label={t('approval.detail.aria')}>
|
||||
<div className={css.headline}>{pending.reason ?? t('approval.escalation', { toolName: pending.toolName })}</div>
|
||||
{command !== undefined && <div className={css.command}>{command}</div>}
|
||||
</div>
|
||||
<div className={css.actionRow}>
|
||||
<button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
|
||||
拒绝
|
||||
{t('approval.reject')}
|
||||
</button>
|
||||
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
|
||||
允许一次
|
||||
{t('approval.allowOnce')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ export type ConversationRootProps = ConversationSlotProps
|
||||
|
||||
export function ConversationRoot({
|
||||
sessionId, useSession, useSessions, useWorkspaces, useInput,
|
||||
renderSlot, renderSlotChain, selectWorkspace,
|
||||
renderSlot, renderSlotChain, selectWorkspace, t,
|
||||
}: ConversationRootProps) {
|
||||
const openState = useSession(s => s.openState)
|
||||
const composerPhase = useSession(s => s.composerPhase)
|
||||
@@ -94,6 +94,7 @@ export function ConversationRoot({
|
||||
label={chipTitle}
|
||||
menuOpen={pickerOpen}
|
||||
onClick={() => { setPickerOpen(open => !open) }}
|
||||
t={t}
|
||||
/>
|
||||
{renderSlot('conversation.hero.workspace', {
|
||||
open: pickerOpen,
|
||||
@@ -120,8 +121,8 @@ export function ConversationRoot({
|
||||
const inputBar = renderSlot('conversation.composer.bar', {
|
||||
variant: hero ? 'hero' : 'composer',
|
||||
...(inert
|
||||
? { disabled: true, placeholder: 'Choose a workspace to start' }
|
||||
: hero ? { placeholder: 'Describe what you want to build' } : {}),
|
||||
? { disabled: true, placeholder: t('placeholder.workspace') }
|
||||
: hero ? { placeholder: t('placeholder.hero') } : {}),
|
||||
overlay: renderSlot('conversation.input.overlay', {}),
|
||||
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
|
||||
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
|
||||
@@ -133,7 +134,7 @@ export function ConversationRoot({
|
||||
const composerBar = (
|
||||
<div className={clsx(css.composerStack, hero && css.composerHero)}>
|
||||
{hero && <HeroGlow className={css.heroGlow} />}
|
||||
{hero && <HeroShell />}
|
||||
{hero && <HeroShell t={t} />}
|
||||
{hero && heroWorkspaceRow}
|
||||
{!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)}
|
||||
{inputBar}
|
||||
|
||||
@@ -24,7 +24,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
|
||||
|
||||
export function ConversationSession({
|
||||
sessionId, useSession, useSessions, useInput, inputActions, useStore, actions,
|
||||
renderSlot, views, bindDraftMirror, open, wrapActiveBody,
|
||||
renderSlot, views, bindDraftMirror, open, wrapActiveBody, t,
|
||||
}: ConversationSessionProps) {
|
||||
useSyncExternalStore(views.subscribe, views.version)
|
||||
const tabs = views.list()
|
||||
@@ -65,7 +65,7 @@ export function ConversationSession({
|
||||
{!hideChrome && (
|
||||
<>
|
||||
<div className={css.crumbRow}>
|
||||
<nav className={css.crumbs} aria-label="Session hierarchy">
|
||||
<nav className={css.crumbs} aria-label={t('session.hierarchy')}>
|
||||
{ancestry.map((summary, index) => {
|
||||
const last = index === ancestry.length - 1
|
||||
return (
|
||||
|
||||
@@ -11,7 +11,7 @@ import { CodeBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { DetailsSlotProps } from '../contract/slots.ts'
|
||||
import { terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import type { ToolCallBlock } from '../contract/tool-call-model.ts'
|
||||
import css from './DetailsPanel.module.css'
|
||||
|
||||
@@ -68,7 +68,7 @@ function pretty(raw: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function DetailsPanel({ useSession, useSessions, sessionId, useStore, closeDetails }: DetailsPanelProps) {
|
||||
export function DetailsPanel({ useSession, useSessions, sessionId, useStore, closeDetails, t }: DetailsPanelProps) {
|
||||
const selection = useStore(s => s.selection)
|
||||
// Session workspace root: an omitted or relative terminal cwd resolves
|
||||
// against it, which the pure presenter cannot see.
|
||||
@@ -84,10 +84,10 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
|
||||
<div className={css.root}>
|
||||
<div className={css.header}>
|
||||
<div className={css.title}>
|
||||
{selection === null ? '详情' : material?.name ?? selection.toolName ?? '详情'}
|
||||
{selection === null ? t('details.title') : material?.name ?? selection.toolName ?? t('details.title')}
|
||||
</div>
|
||||
<button
|
||||
type="button" className={css.close} aria-label="关闭详情"
|
||||
type="button" className={css.close} aria-label={t('details.close')}
|
||||
onClick={() => { closeDetails() }}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
|
||||
@@ -97,24 +97,24 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
|
||||
</div>
|
||||
<div className={css.body}>
|
||||
{selection === null || callId === undefined
|
||||
? <div className={css.empty}>点击消息流中的工具行查看详情</div>
|
||||
? <div className={css.empty}>{t('details.empty')}</div>
|
||||
: material === null
|
||||
? <div className={css.empty}>该调用不在当前窗口内</div>
|
||||
? <div className={css.empty}>{t('details.notInWindow')}</div>
|
||||
: (
|
||||
<>
|
||||
{material.argsRaw !== null && (
|
||||
<section className={css.section}>
|
||||
<div className={css.sectionLabel}>Input</div>
|
||||
<CodeBlock code={pretty(material.argsRaw)} lang="json" />
|
||||
<div className={css.sectionLabel}>{t('details.input')}</div>
|
||||
<CodeBlock code={pretty(material.argsRaw)} lang="json" copyLabel={t('copy')} copiedLabel={t('copied')} />
|
||||
</section>
|
||||
)}
|
||||
<section className={css.section}>
|
||||
<div className={css.sectionLabel}>Output</div>
|
||||
<div className={css.sectionLabel}>{t('details.output')}</div>
|
||||
{/* Keyed by the selected call: the body owns per-call view
|
||||
state (the terminal card's expand and copy), which React
|
||||
would otherwise carry into the next selection because the
|
||||
panel does not unmount between calls. */}
|
||||
<OutputBody key={callId} material={material} cwd={sessionCwd} />
|
||||
<OutputBody key={callId} material={material} cwd={sessionCwd} t={t} />
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
@@ -131,9 +131,10 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
|
||||
* a running call with no terminal card yet, keeps the flattened text form.
|
||||
* @param props.material - the selected call's material from {@link materialFor}.
|
||||
* @param props.cwd - the session workspace root, resolving the terminal view's cwd.
|
||||
* @param props.t - the panel's locale seat, passed down as a plain prop.
|
||||
* @returns the Output section's body element.
|
||||
*/
|
||||
function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | undefined }) {
|
||||
function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string | undefined; t: DetailsPanelProps['t'] }) {
|
||||
const terminal = terminalCardModel(material.block, cwd)
|
||||
if (terminal !== null) {
|
||||
// The contract renders the presenter's description above the card, and the
|
||||
@@ -143,13 +144,13 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u
|
||||
{terminal.description !== undefined && (
|
||||
<div className={css.terminalDescription}>{terminal.description}</div>
|
||||
)}
|
||||
<TerminalBlock {...terminal.card} className={css.terminal} />
|
||||
<TerminalBlock {...terminal.card} labels={terminalBlockLabels(t)} className={css.terminal} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
// A settled call always carries the result node the flattened form needs;
|
||||
// the running shape has no result to flatten.
|
||||
if (!('kind' in material.block)) return <div className={css.empty}>运行中…</div>
|
||||
if (!('kind' in material.block)) return <div className={css.empty}>{t('details.running')}</div>
|
||||
const result = material.block
|
||||
return (
|
||||
<pre className={css.code} data-error={result.isError || undefined}>
|
||||
|
||||
@@ -10,8 +10,12 @@ import {
|
||||
FishLogo, IconChevronDownOutline14, IconFolderClose16, IconFolderOpen16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { workspaceTitleOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSlotProps } from '../contract/slots.ts'
|
||||
import css from './HeroShell.module.css'
|
||||
|
||||
/** The owner's locale seat type, passed to hero chrome as a plain prop. */
|
||||
type HeroTranslate = ConversationSlotProps['t']
|
||||
|
||||
/**
|
||||
* Basename label for the workspace chip (the shared derivation);
|
||||
* separator-only paths echo the raw cwd.
|
||||
@@ -34,18 +38,19 @@ export function workspaceLabel(cwd: string): string {
|
||||
* @param props.onClick - menu toggle.
|
||||
* @returns the chip button element.
|
||||
*/
|
||||
export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: {
|
||||
export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick, t }: {
|
||||
buttonRef?: RefObject<HTMLButtonElement>
|
||||
label?: string | undefined
|
||||
menuOpen?: boolean
|
||||
onClick?: () => void
|
||||
t: HeroTranslate
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className={css.workspace}
|
||||
aria-label="Choose workspace"
|
||||
aria-label={t('hero.chooseWorkspace')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={onClick}
|
||||
@@ -53,7 +58,7 @@ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: {
|
||||
{label === undefined
|
||||
? <IconFolderClose16 className={css.folder} size={16} />
|
||||
: <IconFolderOpen16 className={css.folder} size={16} />}
|
||||
<span className={css.workspaceLabel}>{label ?? 'Choose workspace'}</span>
|
||||
<span className={css.workspaceLabel}>{label ?? t('hero.chooseWorkspace')}</span>
|
||||
<IconChevronDownOutline14 className={css.chevron} size={12} />
|
||||
</button>
|
||||
)
|
||||
@@ -95,6 +100,8 @@ export function HeroGlow({ className }: { className?: string | undefined }) {
|
||||
|
||||
/** Hero chrome props. The workspace row rides the InputBar accessory hole, not here. */
|
||||
export interface HeroShellProps {
|
||||
/** The owner's locale seat, passed down as a plain prop. */
|
||||
t: HeroTranslate
|
||||
/** Overlay content after the stack (modals). */
|
||||
children?: ReactNode
|
||||
}
|
||||
@@ -105,14 +112,14 @@ export interface HeroShellProps {
|
||||
* @param props - see {@link HeroShellProps}.
|
||||
* @returns the centered hero element tree.
|
||||
*/
|
||||
export function HeroShell({ children }: HeroShellProps) {
|
||||
export function HeroShell({ t, children }: HeroShellProps) {
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<div className={css.stack}>
|
||||
<div className={css.headline}>
|
||||
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */}
|
||||
<FishLogo size={34} className={css.fish} />
|
||||
Let's start building
|
||||
{t('hero.headline')}
|
||||
</div>
|
||||
<div className={css.body}>
|
||||
{/* The resident composer (ConversationRoot wrapActiveBody seat; the
|
||||
|
||||
@@ -15,6 +15,7 @@ import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
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 { Translate } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ComposerBarProps } from '../contract/slots.ts'
|
||||
import { deriveDecorations } from '../input/decorations.ts'
|
||||
import type { DraftDecorations } from '../input/decorations.ts'
|
||||
@@ -33,9 +34,9 @@ export interface InputBarError {
|
||||
export type InputBarProps = ComposerBarProps
|
||||
|
||||
export function InputBar({
|
||||
useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon,
|
||||
useSession, useInput, inputActions, keyboard, stop, command, t, renderSlot, useNotices, useLexicon,
|
||||
useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer,
|
||||
onAdd, addLabel = 'Add attachment',
|
||||
onAdd, addLabel,
|
||||
}: InputBarProps) {
|
||||
const input = useInput(s => s)
|
||||
const notice = useNotices(s => s)
|
||||
@@ -256,7 +257,8 @@ export function InputBar({
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const primaryLabel = running ? 'Stop generating' : 'Send message'
|
||||
const addText = addLabel ?? t('input.addAttachment')
|
||||
const primaryLabel = running ? t('input.stop') : t('input.send')
|
||||
const onPrimary = (): void => {
|
||||
if (inputActions === undefined || stop === undefined) return // absent machine: the button is disabled
|
||||
if (running) {
|
||||
@@ -272,7 +274,7 @@ export function InputBar({
|
||||
// or while the command face is absent with the session).
|
||||
const accessSelect: ReactNode = command === undefined
|
||||
? null
|
||||
: <PermissionSelect value={permissions} locked={locked} command={command} />
|
||||
: <PermissionSelect value={permissions} locked={locked} command={command} t={t} />
|
||||
|
||||
// Mirror-layer decorations: a visible backdrop with transparent text. The
|
||||
// claim token highlights through behind the textarea glyphs; each U+FFFC
|
||||
@@ -341,8 +343,10 @@ export function InputBar({
|
||||
if (deco.hint !== null) {
|
||||
// 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 hintKey = `hint.${commandName === 'goal' && hasGoal ? 'goal.active' : commandName}`
|
||||
// Dynamic lookup by claimed command name: unknown commands miss the
|
||||
// dictionary and keep the machine's own hint, so the call is wide.
|
||||
const translated = (t as Translate)(hintKey)
|
||||
const displayHint = translated !== hintKey ? translated : deco.hint
|
||||
backdrop.push(<span key="hint" className={css.hint} data-decoration="hint">{displayHint}</span>)
|
||||
}
|
||||
@@ -376,8 +380,8 @@ export function InputBar({
|
||||
readOnly={machineBusy}
|
||||
data-phase={input?.phase ?? 'inert'}
|
||||
placeholder={placeholder ?? (disabled
|
||||
? 'Session unavailable'
|
||||
: planActive ? translateHint('placeholder.plan') : translateHint('placeholder.default'))}
|
||||
? t('placeholder.unavailable')
|
||||
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
|
||||
rows={2}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
@@ -395,8 +399,8 @@ export function InputBar({
|
||||
<button
|
||||
type="button"
|
||||
className={css.add}
|
||||
aria-label={addLabel}
|
||||
title={addLabel}
|
||||
aria-label={addText}
|
||||
title={addText}
|
||||
disabled={locked}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={onAdd}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 type { ComposerBarProps } from '../contract/slots.ts'
|
||||
import css from './PermissionSelect.module.css'
|
||||
|
||||
/**
|
||||
@@ -19,9 +20,11 @@ export interface PermissionSelectProps {
|
||||
value: PermissionSelectValue | undefined
|
||||
locked: boolean
|
||||
command: (line: string) => Promise<boolean>
|
||||
/** The owning bar's locale seat, passed down as a plain prop. */
|
||||
t: ComposerBarProps['t']
|
||||
}
|
||||
|
||||
export function PermissionSelect({ value, locked, command }: PermissionSelectProps) {
|
||||
export function PermissionSelect({ value, locked, command, t }: PermissionSelectProps) {
|
||||
const [pick, setPick] = useState<string | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
@@ -56,7 +59,7 @@ export function PermissionSelect({ value, locked, command }: PermissionSelectPro
|
||||
<button
|
||||
type="button"
|
||||
className={css.trigger}
|
||||
aria-label={`Access mode, current: ${displayName(current?.name ?? currentValue)}`}
|
||||
aria-label={t('input.accessMode', { name: displayName(current?.name ?? currentValue) })}
|
||||
title={current?.description}
|
||||
disabled={locked || busy}
|
||||
onClick={() => { setOpen(!open) }}
|
||||
|
||||
@@ -7,18 +7,21 @@
|
||||
|
||||
import { useId, useState } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// The domain's client-namespace pure-type outlet: one import edge delivers
|
||||
// the `todos` projection-key merge (single source, no consumer-side restated
|
||||
// declare) and the payload type. Type-only by construction — the outlet is
|
||||
// free of host value imports, so no host Context merge enters this program.
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
|
||||
import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { NS } from '../locales.ts'
|
||||
import css from './TodoPanel.module.css'
|
||||
|
||||
export interface TodoPanelProps {
|
||||
/** The session's current plan (empty renders nothing) — selected by the dock adapter. */
|
||||
todos: readonly TodoItem[]
|
||||
/** The dock entry's locale seat, passed down as a plain prop. */
|
||||
t: TodoDockProps['t']
|
||||
}
|
||||
|
||||
/** Local exhaustiveness helper — client packages do not depend on `dsh-llm`. */
|
||||
@@ -76,18 +79,18 @@ function StatusGlyph({ status }: { status: TodoItem['status'] }) {
|
||||
}
|
||||
|
||||
/** Header summary: "<done>/<total> tasks · <n> in progress". */
|
||||
function progressLabel(todos: readonly TodoItem[]): string {
|
||||
const done = todos.filter(t => t.status === 'completed').length
|
||||
const active = todos.filter(t => t.status === 'in_progress').length
|
||||
return `${done}/${todos.length} tasks · ${active} in progress`
|
||||
function progressLabel(todos: readonly TodoItem[], t: TodoPanelProps['t']): string {
|
||||
const done = todos.filter(item => item.status === 'completed').length
|
||||
const active = todos.filter(item => item.status === 'in_progress').length
|
||||
return t('todo.progress', { done, total: todos.length, active })
|
||||
}
|
||||
|
||||
export function TodoPanel({ todos }: TodoPanelProps) {
|
||||
export function TodoPanel({ todos, t }: TodoPanelProps) {
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
if (todos.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className={css.root} data-testid="todo-panel" aria-label="To-dos">
|
||||
<section className={css.root} data-testid="todo-panel" aria-label={t('todo.title')}>
|
||||
<div className={css.body}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -95,8 +98,8 @@ export function TodoPanel({ todos }: TodoPanelProps) {
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() => { setCollapsed(v => !v) }}
|
||||
>
|
||||
<span className={css.title}>To-dos</span>
|
||||
<span className={css.progress}>{progressLabel(todos)}</span>
|
||||
<span className={css.title}>{t('todo.title')}</span>
|
||||
<span className={css.progress}>{progressLabel(todos, t)}</span>
|
||||
<span className={css.chevron} aria-hidden>
|
||||
{collapsed ? <IconChevronUpOutline14 /> : <IconChevronDownOutline14 />}
|
||||
</span>
|
||||
@@ -116,13 +119,13 @@ export function TodoPanel({ todos }: TodoPanelProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */
|
||||
export type TodoDockProps = PropsRuntime<'conversation.input.dock'>
|
||||
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat + the locale seat. */
|
||||
export type TodoDockProps = PropsRuntime<'conversation.input.dock'> & PropsLocale<'conversation'>
|
||||
|
||||
/** Dock adapter: reads the host-computed 'todos' projection (whole list; absent or null renders nothing). */
|
||||
export function TodoDock({ useProjection }: TodoDockProps) {
|
||||
export function TodoDock({ useProjection, t }: TodoDockProps) {
|
||||
const todos = useProjection('todos')
|
||||
return <TodoPanel todos={todos ?? []} />
|
||||
return <TodoPanel todos={todos ?? []} t={t} />
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,6 +142,6 @@ export const todoDockEntry = {
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 10 }, TodoDock)
|
||||
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 10, locale: NS }, TodoDock)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
|
||||
import { IconQuestionOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from 'cordis'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { toolRowModel } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from '../chat/ToolRow.tsx'
|
||||
import { NS } from '../locales.ts'
|
||||
|
||||
/** One parsed answer entry, shape-checked (result JSON crosses the wire). */
|
||||
interface AnswerEntry { selected?: unknown; custom?: unknown }
|
||||
@@ -19,9 +21,9 @@ function isAnswer(value: unknown): value is AnswerEntry {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
/** `${answered}/${total} answered` off the result JSON (a skipped question has
|
||||
/** Answered-count summary off the result JSON (a skipped question has
|
||||
* empty `selected` and no `custom`); null on unexpected shape (generic fallback). */
|
||||
function answeredSummary(text: string): string | null {
|
||||
function answeredSummary(text: string, t: AskQuestionRowProps['t']): string | null {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
@@ -34,11 +36,14 @@ function answeredSummary(text: string): string | null {
|
||||
const answered = answers.filter(a =>
|
||||
(Array.isArray(a.selected) && a.selected.length > 0)
|
||||
|| (typeof a.custom === 'string' && a.custom !== '')).length
|
||||
return `${answered}/${answers.length} answered`
|
||||
return t('ask.answered', { answered, total: answers.length })
|
||||
}
|
||||
|
||||
/** Full row props: the toolview runtime share plus the standard locale seat. */
|
||||
type AskQuestionRowProps = ToolRowProps & PropsLocale<'conversation'>
|
||||
|
||||
/** One-line question-interaction row (leading toggle expands the raw args). */
|
||||
export function AskQuestionRow({ toolName, block }: ToolRowProps) {
|
||||
export function AskQuestionRow({ toolName, block, t }: AskQuestionRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
// Composer verdicts settle the call as specific UserInteractionErrors
|
||||
// (apiproxy ask_user_question handler): 'ASK_CANCELLED' is the user's own
|
||||
@@ -50,22 +55,23 @@ export function AskQuestionRow({ toolName, block }: ToolRowProps) {
|
||||
let summary = model.summary
|
||||
let state = model.state
|
||||
if (code === 'ASK_CANCELLED') {
|
||||
summary = 'cancelled'
|
||||
summary = t('ask.cancelled')
|
||||
} else if (code === 'ASK_ABORTED') {
|
||||
summary = 'interrupted'
|
||||
summary = t('ask.interrupted')
|
||||
state = 'stopped'
|
||||
} else if (model.state === 'running') {
|
||||
summary = 'waiting'
|
||||
summary = t('ask.waiting')
|
||||
} else if ('kind' in block && model.state === 'ok') {
|
||||
const text = block.content.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
summary = answeredSummary(text) ?? model.summary
|
||||
summary = answeredSummary(text, t) ?? model.summary
|
||||
}
|
||||
return (
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant={model.variant}
|
||||
toolName={toolName}
|
||||
icon={<IconQuestionOutline14 />}
|
||||
title="Ask question"
|
||||
title={t('ask.rowTitle')}
|
||||
summary={summary}
|
||||
body={model.body}
|
||||
state={state}
|
||||
@@ -87,6 +93,6 @@ export const askQuestionToolview = {
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'ask_user_question' }, AskQuestionRow)
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'ask_user_question', locale: NS }, AskQuestionRow)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -15,11 +15,16 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { IconApiOutline14, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { CHAT_TERMINAL_MAX_LINES, terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
|
||||
import { NS } from '../locales.ts'
|
||||
import css from './bash-sample.module.css'
|
||||
|
||||
/** Bash row props: the toolview runtime share plus the standard locale seat. */
|
||||
type BashRowProps = ToolRowProps & PropsLocale<'conversation'>
|
||||
|
||||
function leadingFor(state: ToolRowState) {
|
||||
switch (state) {
|
||||
case 'error': return <StateDot state="error" />
|
||||
@@ -30,11 +35,11 @@ function leadingFor(state: ToolRowState) {
|
||||
}
|
||||
|
||||
/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */
|
||||
function stateStatus(state: ToolRowState): string | null {
|
||||
function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null {
|
||||
switch (state) {
|
||||
case 'running': return '运行中'
|
||||
case 'error': return '失败'
|
||||
case 'stopped': return '已停止'
|
||||
case 'running': return t('bash.running')
|
||||
case 'error': return t('bash.failed')
|
||||
case 'stopped': return t('bash.stopped')
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
@@ -45,14 +50,14 @@ function stateStatus(state: ToolRowState): string | null {
|
||||
* details-panel control (tool rows stopped being one), so the card's copy and
|
||||
* expand controls are the row's only interactions.
|
||||
*/
|
||||
export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProps) {
|
||||
export function BashRow({ toolName, block, sessionId, useSessions, t }: BashRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
// Session workspace root: the terminal view's cwd resolves against it (an
|
||||
// omitted workdir IS the workspace), which the pure presenter cannot do.
|
||||
const cwd = useSessions(list => list.byId[sessionId]?.cwd)
|
||||
const terminal = terminalCardModel(block, cwd)
|
||||
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
|
||||
const status = stateStatus(model.state)
|
||||
const status = stateStatus(model.state, t)
|
||||
return (
|
||||
<div className={css.card}>
|
||||
<div
|
||||
@@ -71,7 +76,12 @@ export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProp
|
||||
<span className={css.summary}>{terminal?.description ?? model.summary}</span>
|
||||
</div>
|
||||
{terminal !== null && (
|
||||
<TerminalBlock {...terminal.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminal} />
|
||||
<TerminalBlock
|
||||
{...terminal.card}
|
||||
maxLines={CHAT_TERMINAL_MAX_LINES}
|
||||
labels={terminalBlockLabels(t)}
|
||||
className={css.terminal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -91,6 +101,6 @@ export const bashToolviewSample = {
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'bash' }, BashRow)
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'bash', locale: NS }, BashRow)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -8,9 +8,14 @@
|
||||
|
||||
import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from 'cordis'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { toolRowModel } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from '../chat/ToolRow.tsx'
|
||||
import { NS } from '../locales.ts'
|
||||
|
||||
/** Todo row props: the toolview runtime share plus the standard locale seat. */
|
||||
type TodoRowProps = ToolRowProps & PropsLocale<'conversation'>
|
||||
|
||||
/** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */
|
||||
interface TodoWriteItem { content?: unknown; status?: unknown }
|
||||
@@ -19,7 +24,7 @@ function isItem(value: unknown): value is TodoWriteItem {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function summarize(argsRaw: string): string | null {
|
||||
function summarize(argsRaw: string, t: TodoRowProps['t']): string | null {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(argsRaw)
|
||||
@@ -32,9 +37,9 @@ function summarize(argsRaw: string): string | null {
|
||||
if (typeof parsed !== 'object' || parsed === null) return null
|
||||
const todos = (parsed as { todos?: unknown }).todos
|
||||
if (!Array.isArray(todos) || !todos.every(isItem)) return null
|
||||
const done = todos.filter(t => t.status === 'completed').length
|
||||
const active = todos.find(t => t.status === 'in_progress')
|
||||
const head = `${done}/${todos.length} 已完成`
|
||||
const done = todos.filter(item => item.status === 'completed').length
|
||||
const active = todos.find(item => item.status === 'in_progress')
|
||||
const head = t('todo.completed', { done, total: todos.length })
|
||||
return typeof active?.content === 'string' && active.content !== ''
|
||||
? `${head} · ${active.content}`
|
||||
: head
|
||||
@@ -43,16 +48,17 @@ function summarize(argsRaw: string): string | null {
|
||||
/** One-line plan update row (leading toggle expands the raw args). Non-ok
|
||||
* execution states keep the shared row's dot semantics — a cancelled call
|
||||
* wrote no todo/write, so it must not read as a completed update. */
|
||||
export function TodoRow({ toolName, block }: ToolRowProps) {
|
||||
export function TodoRow({ toolName, block, t }: TodoRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
|
||||
const summary = summarize(argsRaw) ?? model.summary
|
||||
const summary = summarize(argsRaw, t) ?? model.summary
|
||||
return (
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant={model.variant}
|
||||
toolName={toolName}
|
||||
icon={<IconChecklistOutline14 />}
|
||||
title="更新任务清单"
|
||||
title={t('todo.rowTitle')}
|
||||
summary={summary}
|
||||
body={model.body}
|
||||
state={model.state}
|
||||
@@ -73,6 +79,6 @@ export const todoToolview = {
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'todo_write' }, TodoRow)
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'todo_write', locale: NS }, TodoRow)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -50,7 +50,9 @@ async function bench() {
|
||||
})
|
||||
const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
runtime.provide('layout', layoutFake)
|
||||
runtime.provide('locale', new LocaleService(runtime.ctx))
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
|
||||
// The AppFrame role: the conversation-package slots must be declared by a
|
||||
// live entry before apply can contribute into them.
|
||||
@@ -310,7 +312,7 @@ describe('conversation slot inject surface', () => {
|
||||
// Label falls back to the id when a rider declares none.
|
||||
const off2 = b.slots.register(
|
||||
{ name: 'conversation.view', id: 'bare', order: 6 } as never, (() => null) as never)
|
||||
expect(injected.views.list().map(v => v.label)).toEqual(['Chat', 'X', 'bare'])
|
||||
expect(injected.views.list().map(v => v.label)).toEqual(['对话', 'X', 'bare'])
|
||||
off()
|
||||
off2()
|
||||
unsub()
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
import { AskQuestionRow, askQuestionToolview } from '../src/client/toolviews/ask-question-row.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -28,13 +30,16 @@ const resultNode = (argsRaw: string, resultText: string | null, over?: Partial<T
|
||||
const runningCall = (argsRaw: string) =>
|
||||
({ callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, callView: null })
|
||||
|
||||
function rowProps(block: unknown): ToolRowProps {
|
||||
// Standard locale seat stub mirroring the real ns → common → key chain.
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
|
||||
function rowProps(block: unknown): Parameters<typeof AskQuestionRow>[0] {
|
||||
return {
|
||||
callId: 'c1', toolName: 'ask_user_question', block,
|
||||
callId: 'c1', toolName: 'ask_user_question', block, t,
|
||||
openFile: vi.fn(),
|
||||
sessionId: 's1',
|
||||
useSessions: () => undefined,
|
||||
} as unknown as ToolRowProps
|
||||
} as unknown as Parameters<typeof AskQuestionRow>[0]
|
||||
}
|
||||
|
||||
const answers = (entries: unknown[]): string => JSON.stringify({ answers: entries })
|
||||
@@ -42,8 +47,8 @@ const answers = (entries: unknown[]): string => JSON.stringify({ answers: entrie
|
||||
describe('AskQuestionRow', () => {
|
||||
it('running call reads waiting (args-independent: the composer takeover shows the questions)', () => {
|
||||
const view = render(<AskQuestionRow {...rowProps(runningCall(ARGS))} />)
|
||||
expect(screen.getByText('Ask question')).toBeTruthy()
|
||||
expect(screen.getByText('waiting')).toBeTruthy()
|
||||
expect(screen.getByText('提问')).toBeTruthy()
|
||||
expect(screen.getByText('等待回答')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
@@ -53,7 +58,7 @@ describe('AskQuestionRow', () => {
|
||||
{ id: 'b', selected: [], custom: 'freeform' },
|
||||
{ id: 'c', selected: ['y', 'z'], custom: '' },
|
||||
])))} />)
|
||||
expect(screen.getByText('3/3 answered')).toBeTruthy()
|
||||
expect(screen.getByText('3/3 已回答')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('skipped questions (no selection, no custom) stay out of the answered count', () => {
|
||||
@@ -62,7 +67,7 @@ describe('AskQuestionRow', () => {
|
||||
{ id: 'b', selected: [], custom: '' },
|
||||
{ id: 'c' },
|
||||
])))} />)
|
||||
expect(screen.getByText('1/3 answered')).toBeTruthy()
|
||||
expect(screen.getByText('1/3 已回答')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
@@ -82,7 +87,7 @@ describe('AskQuestionRow', () => {
|
||||
// ASK_CANCELLED: the apiproxy ask_user_question handler's cancel error.
|
||||
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null,
|
||||
{ isError: true, error: { name: 'UserInteractionError', code: 'ASK_CANCELLED' } }))} />)
|
||||
expect(screen.getByText('cancelled')).toBeTruthy()
|
||||
expect(screen.getByText('已取消')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
@@ -90,7 +95,7 @@ describe('AskQuestionRow', () => {
|
||||
// ASK_ABORTED: the apiproxy ask handler's turn-abort settlement.
|
||||
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null,
|
||||
{ isError: true, error: { name: 'UserInteractionError', code: 'ASK_ABORTED' } }))} />)
|
||||
expect(screen.getByText('interrupted')).toBeTruthy()
|
||||
expect(screen.getByText('已中断')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
@@ -98,7 +103,7 @@ describe('AskQuestionRow', () => {
|
||||
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null,
|
||||
{ isError: true, error: { name: 'Interrupted', code: 'interrupted' } }))} />)
|
||||
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
||||
expect(screen.queryByText('cancelled')).toBeNull()
|
||||
expect(screen.queryByText('已取消')).toBeNull()
|
||||
expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -124,6 +129,9 @@ describe('AskQuestionRow', () => {
|
||||
expect(askQuestionToolview.inject).toEqual(['slots', 'conversation'])
|
||||
const register = vi.fn()
|
||||
askQuestionToolview.apply({ slots: { register } } as never)
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'ask_user_question' }, AskQuestionRow)
|
||||
expect(register).toHaveBeenCalledWith(
|
||||
{ name: 'conversation.chat.toolview', key: 'ask_user_question', locale: 'conversation' },
|
||||
AskQuestionRow,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -82,7 +82,9 @@ const LAYOUT_CHILDREN = {
|
||||
async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
runtime.provide('locale', new LocaleService(runtime.ctx))
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
await runtime.sessions.add({
|
||||
id: SID,
|
||||
summary: { title: 'S', displayTitle: 'S', cwd: '/proj' },
|
||||
@@ -116,7 +118,7 @@ describe('todo_write assembly (product registrations, no outlet twins)', () => {
|
||||
// (default-collapsed: the header summary shows; rows appear on expand).
|
||||
const panel = view.container.querySelector('[data-testid="todo-panel"]')
|
||||
expect(panel).not.toBeNull()
|
||||
expect(panel!.textContent).toContain('1/3 tasks · 1 in progress')
|
||||
expect(panel!.textContent).toContain('1/3 项任务 · 1 项进行中')
|
||||
fireEvent.click(panel!.querySelector('button')!)
|
||||
expect([...panel!.querySelectorAll('li')].map(li => li.getAttribute('data-status')))
|
||||
.toEqual(['completed', 'in_progress', 'pending'])
|
||||
@@ -162,7 +164,9 @@ describe('resident composer', () => {
|
||||
it('renders the locked view state while no session exists at all', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
runtime.provide('locale', new LocaleService(runtime.ctx))
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
|
||||
await runtime.mount({ inject: [...inject], apply })
|
||||
const view = runtime.renderRoot()
|
||||
@@ -171,7 +175,7 @@ describe('resident composer', () => {
|
||||
const textarea = view.container.querySelector('textarea')
|
||||
expect(textarea).not.toBeNull()
|
||||
expect(textarea!.disabled).toBe(true)
|
||||
expect(view.getByRole('button', { name: 'Choose workspace' })).toBeTruthy()
|
||||
expect(view.getByRole('button', { name: '选择工作区' })).toBeTruthy()
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -204,7 +208,9 @@ describe('prompt rejection through the assembled composer', () => {
|
||||
it('renders the promptError alert strip and keeps the draft in the machine', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
runtime.provide('locale', new LocaleService(runtime.ctx))
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
const prompt = vi.fn<ISession['prompt']>(async () => ({
|
||||
ok: false, error: { code: 'agent-busy', message: 'prompt rejected before acceptance', details: { reason: 'busy' } },
|
||||
}))
|
||||
@@ -245,7 +251,7 @@ describe('title projection across assembled surfaces', () => {
|
||||
const runtime = await bench([])
|
||||
const view = runtime.renderRoot()
|
||||
// The strict session header breadcrumb reads useSessions ancestry.
|
||||
const crumb = within(view.container.querySelector('[aria-label="Session hierarchy"]') as HTMLElement)
|
||||
const crumb = within(view.container.querySelector('[aria-label="会话层级"]') as HTMLElement)
|
||||
expect(crumb.getByText('S')).toBeTruthy()
|
||||
|
||||
await runtime.sessions.updateSummary(SID, { displayTitle: '修订标题', title: '修订标题' })
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
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'
|
||||
@@ -23,7 +24,9 @@ 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))
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
|
||||
// Declared by ui-layout's root entry in production; the test root declares
|
||||
// them here so the contributions land.
|
||||
@@ -52,7 +55,8 @@ describe('apply wiring', () => {
|
||||
const b = await bench()
|
||||
const entries = b.slots.entries('conversation.view')
|
||||
expect(entries.map(e => e.options.id)).toEqual(['chat'])
|
||||
expect(entries[0]?.options.label).toBe('Chat')
|
||||
// Label is a locale thunk resolving through the zh dictionary.
|
||||
expect(resolveSlotLabel(entries[0]?.options.label)).toBe('对话')
|
||||
expect(entries[0]?.options.order).toBe(0)
|
||||
// Declaring is claiming: the chat entry's registration put the hole on
|
||||
// the ledger with the contract's kind/scope.
|
||||
|
||||
@@ -8,15 +8,21 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import {
|
||||
formatMessageClock, msUntilNextLocalMidnight, startOfLocalDay,
|
||||
} from '../src/client/chat/message-chrome.ts'
|
||||
import { MessageItem } from '../src/client/chat/MessageItem.tsx'
|
||||
import { MessageItem, type MessageItemProps } from '../src/client/chat/MessageItem.tsx'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: MessageItemProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
describe('MessageItem arms', () => {
|
||||
it('user bubbles expose clock / copy / branch / edit; copy writes the text', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
@@ -28,7 +34,7 @@ describe('MessageItem arms', () => {
|
||||
const now = new Date()
|
||||
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
|
||||
render(
|
||||
<MessageItem node={{
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'user', seq: 1, time,
|
||||
content: [{ type: 'text', text: 'hello bubble' }] as never,
|
||||
source: null,
|
||||
@@ -54,7 +60,7 @@ describe('MessageItem arms', () => {
|
||||
value: exec,
|
||||
})
|
||||
render(
|
||||
<MessageItem node={{
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'user', seq: 1, time: 1_000,
|
||||
content: [{ type: 'text', text: 'fallback body' }] as never,
|
||||
source: null,
|
||||
@@ -77,7 +83,7 @@ describe('MessageItem arms', () => {
|
||||
},
|
||||
})
|
||||
render(
|
||||
<MessageItem node={{
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'user', seq: 1, time: 1_000,
|
||||
content: [{ type: 'text', text: 'quiet' }] as never,
|
||||
source: null,
|
||||
@@ -95,7 +101,7 @@ describe('MessageItem arms', () => {
|
||||
|
||||
it('steering bubbles carry the interjection badge and non-text rest blocks, without user actions', () => {
|
||||
const view = render(
|
||||
<MessageItem node={{
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'steering', seq: 2, turn: 1, source: null,
|
||||
content: [{ type: 'text', text: 'steer!' }, { type: 'image', data: 'x' }] as never,
|
||||
} as never}
|
||||
@@ -109,7 +115,7 @@ describe('MessageItem arms', () => {
|
||||
|
||||
it('context uses the Tool calls disclosure chrome and keeps its JSON collapsed by default', () => {
|
||||
const ctxView = render(
|
||||
<MessageItem node={{
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'x\n"y":,[{}]' }],
|
||||
@@ -135,7 +141,7 @@ describe('MessageItem arms', () => {
|
||||
|
||||
it('context preserves the bounded JSON truncation contract', () => {
|
||||
const view = render(
|
||||
<MessageItem node={{
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'x'.repeat(21_000) }],
|
||||
@@ -150,7 +156,7 @@ describe('MessageItem arms', () => {
|
||||
|
||||
it('unknown nodes retain the generic JSON row', () => {
|
||||
const unknownView = render(
|
||||
<MessageItem node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,
|
||||
<MessageItem t={t} node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,
|
||||
)
|
||||
expect(unknownView.getByText(/未知 surface 事件:surface\/next/)).toBeTruthy()
|
||||
})
|
||||
@@ -160,15 +166,15 @@ describe('formatMessageClock', () => {
|
||||
const now = new Date(2026, 6, 29, 10, 0).getTime()
|
||||
|
||||
it('keeps HH:mm on the same calendar day', () => {
|
||||
expect(formatMessageClock(new Date(2026, 6, 29, 14, 24).getTime(), now)).toBe('14:24')
|
||||
expect(formatMessageClock(new Date(2026, 6, 29, 14, 24).getTime(), t, now)).toBe('14:24')
|
||||
})
|
||||
|
||||
it('prefixes month and day across days in the same year', () => {
|
||||
expect(formatMessageClock(new Date(2026, 0, 1, 14, 24).getTime(), now)).toBe('1月1日 14:24')
|
||||
expect(formatMessageClock(new Date(2026, 0, 1, 14, 24).getTime(), t, now)).toBe('1月1日 14:24')
|
||||
})
|
||||
|
||||
it('prefixes year, month, and day across years', () => {
|
||||
expect(formatMessageClock(new Date(2025, 11, 31, 9, 5).getTime(), now)).toBe('2025年12月31日 09:05')
|
||||
expect(formatMessageClock(new Date(2025, 11, 31, 9, 5).getTime(), t, now)).toBe('2025年12月31日 09:05')
|
||||
})
|
||||
|
||||
it('arms the next local midnight from an in-day instant', () => {
|
||||
@@ -191,7 +197,7 @@ describe('useCalendarDay boundary refresh', () => {
|
||||
vi.setSystemTime(dayStart)
|
||||
const time = new Date(2026, 6, 29, 14, 24).getTime()
|
||||
render(
|
||||
<MessageItem node={{
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'user', seq: 1, time,
|
||||
content: [{ type: 'text', text: 'night bubble' }] as never,
|
||||
source: null,
|
||||
@@ -209,7 +215,7 @@ describe('useCalendarDay boundary refresh', () => {
|
||||
describe('small branch tails', () => {
|
||||
it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown blocks={[{ kind: 'reasoning', text: 'one-liner' }]} streaming={false} />,
|
||||
<AssistantMarkdown t={t} blocks={[{ kind: 'reasoning', text: 'one-liner' }]} streaming={false} />,
|
||||
)
|
||||
expect(view.getByText('one-liner')).toBeTruthy()
|
||||
})
|
||||
@@ -224,6 +230,7 @@ describe('small branch tails', () => {
|
||||
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
|
||||
const settled = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'text', text: 'answer body' }, { kind: 'reasoning', text: 'hidden' }]}
|
||||
streaming={false}
|
||||
time={time}
|
||||
@@ -238,6 +245,7 @@ describe('small branch tails', () => {
|
||||
|
||||
const thinkOnly = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'reasoning', text: 'only thinking' }]}
|
||||
streaming={false}
|
||||
time={time}
|
||||
@@ -248,7 +256,7 @@ describe('small branch tails', () => {
|
||||
thinkOnly.unmount()
|
||||
|
||||
const streaming = render(
|
||||
<AssistantMarkdown blocks={[{ kind: 'text', text: 'partial' }]} streaming time={time} />,
|
||||
<AssistantMarkdown t={t} blocks={[{ kind: 'text', text: 'partial' }]} streaming time={time} />,
|
||||
)
|
||||
expect(streaming.queryByRole('button', { name: '复制' })).toBeNull()
|
||||
expect(streaming.queryByText('14:24')).toBeNull()
|
||||
|
||||
@@ -137,7 +137,9 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
}
|
||||
ctx.provide('workspaces', workspaces)
|
||||
ctx.provide('layout', layout)
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
slots.installLocale(locale)
|
||||
|
||||
slots.install(createSlotRenderer())
|
||||
slots.register({
|
||||
|
||||
@@ -11,9 +11,16 @@ import type {
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { StatsLine, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
type BashRowProps = Parameters<typeof BashRow>[0]
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: BashRowProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -168,12 +175,13 @@ describe('bash sample row', () => {
|
||||
|
||||
const rowProps = (sessionId: SessionId, over?: {
|
||||
store?: ReturnType<typeof listStore>
|
||||
}): ToolRowProps => ({
|
||||
}): BashRowProps => ({
|
||||
callId: 'c1', toolName: 'bash', block: result('c1'),
|
||||
openFile: vi.fn(),
|
||||
sessionId,
|
||||
useSessions: bindSnapshotSelector(over?.store ?? listStore()),
|
||||
} as unknown as ToolRowProps)
|
||||
t,
|
||||
} as unknown as BashRowProps)
|
||||
|
||||
it('differential rendering: the scoped variant in sub-sessions, global at roots', () => {
|
||||
const scoped = render(<BashRow {...rowProps(CHILD)} />)
|
||||
|
||||
@@ -4,11 +4,16 @@ import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
|
||||
afterEach(cleanup)
|
||||
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { classifyTool, resolveToolPath, toolRowModel } from '../src/client/contract/tool-call-model.ts'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
|
||||
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import type { ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
|
||||
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}',
|
||||
@@ -132,6 +137,7 @@ describe('tool-call-model', () => {
|
||||
|
||||
describe('ToolRow', () => {
|
||||
const rowProps = {
|
||||
t,
|
||||
variant: 'bash' as const, icon: <i data-testid="tool-icon" />, title: 'Bash',
|
||||
summary: 'List files', body: '{\n "a": 1\n}', state: 'ok' as const,
|
||||
}
|
||||
@@ -221,6 +227,7 @@ describe('ThinkRow', () => {
|
||||
it('expands from either Think or the reasoning summary', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
|
||||
streaming={false}
|
||||
/>,
|
||||
@@ -237,8 +244,8 @@ describe('ThinkRow', () => {
|
||||
})
|
||||
|
||||
describe('GenericToolCard', () => {
|
||||
const props = (toolName: string, block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({
|
||||
callId: 'c1', toolName, block, openFile: vi.fn(),
|
||||
const props = (toolName: string, block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
|
||||
callId: 'c1', toolName, block, openFile: vi.fn(), t,
|
||||
})
|
||||
|
||||
it('renders the classified variant row from the frozen slice', () => {
|
||||
|
||||
@@ -65,7 +65,9 @@ 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))
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
await runtime.sessions.add({
|
||||
id: SID,
|
||||
summary: { title: 'S', displayTitle: 'S' },
|
||||
@@ -193,7 +195,9 @@ 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))
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
|
||||
|
||||
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject
|
||||
|
||||
@@ -14,8 +14,11 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ChatViewSlotProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { ChatView } from '../src/client/chat/ChatView.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { assistantActionsSeqs, deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -122,6 +125,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
openFile,
|
||||
loadOlder,
|
||||
forkAt,
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
t: makeTranslate(zh, commonZh),
|
||||
}
|
||||
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
|
||||
return { set, ChatView, props, openDetails, openFile, loadOlder, forkAt, setSelection }
|
||||
|
||||
@@ -8,12 +8,19 @@ import { cleanup, render } from '@testing-library/react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { RunningToolCall, SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { apply as nodeApply } from '../src/index.ts'
|
||||
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
type BashRowProps = Parameters<typeof BashRow>[0]
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -24,7 +31,7 @@ describe('tails', () => {
|
||||
|
||||
it('ToolRow stopped state renders the warning dot in the leading slot', () => {
|
||||
const view = render(
|
||||
<ToolRow variant="bash" icon={<i data-testid="icon" />} title="Bash" summary="s" body={null} state="stopped" />,
|
||||
<ToolRow t={t} variant="bash" icon={<i data-testid="icon" />} title="Bash" summary="s" body={null} state="stopped" />,
|
||||
)
|
||||
expect(view.queryByTestId('icon')).toBeNull()
|
||||
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
||||
@@ -33,6 +40,7 @@ describe('tails', () => {
|
||||
it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[
|
||||
{ kind: 'reasoning', text: 'thinking hard\nsecond line' },
|
||||
{ kind: 'tool-call', callId: 'c', name: 'bash', argsRaw: '{}' },
|
||||
@@ -45,7 +53,7 @@ describe('tails', () => {
|
||||
expect(view.getByText('thinking hard')).toBeTruthy()
|
||||
expect(view.getByText(/未知内容块/)).toBeTruthy()
|
||||
const stopped = render(
|
||||
<AssistantMarkdown blocks={[{ kind: 'text', text: 'partial words' }]} streaming={false} interrupted />,
|
||||
<AssistantMarkdown t={t} blocks={[{ kind: 'text', text: 'partial words' }]} streaming={false} interrupted />,
|
||||
)
|
||||
expect(stopped.getByText('已停止')).toBeTruthy()
|
||||
})
|
||||
@@ -55,12 +63,13 @@ describe('tails', () => {
|
||||
// groups is layout noise (no text, no pulse, no interrupted marker).
|
||||
const empty = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'tool-call', callId: 'c', name: 'todo_write', argsRaw: '{}' }]}
|
||||
streaming={false}
|
||||
/>,
|
||||
)
|
||||
expect(empty.container.firstChild).toBeNull()
|
||||
const blank = render(<AssistantMarkdown blocks={[]} streaming={false} />)
|
||||
const blank = render(<AssistantMarkdown t={t} blocks={[]} streaming={false} />)
|
||||
expect(blank.container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
@@ -71,8 +80,8 @@ describe('tails', () => {
|
||||
callTime: 1_000,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
}
|
||||
const props: ToolRowOwnerProps = {
|
||||
callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(),
|
||||
const props: GenericToolCardProps = {
|
||||
callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(), t,
|
||||
}
|
||||
const view = render(<GenericToolCard {...props} />)
|
||||
// Settled ok state keeps the variant icon (sparkle) instead of a StateDot.
|
||||
@@ -91,7 +100,8 @@ describe('tails', () => {
|
||||
const props = (block: RunningToolCall | ToolResultNode) => ({
|
||||
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
|
||||
sessionId: sid, useSessions: bindSnapshotSelector(list),
|
||||
} as unknown as ToolRowProps)
|
||||
t,
|
||||
} as unknown as BashRowProps)
|
||||
|
||||
const running: RunningToolCall = {
|
||||
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}',
|
||||
|
||||
@@ -7,10 +7,16 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { StatsLine } from '../src/client/chat/StatsLine.tsx'
|
||||
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -28,6 +34,7 @@ describe('render branch tails', () => {
|
||||
it('AssistantMarkdown reasoning row is ok-state when not the streaming tail', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'reasoning', text: 'done thinking' }, { kind: 'text', text: 'answer' }]}
|
||||
streaming
|
||||
/>,
|
||||
@@ -54,7 +61,7 @@ describe('render branch tails', () => {
|
||||
|
||||
it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown blocks={[{ kind: 'reasoning', text: 'still thinking' }]} streaming />,
|
||||
<AssistantMarkdown t={t} blocks={[{ kind: 'reasoning', text: 'still thinking' }]} streaming />,
|
||||
)
|
||||
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
})
|
||||
@@ -82,6 +89,7 @@ describe('render branch tails', () => {
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
expect(view.getByText('详情')).toBeTruthy()
|
||||
@@ -118,6 +126,7 @@ describe('render branch tails', () => {
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
// Sub-call material: the sub-tool name titles the panel, args pretty-print,
|
||||
|
||||
@@ -8,10 +8,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SessionInputShell } from '../src/client/input/facade.ts'
|
||||
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -42,7 +45,7 @@ interface BenchOptions {
|
||||
promptError?: ConversationSnapshot['promptError']
|
||||
variant?: 'hero' | 'composer'
|
||||
placeholder?: string
|
||||
translateHint?: (key: string) => string
|
||||
t?: InputBarProps['t']
|
||||
accessory?: React.ReactNode
|
||||
overlay?: React.ReactNode
|
||||
leftItems?: React.ReactNode
|
||||
@@ -101,11 +104,8 @@ 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),
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
t: over?.t ?? makeTranslate(zh, commonZh),
|
||||
renderSlot,
|
||||
variant: over?.variant ?? 'composer',
|
||||
...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}),
|
||||
@@ -118,7 +118,7 @@ function bench(over?: BenchOptions) {
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
// aria-label (not role name): title carries the same label and would double-match.
|
||||
const button = view.container.querySelector<HTMLButtonElement>(
|
||||
`button[aria-label="${over?.running === true ? 'Stop generating' : 'Send message'}"]`,
|
||||
`button[aria-label="${over?.running === true ? '停止生成' : '发送消息'}"]`,
|
||||
)!
|
||||
return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls }
|
||||
}
|
||||
@@ -196,7 +196,7 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
fireEvent.change(textarea, { target: { value: '排队消息2' } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('排队消息2', 'queue')
|
||||
expect(button.getAttribute('aria-label')).toBe('Stop generating')
|
||||
expect(button.getAttribute('aria-label')).toBe('停止生成')
|
||||
fireEvent.click(button)
|
||||
expect(stop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -204,8 +204,8 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
it('disabled (session removed) locks the textarea and chrome', () => {
|
||||
const { textarea, view } = bench({ disabled: true })
|
||||
expect(textarea.disabled).toBe(true)
|
||||
expect(textarea.placeholder).toBe('Session unavailable')
|
||||
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(textarea.placeholder).toBe('会话不可用')
|
||||
expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('idle primary sends and disables on empty draft', () => {
|
||||
@@ -222,7 +222,7 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
const textarea = first.view.container.querySelector('textarea')!
|
||||
expect(document.activeElement).toBe(textarea)
|
||||
textarea.blur()
|
||||
fireEvent.mouseDown(first.view.container.querySelector('button[aria-label="Send message"]')!)
|
||||
fireEvent.mouseDown(first.view.container.querySelector('button[aria-label="发送消息"]')!)
|
||||
expect(document.activeElement).toBe(textarea)
|
||||
})
|
||||
|
||||
@@ -285,22 +285,22 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
|
||||
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
|
||||
const { textarea } = bench({ disabled: true })
|
||||
expect(textarea.placeholder).toBe('Session unavailable')
|
||||
expect(textarea.placeholder).toBe('会话不可用')
|
||||
const live = bench()
|
||||
expect(live.textarea.placeholder).toBe('Message the agent')
|
||||
expect(live.textarea.placeholder).toBe('给智能体发消息')
|
||||
const custom = bench({ placeholder: 'Custom placeholder' })
|
||||
expect(custom.textarea.placeholder).toBe('Custom placeholder')
|
||||
})
|
||||
|
||||
it('the plan projection swaps the placeholder while its effective target is plan mode', () => {
|
||||
const active = bench({ plan: { active: true, pending: false } })
|
||||
expect(active.textarea.placeholder).toBe('describe your task to generate plan')
|
||||
expect(active.textarea.placeholder).toBe('描述你的任务以生成计划')
|
||||
// /plan just ran: pending entry already reads as the plan target.
|
||||
const entering = bench({ plan: { active: false, pending: true } })
|
||||
expect(entering.textarea.placeholder).toBe('describe your task to generate plan')
|
||||
expect(entering.textarea.placeholder).toBe('描述你的任务以生成计划')
|
||||
// Pending exit: target is default again.
|
||||
const leaving = bench({ plan: { active: true, pending: true } })
|
||||
expect(leaving.textarea.placeholder).toBe('Message the agent')
|
||||
expect(leaving.textarea.placeholder).toBe('给智能体发消息')
|
||||
// Owner placeholder outranks the plan swap.
|
||||
const custom = bench({ plan: { active: true, pending: false }, placeholder: 'Custom placeholder' })
|
||||
expect(custom.textarea.placeholder).toBe('Custom placeholder')
|
||||
@@ -325,13 +325,14 @@ describe('machine pending lock', () => {
|
||||
expect(shell.snapshot.phase).toBe('submitting')
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
expect(textarea.readOnly).toBe(true)
|
||||
expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="Send message"]')!.disabled).toBe(true)
|
||||
expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="发送消息"]')!.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('decorations', () => {
|
||||
it('claimed token renders the mirror highlight and the blank-args hint', () => {
|
||||
const { view, shell } = bench()
|
||||
// Dictionary-less stub: an unmatched hint key keeps the machine's raw hint.
|
||||
const { view, shell } = bench({ t: makeTranslate({}) })
|
||||
act(() => {
|
||||
shell.setDraft('/goal ')
|
||||
shell.beginCommand(
|
||||
@@ -349,8 +350,7 @@ describe('decorations', () => {
|
||||
})
|
||||
|
||||
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 })
|
||||
const { view, shell } = bench()
|
||||
act(() => {
|
||||
shell.setDraft('/goal ')
|
||||
shell.beginCommand(
|
||||
@@ -441,9 +441,9 @@ describe('strips and variants', () => {
|
||||
describe('placeholder chrome and control seats', () => {
|
||||
it('renders attach; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => {
|
||||
const { view, slotCalls } = bench()
|
||||
expect(view.getByLabelText('Add attachment')).toBeTruthy()
|
||||
expect(view.getByLabelText('添加附件')).toBeTruthy()
|
||||
// Capability absent (no projection value): the chip renders nothing.
|
||||
expect(view.queryByLabelText(/^Access mode/)).toBeNull()
|
||||
expect(view.queryByLabelText(/^访问模式/)).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()
|
||||
@@ -459,7 +459,7 @@ describe('placeholder chrome and control seats', () => {
|
||||
currentValue: 'workspace-write',
|
||||
}
|
||||
const { view } = bench({ permissions })
|
||||
const trigger = view.getByLabelText(/^Access mode/) as HTMLButtonElement
|
||||
const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement
|
||||
// Title-case display is presentation only; the menu ids stay machine names.
|
||||
expect(trigger.textContent).toBe('Workspace Write')
|
||||
fireEvent.click(trigger)
|
||||
@@ -467,11 +467,11 @@ describe('placeholder chrome and control seats', () => {
|
||||
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).
|
||||
const busy = view.getByLabelText(/^Access mode/) as HTMLButtonElement
|
||||
const busy = view.getByLabelText(/^访问模式/) as HTMLButtonElement
|
||||
expect(busy.textContent).toBe('Danger Full Access')
|
||||
expect(busy.disabled).toBe(true)
|
||||
await act(async () => {})
|
||||
expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(false)
|
||||
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('a registered entry fills its seat and receives the locked owner prop', () => {
|
||||
@@ -492,10 +492,10 @@ describe('placeholder chrome and control seats', () => {
|
||||
it('disabled locks the Access chip and attach control (running does not)', () => {
|
||||
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 HTMLButtonElement).disabled).toBe(true)
|
||||
expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(true)
|
||||
cleanup()
|
||||
const live = bench({ running: true, permissions })
|
||||
expect((live.view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(false)
|
||||
expect((live.view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,9 +11,12 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { SessionInputShell } from '../src/client/input/facade.ts'
|
||||
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -48,7 +51,8 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
renderSlot: (() => null) as InputBarProps['renderSlot'],
|
||||
stop: vi.fn(),
|
||||
command: () => Promise.resolve(true),
|
||||
translateHint: (key: string) => key,
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
t: makeTranslate(zh, commonZh),
|
||||
variant: 'composer',
|
||||
}
|
||||
return render(<InputBar {...props} />)
|
||||
@@ -92,7 +96,8 @@ describe('matrix row: claimed', () => {
|
||||
claim()
|
||||
expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标' })
|
||||
expect(view.container.querySelector('[data-decoration="token"]')?.textContent).toBe('/goal ')
|
||||
expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标')
|
||||
// The zh dictionary owns a hint.goal entry, which overrides the raw claim hint (production behavior).
|
||||
expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('输入目标,智能体将持续执行')
|
||||
expect((textarea).readOnly).toBe(false)
|
||||
// Free editing beyond the token: hint drops, claim holds.
|
||||
fireEvent.change(textarea, { target: { value: '/goal 发布版本' } })
|
||||
@@ -170,7 +175,7 @@ describe('matrix row: locked (session disabled)', () => {
|
||||
it('disables the textarea and chrome; the machine currency is untouched', () => {
|
||||
const { view, textarea, shell } = bench({ disabled: true })
|
||||
expect((textarea).disabled).toBe(true)
|
||||
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(shell.snapshot.phase).toBe('plain')
|
||||
})
|
||||
|
||||
|
||||
@@ -15,9 +15,12 @@ import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { SessionInputShell } from '../src/client/input/facade.ts'
|
||||
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -134,7 +137,8 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
renderSlot: (() => null) as InputBarProps['renderSlot'],
|
||||
stop: vi.fn(),
|
||||
command: () => Promise.resolve(true),
|
||||
translateHint: (key: string) => key,
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
t: makeTranslate(zh, commonZh),
|
||||
variant: 'composer',
|
||||
}
|
||||
const view = render(<InputBar {...barProps} />)
|
||||
@@ -168,7 +172,8 @@ describe('scenario A: menu-pick /goal, type args, enter submits', () => {
|
||||
expect(b.shell.snapshot.phase).toBe('claimed')
|
||||
expect(b.textarea.value).toBe('/goal ')
|
||||
expect(b.view.container.querySelector('[data-decoration="token"]')?.textContent).toBe('/goal ')
|
||||
expect(b.view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标内容')
|
||||
// The zh dictionary owns a hint.goal entry, which overrides the machine's raw hint (production behavior).
|
||||
expect(b.view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('输入目标,智能体将持续执行')
|
||||
// Continue typing args; hint drops; claim holds.
|
||||
b.type('/goal 发布 v1')
|
||||
expect(b.shell.snapshot.phase).toBe('claimed')
|
||||
|
||||
@@ -10,9 +10,12 @@ import type {
|
||||
ConversationSnapshot, QueuedMessage, SessionId, SessionListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import type { QueueItemId } from '../src/client/contract/queue.ts'
|
||||
import type { InputState } from '../src/client/input/contract.ts'
|
||||
import { QueueDock, queueDockEntry, type QueueDockInjected } from '../src/client/queue/QueueDock.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { QueueDock, queueDockEntry, type QueueDockInjected, type QueueDockProps } from '../src/client/queue/QueueDock.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -54,9 +57,13 @@ function liveSession(initial: ConversationSnapshot) {
|
||||
|
||||
const INPUT_STATE: InputState = { draft: '', draftRev: 0, phase: 'plain', occurrences: [], queue: [] }
|
||||
|
||||
// Standard locale seat stub mirroring the real ns → common → key chain.
|
||||
const t: QueueDockProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
function kitFor(snapshot: ConversationSnapshot, injected: Partial<QueueDockInjected> = {}) {
|
||||
return {
|
||||
sessionId: SID,
|
||||
t,
|
||||
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
|
||||
useWorkspaces: (() => { throw new Error('unused') }) as never,
|
||||
useProjection: (() => undefined) as never,
|
||||
|
||||
@@ -11,8 +11,11 @@ import type {
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { SessionInputShell } from '../src/client/input/facade.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
|
||||
import { ConversationSession } from '../src/client/skeleton/ConversationSession.tsx'
|
||||
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||
@@ -44,6 +47,9 @@ beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
})
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: ConversationRootProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
const sid = (id: string) => id as SessionId
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
const SID = sid('s1')
|
||||
@@ -126,6 +132,7 @@ function mount(
|
||||
}}
|
||||
bindDraftMirror={write => wiring.bindMirror(write)}
|
||||
open={open}
|
||||
t={t}
|
||||
{...owner}
|
||||
/>
|
||||
)
|
||||
@@ -149,7 +156,7 @@ function mount(
|
||||
useLexicon={bindSnapshotSelector(wiring.lexicon)}
|
||||
stop={stop}
|
||||
command={() => Promise.resolve(true)}
|
||||
translateHint={(key: string) => key}
|
||||
t={t}
|
||||
renderSlot={(() => null) as InputBarProps['renderSlot']}
|
||||
{...bar}
|
||||
/>
|
||||
@@ -181,6 +188,7 @@ function mount(
|
||||
renderSlot,
|
||||
renderSlotChain,
|
||||
selectWorkspace: retargetWorkspace,
|
||||
t,
|
||||
}
|
||||
const view = render(<ConversationRoot {...props} />)
|
||||
return {
|
||||
@@ -241,7 +249,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
const header = b.view.container.querySelector('header')
|
||||
expect(host).not.toBeNull()
|
||||
expect(header?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(b.view.getByText("Let's start building")).toBeTruthy()
|
||||
expect(b.view.getByText('开始构建吧')).toBeTruthy()
|
||||
expect(b.view.queryByTestId('view-chat')).toBeNull()
|
||||
// The same machine-backed textarea is live in the hero, and the
|
||||
// persistence mirror stays bound (ConversationSession mounts chrome-hidden
|
||||
@@ -252,7 +260,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('draft in hero')
|
||||
// Picker: open through the chip; a pick switches to the other
|
||||
// workspace's blank session (draft carry is apply-layer wiring).
|
||||
fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' }))
|
||||
fireEvent.click(b.view.getByRole('button', { name: '选择工作区' }))
|
||||
const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void }
|
||||
expect(owner.open).toBe(true)
|
||||
act(() => { owner.onPick(wid('second')) })
|
||||
@@ -274,7 +282,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(after.value).toBe('kept across flip')
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('kept across flip')
|
||||
expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
|
||||
expect(b.view.queryByText("Let's start building")).toBeNull()
|
||||
expect(b.view.queryByText('开始构建吧')).toBeNull()
|
||||
expect(b.view.getByTestId('view-chat')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -295,7 +303,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
],
|
||||
selectWorkspace,
|
||||
)
|
||||
fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' }))
|
||||
fireEvent.click(b.view.getByRole('button', { name: '选择工作区' }))
|
||||
const owner = b.pickerOwner() as { onPick(id: WorkspaceId): void }
|
||||
await act(async () => { owner.onPick(wid('second')); await Promise.resolve() })
|
||||
expect(selectWorkspace).toHaveBeenCalledWith(wid('second'))
|
||||
@@ -305,7 +313,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
|
||||
it('blank session keeps the interactive picker chip (workspace switchable until the first message)', () => {
|
||||
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
|
||||
const chip = b.view.getByRole('button', { name: 'Choose workspace' })
|
||||
const chip = b.view.getByRole('button', { name: '选择工作区' })
|
||||
expect((chip as HTMLButtonElement).disabled).toBe(false)
|
||||
expect(b.slotCalls).toContain('conversation.hero.workspace')
|
||||
})
|
||||
|
||||
@@ -12,12 +12,20 @@ import type {
|
||||
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../src/client/contract/terminal-card-model.ts'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
|
||||
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
type BashRowProps = Parameters<typeof BashRow>[0]
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -217,8 +225,8 @@ describe('terminalCardModel', () => {
|
||||
})
|
||||
|
||||
describe('chat row terminal body', () => {
|
||||
const ownerProps = (block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({
|
||||
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
|
||||
const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
|
||||
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(), t,
|
||||
})
|
||||
|
||||
it('the expanded body is the command output, capped tighter than the panel', () => {
|
||||
@@ -316,10 +324,11 @@ describe('BashRow terminal card', () => {
|
||||
phase: 'ready',
|
||||
})
|
||||
|
||||
const rowProps = (block: RunningToolCall | ToolResultNode): ToolRowProps => ({
|
||||
const rowProps = (block: RunningToolCall | ToolResultNode): BashRowProps => ({
|
||||
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
|
||||
sessionId: SID, useSessions: bindSnapshotSelector(list()),
|
||||
} as unknown as ToolRowProps)
|
||||
t,
|
||||
} as unknown as BashRowProps)
|
||||
|
||||
it('renders the command output under the summary row, without an expand gesture', () => {
|
||||
const view = render(<BashRow {...rowProps(settled())} />)
|
||||
@@ -400,6 +409,7 @@ describe('DetailsPanel Output section', () => {
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
@@ -507,7 +517,7 @@ describe('DetailsPanel Output section', () => {
|
||||
// No terminal card: the generic path renders the result text in the Output
|
||||
// section's <pre> (the Input section has its own, hence the scoping).
|
||||
expect(view.container.querySelector('[data-terminal]')).toBeNull()
|
||||
const output = view.getByText('Output').closest('section')
|
||||
const output = view.getByText('输出').closest('section')
|
||||
expect(output?.querySelector('pre')?.textContent).toContain('a.ts b.ts')
|
||||
})
|
||||
|
||||
@@ -524,8 +534,8 @@ describe('DetailsPanel Output section', () => {
|
||||
nodes: [settled({ call: null, callView: null, resultView: resultTerminal({ title: 'ls -la' }) })],
|
||||
}), target)
|
||||
expect(view.getByText('c1')).toBeTruthy()
|
||||
expect(view.queryByText('Input')).toBeNull()
|
||||
expect(view.getByText('Output')).toBeTruthy()
|
||||
expect(view.queryByText('输入')).toBeNull()
|
||||
expect(view.getByText('输出')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('scans past other nodes and other calls before reporting the call out of window', () => {
|
||||
@@ -571,6 +581,7 @@ describe('DetailsPanel Output section', () => {
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={closeDetails}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: '关闭详情' }))
|
||||
@@ -586,7 +597,7 @@ describe('DetailsPanel Output section', () => {
|
||||
}), target)
|
||||
// Scope to the Output section: the Input section's CodeBlock renders a
|
||||
// <pre> of its own, and it comes first in document order.
|
||||
expect(nonText.getByText('Output').closest('section')?.querySelector('pre')?.textContent)
|
||||
expect(nonText.getByText('输出').closest('section')?.querySelector('pre')?.textContent)
|
||||
.toBe('{\n "type": "reasoning",\n "text": "why"\n}')
|
||||
cleanup()
|
||||
const empty = mount(snapshot({
|
||||
|
||||
@@ -11,11 +11,18 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
import { TodoRow, todoToolview } from '../src/client/toolviews/todo-row.tsx'
|
||||
import type { TodoDockProps } from '../src/client/skeleton/TodoPanel.tsx'
|
||||
import { TodoDock, TodoPanel, todoDockEntry } from '../src/client/skeleton/TodoPanel.tsx'
|
||||
import { NS, zh } from '../src/client/locales.ts'
|
||||
|
||||
type TodoRowProps = Parameters<typeof TodoRow>[0]
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: TodoDockProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -27,21 +34,21 @@ const LIST: TodoItem[] = [
|
||||
|
||||
describe('TodoPanel', () => {
|
||||
it('renders nothing while the list is empty', () => {
|
||||
const { container } = render(<TodoPanel todos={[]} />)
|
||||
const { container } = render(<TodoPanel todos={[]} t={t} />)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('starts collapsed with the progress summary visible', () => {
|
||||
render(<TodoPanel todos={LIST} />)
|
||||
render(<TodoPanel todos={LIST} t={t} />)
|
||||
expect(screen.getByTestId('todo-panel')).toBeTruthy()
|
||||
expect(screen.getByText('To-dos')).toBeTruthy()
|
||||
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
|
||||
expect(screen.getByText('任务清单')).toBeTruthy()
|
||||
expect(screen.getByText('1/3 项任务 · 1 项进行中')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { expanded: false })).toBeTruthy()
|
||||
expect(screen.queryByRole('list')).toBeNull()
|
||||
})
|
||||
|
||||
it('expands to show one row per item with its status glyph', () => {
|
||||
render(<TodoPanel todos={LIST} />)
|
||||
render(<TodoPanel todos={LIST} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
const items = screen.getAllByRole('listitem')
|
||||
expect(items.map(li => li.getAttribute('data-status'))).toEqual(['completed', 'in_progress', 'pending'])
|
||||
@@ -52,23 +59,23 @@ describe('TodoPanel', () => {
|
||||
})
|
||||
|
||||
it('collapse hides an expanded list; expand restores; header keeps the count summary', () => {
|
||||
render(<TodoPanel todos={LIST} />)
|
||||
render(<TodoPanel todos={LIST} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
const header = screen.getByRole('button', { expanded: true })
|
||||
fireEvent.click(header)
|
||||
expect(screen.queryByRole('list')).toBeNull()
|
||||
// Collapsed header is title + progress only (no in-progress content hint).
|
||||
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
|
||||
expect(screen.getByText('1/3 项任务 · 1 项进行中')).toBeTruthy()
|
||||
expect(screen.queryByText('写组件')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('collapsed header still shows zero in-progress when nothing is active', () => {
|
||||
render(<TodoPanel todos={[{ content: '都完了', status: 'completed' }]} />)
|
||||
render(<TodoPanel todos={[{ content: '都完了', status: 'completed' }]} t={t} />)
|
||||
expect(screen.getByRole('button', { expanded: false })).toBeTruthy()
|
||||
expect(screen.queryByText('都完了')).toBeNull()
|
||||
expect(screen.getByText('1/1 tasks · 0 in progress')).toBeTruthy()
|
||||
expect(screen.getByText('1/1 项任务 · 0 项进行中')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,7 +83,7 @@ describe('TodoPanel', () => {
|
||||
function dockProps(store: ReturnType<typeof createSnapshotStore<{ value: readonly TodoItem[] | null | undefined }>>): TodoDockProps {
|
||||
const useProjection = (_key: string, selector?: (v: unknown) => unknown) =>
|
||||
bindSnapshotSelector(store)(s => (selector ?? (v => v))(s.value))
|
||||
return { useProjection } as unknown as TodoDockProps
|
||||
return { useProjection, t } as unknown as TodoDockProps
|
||||
}
|
||||
|
||||
describe('TodoDock', () => {
|
||||
@@ -86,7 +93,7 @@ describe('TodoDock', () => {
|
||||
// Capability absent (no baseline/frame yet) renders nothing.
|
||||
expect(screen.queryByTestId('todo-panel')).toBeNull()
|
||||
act(() => { store.set({ value: LIST }) })
|
||||
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
|
||||
expect(screen.getByText('1/3 项任务 · 1 项进行中')).toBeTruthy()
|
||||
// The pre-first-write whole value (null) retires the strip (the panel owns no data).
|
||||
act(() => { store.set({ value: null }) })
|
||||
expect(screen.queryByTestId('todo-panel')).toBeNull()
|
||||
@@ -97,7 +104,7 @@ describe('TodoDock', () => {
|
||||
expect(todoDockEntry.inject).toEqual(['slots', 'conversation'])
|
||||
const register = vi.fn()
|
||||
todoDockEntry.apply({ slots: { register } } as never)
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 10 }, TodoDock)
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 10, locale: NS }, TodoDock)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -107,13 +114,14 @@ const resultNode = (argsRaw: string, over?: Partial<ToolResultNode>): ToolResult
|
||||
content: [], isError: false, callView: null, resultView: null, ...over,
|
||||
})
|
||||
|
||||
function rowProps(block: unknown): ToolRowProps {
|
||||
function rowProps(block: unknown): TodoRowProps {
|
||||
return {
|
||||
callId: 'c1', toolName: 'todo_write', block,
|
||||
openFile: vi.fn(),
|
||||
sessionId: 's1',
|
||||
useSessions: () => undefined,
|
||||
} as unknown as ToolRowProps
|
||||
t,
|
||||
} as unknown as TodoRowProps
|
||||
}
|
||||
|
||||
describe('TodoRow', () => {
|
||||
@@ -183,6 +191,6 @@ describe('TodoRow', () => {
|
||||
expect(todoToolview.inject).toEqual(['slots', 'conversation'])
|
||||
const register = vi.fn()
|
||||
todoToolview.apply({ slots: { register } } as never)
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'todo_write' }, TodoRow)
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'todo_write', locale: NS }, TodoRow)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
@@ -36,6 +37,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
@@ -47,7 +49,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
|
||||
@@ -13,7 +13,9 @@ import type { GoalSnapshot } from '@deepseek-ai/dsh-goal/client'
|
||||
import {
|
||||
IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconPauseOutline16, IconPlayOutline16, IconSparkle16, IconTrashOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { GoalActionResult, GoalBarActions } from './slots.ts'
|
||||
import type { GoalKey } from './locales.ts'
|
||||
import css from './GoalBar.module.css'
|
||||
|
||||
export interface GoalBarProps extends GoalBarActions {
|
||||
@@ -21,14 +23,14 @@ export interface GoalBarProps extends GoalBarActions {
|
||||
goal: GoalSnapshot | null | undefined
|
||||
}
|
||||
|
||||
/** Strip labels per visible phase; complete goals render nothing. */
|
||||
/** Strip label keys per visible phase; complete goals render nothing. */
|
||||
const PHASE_LABELS = {
|
||||
active: 'Ongoing Goal',
|
||||
paused: 'Paused Goal',
|
||||
blocked: 'Blocked Goal',
|
||||
} as const
|
||||
active: 'phase.active',
|
||||
paused: 'phase.paused',
|
||||
blocked: 'phase.blocked',
|
||||
} as const satisfies Record<string, GoalKey>
|
||||
|
||||
export function GoalBar({ goal, onEdit, onPause, onResume, onClear }: GoalBarProps) {
|
||||
export function GoalBar({ goal, onEdit, onPause, onResume, onClear, t }: GoalBarProps & PropsLocale<'goal'>) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [pending, setPending] = useState(false)
|
||||
@@ -74,7 +76,7 @@ export function GoalBar({ goal, onEdit, onPause, onResume, onClear }: GoalBarPro
|
||||
<input
|
||||
className={css.objectiveInput}
|
||||
type="text"
|
||||
aria-label="Goal objective"
|
||||
aria-label={t('objective.aria')}
|
||||
value={draft}
|
||||
onChange={(e) => { setDraft(e.target.value) }}
|
||||
onKeyDown={(e) => {
|
||||
@@ -90,8 +92,8 @@ export function GoalBar({ goal, onEdit, onPause, onResume, onClear }: GoalBarPro
|
||||
className={css.iconBtn}
|
||||
onClick={() => { void handleEdit() }}
|
||||
disabled={pending || draft.trim() === ''}
|
||||
title="Save goal"
|
||||
aria-label="Save goal"
|
||||
title={t('action.save')}
|
||||
aria-label={t('action.save')}
|
||||
>
|
||||
<IconCheckOutline16 />
|
||||
</button>
|
||||
@@ -100,8 +102,8 @@ export function GoalBar({ goal, onEdit, onPause, onResume, onClear }: GoalBarPro
|
||||
className={css.iconBtn}
|
||||
onClick={() => { setEditing(false) }}
|
||||
disabled={pending}
|
||||
title="Cancel edit"
|
||||
aria-label="Cancel edit"
|
||||
title={t('action.cancel')}
|
||||
aria-label={t('action.cancel')}
|
||||
>
|
||||
<IconCloseOutline16 />
|
||||
</button>
|
||||
@@ -116,17 +118,17 @@ export function GoalBar({ goal, onEdit, onPause, onResume, onClear }: GoalBarPro
|
||||
<div className={css.dock} data-goal-bar>
|
||||
<div className={css.bar} title={title}>
|
||||
<span className={css.sparkle}><IconSparkle16 /></span>
|
||||
<span className={css.label}>{PHASE_LABELS[goal.phase]}</span>
|
||||
<span className={css.label}>{t(PHASE_LABELS[goal.phase])}</span>
|
||||
<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">
|
||||
<button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void runAction(onPause) }} title={t('action.pause')} aria-label={t('action.pause')}>
|
||||
<IconPauseOutline16 />
|
||||
</button>
|
||||
)}
|
||||
{goal.phase === 'paused' && (
|
||||
<button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void runAction(onResume) }} title="Resume goal" aria-label="Resume goal">
|
||||
<button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void runAction(onResume) }} title={t('action.resume')} aria-label={t('action.resume')}>
|
||||
<IconPlayOutline16 />
|
||||
</button>
|
||||
)}
|
||||
@@ -135,12 +137,12 @@ export function GoalBar({ goal, onEdit, onPause, onResume, onClear }: GoalBarPro
|
||||
className={css.iconBtn}
|
||||
disabled={pending}
|
||||
onClick={() => { setDraft(goal.objective); setEditing(true) }}
|
||||
title="Edit goal"
|
||||
aria-label="Edit goal"
|
||||
title={t('action.edit')}
|
||||
aria-label={t('action.edit')}
|
||||
>
|
||||
<IconEditOutline16 />
|
||||
</button>
|
||||
<button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void runAction(onClear) }} title="Clear goal" aria-label="Clear goal">
|
||||
<button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void runAction(onClear) }} title={t('action.clear')} aria-label={t('action.clear')}>
|
||||
<IconTrashOutline16 />
|
||||
</button>
|
||||
</div>
|
||||
@@ -149,11 +151,11 @@ export function GoalBar({ goal, onEdit, onPause, onResume, onClear }: GoalBarPro
|
||||
)
|
||||
}
|
||||
|
||||
/** Full props of the dock entry: InputZone owner share + session standard kit + injected verbs. */
|
||||
export type GoalDockProps = import('@deepseek-ai/dsh-client-ui-slots').PropsRuntime<'conversation.input.dock'> & GoalBarActions
|
||||
/** Full props of the dock entry: InputZone owner share + session standard kit + injected verbs + the locale seat. */
|
||||
export type GoalDockProps = import('@deepseek-ai/dsh-client-ui-slots').PropsRuntime<'conversation.input.dock'> & GoalBarActions & PropsLocale<'goal'>
|
||||
|
||||
/** Dock adapter: reads the host-computed 'goal' projection (whole value; absent or null renders nothing). */
|
||||
export function GoalDock({ useProjection, onEdit, onPause, onResume, onClear }: GoalDockProps) {
|
||||
export function GoalDock({ useProjection, onEdit, onPause, onResume, onClear, t }: GoalDockProps) {
|
||||
const projection = useProjection('goal')
|
||||
return (
|
||||
<GoalBar
|
||||
@@ -162,6 +164,7 @@ export function GoalDock({ useProjection, onEdit, onPause, onResume, onClear }:
|
||||
onPause={onPause}
|
||||
onResume={onResume}
|
||||
onClear={onClear}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,16 +13,30 @@ import type { RpcResult } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the input.dock entry).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// Type-only: the `goal` SessionProjectionMap key merge (single source, the domain's pure outlet).
|
||||
import type { GoalProjection } from '@deepseek-ai/dsh-goal/client'
|
||||
import type { GoalActionResult, GoalBarActions } from './slots.ts'
|
||||
import { GoalDock } from './GoalBar.tsx'
|
||||
import { en, zh, type GoalKey } from './locales.ts'
|
||||
|
||||
export { GoalBar, GoalDock } from './GoalBar.tsx'
|
||||
export type { GoalActionResult, GoalBarActions } from './slots.ts'
|
||||
export type { GoalKey } from './locales.ts'
|
||||
|
||||
/** Required services: slots for the dock entry, sessions for the projected ref, connection for the wire verbs. */
|
||||
export const inject = ['slots', 'sessions', 'connection']
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** The goal strip's copy. */
|
||||
goal: GoalKey
|
||||
}
|
||||
}
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
const NS = 'goal'
|
||||
|
||||
/** Required services: slots for the dock entry, sessions for the projected ref, connection for the wire verbs, locale for the copy. */
|
||||
export const inject = ['slots', 'sessions', 'connection', 'locale']
|
||||
|
||||
/** Map one settled RPC result onto the strip's inline-render shape. */
|
||||
function settle<T>(result: RpcResult<T>): GoalActionResult {
|
||||
@@ -35,6 +49,8 @@ function settle<T>(result: RpcResult<T>): GoalActionResult {
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-goal: dictionaries')
|
||||
|
||||
const { goals } = (ctx.get('connection') as ConnectionHandle).api
|
||||
|
||||
// Conditional mount: 'conversation.input.dock' is declared by the
|
||||
@@ -60,6 +76,7 @@ export function apply(ctx: ClientContext): void {
|
||||
name: 'conversation.input.dock',
|
||||
id: 'goal',
|
||||
order: 0,
|
||||
locale: NS,
|
||||
inject: (sessionId): GoalBarActions => ({
|
||||
onEdit: async (objective) => {
|
||||
const ref = refOf(sessionId)
|
||||
|
||||
32
packages/client/ui-goal/src/client/locales.ts
Normal file
32
packages/client/ui-goal/src/client/locales.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/** `goal` namespace dictionaries. */
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'phase.active': '进行中的目标',
|
||||
'phase.paused': '已暂停的目标',
|
||||
'phase.blocked': '受阻的目标',
|
||||
'objective.aria': '目标内容',
|
||||
'action.save': '保存目标',
|
||||
'action.cancel': '取消编辑',
|
||||
'action.pause': '暂停目标',
|
||||
'action.resume': '恢复目标',
|
||||
'action.edit': '编辑目标',
|
||||
'action.clear': '清除目标',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The goal namespace key union. */
|
||||
export type GoalKey = keyof typeof zh
|
||||
|
||||
/** English dictionary, checked complete against the zh key set. */
|
||||
export const en = {
|
||||
'phase.active': 'Ongoing Goal',
|
||||
'phase.paused': 'Paused Goal',
|
||||
'phase.blocked': 'Blocked Goal',
|
||||
'objective.aria': 'Goal objective',
|
||||
'action.save': 'Save goal',
|
||||
'action.cancel': 'Cancel edit',
|
||||
'action.pause': 'Pause goal',
|
||||
'action.resume': 'Resume goal',
|
||||
'action.edit': 'Edit goal',
|
||||
'action.clear': 'Clear goal',
|
||||
} satisfies Record<GoalKey, string>
|
||||
@@ -16,9 +16,13 @@ import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { GoalProjection } from '@deepseek-ai/dsh-goal/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import type { GoalBarActions } from '../src/client/slots.ts'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { GoalDock } from '../src/client/GoalBar.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { apply as nodeApply } from '../src/index.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -61,14 +65,15 @@ function bench(options: { projection?: GoalProjection | null | undefined; failWi
|
||||
resume: answer('goal.resume', { ref }),
|
||||
clear: answer('goal.clear', { cleared: true as const }),
|
||||
} } })
|
||||
const entries = new Map<string, { id?: string; order?: number; inject?: (sessionId: SessionId) => GoalBarActions }>()
|
||||
const entries = new Map<string, { id?: string; order?: number; locale?: string; inject?: (sessionId: SessionId) => GoalBarActions }>()
|
||||
ctx.provide('slots', {
|
||||
register(reg: { name: string; id?: string; order?: number; inject?: (sessionId: SessionId) => GoalBarActions }) {
|
||||
register(reg: { name: string; id?: string; order?: number; locale?: string; inject?: (sessionId: SessionId) => GoalBarActions }) {
|
||||
entries.set(reg.name, reg)
|
||||
return () => { entries.delete(reg.name) }
|
||||
},
|
||||
})
|
||||
ctx.provide('conversation', {})
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
ctx.provide('sessions', {
|
||||
binding: (id: SessionId) => ({
|
||||
sessionId: id,
|
||||
@@ -92,7 +97,7 @@ describe('ui-goal browser plugin', () => {
|
||||
it('registers the GoalBar dock entry with the documented id and order', async () => {
|
||||
const b = bench()
|
||||
await b.fiber.await()
|
||||
expect(b.entry()).toMatchObject({ id: 'goal', order: 0 })
|
||||
expect(b.entry()).toMatchObject({ id: 'goal', order: 0, locale: 'goal' })
|
||||
expect(b.entry()?.inject).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
@@ -150,8 +155,9 @@ describe('GoalDock adapter', () => {
|
||||
onResume: () => Promise.resolve({ ok: true }),
|
||||
onClear: () => Promise.resolve({ ok: true }),
|
||||
}
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
const dockProps = (up: () => GoalProjection | null | undefined) =>
|
||||
({ useProjection: up, ...actions }) as unknown as Parameters<typeof GoalDock>[0]
|
||||
({ useProjection: up, ...actions, t }) as unknown as Parameters<typeof GoalDock>[0]
|
||||
const shown = render(<GoalDock {...dockProps(useProjection)} />)
|
||||
expect(shown.getByText('Ship it')).toBeTruthy()
|
||||
cleanup()
|
||||
|
||||
@@ -6,8 +6,14 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GoalSnapshot } from '@deepseek-ai/dsh-goal/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { GoalBar } from '../src/client/GoalBar.tsx'
|
||||
import type { GoalBarActions } from '../src/client/slots.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
// The framework-injected t seat, stubbed over the zh dictionaries (the default locale).
|
||||
const t: Parameters<typeof GoalBar>[0]['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -34,144 +40,144 @@ function makeActions() {
|
||||
describe('GoalBar', () => {
|
||||
it('renders nothing while loading, absent, or when the goal is complete', () => {
|
||||
const actions = makeActions()
|
||||
const loading = render(<GoalBar goal={undefined} {...actions} />)
|
||||
const loading = render(<GoalBar goal={undefined} {...actions} t={t} />)
|
||||
expect(loading.container.firstChild).toBeNull()
|
||||
cleanup()
|
||||
|
||||
const absent = render(<GoalBar goal={null} {...actions} />)
|
||||
const absent = render(<GoalBar goal={null} {...actions} t={t} />)
|
||||
expect(absent.container.firstChild).toBeNull()
|
||||
cleanup()
|
||||
|
||||
const complete = render(<GoalBar goal={makeGoal({ phase: 'complete' })} {...actions} />)
|
||||
const complete = render(<GoalBar goal={makeGoal({ phase: 'complete' })} {...actions} t={t} />)
|
||||
expect(complete.container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('active goal: sparkle, "Ongoing Goal", truncated objective, edit and clear actions', () => {
|
||||
it('active goal: sparkle, "进行中的目标", truncated objective, edit and clear actions', () => {
|
||||
const actions = makeActions()
|
||||
render(<GoalBar goal={makeGoal()} {...actions} />)
|
||||
expect(screen.getByText('Ongoing Goal')).toBeTruthy()
|
||||
render(<GoalBar goal={makeGoal()} {...actions} t={t} />)
|
||||
expect(screen.getByText('进行中的目标')).toBeTruthy()
|
||||
expect(screen.getByText('Ship the redesign')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear goal' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '清除目标' }))
|
||||
expect(actions.onClear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('edit swaps the strip for a prefilled form; Enter saves, empty stays disabled', async () => {
|
||||
const actions = makeActions()
|
||||
render(<GoalBar goal={makeGoal()} {...actions} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit goal' }))
|
||||
const box = screen.getByRole('textbox', { name: 'Goal objective' })
|
||||
render(<GoalBar goal={makeGoal()} {...actions} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑目标' }))
|
||||
const box = screen.getByRole('textbox', { name: '目标内容' })
|
||||
expect(box).toHaveProperty('value', 'Ship the redesign')
|
||||
|
||||
fireEvent.change(box, { target: { value: ' ' } })
|
||||
expect(screen.getByRole('button', { name: 'Save goal' })).toHaveProperty('disabled', true)
|
||||
expect(screen.getByRole('button', { name: '保存目标' })).toHaveProperty('disabled', true)
|
||||
|
||||
fireEvent.change(box, { target: { value: 'Ship v2' } })
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(actions.onEdit).toHaveBeenCalledWith('Ship v2')
|
||||
await waitFor(() => { expect(screen.getByText('Ongoing Goal')).toBeTruthy() })
|
||||
await waitFor(() => { expect(screen.getByText('进行中的目标')).toBeTruthy() })
|
||||
})
|
||||
|
||||
it('Esc cancels the edit without calling onEdit', () => {
|
||||
const actions = makeActions()
|
||||
render(<GoalBar goal={makeGoal()} {...actions} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit goal' }))
|
||||
fireEvent.keyDown(screen.getByRole('textbox', { name: 'Goal objective' }), { key: 'Escape' })
|
||||
render(<GoalBar goal={makeGoal()} {...actions} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑目标' }))
|
||||
fireEvent.keyDown(screen.getByRole('textbox', { name: '目标内容' }), { key: 'Escape' })
|
||||
expect(actions.onEdit).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Ongoing Goal')).toBeTruthy()
|
||||
expect(screen.getByText('进行中的目标')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('the cancel button exits the form and drops the draft (re-edit starts from the objective)', () => {
|
||||
const actions = makeActions()
|
||||
render(<GoalBar goal={makeGoal()} {...actions} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit goal' }))
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Goal objective' }), { target: { value: 'abandoned draft' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel edit' }))
|
||||
render(<GoalBar goal={makeGoal()} {...actions} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑目标' }))
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '目标内容' }), { target: { value: 'abandoned draft' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消编辑' }))
|
||||
expect(actions.onEdit).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Ongoing Goal')).toBeTruthy()
|
||||
expect(screen.getByText('进行中的目标')).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit goal' }))
|
||||
expect(screen.getByRole('textbox', { name: 'Goal objective' })).toHaveProperty('value', 'Ship the redesign')
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑目标' }))
|
||||
expect(screen.getByRole('textbox', { name: '目标内容' })).toHaveProperty('value', 'Ship the redesign')
|
||||
})
|
||||
|
||||
it('Enter with a blank draft neither saves nor closes the form', () => {
|
||||
const actions = makeActions()
|
||||
render(<GoalBar goal={makeGoal()} {...actions} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit goal' }))
|
||||
const box = screen.getByRole('textbox', { name: 'Goal objective' })
|
||||
render(<GoalBar goal={makeGoal()} {...actions} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑目标' }))
|
||||
const box = screen.getByRole('textbox', { name: '目标内容' })
|
||||
fireEvent.change(box, { target: { value: ' ' } })
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(actions.onEdit).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('textbox', { name: 'Goal objective' })).toBeTruthy()
|
||||
expect(screen.getByRole('textbox', { name: '目标内容' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('active goal: the pause action pauses', () => {
|
||||
const actions = makeActions()
|
||||
render(<GoalBar goal={makeGoal()} {...actions} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Pause goal' }))
|
||||
render(<GoalBar goal={makeGoal()} {...actions} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '暂停目标' }))
|
||||
expect(actions.onPause).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('paused goal: "Paused Goal" with a resume action before edit', () => {
|
||||
it('paused goal: "已暂停的目标" with a resume action before edit', () => {
|
||||
const actions = makeActions()
|
||||
render(<GoalBar goal={makeGoal({ phase: 'paused' })} {...actions} />)
|
||||
expect(screen.getByText('Paused Goal')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Resume goal' }))
|
||||
render(<GoalBar goal={makeGoal({ phase: 'paused' })} {...actions} t={t} />)
|
||||
expect(screen.getByText('已暂停的目标')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: '恢复目标' }))
|
||||
expect(actions.onResume).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a new goal identity drops the edit form (no stale draft over the new goal)', () => {
|
||||
const actions = makeActions()
|
||||
const { rerender } = render(<GoalBar goal={makeGoal()} {...actions} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit goal' }))
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Goal objective' }), { target: { value: 'stale draft' } })
|
||||
const { rerender } = render(<GoalBar goal={makeGoal()} {...actions} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑目标' }))
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '目标内容' }), { target: { value: 'stale draft' } })
|
||||
|
||||
rerender(<GoalBar goal={makeGoal({ id: 'g2' as GoalSnapshot['id'], objective: 'New goal' })} {...actions} />)
|
||||
rerender(<GoalBar goal={makeGoal({ id: 'g2' as GoalSnapshot['id'], objective: 'New goal' })} {...actions} t={t} />)
|
||||
expect(screen.queryByRole('textbox')).toBeNull()
|
||||
expect(screen.getByText('Ongoing Goal')).toBeTruthy()
|
||||
expect(screen.getByText('进行中的目标')).toBeTruthy()
|
||||
expect(screen.getByText('New goal')).toBeTruthy()
|
||||
|
||||
rerender(<GoalBar goal={null} {...actions} />)
|
||||
expect(screen.queryByText('Ongoing Goal')).toBeNull()
|
||||
rerender(<GoalBar goal={null} {...actions} t={t} />)
|
||||
expect(screen.queryByText('进行中的目标')).toBeNull()
|
||||
})
|
||||
|
||||
it('blocked goal: "Blocked Goal" with the block reason as the strip tooltip', () => {
|
||||
it('blocked goal: "受阻的目标" with the block reason as the strip tooltip', () => {
|
||||
const actions = makeActions()
|
||||
const goal = makeGoal({ phase: 'blocked', blockedReason: { code: 'stalled', message: 'No progress in 3 rounds' } })
|
||||
render(<GoalBar goal={goal} {...actions} />)
|
||||
expect(screen.getByText('Blocked Goal')).toBeTruthy()
|
||||
expect(screen.getByText('Blocked Goal').closest('[title]')?.getAttribute('title')).toBe('No progress in 3 rounds')
|
||||
render(<GoalBar goal={goal} {...actions} t={t} />)
|
||||
expect(screen.getByText('受阻的目标')).toBeTruthy()
|
||||
expect(screen.getByText('受阻的目标').closest('[title]')?.getAttribute('title')).toBe('No progress in 3 rounds')
|
||||
})
|
||||
|
||||
it('blocked goal without a reason carries no tooltip', () => {
|
||||
const actions = makeActions()
|
||||
render(<GoalBar goal={makeGoal({ phase: 'blocked' })} {...actions} />)
|
||||
expect(screen.getByText('Blocked Goal')).toBeTruthy()
|
||||
expect(screen.getByText('Blocked Goal').closest('[title]')).toBeNull()
|
||||
render(<GoalBar goal={makeGoal({ phase: 'blocked' })} {...actions} t={t} />)
|
||||
expect(screen.getByText('受阻的目标')).toBeTruthy()
|
||||
expect(screen.getByText('受阻的目标').closest('[title]')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the edit draft open and reports a failed save', async () => {
|
||||
const actions = makeActions()
|
||||
actions.onEdit.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'stale revision' } })
|
||||
render(<GoalBar goal={makeGoal()} {...actions} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit goal' }))
|
||||
const box = screen.getByRole('textbox', { name: 'Goal objective' })
|
||||
render(<GoalBar goal={makeGoal()} {...actions} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑目标' }))
|
||||
const box = screen.getByRole('textbox', { name: '目标内容' })
|
||||
fireEvent.change(box, { target: { value: 'retry this draft' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save goal' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存目标' }))
|
||||
|
||||
expect((await screen.findByRole('alert')).textContent).toBe('stale revision (agent-busy)')
|
||||
expect(screen.getByRole('textbox', { name: 'Goal objective' })).toHaveProperty('value', 'retry this draft')
|
||||
expect(screen.getByRole('textbox', { name: '目标内容' })).toHaveProperty('value', 'retry this draft')
|
||||
})
|
||||
|
||||
it('reports resume and clear failures without hiding the goal', async () => {
|
||||
const actions = makeActions()
|
||||
actions.onResume.mockResolvedValue({ ok: false, error: { code: 'internal', message: 'resume failed' } })
|
||||
const { rerender } = render(<GoalBar goal={makeGoal({ phase: 'paused' })} {...actions} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Resume goal' }))
|
||||
const { rerender } = render(<GoalBar goal={makeGoal({ phase: 'paused' })} {...actions} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '恢复目标' }))
|
||||
expect((await screen.findByRole('alert')).textContent).toBe('resume failed (internal)')
|
||||
|
||||
actions.onClear.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'clear failed' } })
|
||||
rerender(<GoalBar goal={makeGoal()} {...actions} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear goal' }))
|
||||
rerender(<GoalBar goal={makeGoal()} {...actions} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '清除目标' }))
|
||||
expect((await screen.findByRole('alert')).textContent).toBe('clear failed (agent-busy)')
|
||||
expect(screen.getByText('Ship the redesign')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
|
||||
@@ -18,9 +18,20 @@ import type { ModelsSectionInjected } from './ModelsSection.tsx'
|
||||
import { DeepSeekOnboardingDialog } from './DeepSeekOnboardingDialog.tsx'
|
||||
import type { DeepSeekOnboardingInjected } from './DeepSeekOnboardingDialog.tsx'
|
||||
import { ModelsSettingsStore } from './store.ts'
|
||||
import { en, zh } from './locales.ts'
|
||||
import { en, zh, type ModelsKey } from './locales.ts'
|
||||
|
||||
export type { ModelsSectionInjected, ModelsSectionProps } from './ModelsSection.tsx'
|
||||
export type { ModelsKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** The Models page + onboarding overlay copy. */
|
||||
'settings.models': ModelsKey
|
||||
}
|
||||
}
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
const NS = 'settings.models'
|
||||
export type { ModelsSettingsState, ProviderRow } from './store.ts'
|
||||
|
||||
/**
|
||||
@@ -47,18 +58,14 @@ export const inject = ['slots', 'locale', 'connection']
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => {
|
||||
const disposers = [
|
||||
ctx.locale.register('settings.models', 'zh', zh),
|
||||
ctx.locale.register('settings.models', 'en', en),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-models: copy dictionaries')
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-models: copy dictionaries')
|
||||
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const controller = new ModelsSettingsStore(connection.api)
|
||||
const useSnapshot = bindSnapshotSelector(controller.store)
|
||||
const t = ctx.locale.bind('settings.models') as ModelsSectionInjected['t']
|
||||
// Registration-time text (the nav label thunk) and the inject faces share
|
||||
// one bound translate; copy freshness rides the locale revision.
|
||||
const t = ctx.locale.bind(NS) as ModelsSectionInjected['t']
|
||||
const injected = (): ModelsSectionInjected => ({
|
||||
controller,
|
||||
useSnapshot,
|
||||
@@ -90,7 +97,7 @@ export function apply(ctx: ClientContext): void {
|
||||
name: 'settings.section',
|
||||
id: 'models',
|
||||
order: 10,
|
||||
label: t('nav'),
|
||||
label: () => t('nav'),
|
||||
inject: injected,
|
||||
}, ModelsSection))
|
||||
const onboarding = deferRegistration(
|
||||
@@ -104,14 +111,7 @@ export function apply(ctx: ClientContext): void {
|
||||
inject: onboardingInjected,
|
||||
}, DeepSeekOnboardingDialog),
|
||||
)
|
||||
// Nav labels are registrant-localized: refresh on locale change so the
|
||||
// ledger carries fresh text (the version bump re-renders the shell).
|
||||
const offLocale = ctx.on('locale/change', () => {
|
||||
section.refresh()
|
||||
onboarding.refresh()
|
||||
})
|
||||
return () => {
|
||||
offLocale()
|
||||
section.dispose()
|
||||
onboarding.dispose()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Copy dictionaries for the Models settings section. */
|
||||
|
||||
/** English strings. */
|
||||
/** English strings (the key-set source of truth for this pair). */
|
||||
export const en = {
|
||||
nav: 'Models',
|
||||
title: 'Models',
|
||||
@@ -34,6 +34,9 @@ export const en = {
|
||||
onboardingLater: 'Configure later',
|
||||
}
|
||||
|
||||
/** The settings.models namespace key union. */
|
||||
export type ModelsKey = keyof typeof en
|
||||
|
||||
/** Chinese strings (same keys as {@link en}). */
|
||||
export const zh: typeof en = {
|
||||
nav: '模型',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Models section registration: declaration-aware deferral, locale re-registration, and HMR recovery. */
|
||||
/** Models section registration: declaration-aware deferral, the locale-following label thunk, and HMR recovery. */
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-models/client'
|
||||
@@ -42,7 +43,9 @@ describe('ui-models apply', () => {
|
||||
await before.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entry = before.slots.entries('settings.section')[0]!
|
||||
expect(entry.component).toBe(ModelsSection)
|
||||
expect(entry.options).toMatchObject({ id: 'models', order: 10, label: '模型' })
|
||||
expect(entry.options).toMatchObject({ id: 'models', order: 10 })
|
||||
// The nav label is a locale-following thunk; owners resolve at read time.
|
||||
expect(resolveSlotLabel(entry.options.label)).toBe('模型')
|
||||
const injected = (entry.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected)()
|
||||
expect(injected.t('nav')).toBe('模型')
|
||||
expect(typeof injected.controller.load).toBe('function')
|
||||
@@ -64,14 +67,14 @@ describe('ui-models apply', () => {
|
||||
expect(after.slots.entries('settings.section')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('re-registers with fresh label text on locale change', async () => {
|
||||
it('the label thunk follows the active locale without re-registration', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
b.locale.setLocale('en')
|
||||
expect(b.slots.entries('settings.section')[0]!.options.label).toBe('Models')
|
||||
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Models')
|
||||
b.locale.setLocale('zh')
|
||||
expect(b.slots.entries('settings.section')[0]!.options.label).toBe('模型')
|
||||
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('模型')
|
||||
})
|
||||
|
||||
it('locale change while the slot is undeclared stays a no-op', async () => {
|
||||
@@ -98,7 +101,7 @@ describe('ui-models apply', () => {
|
||||
expect(b.slots.entries('settings.onboarding')[0]!.component).toBe(DeepSeekOnboardingDialog)
|
||||
// The locale path also recovers through the same ledger re-check.
|
||||
b.locale.setLocale('en')
|
||||
expect(b.slots.entries('settings.section')[0]!.options.label).toBe('Models')
|
||||
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Models')
|
||||
b.locale.setLocale('zh')
|
||||
})
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-plan/README.md
|
||||
README.md: 1d22c057b439ff337bf9daadcdba96dd4cca4540
|
||||
README.zh.md: 183b8ef7776b60c1f0afa630e04627a474d40391
|
||||
README.md: 568539c19331cc268217ee2c28b928c38a68323c
|
||||
README.zh.md: 68e3092ad77267a779d21ba627ce2f19469ae05b
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Plan-mode status chip, a pure browser surface plugin. The browser half occupies the conversation-declared `conversation.input.plan` single seat (to the right of the access-mode control); the node half is an empty apply (the roster row). Plan behavior itself — the `/plan` command, the boundary-or-idle-committed `plan/mode` state, the `plan` projection unit, and the policy section — is owned by [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md), composed independently on the host roster.
|
||||
|
||||
Plan mode is entered through the `/plan` command only; there is no UI control that turns it on. While the host-computed `plan` projection's effective target is plan mode (`pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way), the seat renders a read-only "Plan" chip whose hover × executes `/plan off` through `command.execute`; otherwise the seat stays empty — a host without plan-mode (or a Draft with no session) shows nothing. While plan mode is the effective target, the composer textarea's placeholder switches to the plan-task hint — "describe your task to generate plan", localized through ui-conversation's `command.hint` locale namespace and shared verbatim with the claimed `/plan` command hint (rendered by the composer from the same projection; owner-supplied placeholders win).
|
||||
Plan mode is entered through the `/plan` command only; there is no UI control that turns it on. While the host-computed `plan` projection's effective target is plan mode (`pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way), the seat renders a read-only "Plan" chip whose hover × executes `/plan off` through `command.execute`; otherwise the seat stays empty — a host without plan-mode (or a Draft with no session) shows nothing. While plan mode is the effective target, the composer textarea's placeholder switches to the plan-task hint — "describe your task to generate plan", localized through ui-conversation's `conversation` locale namespace (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (rendered by the composer from the same projection; owner-supplied placeholders win).
|
||||
|
||||
The chip carries the accessible description "Plan mode on, press to turn off". Admission failures (`matched: false`, business errors, transport faults) surface as an inline error and the chip stays until the projection confirms the exit.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Plan mode 状态徽章,纯浏览器 surface 插件。浏览器侧占据会话声明的 `conversation.input.plan` 单座(位于 access 模式控件右侧);node 侧是空 apply(roster 行)。plan 行为本身——`/plan` 命令、边界或空闲即时提交的 `plan/mode` 状态、`plan` 投影单元与 policy 段——归 [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md) 所有,由 host roster 独立组合。
|
||||
|
||||
plan mode 只经 `/plan` 命令进入;UI 上没有打开它的控件。当 host 计算的 `plan` 投影有效目标为 plan mode 时(`pending ? !active : active`——折叠的 host 值而非客户端乐观态,帧到达即自动纠正),座位渲染一个只读 "Plan" chip,hover 出现的 × 经 `command.execute` 执行 `/plan off`;否则座位保持为空——未组合 plan-mode 的 host(或尚无会话的 Draft)不显示任何内容。plan mode 为有效目标期间,composer 文本框的 placeholder 切换为 plan 任务提示——"describe your task to generate plan"(中文「描述你的任务以生成计划」),经 ui-conversation 的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(由 composer 从同一投影渲染;owner 提供的 placeholder 优先)。
|
||||
plan mode 只经 `/plan` 命令进入;UI 上没有打开它的控件。当 host 计算的 `plan` 投影有效目标为 plan mode 时(`pending ? !active : active`——折叠的 host 值而非客户端乐观态,帧到达即自动纠正),座位渲染一个只读 "Plan" chip,hover 出现的 × 经 `command.execute` 执行 `/plan off`;否则座位保持为空——未组合 plan-mode 的 host(或尚无会话的 Draft)不显示任何内容。plan mode 为有效目标期间,composer 文本框的 placeholder 切换为 plan 任务提示——"describe your task to generate plan"(中文「描述你的任务以生成计划」),经 ui-conversation 的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(由 composer 从同一投影渲染;owner 提供的 placeholder 优先)。
|
||||
|
||||
chip 携带无障碍描述 "Plan mode on, press to turn off"。准入失败(`matched: false`、业务错误、传输故障)以内联错误呈现,chip 保持显示直至投影确认退出。
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
@@ -36,6 +37,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
@@ -46,7 +48,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the input.plan seat and
|
||||
// its {locked} owner share).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { PlanChipInjected } from './index.ts'
|
||||
import css from './PlanModeControl.module.css'
|
||||
|
||||
/** Full plan-seat component props: runtime share (standard kit + locked owner prop) & injected share. */
|
||||
/** Full plan-seat component props: runtime share (standard kit + locked owner prop) & injected share & the locale seat. */
|
||||
export type PlanChipProps =
|
||||
PropsRuntime<'conversation.input.plan'> & InjectFace<PlanChipInjected>
|
||||
PropsRuntime<'conversation.input.plan'> & InjectFace<PlanChipInjected> & PropsLocale<'plan'>
|
||||
|
||||
/**
|
||||
* Plan-mode toggle over the host-computed `plan` projection. The chip renders
|
||||
@@ -17,7 +17,7 @@ export type PlanChipProps =
|
||||
* client optimism, so an arriving frame corrects it). Clicking executes
|
||||
* /plan or /plan off toward the opposite target.
|
||||
*/
|
||||
export function PlanChip({ useProjection, locked, setPlanMode }: PlanChipProps) {
|
||||
export function PlanChip({ useProjection, locked, setPlanMode, t }: PlanChipProps) {
|
||||
const plan = useProjection('plan')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<{ text: string; detail: string } | null>(null)
|
||||
@@ -37,8 +37,9 @@ export function PlanChip({ useProjection, locked, setPlanMode }: PlanChipProps)
|
||||
|
||||
const toggle = (): void => {
|
||||
// No busy/locked guard: both disable the button, so no click arrives.
|
||||
// Failure copy stays English (error-surface policy: not localized).
|
||||
const on = !target
|
||||
const failText = on ? '进入 plan mode 失败' : '退出 plan mode 失败'
|
||||
const failText = on ? 'failed to enter plan mode' : 'failed to exit plan mode'
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
void setPlanMode(on).then((failure) => {
|
||||
@@ -58,13 +59,12 @@ export function PlanChip({ useProjection, locked, setPlanMode }: PlanChipProps)
|
||||
type="button"
|
||||
className={css.chip}
|
||||
aria-pressed={target}
|
||||
aria-label={target ? 'Plan mode on, press to turn off' : 'Plan mode off, press to turn on'}
|
||||
title={target
|
||||
? 'Plan mode on — click to turn off (/plan off)'
|
||||
: 'Plan mode off — click to turn on (/plan)'}
|
||||
aria-label={target ? t('chip.on.aria') : t('chip.off.aria')}
|
||||
title={target ? t('chip.on.title') : t('chip.off.title')}
|
||||
disabled={locked || busy}
|
||||
onClick={toggle}
|
||||
>
|
||||
{/* Design literal, not copy: the chip wordmark stays 'Plan on/off' in every locale. */}
|
||||
Plan { target ? 'on' : 'off' }
|
||||
</button>
|
||||
{error !== null && <span className={css.error} role="status" title={error.detail}>{error.text}</span>}
|
||||
|
||||
@@ -11,9 +11,24 @@ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client
|
||||
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the input.plan seat).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// Type-only: pulls the `plan` SessionProjectionMap merge for useProjection.
|
||||
import type {} from '@deepseek-ai/dsh-plan-mode/client'
|
||||
import { PlanChip } from './PlanModeControl.tsx'
|
||||
import { en, zh, type PlanKey } from './locales.ts'
|
||||
|
||||
export type { PlanKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** The composer plan chip's copy. */
|
||||
plan: PlanKey
|
||||
}
|
||||
}
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
const NS = 'plan'
|
||||
|
||||
/** Injected business face of the composer plan seat. */
|
||||
export interface PlanChipInjected {
|
||||
@@ -26,25 +41,30 @@ export interface PlanChipInjected {
|
||||
}
|
||||
|
||||
/**
|
||||
* Required services: the seat's slot registry, the transport, and the
|
||||
* conversation service whose presence guarantees the seat is declared.
|
||||
* Required services: the seat's slot registry, the transport, the copy's
|
||||
* locale registry, and the conversation service whose presence guarantees
|
||||
* the seat is declared.
|
||||
*/
|
||||
export const inject = ['slots', 'connection', 'conversation']
|
||||
export const inject = ['slots', 'connection', 'conversation', 'locale']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the plan chip over the command channel.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plan: dictionaries')
|
||||
|
||||
ctx.effect(() => ctx.slots.register({
|
||||
name: 'conversation.input.plan',
|
||||
locale: NS,
|
||||
inject: (sessionId: SessionId): PlanChipInjected => ({
|
||||
// Failure strings stay English (error-surface policy: not localized).
|
||||
setPlanMode: async (on) => {
|
||||
const line = on ? '/plan' : '/plan off'
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const { result } = await connection.api.commands.execute({ sessionId, line })
|
||||
if (!result.ok) return `${result.error.message}(${result.error.code})`
|
||||
if (!result.value.matched) return `未知命令:${line}`
|
||||
if (!result.ok) return `${result.error.message} (${result.error.code})`
|
||||
if (!result.value.matched) return `unknown command: ${line}`
|
||||
return null
|
||||
},
|
||||
}),
|
||||
|
||||
20
packages/client/ui-plan/src/client/locales.ts
Normal file
20
packages/client/ui-plan/src/client/locales.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/** `plan` namespace dictionaries (the composer plan chip's copy). */
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'chip.on.aria': 'plan mode 已开启,按下关闭',
|
||||
'chip.on.title': 'plan mode 已开启 — 点击关闭(/plan off)',
|
||||
'chip.off.aria': 'plan mode 已关闭,按下开启',
|
||||
'chip.off.title': 'plan mode 已关闭 — 点击开启(/plan)',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The plan namespace key union. */
|
||||
export type PlanKey = keyof typeof zh
|
||||
|
||||
/** English dictionary, checked complete against the zh key set. */
|
||||
export const en = {
|
||||
'chip.on.aria': 'Plan mode on, press to turn off',
|
||||
'chip.on.title': 'Plan mode on — click to turn off (/plan off)',
|
||||
'chip.off.aria': 'Plan mode off, press to turn on',
|
||||
'chip.off.title': 'Plan mode off — click to turn on (/plan)',
|
||||
} satisfies Record<PlanKey, string>
|
||||
@@ -9,6 +9,7 @@ import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { PlanChip } from '../src/client/PlanModeControl.tsx'
|
||||
import type { PlanChipInjected } from '../src/client/index.ts'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
@@ -28,12 +29,13 @@ async function bench() {
|
||||
Promise.resolve({ result: { ok: true as const, value: { matched: true as const, commandId: 'c1' } } }))
|
||||
ctx.provide('connection', { api: { commands: { execute } } })
|
||||
ctx.provide('conversation', {})
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
return { ctx, slots, execute }
|
||||
}
|
||||
|
||||
describe('ui-plan browser apply', () => {
|
||||
it('declares every service it binds', () => {
|
||||
expect(inject).toEqual(['slots', 'connection', 'conversation'])
|
||||
expect(inject).toEqual(['slots', 'connection', 'conversation', 'locale'])
|
||||
})
|
||||
|
||||
it('node-half apply is an intentional no-op', () => {
|
||||
@@ -45,6 +47,7 @@ describe('ui-plan browser apply', () => {
|
||||
await ctx.plugin(SlotsService).await()
|
||||
ctx.provide('connection', {})
|
||||
ctx.provide('conversation', {})
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
await expect(ctx.plugin({ inject: [...inject], apply }))
|
||||
.rejects.toThrow(/slot "conversation.input.plan" is not declared/)
|
||||
})
|
||||
@@ -66,13 +69,13 @@ describe('ui-plan browser apply', () => {
|
||||
b.execute.mockResolvedValueOnce({
|
||||
result: { ok: false as const, error: { code: 'session-not-found', message: 'gone', details: {} } },
|
||||
} as never)
|
||||
await expect(injected.setPlanMode(false)).resolves.toBe('gone(session-not-found)')
|
||||
await expect(injected.setPlanMode(false)).resolves.toBe('gone (session-not-found)')
|
||||
|
||||
// Unmatched admission (plan-mode not composed host-side) is also a failure line.
|
||||
b.execute.mockResolvedValueOnce({
|
||||
result: { ok: true as const, value: { matched: false as const } },
|
||||
} as never)
|
||||
await expect(injected.setPlanMode(true)).resolves.toBe('未知命令:/plan')
|
||||
await expect(injected.setPlanMode(true)).resolves.toBe('unknown command: /plan')
|
||||
|
||||
await fiber.dispose()
|
||||
expect(b.slots.entries('conversation.input.plan')).toHaveLength(0)
|
||||
|
||||
@@ -13,9 +13,15 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { PlanProjection } from '@deepseek-ai/dsh-plan-mode/client'
|
||||
import { PlanChip, type PlanChipProps } from '../src/client/PlanModeControl.tsx'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
// The framework-injected t seat, stubbed over the zh dictionaries (the default locale).
|
||||
const t: PlanChipProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
function setup(
|
||||
plan: PlanProjection | undefined,
|
||||
setPlanMode = vi.fn((_on: boolean) => Promise.resolve<string | null>(null)),
|
||||
@@ -24,13 +30,13 @@ function setup(
|
||||
const store = createSnapshotStore<{ value: PlanProjection | undefined }>({ value: plan })
|
||||
const useProjection = (_key: string, selector?: (v: unknown) => unknown) =>
|
||||
bindSnapshotSelector(store)(s => (selector ?? (v => v))(s.value))
|
||||
const props = { useProjection, locked, setPlanMode } as unknown as PlanChipProps
|
||||
const props = { useProjection, locked, setPlanMode, t } as unknown as PlanChipProps
|
||||
const view = render(<PlanChip {...props} />)
|
||||
return { store, setPlanMode, view }
|
||||
}
|
||||
|
||||
const onChip = () => screen.getByRole('button', { name: 'Plan mode on, press to turn off' })
|
||||
const offChip = () => screen.getByRole('button', { name: 'Plan mode off, press to turn on' })
|
||||
const onChip = () => screen.getByRole('button', { name: 'plan mode 已开启,按下关闭' })
|
||||
const offChip = () => screen.getByRole('button', { name: 'plan mode 已关闭,按下开启' })
|
||||
|
||||
describe('PlanChip', () => {
|
||||
it('renders nothing while the capability is absent', () => {
|
||||
@@ -95,7 +101,7 @@ describe('PlanChip', () => {
|
||||
.mockRejectedValueOnce('socket closed')
|
||||
setup({ active: true, pending: false }, exitFailing)
|
||||
fireEvent.click(onChip())
|
||||
expect((await screen.findByText('退出 plan mode 失败')).getAttribute('title')).toBe('host said no')
|
||||
expect((await screen.findByText('failed to exit plan mode')).getAttribute('title')).toBe('host said no')
|
||||
expect(onChip()).toBeTruthy()
|
||||
|
||||
fireEvent.click(onChip())
|
||||
@@ -108,7 +114,7 @@ describe('PlanChip', () => {
|
||||
const enterFailing = vi.fn().mockResolvedValueOnce('agent busy')
|
||||
setup({ active: false, pending: false }, enterFailing)
|
||||
fireEvent.click(offChip())
|
||||
expect((await screen.findByText('进入 plan mode 失败')).getAttribute('title')).toBe('agent busy')
|
||||
expect((await screen.findByText('failed to enter plan mode')).getAttribute('title')).toBe('agent busy')
|
||||
expect(offChip()).toBeTruthy()
|
||||
})
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation"
|
||||
},
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
|
||||
README.md: 0ef3c20f848b3d331c007911d0837f11cd72c024
|
||||
README.zh.md: af94551bfb9e12dbadcef6a96a54f9bf7ea71299
|
||||
README.md: 4075f0e7472141b5d41fe0f51c1a620eae913bfb
|
||||
README.zh.md: 7fd9529e597bc473a7c35fc3614f21f84cd19f44
|
||||
|
||||
@@ -25,5 +25,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
- **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists.
|
||||
- **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms.
|
||||
- **StateDot `Active` variant is a hidden placeholder in the design** — not implemented; the four shipped states (done/warning/ongoing/error) are the complete P-I surface.
|
||||
- **This package's user-facing copy is inline Chinese, not localized** — the atoms are zero-cordis and so cannot reach `ctx.locale`; `TerminalBlock`'s exit-code and signal pills, its copy and expand controls, and `CodeBlock`'s copy control are all hardcoded. This matches the repo-wide state the locale package records (only the Settings surface is translated); extracting these into the `zh`/`en` dictionaries needs a localization channel for zero-cordis atoms and belongs to that repo-wide extraction.
|
||||
- **User-facing copy localizes through label props, defaulting to the original Chinese literals** — the atoms are zero-cordis and cannot reach `ctx.locale`, so `TerminalBlock` (`labels`), `JsonTree` (`labels`), `CodeBlock` (`copyLabel`/`copiedLabel`), `MarkdownText` (`codeLabels`), `JsonBlock` (`truncatedLabel`), `ConnectionBanner` (`label`), and `Modal` (`closeLabel`) take their copy as optional props with the previous hardcoded strings as defaults. Localized plugins pass dictionary-driven labels from their own `t` seat; a consumer that passes nothing renders exactly the pre-localization output.
|
||||
- **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR color and attributes are honored, and so are the in-line cursor movements a progress line uses — carriage return, backspace, erase-in-line, tab stops and character width. Absolute cursor positioning, screen clearing, and alternate-screen sequences are stripped. Basic-16 magenta and cyan have no token equivalent and stay literal rgb.
|
||||
|
||||
@@ -24,5 +24,5 @@
|
||||
- **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。
|
||||
- **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。
|
||||
- **StateDot 的 `Active` 变体是设计中的隐藏占位符**:尚未实现;已交付的四种状态(done/warning/ongoing/error)构成完整的 P-I 表层。
|
||||
- **本包面向用户的文案是内联中文,未做本地化**:这些原子组件是 zero-cordis 的,因此拿不到 `ctx.locale`;`TerminalBlock` 的退出码与信号胶囊、它的复制与展开控件,以及 `CodeBlock` 的复制控件全部硬编码。这与 locale 包记录的全仓现状一致(只有 Settings 表面做了翻译);把它们抽取进 `zh`/`en` 字典需要为 zero-cordis 原子组件提供一条本地化通道,属于那次全仓抽取的范围。
|
||||
- **面向用户的文案经 label props 本地化,默认值为原中文字面量**:这些原子组件是 zero-cordis 的,拿不到 `ctx.locale`,因此 `TerminalBlock`(`labels`)、`JsonTree`(`labels`)、`CodeBlock`(`copyLabel`/`copiedLabel`)、`MarkdownText`(`codeLabels`)、`JsonBlock`(`truncatedLabel`)、`ConnectionBanner`(`label`)和 `Modal`(`closeLabel`)都把文案作为可选 props 接收,默认值即此前的硬编码字符串。已本地化的插件用自己的 `t` 席位传入字典驱动的 label;什么都不传的消费者渲染与本地化之前逐字节一致。
|
||||
- **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色与属性会被遵循,进度行所用的行内光标移动同样被遵循——回车、退格、行内擦除、制表位与字符宽度。绝对光标定位、清屏与备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token,保持字面 rgb。
|
||||
|
||||
@@ -8,9 +8,14 @@ import css from './ConnectionBanner.module.css'
|
||||
/**
|
||||
* Render the reconnecting banner.
|
||||
* @param props.reconnecting - true while the connection is in backoff/retry.
|
||||
* @param props.label - banner text; the owner passes localized copy (this
|
||||
* package is cordis-free, so copy arrives via props).
|
||||
* @returns the banner, or null when connected.
|
||||
*/
|
||||
export function ConnectionBanner({ reconnecting }: { reconnecting: boolean }) {
|
||||
export function ConnectionBanner({ reconnecting, label = '连接已断开,正在重连…' }: {
|
||||
reconnecting: boolean
|
||||
label?: string | undefined
|
||||
}) {
|
||||
if (!reconnecting) return null
|
||||
return <div className={css.banner}>连接已断开,正在重连…</div>
|
||||
return <div className={css.banner}>{label}</div>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from 'clsx'
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import type {
|
||||
KeyboardEvent as ReactKeyboardEvent,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
@@ -14,16 +14,64 @@ import css from './JsonTree.module.css'
|
||||
const OBJECT_PREVIEW_LIMIT = 4
|
||||
const ARRAY_PREVIEW_LIMIT = 5
|
||||
const PREVIEW_DEPTH_LIMIT = 2
|
||||
const VALUE_COPY_MENU_ITEMS: readonly MenuEntry[] = [
|
||||
{ id: 'value', label: 'Copy value' },
|
||||
{ id: 'json', label: 'Copy JSON' },
|
||||
{ id: 'path', label: 'Copy property path' },
|
||||
]
|
||||
const OBJECT_COPY_MENU_ITEMS: readonly MenuEntry[] = [
|
||||
{ id: 'prettyJson', label: 'Copy pretty JSON' },
|
||||
{ id: 'json', label: 'Copy compact JSON' },
|
||||
{ id: 'path', label: 'Copy property path' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Display copy for the tree's copy affordance; the owner passes localized
|
||||
* labels (this package is cordis-free, so copy arrives via props). Every
|
||||
* field defaults to the current built-in value, so existing consumers render
|
||||
* unchanged.
|
||||
*/
|
||||
export interface JsonTreeLabels {
|
||||
/** Menu item: copy the raw primitive value. */
|
||||
copyValue: string
|
||||
/** Menu item: copy the value as compact JSON (primitive rows). */
|
||||
copyJson: string
|
||||
/** Menu item: copy the property path. */
|
||||
copyPath: string
|
||||
/** Menu item: copy the value as pretty-printed JSON. */
|
||||
copyPrettyJson: string
|
||||
/** Menu item: copy the value as compact JSON (object rows). */
|
||||
copyCompactJson: string
|
||||
/** Copy-button state label after a successful copy. */
|
||||
copied: string
|
||||
/** Copy-button state label after a failed copy. */
|
||||
copyFailed: string
|
||||
/** Expander aria label while expanded. */
|
||||
collapseNode: string
|
||||
/** Expander aria label while collapsed. */
|
||||
expandNode: string
|
||||
/** Copy-button tooltip, given the current action label. */
|
||||
copyButtonTitle: (action: string) => string
|
||||
}
|
||||
|
||||
const DEFAULT_LABELS: JsonTreeLabels = {
|
||||
copyValue: 'Copy value',
|
||||
copyJson: 'Copy JSON',
|
||||
copyPath: 'Copy property path',
|
||||
copyPrettyJson: 'Copy pretty JSON',
|
||||
copyCompactJson: 'Copy compact JSON',
|
||||
copied: 'Copied',
|
||||
copyFailed: 'Copy failed',
|
||||
collapseNode: 'Collapse JSON node',
|
||||
expandNode: 'Expand JSON node',
|
||||
copyButtonTitle: action => `${action}; right-click for copy options`,
|
||||
}
|
||||
|
||||
function valueCopyMenuItems(labels: JsonTreeLabels): readonly MenuEntry[] {
|
||||
return [
|
||||
{ id: 'value', label: labels.copyValue },
|
||||
{ id: 'json', label: labels.copyJson },
|
||||
{ id: 'path', label: labels.copyPath },
|
||||
]
|
||||
}
|
||||
|
||||
function objectCopyMenuItems(labels: JsonTreeLabels): readonly MenuEntry[] {
|
||||
return [
|
||||
{ id: 'prettyJson', label: labels.copyPrettyJson },
|
||||
{ id: 'json', label: labels.copyCompactJson },
|
||||
{ id: 'path', label: labels.copyPath },
|
||||
]
|
||||
}
|
||||
|
||||
type JsonPath = readonly (number | string)[]
|
||||
|
||||
@@ -193,6 +241,7 @@ function NodeField({
|
||||
interface JsonTreeNodeProps {
|
||||
field?: string
|
||||
initialExpanded: boolean
|
||||
labels: JsonTreeLabels
|
||||
lastElement: boolean
|
||||
onClaimTabStop: (id: string) => void
|
||||
onRowHover: (row: HTMLElement, target: RowTarget) => void
|
||||
@@ -204,6 +253,7 @@ interface JsonTreeNodeProps {
|
||||
function JsonTreeNode({
|
||||
field,
|
||||
initialExpanded,
|
||||
labels,
|
||||
lastElement,
|
||||
onClaimTabStop,
|
||||
onRowHover,
|
||||
@@ -279,7 +329,7 @@ function JsonTreeNode({
|
||||
className={clsx(css.expander, expanded ? css.collapseIcon : css.expandIcon)}
|
||||
data-json-expander
|
||||
role="button"
|
||||
aria-label={expanded ? 'Collapse JSON node' : 'Expand JSON node'}
|
||||
aria-label={expanded ? labels.collapseNode : labels.expandNode}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={expanded ? contentsId : undefined}
|
||||
tabIndex={tabStopId === nodeId ? 0 : -1}
|
||||
@@ -298,6 +348,7 @@ function JsonTreeNode({
|
||||
field={key}
|
||||
value={item}
|
||||
path={[...path, Array.isArray(value) ? index : key]}
|
||||
labels={labels}
|
||||
lastElement={index === entries.length - 1}
|
||||
initialExpanded={false}
|
||||
tabStopId={tabStopId}
|
||||
@@ -344,6 +395,8 @@ export interface JsonTreeProps {
|
||||
copyable?: boolean
|
||||
/** Whether the top-level object or array is always expanded. */
|
||||
expandTopLevel?: boolean
|
||||
/** Localized display copy; omitted fields keep the built-in defaults. */
|
||||
labels?: Partial<JsonTreeLabels> | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -357,7 +410,12 @@ export function JsonTree({
|
||||
className,
|
||||
copyable = true,
|
||||
expandTopLevel = true,
|
||||
labels,
|
||||
}: JsonTreeProps) {
|
||||
const copyLabels = useMemo<JsonTreeLabels>(
|
||||
() => (labels === undefined ? DEFAULT_LABELS : { ...DEFAULT_LABELS, ...labels }),
|
||||
[labels],
|
||||
)
|
||||
const rootEntries = entriesOf(data)
|
||||
const firstExpandableIndex = rootEntries.findIndex(([, value]) => (
|
||||
isExpandableValue(value) && entriesOf(value).length > 0
|
||||
@@ -486,10 +544,10 @@ export function JsonTree({
|
||||
const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null
|
||||
const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value'
|
||||
const copyTitle = copyState === 'copied'
|
||||
? 'Copied'
|
||||
? copyLabels.copied
|
||||
: copyState === 'failed'
|
||||
? 'Copy failed'
|
||||
: copyTargetIsObject ? 'Copy pretty JSON' : 'Copy value'
|
||||
? copyLabels.copyFailed
|
||||
: copyTargetIsObject ? copyLabels.copyPrettyJson : copyLabels.copyValue
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -525,6 +583,7 @@ export function JsonTree({
|
||||
field={key}
|
||||
value={value}
|
||||
path={[Array.isArray(data) ? index : key]}
|
||||
labels={copyLabels}
|
||||
lastElement={index === rootEntries.length - 1}
|
||||
initialExpanded={false}
|
||||
tabStopId={tabStopId}
|
||||
@@ -543,6 +602,7 @@ export function JsonTree({
|
||||
<JsonTreeNode
|
||||
value={data}
|
||||
path={[]}
|
||||
labels={copyLabels}
|
||||
lastElement
|
||||
initialExpanded
|
||||
tabStopId={tabStopId}
|
||||
@@ -570,7 +630,7 @@ export function JsonTree({
|
||||
data-json-copy-button
|
||||
data-state={copyState}
|
||||
aria-label={copyTitle}
|
||||
title={`${copyTitle}; right-click for copy options`}
|
||||
title={copyLabels.copyButtonTitle(copyTitle)}
|
||||
onClick={() => void copy(defaultCopyMode)}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
@@ -584,7 +644,7 @@ export function JsonTree({
|
||||
: <IconCopyOutline16 size={12} />}
|
||||
</button>
|
||||
)}
|
||||
items={copyTargetIsObject ? OBJECT_COPY_MENU_ITEMS : VALUE_COPY_MENU_ITEMS}
|
||||
items={copyTargetIsObject ? objectCopyMenuItems(copyLabels) : valueCopyMenuItems(copyLabels)}
|
||||
onSelect={(id) => {
|
||||
void copy(id as 'json' | 'path' | 'prettyJson' | 'value')
|
||||
copyMenuOpenRef.current = false
|
||||
|
||||
@@ -20,6 +20,8 @@ import css from './Modal.module.css'
|
||||
* @param props.headless - render children directly in the card (no default
|
||||
* header/close/body chrome) for dialogs whose figma frame owns its own
|
||||
* header structure; mask, card, Escape, and aria-label remain.
|
||||
* @param props.closeLabel - close-button aria label; the owner passes
|
||||
* localized copy (this package is cordis-free, so copy arrives via props).
|
||||
* @returns null when closed; otherwise the overlay tree.
|
||||
*/
|
||||
export function Modal({
|
||||
|
||||
@@ -20,6 +20,54 @@ import css from './TerminalBlock.module.css'
|
||||
*/
|
||||
export const DEFAULT_TERMINAL_MAX_LINES = 16
|
||||
|
||||
/**
|
||||
* Display copy for the terminal surface; the owner passes localized labels
|
||||
* (this package is cordis-free, so copy arrives via props). Every field
|
||||
* defaults to the current built-in value, so existing consumers render
|
||||
* unchanged.
|
||||
*/
|
||||
export interface TerminalBlockLabels {
|
||||
/** Status pill text for a signal-terminated command. */
|
||||
signal: (signal: string) => string
|
||||
/** Status pill text for a non-zero exit code. */
|
||||
exitCode: (exitCode: number) => string
|
||||
/** Run-state text while the command is still running. */
|
||||
running: string
|
||||
/** Run-state text for a signal or non-zero-exit settle. */
|
||||
failed: string
|
||||
/** Run-state text for a clean settle. */
|
||||
done: string
|
||||
/** Copy-button idle label. */
|
||||
copy: string
|
||||
/** Copy-button label during the post-copy confirmation window. */
|
||||
copied: string
|
||||
/** Placeholder when a settled command produced no visible output. */
|
||||
noOutput: string
|
||||
/** Collapse-toggle aria label while expanded. */
|
||||
collapseAria: string
|
||||
/** Collapse-toggle text while expanded. */
|
||||
collapse: string
|
||||
/** Expand-toggle aria label while capped, given the hidden line count. */
|
||||
expandAria: (hidden: number) => string
|
||||
/** Expand-toggle text while capped, given the hidden line count. */
|
||||
expand: (hidden: number) => string
|
||||
}
|
||||
|
||||
const DEFAULT_LABELS: TerminalBlockLabels = {
|
||||
signal: signal => `信号 ${signal}`,
|
||||
exitCode: exitCode => `退出码 ${exitCode}`,
|
||||
running: '运行中',
|
||||
failed: '失败',
|
||||
done: '已完成',
|
||||
copy: '复制',
|
||||
copied: '复制成功',
|
||||
noOutput: '无输出',
|
||||
collapseAria: '收起输出',
|
||||
collapse: '收起',
|
||||
expandAria: hidden => `展开其余 ${hidden} 行输出`,
|
||||
expand: hidden => `… 其余 ${hidden} 行`,
|
||||
}
|
||||
|
||||
export interface TerminalBlockProps {
|
||||
/** The command line, rendered verbatim after the prompt label. */
|
||||
command: string
|
||||
@@ -39,6 +87,8 @@ export interface TerminalBlockProps {
|
||||
maxLines?: number | undefined
|
||||
/** Extra class merged onto the wrapper (callers position; this component draws). */
|
||||
className?: string | undefined
|
||||
/** Localized display copy; omitted fields keep the built-in defaults. */
|
||||
labels?: Partial<TerminalBlockLabels> | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,11 +113,16 @@ function promptLabel(cwd: string, home: string | undefined): string {
|
||||
* distinction the bash tool's own exit-status markers draw.
|
||||
* @param exitCode - settled exit code, when known.
|
||||
* @param signal - settled terminating signal name, when known.
|
||||
* @param labels - display copy for the pill text.
|
||||
* @returns the pill text, or undefined for a clean exit.
|
||||
*/
|
||||
function statusText(exitCode: number | undefined, signal: string | undefined): string | undefined {
|
||||
if (signal !== undefined) return `信号 ${signal}`
|
||||
if (exitCode !== undefined && exitCode !== 0) return `退出码 ${exitCode}`
|
||||
function statusText(
|
||||
exitCode: number | undefined,
|
||||
signal: string | undefined,
|
||||
labels: TerminalBlockLabels,
|
||||
): string | undefined {
|
||||
if (signal !== undefined) return labels.signal(signal)
|
||||
if (exitCode !== undefined && exitCode !== 0) return labels.exitCode(exitCode)
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -84,16 +139,18 @@ function statusText(exitCode: number | undefined, signal: string | undefined): s
|
||||
* @param running - the command has not settled.
|
||||
* @param exitCode - settled exit code, when known.
|
||||
* @param signal - settled terminating signal name, when known.
|
||||
* @param labels - display copy for the text label.
|
||||
* @returns the dot's state and its text label, since the dot is aria-hidden.
|
||||
*/
|
||||
function runState(
|
||||
running: boolean,
|
||||
exitCode: number | undefined,
|
||||
signal: string | undefined,
|
||||
labels: TerminalBlockLabels,
|
||||
): { state: StateDotState; label: string } {
|
||||
if (running) return { state: 'ongoing', label: '运行中' }
|
||||
if (statusText(exitCode, signal) !== undefined) return { state: 'error', label: '失败' }
|
||||
return { state: 'done', label: '已完成' }
|
||||
if (running) return { state: 'ongoing', label: labels.running }
|
||||
if (statusText(exitCode, signal, labels) !== undefined) return { state: 'error', label: labels.failed }
|
||||
return { state: 'done', label: labels.done }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,7 +180,12 @@ export function TerminalBlock({
|
||||
running = false,
|
||||
maxLines = DEFAULT_TERMINAL_MAX_LINES,
|
||||
className,
|
||||
labels,
|
||||
}: TerminalBlockProps) {
|
||||
const copy = useMemo<TerminalBlockLabels>(
|
||||
() => (labels === undefined ? DEFAULT_LABELS : { ...DEFAULT_LABELS, ...labels }),
|
||||
[labels],
|
||||
)
|
||||
const text = output ?? ''
|
||||
// A command's output ends with a newline; that terminator is not an extra
|
||||
// blank line to draw or to count against the height cap. The check runs on the
|
||||
@@ -155,8 +217,8 @@ export function TerminalBlock({
|
||||
|
||||
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
|
||||
|
||||
const status = statusText(exitCode, signal)
|
||||
const state = runState(running, exitCode, signal)
|
||||
const status = statusText(exitCode, signal, copy)
|
||||
const state = runState(running, exitCode, signal, copy)
|
||||
// A multi-line command gets one prompt row per line, so a two-command shell
|
||||
// snippet reads as the two commands it is instead of collapsing into one
|
||||
// ellipsized row. A trailing newline is a terminator, not an empty command.
|
||||
@@ -205,12 +267,12 @@ export function TerminalBlock({
|
||||
{status !== undefined && <Pill className={css.status}>{status}</Pill>}
|
||||
{!running && !empty && (
|
||||
<button type="button" className={css.copyButton} onClick={onCopy}>
|
||||
{copied ? '复制成功' : '复制'}
|
||||
{copied ? copy.copied : copy.copy}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!running && (empty
|
||||
? <div className={css.empty}>无输出</div>
|
||||
? <div className={css.empty}>{copy.noOutput}</div>
|
||||
: (
|
||||
<div className={css.output}>
|
||||
{(capped ? lines.slice(0, headLines) : lines).map((line, index) => (
|
||||
@@ -221,10 +283,10 @@ export function TerminalBlock({
|
||||
type="button"
|
||||
className={css.expand}
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? '收起输出' : `展开其余 ${hidden} 行输出`}
|
||||
aria-label={expanded ? copy.collapseAria : copy.expandAria(hidden)}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{expanded ? '收起' : `… 其余 ${hidden} 行`}
|
||||
{expanded ? copy.collapse : copy.expand(hidden)}
|
||||
</button>
|
||||
)}
|
||||
{capped && lines.slice(lines.length - tailLines).map((line, index) => (
|
||||
|
||||
@@ -19,12 +19,14 @@ export { BrandWordmark } from './BrandWordmark.tsx'
|
||||
export { Tooltip } from './Tooltip.tsx'
|
||||
export type { TooltipSide } from './Tooltip.tsx'
|
||||
export { JsonTree } from './JsonTree.tsx'
|
||||
export type { JsonTreeProps } from './JsonTree.tsx'
|
||||
export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx'
|
||||
export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
|
||||
export type { TerminalBlockProps } from './TerminalBlock.tsx'
|
||||
export type { TerminalBlockProps, TerminalBlockLabels } from './TerminalBlock.tsx'
|
||||
export { CodeBlock } from './markdown/CodeBlock.tsx'
|
||||
export type { CodeBlockProps } from './markdown/CodeBlock.tsx'
|
||||
export { JsonBlock } from './markdown/JsonBlock.tsx'
|
||||
export { MarkdownText } from './markdown/MarkdownText.tsx'
|
||||
export type { MarkdownCodeLabels } from './markdown/MarkdownText.tsx'
|
||||
export { MessageText } from './markdown/MessageText.tsx'
|
||||
export { extractMarkdownPlainText } from './markdown/plain-text.ts'
|
||||
export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts'
|
||||
|
||||
@@ -17,9 +17,13 @@ export interface CodeBlockProps {
|
||||
lang?: string | undefined
|
||||
/** Extra class merged onto the wrapper (callers position; this component draws). */
|
||||
className?: string | undefined
|
||||
/** Copy-button idle label; the owner passes localized copy (this package is cordis-free, so copy arrives via props). */
|
||||
copyLabel?: string | undefined
|
||||
/** Copy-button label during the post-copy confirmation window. */
|
||||
copiedLabel?: string | undefined
|
||||
}
|
||||
|
||||
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
|
||||
export function CodeBlock({ code, lang, className, copyLabel = '复制', copiedLabel = '复制成功' }: CodeBlockProps) {
|
||||
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
|
||||
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
@@ -55,7 +59,7 @@ export function CodeBlock({ code, lang, className }: CodeBlockProps) {
|
||||
<div className={css.infostring}>{lang ?? ''}</div>
|
||||
<div className={css.action}>
|
||||
<button type="button" className={css.copyButton} onClick={onCopy}>
|
||||
{copied ? '复制成功' : '复制'}
|
||||
{copied ? copiedLabel : copyLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,17 @@ import css from './JsonBlock.module.css'
|
||||
|
||||
const MAX_CHARS = 20_000
|
||||
|
||||
export function JsonBlock({ label, payload, defaultOpen = false }: {
|
||||
/** Default truncation footer; the owner passes a localized formatter. */
|
||||
function defaultTruncatedLabel(total: number): string {
|
||||
return `… 已截断,共 ${total} 字符`
|
||||
}
|
||||
|
||||
export function JsonBlock({ label, payload, defaultOpen = false, truncatedLabel = defaultTruncatedLabel }: {
|
||||
label: string
|
||||
payload: unknown
|
||||
defaultOpen?: boolean
|
||||
/** Footer appended when the body exceeds the char cap, given the full length (this package is cordis-free, so copy arrives via props). */
|
||||
truncatedLabel?: ((total: number) => string) | undefined
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
const body = useMemo(() => {
|
||||
@@ -21,8 +28,8 @@ export function JsonBlock({ label, payload, defaultOpen = false }: {
|
||||
} catch {
|
||||
s = String(payload)
|
||||
}
|
||||
return s.length > MAX_CHARS ? `${s.slice(0, MAX_CHARS)}\n… 已截断,共 ${s.length} 字符` : s
|
||||
}, [open, payload])
|
||||
return s.length > MAX_CHARS ? `${s.slice(0, MAX_CHARS)}\n${truncatedLabel(s.length)}` : s
|
||||
}, [open, payload, truncatedLabel])
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<button type="button" className={css.toggle} onClick={() => { setOpen(v => !v) }}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isValidElement } from 'react'
|
||||
import { isValidElement, useMemo } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import type { Components, UrlTransform } from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
@@ -24,8 +24,16 @@ function sanitizeUrl(url: string): string {
|
||||
|
||||
const safeUrl: UrlTransform = url => sanitizeUrl(url)
|
||||
|
||||
/** Copy-button labels forwarded to fence CodeBlocks (this package is cordis-free, so copy arrives via props). */
|
||||
export interface MarkdownCodeLabels {
|
||||
/** Copy-button idle label. */
|
||||
copyLabel?: string | undefined
|
||||
/** Copy-button label during the post-copy confirmation window. */
|
||||
copiedLabel?: string | undefined
|
||||
}
|
||||
|
||||
/** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */
|
||||
function buildComponents(streaming: boolean): Components {
|
||||
function buildComponents(streaming: boolean, codeLabels?: MarkdownCodeLabels): Components {
|
||||
return {
|
||||
a: ({ href = '', children }) => {
|
||||
const safeHref = sanitizeUrl(href)
|
||||
@@ -62,7 +70,14 @@ function buildComponents(streaming: boolean): Components {
|
||||
// keeps the stock <pre> rather than guessing.
|
||||
if (typeof raw !== 'string') return <pre>{children}</pre>
|
||||
const lang = /language-([\w-]+)/.exec(child?.props.className ?? '')?.[1]
|
||||
return <CodeBlock code={raw} lang={streaming ? undefined : lang} />
|
||||
return (
|
||||
<CodeBlock
|
||||
code={raw}
|
||||
lang={streaming ? undefined : lang}
|
||||
copyLabel={codeLabels?.copyLabel}
|
||||
copiedLabel={codeLabels?.copiedLabel}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -73,15 +88,29 @@ const streamingComponents = buildComponents(true)
|
||||
/**
|
||||
* Render untrusted assistant-authored Markdown as semantic React elements.
|
||||
* @param props - Markdown source text preserved by the session projection;
|
||||
* `streaming` renders fences plain (highlighting lands on the finalize swap).
|
||||
* `streaming` renders fences plain (highlighting lands on the finalize swap);
|
||||
* `codeLabels` forwards localized copy-button labels to fence CodeBlocks —
|
||||
* pass a reference-stable object (memoized per locale revision), because the
|
||||
* component table memoizes on its identity and a fresh literal per render
|
||||
* would rebuild it every streaming chunk.
|
||||
* @returns A GFM document with raw HTML, relative links, unsafe protocols, and remote images disabled.
|
||||
*/
|
||||
export function MarkdownText({ text, streaming = false }: { text: string; streaming?: boolean }) {
|
||||
export function MarkdownText({ text, streaming = false, codeLabels }: {
|
||||
text: string
|
||||
streaming?: boolean
|
||||
codeLabels?: MarkdownCodeLabels | undefined
|
||||
}) {
|
||||
// The label-free tables stay module-level singletons so the common case
|
||||
// keeps referential stability across renders without a hook.
|
||||
const components = useMemo(() => {
|
||||
if (codeLabels === undefined) return streaming ? streamingComponents : staticComponents
|
||||
return buildComponents(streaming, codeLabels)
|
||||
}, [streaming, codeLabels])
|
||||
return (
|
||||
<div className={css.markdown}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
components={streaming ? streamingComponents : staticComponents}
|
||||
components={components}
|
||||
urlTransform={safeUrl}
|
||||
>
|
||||
{text}
|
||||
|
||||
@@ -6,18 +6,12 @@
|
||||
* separator.
|
||||
*/
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import css from './GeneralSection.module.css'
|
||||
|
||||
/** Injected face of the General section: the settings-namespace translate. */
|
||||
export interface GeneralSectionInjected {
|
||||
/** Translate a `settings` dictionary key to the active-locale text. */
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
/** Full component props: section owner share + item render share + inject face. */
|
||||
/** Full component props: section owner share + item render share + the standard locale seat. */
|
||||
export type GeneralSectionComponentProps =
|
||||
PropsRuntime<'settings.section'> & PropsRenderSlots<'settings.general.item'> & GeneralSectionInjected
|
||||
PropsRuntime<'settings.section'> & PropsRenderSlots<'settings.general.item'> & PropsLocale<'settings'>
|
||||
|
||||
/**
|
||||
* Render the General section content column.
|
||||
|
||||
@@ -5,20 +5,14 @@
|
||||
* reads each entry's `label` option for aria text.
|
||||
*/
|
||||
import { IconSettingsOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import css from './chrome.module.css'
|
||||
|
||||
/** Injected face of both chrome seats: the settings-namespace translate. */
|
||||
export interface ChromeInjected {
|
||||
/** Translate a `settings` dictionary key to the active-locale text. */
|
||||
t: (key: string) => string
|
||||
}
|
||||
/** Trigger content props: the sidebar column state + the standard locale seat. */
|
||||
export type TriggerContentProps = PropsRuntime<'settings.trigger'> & PropsLocale<'settings'>
|
||||
|
||||
/** Trigger content props: the sidebar column state + translate. */
|
||||
export type TriggerContentProps = PropsRuntime<'settings.trigger'> & ChromeInjected
|
||||
|
||||
/** Header content props: translate only. */
|
||||
export type HeaderContentProps = PropsRuntime<'settings.header'> & ChromeInjected
|
||||
/** Header content props: the standard locale seat only. */
|
||||
export type HeaderContentProps = PropsRuntime<'settings.header'> & PropsLocale<'settings'>
|
||||
|
||||
/**
|
||||
* Render the trigger row content (icon; label only in the wide column).
|
||||
@@ -43,8 +37,8 @@ export function HeaderContent({ t }: HeaderContentProps) {
|
||||
return <>{t('title')}</>
|
||||
}
|
||||
|
||||
/** Close-button label text props: translate only. */
|
||||
export type CloseLabelProps = PropsRuntime<'settings.close'> & ChromeInjected
|
||||
/** Close-button label text props: the standard locale seat only. */
|
||||
export type CloseLabelProps = PropsRuntime<'settings.close'> & PropsLocale<'settings'>
|
||||
|
||||
/**
|
||||
* Render the close button's visually-hidden label text.
|
||||
|
||||
@@ -10,18 +10,24 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: pulls the shell's SlotMap merges (trigger/header/section/item).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { ChromeInjected } from './chrome.tsx'
|
||||
// Type-only: pulls ctx.locale and the 'settings.general.item' SlotMap merge.
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx'
|
||||
import type { GeneralSectionInjected } from './GeneralSection.tsx'
|
||||
import { GeneralSection } from './GeneralSection.tsx'
|
||||
import { en, zh } from './locales.ts'
|
||||
import { en, zh, type SettingsKey } from './locales.ts'
|
||||
|
||||
export type {
|
||||
ChromeInjected, CloseLabelProps, HeaderContentProps, TriggerContentProps,
|
||||
CloseLabelProps, HeaderContentProps, TriggerContentProps,
|
||||
} from './chrome.tsx'
|
||||
export type {
|
||||
GeneralSectionComponentProps, GeneralSectionInjected,
|
||||
} from './GeneralSection.tsx'
|
||||
export type { GeneralSectionComponentProps } from './GeneralSection.tsx'
|
||||
export type { SettingsKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** Shell chrome + shell-owned General section copy. */
|
||||
settings: SettingsKey
|
||||
}
|
||||
}
|
||||
|
||||
/** Dictionary namespace owned by this plugin (shell chrome + General copy). */
|
||||
const NS = 'settings'
|
||||
@@ -39,45 +45,29 @@ export const inject = ['slots', 'locale']
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => {
|
||||
const disposers = [
|
||||
ctx.locale.register(NS, 'zh', zh),
|
||||
ctx.locale.register(NS, 'en', en),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-settings-general: dictionaries')
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-general: dictionaries')
|
||||
|
||||
// Copy freshness is framework-owned: components read the standard `t`
|
||||
// seat, and the nav label is a thunk the owner resolves per render — no
|
||||
// locale/change re-registration wiring.
|
||||
const t = ctx.locale.bind(NS)
|
||||
const chromeInjected = (): ChromeInjected => ({ t })
|
||||
const generalInjected = (): GeneralSectionInjected => ({ t })
|
||||
|
||||
// All four seats refresh on locale change: re-registration bumps each
|
||||
// slot's ledger version, which re-renders the outlets through their own
|
||||
// subscriptions (outlet memoization would swallow a parent-only render).
|
||||
ctx.effect(() => {
|
||||
const trigger = deferRegistration(ctx.slots, 'settings.trigger', TriggerContent, () =>
|
||||
ctx.slots.register({ name: 'settings.trigger', inject: chromeInjected }, TriggerContent))
|
||||
ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent))
|
||||
const header = deferRegistration(ctx.slots, 'settings.header', HeaderContent, () =>
|
||||
ctx.slots.register({ name: 'settings.header', inject: chromeInjected }, HeaderContent))
|
||||
ctx.slots.register({ name: 'settings.header', locale: NS }, HeaderContent))
|
||||
const close = deferRegistration(ctx.slots, 'settings.close', CloseLabel, () =>
|
||||
ctx.slots.register({ name: 'settings.close', inject: chromeInjected }, CloseLabel))
|
||||
ctx.slots.register({ name: 'settings.close', locale: NS }, CloseLabel))
|
||||
const general = deferRegistration(ctx.slots, 'settings.section', GeneralSection, () =>
|
||||
ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'general',
|
||||
order: 0,
|
||||
label: t('general.nav'),
|
||||
label: () => t('general.nav'),
|
||||
locale: NS,
|
||||
children: { 'settings.general.item': { kind: 'list', scope: 'root' } },
|
||||
inject: generalInjected,
|
||||
}, GeneralSection))
|
||||
const offLocale = ctx.on('locale/change', () => {
|
||||
trigger.refresh()
|
||||
header.refresh()
|
||||
close.refresh()
|
||||
general.refresh()
|
||||
})
|
||||
return () => {
|
||||
offLocale()
|
||||
trigger.dispose()
|
||||
header.dispose()
|
||||
close.dispose()
|
||||
|
||||
@@ -5,18 +5,16 @@
|
||||
* verbatim across locales per the Figma design. Feature-owned rows
|
||||
* (Language, Appearance) ship their copy in their own packages.
|
||||
*/
|
||||
import type { LocaleDict } from '@deepseek-ai/dsh-client-locale/client'
|
||||
|
||||
const SHARED = {
|
||||
'permission.value': 'Read only',
|
||||
'toolcall.schema.title': 'Schema mode',
|
||||
'toolcall.schema.desc': 'Traditional function calling — invoke tools one at a time',
|
||||
'toolcall.code.title': 'Code mode',
|
||||
'toolcall.code.desc': 'Chain multiple tools with code — multi-step orchestration',
|
||||
} satisfies LocaleDict
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** Simplified Chinese dictionary. */
|
||||
export const zh: LocaleDict = {
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
...SHARED,
|
||||
'trigger': '设置',
|
||||
'title': '设置',
|
||||
@@ -25,10 +23,13 @@ export const zh: LocaleDict = {
|
||||
'permission.title': '权限',
|
||||
'permission.desc': '选择默认权限模式',
|
||||
'toolcall.title': '工具调用',
|
||||
}
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** English dictionary. */
|
||||
export const en: LocaleDict = {
|
||||
/** The settings namespace key union. */
|
||||
export type SettingsKey = keyof typeof zh
|
||||
|
||||
/** English dictionary, checked complete against the zh key set. */
|
||||
export const en = {
|
||||
...SHARED,
|
||||
'trigger': 'Settings',
|
||||
'title': 'Settings',
|
||||
@@ -37,4 +38,4 @@ export const en: LocaleDict = {
|
||||
'permission.title': 'Permission',
|
||||
'permission.desc': 'Choose default permission mode',
|
||||
'toolcall.title': 'Tool Call',
|
||||
}
|
||||
} satisfies Record<SettingsKey, string>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/** Ownerless-copy registrations: the four seats, the dictionaries, locale refresh, and HMR recovery. */
|
||||
/** Ownerless-copy registrations: the four seats, the dictionaries, thunked labels, and HMR recovery. */
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client'
|
||||
import type { GeneralSectionInjected } from '@deepseek-ai/dsh-client-ui-settings-general/client'
|
||||
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
|
||||
import { GeneralSection } from '../src/client/GeneralSection.tsx'
|
||||
|
||||
@@ -57,13 +57,14 @@ describe('ui-settings-general apply', () => {
|
||||
expect(before.slots.entries(name)[0]!.component).toBe(component)
|
||||
}
|
||||
const entry = generalEntry(before.slots)!
|
||||
expect(entry.options).toEqual({ id: 'general', order: 0, label: '通用设置' })
|
||||
expect(entry.options).toMatchObject({ id: 'general', order: 0 })
|
||||
// The nav label is a locale-following thunk; owners resolve at read time.
|
||||
expect(resolveSlotLabel(entry.options.label)).toBe('通用设置')
|
||||
expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
|
||||
const injected = (entry.inject as unknown as () => GeneralSectionInjected)()
|
||||
expect(injected.t('permission.title')).toBe('权限')
|
||||
// The chrome seats share one inject face: the settings-ns translate.
|
||||
const chrome = (before.slots.entries('settings.trigger')[0]!.inject as unknown as () => GeneralSectionInjected)()
|
||||
expect(chrome.t('trigger')).toBe('设置')
|
||||
// Copy rides the standard locale seat: every seat declares the namespace.
|
||||
for (const [name] of SEATS) {
|
||||
expect(before.slots.entries(name)[0]!.locale).toBe('settings')
|
||||
}
|
||||
|
||||
const after = await bench()
|
||||
await after.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
@@ -87,33 +88,26 @@ describe('ui-settings-general apply', () => {
|
||||
expect(b.locale.bind('settings')('close')).toBe('Close')
|
||||
b.locale.setLocale('zh')
|
||||
await fiber.dispose()
|
||||
// The (ns, locale) seats are free again — the dictionary disposers ran.
|
||||
// The (ns, locale) seats are free again — the dictionary disposer ran.
|
||||
expect(() => b.locale.register('settings', 'zh', {})).not.toThrow()
|
||||
expect(() => b.locale.register('settings', 'en', {})).not.toThrow()
|
||||
})
|
||||
|
||||
it('refreshes all four seats on locale change with fresh General label text', async () => {
|
||||
it('the nav label thunk follows the active locale without re-registration', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const zhVersions = SEATS.map(([name]) => b.slots.getVersion(name))
|
||||
b.locale.setLocale('en')
|
||||
// Every seat re-registered (version moved) and the label re-resolved.
|
||||
// No ledger churn: freshness rides the thunk (and the renderer's locale
|
||||
// subscription), not re-registration.
|
||||
SEATS.forEach(([name], i) => {
|
||||
expect(b.slots.getVersion(name)).toBeGreaterThan(zhVersions[i]!)
|
||||
expect(b.slots.getVersion(name)).toBe(zhVersions[i]!)
|
||||
expect(b.slots.entries(name)).toHaveLength(1)
|
||||
})
|
||||
expect(generalEntry(b.slots)!.options.label).toBe('General')
|
||||
b.locale.setLocale('zh')
|
||||
expect(generalEntry(b.slots)!.options.label).toBe('通用设置')
|
||||
})
|
||||
|
||||
it('locale change while the slots are undeclared stays a no-op', async () => {
|
||||
const b = await bench()
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
b.locale.setLocale('en')
|
||||
for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0)
|
||||
expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('General')
|
||||
b.locale.setLocale('zh')
|
||||
expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('通用设置')
|
||||
})
|
||||
|
||||
it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => {
|
||||
@@ -133,7 +127,7 @@ describe('ui-settings-general apply', () => {
|
||||
expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
|
||||
// The recovered registrations still ride the locale path.
|
||||
b.locale.setLocale('en')
|
||||
expect(generalEntry(b.slots)!.options.label).toBe('General')
|
||||
expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('General')
|
||||
b.locale.setLocale('zh')
|
||||
})
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const t = (key: string) => en[key] ?? key
|
||||
// The seat's key domain is settings ∪ common; the stub answers from the
|
||||
// package dictionary and falls back to the key like the real chain.
|
||||
const t: GeneralSectionComponentProps['t'] = key => (en as Record<string, string>)[key] ?? key
|
||||
|
||||
// Global standard kit stubs: none of these components consume the hooks.
|
||||
const unusedHook = (() => { throw new Error('unused by settings-general components') }) as never
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-settings/README.md
|
||||
README.md: 9388e9dd3a984bfcebc85b6b1a35bcce4b9b116e
|
||||
README.zh.md: 57c91ac5dd0bcc0a3e5e029359bf6c3a2be58ec7
|
||||
README.md: eaa588489bbb6369a9fe073f0a9a37efb3af7d9f
|
||||
README.zh.md: 4330ea90e487270c8da31dace3d531d94f61b8de
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.section` (one page per feature), and `settings.onboarding` (feature-owned overlays on the empty Hero). The shell ships no copy and reads no locale state — all text arrives from registrants (ui-settings-general owns chrome and General; features own their sections, rows, and onboarding overlays).
|
||||
Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.section` (one page per feature), and `settings.onboarding` (feature-owned overlays on the empty Hero). The shell ships no copy of its own — all text arrives from registrants (ui-settings-general owns chrome and General; features own their sections, rows, and onboarding overlays). Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency).
|
||||
|
||||
The shell supplies onboarding registrants only two navigation facts: whether the session surface is the empty Hero and an `openSection(id)` callback that opens the panel on a registered section. Registrants own capability readiness, dismissal, copy, and mutations; the shell therefore does not become a second configuration fact source.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、覆盖在空白 Hero 之上的浮层)。外壳不自带文案,也不读取 locale 状态:所有文本都来自注册方(ui-settings-general 拥有界面框架和「通用」分区;各功能拥有各自的分区、行和首次使用浮层)。
|
||||
设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、覆盖在空白 Hero 之上的浮层)。外壳不自带文案:所有文本都来自注册方(ui-settings-general 拥有界面框架和「通用」分区;各功能拥有各自的分区、行和首次使用浮层)。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。
|
||||
|
||||
外壳只向首次使用注册方提供两个导航事实:当前会话界面是否为空白 Hero,以及一个 `openSection(id)` 回调;后者会打开设置面板并切换到已注册的指定分区。能力就绪状态、浮层关闭、文案和变更操作均由注册方持有,因此外壳不会成为第二个配置事实来源。
|
||||
|
||||
|
||||
@@ -46,6 +46,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-sidebar": "workspace:^",
|
||||
|
||||
@@ -8,7 +8,11 @@
|
||||
* onboarding overlays). Export discipline: packages/client/AGENTS.md.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: the ctx.locale Context merge for the optional ctx.get('locale')
|
||||
// read (nav labels may be locale-following thunks; the shell still ships no
|
||||
// copy of its own and takes no hard locale dependency).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { deferRegistration, resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SettingsRootInjected, SettingsSectionRow } from './contract/slots.ts'
|
||||
import { SettingsRoot } from './SettingsRoot.tsx'
|
||||
|
||||
@@ -33,27 +37,40 @@ export const inject = ['slots']
|
||||
export function apply(ctx: ClientContext): void {
|
||||
// Ledger → nav-row projection as an observable source (uSES contract:
|
||||
// getSnapshot returns the cached rows until the ledger version moves).
|
||||
// Labels may be locale-following thunks, so the cache key includes the
|
||||
// locale revision and subscribers ride both sources.
|
||||
let rowsVersion = -1
|
||||
let rowsRevision = -1
|
||||
let rows: readonly SettingsSectionRow[] = []
|
||||
const localeRevision = (): number => ctx.get('locale')?.getSnapshot().revision ?? 0
|
||||
const injected = (): SettingsRootInjected => ({
|
||||
hooks: {
|
||||
sections: {
|
||||
getSnapshot: () => {
|
||||
const version = ctx.slots.getVersion('settings.section')
|
||||
if (version !== rowsVersion) {
|
||||
const revision = localeRevision()
|
||||
if (version !== rowsVersion || revision !== rowsRevision) {
|
||||
rowsVersion = version
|
||||
rowsRevision = revision
|
||||
rows = ctx.slots.entries('settings.section')
|
||||
.map(e => ({
|
||||
/* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */
|
||||
id: e.options.id ?? '',
|
||||
order: e.options.order ?? 0,
|
||||
label: e.options.label ?? '',
|
||||
label: resolveSlotLabel(e.options.label) ?? '',
|
||||
}))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
return rows
|
||||
},
|
||||
subscribe: listener => ctx.slots.subscribe('settings.section', listener),
|
||||
subscribe: (listener) => {
|
||||
const offLedger = ctx.slots.subscribe('settings.section', listener)
|
||||
const offLocale = ctx.get('locale')?.subscribe(listener)
|
||||
return () => {
|
||||
offLedger()
|
||||
offLocale?.()
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user