Merge branch 'master' into worktree-renameweb

This commit is contained in:
imccyu
2026-07-29 22:28:34 +08:00
committed by GitHub
121 changed files with 1037 additions and 430 deletions

View File

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

View File

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

View File

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

View File

@@ -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: 2f7ca50fe241ddc7927b9cddf9496d1b990d372d
README.zh.md: 8a27ccb1ab59e98ca893190887a81fc0c9f4b910
README.md: 275c8097ebbc2a0fd1356c77e027064d3454f8d6
README.zh.md: 13f54215d21f72b17bf08313d67e5f3a95cd1c33

View File

@@ -8,7 +8,7 @@ The resident conversation shell survives no-session and session transitions. Wit
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); a pick submits the `/permission <preset>` command line through the bar's injected `command` callback.
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels (the `/permission` popup's display transform twin), and a pick submits the `/permission <preset>` command line through the bar's injected `command` callback.
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
@@ -20,7 +20,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 (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 resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats.
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 resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats.
`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).

View File

@@ -14,13 +14,13 @@
工具行同样是 slot独立工具环`ToolViewRegistry``ctx.toolviews`outlet已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位Session scopekey 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openFile``ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seamapply 在聊天注册后挂载 ConversationService因此服务存在即可保证 slot 已声明Session 区分在组件内部完成(`useSessions` 读取 `parentId`bash 示例是第三方姿态的范例。Trajectory/waterfall 工具视图 slot 共享此形状并随各自的渲染点落地RendersCheck 会拒绝没有任何渲染方的声明)。
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位未实例化会话同样点亮镜像该阻塞状态其优先级高于运行中圆环直至问题解决。未决等待完全离开消息流问题ui-question与审批ApprovalPanel都经编辑器接管作答不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数key 缺席即隐藏 chip选中会经由输入栏注入的 `command` 回调提交 `/permission <preset>` 命令行。
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位未实例化会话同样点亮镜像该阻塞状态其优先级高于运行中圆环直至问题解决。未决等待完全离开消息流问题ui-question与审批ApprovalPanel都经编辑器接管作答不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数key 缺席即隐藏 chipchip 打开 Menu 原语下拉kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission <preset>` 命令行。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: -1` 占用 `'conversation.input.dock'` 列表 slot位于队列行之上是计划条它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条。
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store`stores.ts` `createChatStore`InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession``sessionId`、全局 `useSessions``useWorkspaces`,以及输入状态机的 `useInput``inputActions`store 表层与 inject factory 提供其余状态和回调。
输入栏声明两个会话作用域的单实例 seat`'conversation.input.plan'` 位于本地 access 模式控件右侧,而 `'conversation.input.model'` 紧接在 pending 指示器与发送/停止按钮之前;它还为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时InputBar 将文本框 placeholder 切换为 plan 任务文案(它通过标准工具包的 `useProjection` 读取 host 折叠owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent智能体仍能收到回答没有待处理交互时活跃会话的 composer 归 Chat 所有。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 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 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取 host 折叠值owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent智能体仍能收到回答没有待处理交互时活跃会话的 composer 归 Chat 所有。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 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/*` 子路径获取它们)。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,8 @@ import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
// Type-only: the `plan` projection key merge (the TodoDock posture — the
// composer reads a host-computed value; the domain owns the key).
import type {} from '@deepseek-ai/dsh-plan-mode/client'
// Type-only: the `goal` projection key merge (hint disambiguation).
import type {} from '@deepseek-ai/dsh-goal/client'
import type { ComposerBarProps } from '../contract/slots.ts'
import { deriveDecorations } from '../input/decorations.ts'
import { PermissionSelect } from './PermissionSelect.tsx'
@@ -27,7 +29,7 @@ export interface InputBarError {
export type InputBarProps = ComposerBarProps
export function InputBar({
useSession, useInput, inputActions, keyboard, stop, command, renderSlot, useNotices, useLexicon, useProjection,
useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon, useProjection,
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
}: InputBarProps) {
const input = useInput(s => s)
@@ -39,6 +41,8 @@ export function InputBar({
// Plan mode swaps the textarea placeholder (the projection is the folded
// host value; owner-prop placeholders — hero, session-unavailable — win).
const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active))
// Absent (undefined: no frame yet) and cleared (null) both mean no goal.
const hasGoal = useProjection('goal', goal => goal != null)
// Prompt failures are ordinary failures (no create/attach transaction
// exists anymore): the strip renders promptError, the draft stays in the
// machine, and the user resubmits.
@@ -296,7 +300,12 @@ export function InputBar({
}
pushPlain(draft.length)
if (deco.hint !== null) {
backdrop.push(<span key="hint" className={css.hint} data-decoration="hint">{deco.hint}</span>)
// Claim tokens are shaped `/name ` (trailing space); trim to the bare name.
const commandName = input.claim?.token.slice(1).trim() ?? ''
const hintKey = commandName === 'goal' && hasGoal ? 'goal.active' : commandName
const translated = translateHint(hintKey)
const displayHint = translated !== hintKey ? translated : deco.hint
backdrop.push(<span key="hint" className={css.hint} data-decoration="hint">{displayHint}</span>)
}
}
@@ -312,7 +321,7 @@ export function InputBar({
{notice.text}
</div>
)}
<div className={css.card}>
<div className={css.card} data-composer-card>
{overlay !== undefined && <div className={css.overlayAnchor}>{overlay}</div>}
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
{/* Mirror-div auto-grow: the hidden mirror renders draft+'\n' and stretches the wrapper
@@ -329,7 +338,7 @@ export function InputBar({
data-phase={input.phase}
placeholder={placeholder ?? (disabled
? 'Session unavailable'
: planActive ? 'describe your task to generate plan' : 'Message the agent')}
: planActive ? translateHint('placeholder.plan') : translateHint('placeholder.default'))}
rows={2}
onChange={onChange}
onKeyDown={onKeyDown}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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-goal/README.md
README.md: 476096a43532a0bf514cd191585872ef17f65c50
README.zh.md: 27bd9a2e735cb4895d30eaf3b08dd939a00436fc
README.md: fed4870f73277b22760417297d668853b8afb2db
README.zh.md: cc607edc856e04c6ee42cc8f596aa658679a02ca

View File

@@ -2,13 +2,13 @@
English | [中文](README.zh.md)
Goal surface plugin, browser half: the `GoalBar` strip in the `conversation.input.dock` list (order 1, tucked against the composer). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no store, no refresh chain, and no event listener. The slot inject face carries only the three mutation verbs (edit / resume / clear over the `goal.*` wire domain); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline (the RPC's compare-and-set is the staleness guard — there is no client fence). Goal creation stays on the `/goal` host command; loading, absent, and completed goals render nothing.
Goal surface plugin, browser half: the `GoalBar` strip in the `conversation.input.dock` list (order 1, tucked against the composer). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no store, no refresh chain, and no event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline (the RPC's compare-and-set is the staleness guard — there is no client fence). Goal creation stays on the `/goal` host command; loading, absent, and completed goals render nothing.
The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types.
## Model Experience
Indirectly, through the `goal.edit`/`goal.resume`/`goal.clear` RPCs the strip's verbs submit: each accepted mutation appends a model-visible `goal/change` context message to the session (the same durable event the projection folds), so the model sees the updated goal state on its next turn. The strip itself adds no prompt content.
Indirectly, through the `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPCs the strip's verbs submit: each accepted mutation appends a model-visible `goal/change` context message to the session (the same durable event the projection folds), so the model sees the updated goal state on its next turn. The strip itself adds no prompt content.
#### KV Cache effect

View File

@@ -2,13 +2,13 @@
[English](README.md) | 中文
Goal 表面插件(浏览器半件):`conversation.input.dock` 列表中的 `GoalBar` 条带order 1紧贴 composer。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有 store、不设刷新链、不挂事件监听。slot 注入面只携带个变更动词edit / resume / clear`goal.*` 协议域);每个动词在调用时从会话当前投影值读取 CAS ref并把结算后的 RPC 错误内联呈现RPC 的 compare-and-set 即陈旧性防护——客户端没有任何栅栏。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成三种状态一律不渲染。
Goal 表面插件(浏览器半件):`conversation.input.dock` 列表中的 `GoalBar` 条带order 1紧贴 composer。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有 store、不设刷新链、不挂事件监听。slot 注入面只携带个变更动词edit / pause / resume / clear`goal.*` 协议域——active 的 goal 提供暂停动作paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref并把结算后的 RPC 错误内联呈现RPC 的 compare-and-set 即陈旧性防护——客户端没有任何栅栏。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成三种状态一律不渲染。
`/client` 出口面为插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。
## Model Experience
间接影响:条带动词提交的 `goal.edit`/`goal.resume`/`goal.clear` RPC 每次被接受后,会向会话追加一条模型可见的 `goal/change` 上下文消息(与投影折叠的正是同一条持久事件),模型在下一轮即可看到更新后的 goal 状态。条带自身不添加任何提示词内容。
间接影响:条带动词提交的 `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPC 每次被接受后,会向会话追加一条模型可见的 `goal/change` 上下文消息(与投影折叠的正是同一条持久事件),模型在下一轮即可看到更新后的 goal 状态。条带自身不添加任何提示词内容。
#### KV Cache effect

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,6 +25,7 @@ function makeGoal(over: Partial<GoalSnapshot> = {}): GoalSnapshot {
function makeActions() {
return {
onEdit: vi.fn<GoalBarActions['onEdit']>(() => Promise.resolve({ ok: true })),
onPause: vi.fn<GoalBarActions['onPause']>(() => Promise.resolve({ ok: true })),
onResume: vi.fn<GoalBarActions['onResume']>(() => Promise.resolve({ ok: true })),
onClear: vi.fn<GoalBarActions['onClear']>(() => Promise.resolve({ ok: true })),
} satisfies GoalBarActions
@@ -103,6 +104,13 @@ describe('GoalBar', () => {
expect(screen.getByRole('textbox', { name: 'Goal objective' })).toBeTruthy()
})
it('active goal: the pause action pauses', () => {
const actions = makeActions()
render(<GoalBar goal={makeGoal()} {...actions} />)
fireEvent.click(screen.getByRole('button', { name: 'Pause goal' }))
expect(actions.onPause).toHaveBeenCalledTimes(1)
})
it('paused goal: "Paused Goal" with a resume action before edit', () => {
const actions = makeActions()
render(<GoalBar goal={makeGoal({ phase: 'paused' })} {...actions} />)

View File

@@ -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-permission/README.md
README.md: 0cd8e7f878a151ffacd749eb625afcb20d44ad93
README.zh.md: 6bc299529c9795ef44cbe5429e78d6355c02a6ca
README.md: 3377a1c5907b67b065879b012923427685c106d6
README.zh.md: 34cf6f72394632968ded1671a5ac0377e5c78cc6

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Permission preset selection plugin, browser half: a popupSelect DECORATION hung on the host `/permission` command (`ctx.command.decorate`). A decoration is not a second command — the host command keeps its slash-menu row, the argued path (`/permission <preset>` switches directly), and the durable lifecycle logging; the decoration replaces only the bare invocation with the picker: one flat preset list with the current value marked active, where a pick submits the `/permission <preset>` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The decoration is available exactly while the projection key is present; a permission-less composition shows no picker (a decoration never manufactures a catalog row).
Permission preset selection plugin, browser half: a popupSelect DECORATION hung on the host `/permission` command (`ctx.command.decorate`). A decoration is not a second command — the host command keeps its slash-menu row, the argued path (`/permission <preset>` switches directly), and the durable lifecycle logging; the decoration replaces only the bare invocation with the picker: one flat preset list with the current value marked active and kebab-case preset names rendered as title-case labels (`workspace-write``Workspace Write`, the composer chip's display transform twin), where a pick submits the `/permission <preset>` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The decoration is available exactly while the projection key is present; a permission-less composition shows no picker (a decoration never manufactures a catalog row).
The `/client` export surface is the plugin body (`apply`/`inject`).

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
权限预设选择插件(浏览器半侧):挂在 host `/permission` 命令上的 popupSelect **装饰**`ctx.command.decorate`。装饰不是第二条命令——host 命令保留斜杠菜单行、带参路径(`/permission <preset>` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 active选中即提交 `/permission <preset>` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select因此两个界面共享同一读源与同一写路径推送的投影帧是两者共同跟随的唯一确认。装饰恰在投影 key 存在时可用;无权限组合不显示选择框(装饰绝不无中生有目录行)。
权限预设选择插件(浏览器半侧):挂在 host `/permission` 命令上的 popupSelect **装饰**`ctx.command.decorate`。装饰不是第二条命令——host 命令保留斜杠菜单行、带参路径(`/permission <preset>` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 activekebab-case 预设名渲染为 Title Case 标签(`workspace-write``Workspace Write`,与 composer chip 的显示变换孪生),选中即提交 `/permission <preset>` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select因此两个界面共享同一读源与同一写路径推送的投影帧是两者共同跟随的唯一确认。装饰恰在投影 key 存在时可用;无权限组合不显示选择框(装饰绝不无中生有目录行)。
`/client` 导出面为插件本体(`apply`/`inject`)。

View File

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

View File

@@ -85,6 +85,11 @@ describe('ui-permission browser plugin', () => {
const again = await c.ui.options(proj, new AbortController().signal)
expect(again.find(option => option.id === 'workspace-write')?.active).toBe(true)
expect(again.find(option => option.id === 'read-only')?.detail).toBe('Reads only.')
// Kebab-case names title-case; non-kebab host-configured names pass through.
expect(again.map(option => option.label)).toEqual(['Read Only', 'Workspace Write', 'Danger Full Access'])
b.values.set(sid('s1'), { ...SELECT, options: [{ value: 'plain', name: 'Ask Every Time' }] })
const passthrough = await c.ui.options(proj, new AbortController().signal)
expect(passthrough[0]?.label).toBe('Ask Every Time')
// A projection that vanished between availability and open throws.
expect(() => c.ui.options({ sessionId: sid('ghost') }, new AbortController().signal))
.toThrow(/not available on this host/)

View File

@@ -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: de43ce66d17498d31e05f8c64092ea0843103054
README.zh.md: b4d2f4fd1a6d45f814d4a20195434f34d207e9c8
README.md: 1d22c057b439ff337bf9daadcdba96dd4cca4540
README.zh.md: 183b8ef7776b60c1f0afa630e04627a474d40391

View File

@@ -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 "describe your task to generate plan" (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 `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).
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.

View File

@@ -4,7 +4,7 @@
Plan mode 状态徽章,纯浏览器 surface 插件。浏览器侧占据会话声明的 `conversation.input.plan` 单座(位于 access 模式控件右侧node 侧是空 applyroster 行。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" chiphover 出现的 ×`command.execute` 执行 `/plan off`;否则座位保持为空——未组合 plan-mode 的 host或尚无会话的 Draft不显示任何内容。plan mode 为有效目标期间composer 文本框的 placeholder 切换为 "describe your task to generate plan"(由 composer 从同一投影渲染owner 提供的 placeholder 优先)。
plan mode 只经 `/plan` 命令进入UI 上没有打开它的控件。当 host 计算的 `plan` 投影有效目标为 plan mode 时(`pending ? !active : active`——折叠的 host 值而非客户端乐观态,帧到达即自动纠正),座位渲染一个只读 "Plan" chiphover 出现的 ×`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 优先)。
chip 携带无障碍描述 "Plan mode on, press to turn off"。准入失败(`matched: false`、业务错误、传输故障以内联错误呈现chip 保持显示直至投影确认退出。

View File

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

View File

@@ -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: 3ff2717af7eeb6ef7f85c24456c7fe23b09d0faa
README.zh.md: 56c4e9f1dae3eee1dc7ea64616e2ed4d536928e2
README.md: 0ef3c20f848b3d331c007911d0837f11cd72c024
README.zh.md: af94551bfb9e12dbadcef6a96a54f9bf7ea71299

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, and TerminalBlock. Contract: api-contracts v3 §8.
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), and TerminalBlock. Contract: api-contracts v3 §8.
## Markdown rendering

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
纯 React 原子组件(零 cordisStateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族MessageText/MarkdownText/JsonBlock、只读 JsonTree 检查器,以及 TerminalBlock。契约api-contracts v3 §8。
纯 React 原子组件(零 cordisStateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族MessageText/MarkdownText/JsonBlock、只读 JsonTree 检查器`useAnchoredMaxHeight` hook把底部锚定的浮层高度收敛到锚点上方的视口空间并在 resize、scroll 与调用方提供的依赖变化时重新测量),以及 TerminalBlock。契约api-contracts v3 §8。
## Markdown 渲染

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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-slash/README.md
README.md: 4e363c2682bf91862ec40f3f2174831451fb9b0d
README.zh.md: 20770f37f33c4a8a94486b116856b41bedec913e
README.md: 29f1a71ce20f898ffe2ab3a1c6f3d73a7aecfe38
README.zh.md: 03dac56870de5b083124716825001009b4293736

View File

@@ -6,7 +6,7 @@ Input trigger pipeline plugin: `/` and `@` detection under the caret (word-bound
Layering: `src/core/` (T2) is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract (design v4 §5.1); changes require main-thread arbitration.
MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-slash) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`.
MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. Groups sort by the optional `SlashSource.order` (lower first, default 0, ties keep registration order) under title rows localized through the `slash.menu` locale namespace (an unknown source shows its raw name); the list height clamps to the space above the composer, and a pointer down outside both the menu and the surrounding composer card dismisses it. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-slash) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`.
The `/client` export surface is the plugin body (`apply`/`inject`), `SlashService`, `MenuViewInjected`, and the contract types. MenuView itself is internal — the slot registration closes over it.
@@ -23,4 +23,3 @@ None; this package neither assembles nor sends a provider request.
- **Global source layer only** — session-scope source registration (per-session shadowing, ScopedLayers-alike) is designed but not enabled; the ledger tracks the trigger condition (a real per-session source need).
- **`SlashCandidate.icon` renders as text** — MenuView drops the string into the icon slot verbatim; wiring to the design-system icon enum (iconFile five-variant family) lands when that enum ships.
- **Overlay SlotMap merge home is split from slot ownership** — the `conversation.input.overlay` merge lives here (sole copy) while the slot's owner semantics (anchor, children declaration, lifecycle) stay with ui-conversation; the dependency direction (ui-conversation → ui-slash) forces the split, so a future dependency reshuffle should revisit it.
- **Menu group order is registration order** — no explicit ordering seam across sources; acceptable while the roster is command/skill/subagent, revisit if business sources join.

View File

@@ -6,7 +6,7 @@
分层:`src/core/`T2是纯内核——`detectTrigger``menuReduce``seedGroups``MENU_CLOSED``exactMatch`,零 ReactDOMcordis`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包契约(设计 v4 §5.1);变更需经主线程仲裁。
MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot列表类会话 scope菜单关闭期间渲染 null。该 slot 由 ui-conversation 的组合器条目拥有锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`因为依赖方向ui-conversation → ui-slash不允许反向的类型导入。combobox 模式:焦点始终留在 textarea行在 mousedown 时完成 pick高亮由 `aria-activedescendant` 承载。
MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot列表类会话 scope菜单关闭期间渲染 null。分组按可选的 `SlashSource.order` 排序(越小越靠前,默认 0同值保持注册序组标题行经 `slash.menu` locale 命名空间本地化(未知 source 显示其原名);列表高度收敛到 composer 上方的可用空间,指针落在菜单与所在 composer 卡片之外即关闭菜单。该 slot 由 ui-conversation 的组合器条目拥有锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`因为依赖方向ui-conversation → ui-slash不允许反向的类型导入。combobox 模式:焦点始终留在 textarea行在 mousedown 时完成 pick高亮由 `aria-activedescendant` 承载。
`/client` 导出表层是插件主体(`apply``inject`)、`SlashService``MenuViewInjected` 与契约类型。MenuView 本身是内部实现——slot 注册以闭包持有它。
@@ -23,4 +23,3 @@ MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot列表类
- **只有全局 source 层**:会话 scope 的 source 注册(逐会话遮蔽、类 ScopedLayers 机制)已有设计但未启用;台账记录着触发条件(出现真实的逐会话 source 需求)。
- **`SlashCandidate.icon` 以文本渲染**MenuView 把该字符串原样放进图标位与设计系统图标枚举iconFile 五变体家族)的接入将在该枚举交付后完成。
- **overlay 的 SlotMap 合并归属与 slot 所有权分离**`conversation.input.overlay` 的合并放在本包(唯一副本),而该 slot 的 owner 语义锚点、children 声明、生命周期)留在 ui-conversation依赖方向ui-conversation → ui-slash迫使这一拆分未来依赖关系调整时应重新审视。
- **菜单组顺序即注册顺序**source 之间没有显式排序 seamroster 还是 commandskill技能subagent 时可以接受,业务 source 加入后需重新审视。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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/core/agent-loop/README.md
README.md: 6bb8b12af69f54c2a75cd672e4d3802887808c76
README.zh.md: 80a01f3e3fdddba8c243cad28c43072148af1dd9
README.md: 16d70cc06498fec1221b7872f988a0126f69f39f
README.zh.md: ce68595072766ebbf1e4cbd9f7c262cee36c5eff

View File

@@ -67,7 +67,7 @@ After `agent/request` returns a provider/model call config, the loop asks `ctx.l
Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and other extension failures close directly. Recovery receives the exact live error, immutable provider facts, immutable prior failures, the immutable retry policy of the adapter registration that served the request, and the turn signal after the failed step closes; the policy is absent if no final adapter served it. A handling listener returns `{ kind: 'retry' }`; the loop closes the failed turn with its error and opens one numbered retry turn without an intervening idle notification. Success clears the consecutive history, and an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract.
Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, and retains their finalized result context without distinguishing the cancellation cause.
Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, and retains their finalized result context without distinguishing the cancellation cause. An internal scheduler failure stops new dispatches, waits for already-started dispatches, and reaches the turn error boundary without fabricating tool results.
### What belongs to plugins

View File

@@ -67,7 +67,7 @@ interface Config {
插件失败会结束当前轮次,而不是结束循环。只有最终适配器分发/迭代失败以及带内的终止错误或中止结束才进入 `agent/request-error`;中间件、结果处理、工具及其他扩展失败会直接关闭轮次。失败步骤关闭后,恢复逻辑会接收确切的实时错误、不可变的提供方事实、不可变的先前失败、为请求提供服务的适配器注册所对应的不可变重试策略,以及轮次信号;如果没有最终适配器为其提供服务,则该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`循环用其错误关闭失败轮次并在不插入空闲通知的情况下开启一个编号重试轮次。成功会清除连续失败历史未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end``user``parent` 记录 `aborted`dispose资源释放则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call``ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。
在步骤内独占调用形成屏障并行安全调用使用有界滚动池并在启动前重新分类。只有分发主体会重叠。策略、持久结果和结果上下文仍保持模型顺序。中止会停止新调用drain 已启动的结果,并保留其已定案的结果上下文,不区分取消原因。
在步骤内独占调用形成屏障并行安全调用使用有界滚动池并在启动前重新分类。只有分发主体会重叠。策略、持久结果和结果上下文仍保持模型顺序。中止会停止新调用drain 已启动的结果,并保留其已定案的结果上下文,不区分取消原因。内部调度器故障会停止新的分发,等待已启动的分发,然后在不虚构工具结果的情况下到达轮次错误边界。
### 插件负责的内容

View File

@@ -2,10 +2,12 @@
* Schedules one assistant step's tool calls. Exclusive calls form barriers;
* parallel calls use a bounded rolling pool and are reclassified before start.
* Dispatch may overlap, while policy, results, and result context remain
* model-ordered. Abort stops replenishment and drains started calls.
* model-ordered. Abort or an internal scheduler failure stops replenishment
* and drains started calls.
*
* Each advertised call records a balanced `tool/call`/`tool/result` pair. Calls
* skipped after abort receive synthetic error results so replay stays valid.
* Abort records synthetic error results for skipped calls so replay stays
* valid. A terminal scheduler failure preserves already-recorded `tool/call`
* events without fabricating results.
* @module dsh-agent-loop/tool-calls
*/
@@ -37,10 +39,13 @@ interface GroupOutcome {
/**
* Schedule one assistant step's tool calls by their live concurrency mode.
* Started calls receive ordered results. Abort drains them, records synthetic
* results for unstarted calls, and returns with the signal still aborted after
* accepting started-call context through the caller-supplied acceptor (the
* machine stages it on its outbox for the next step boundary).
* Ordinary completion and abort commit started-call results in order. Abort
* drains them, records synthetic results for unstarted calls, and returns with
* the signal still aborted after accepting started-call context through the
* caller-supplied acceptor (the machine stages it on its outbox for the next
* step boundary). An internal scheduler failure stops new dispatches, drains
* already-started dispatches, and rejects with the first failure without
* fabricating tool results.
* The committed step's AgentLoop driver boundary supplies the initiating Agent
* that becomes each explicit {@link ToolExecutionInput.agent}.
*
@@ -110,7 +115,8 @@ function parseArguments(raw: string): unknown {
* drain and remains for the caller's next barrier. Results and contexts commit
* in model order. Abort stops starts, drains and commits started calls, accepts
* their contexts into the owning batch, records results for skipped calls, and
* returns an aborted outcome.
* returns an aborted outcome. Scheduler failure drains dispatches without
* committing synthetic recovery results.
*/
async function runGroup(
ctx: Context,
@@ -131,6 +137,10 @@ async function runGroup(
let started = 0
let aborted: boolean = signal.aborted
let concluded = false
let schedulerFailure: { error: unknown } | undefined
const throwSchedulerFailure = (): void => {
if (schedulerFailure !== undefined) throw schedulerFailure.error
}
// `committed` advances only across contiguous model-order slots.
const commitReady = async (): Promise<void> => {
@@ -157,12 +167,19 @@ async function runGroup(
callSeqs[index] = appendToolCall(session, turn, step, call.block)
started++
const prepared = await ctx.tools[TOOL_REGISTRY_SCHEDULER].prepare(call.exec)
throwSchedulerFailure()
switch (prepared.kind) {
case 'dispatch': {
const promise = ctx.tools[TOOL_REGISTRY_SCHEDULER].dispatch(prepared.exec).then((outcome) => {
slots[index] = { exec: prepared.exec, result: outcome.result, needsPost: outcome.kind === 'post-result' }
return index
})
const promise = ctx.tools[TOOL_REGISTRY_SCHEDULER].dispatch(prepared.exec).then(
(outcome) => {
slots[index] = { exec: prepared.exec, result: outcome.result, needsPost: outcome.kind === 'post-result' }
return index
},
(error: unknown) => {
schedulerFailure ??= { error }
return index
},
)
inFlight.set(index, promise)
break
}
@@ -187,24 +204,34 @@ async function runGroup(
&& ctx.tools.executionMode(nextCall.exec).kind !== 'parallel') break
await startCall(nextToStart)
nextToStart++
throwSchedulerFailure()
await commitReady()
throwSchedulerFailure()
// Abort may arrive while pre-execute awaits.
if (signal.aborted) aborted = true
}
}
// Ordered pre-execute may await; only dispatch/body overlaps.
// TODO: Drain every started call before rethrowing a scheduler error; tool
// bodies must not outlive the failed turn.
await fillPool()
while (inFlight.size > 0) {
const settledIndex = await Promise.race(inFlight.values())
inFlight.delete(settledIndex)
await commitReady()
// Abort may arrive while a tool or ordered commit awaits.
if (signal.aborted) aborted = true
// Ordered pre-execute may await; only dispatch/body overlaps. A scheduler
// failure stops new dispatches and reaches the turn boundary after every
// already-started dispatch settles.
try {
await fillPool()
while (inFlight.size > 0) {
const settledIndex = await Promise.race(inFlight.values())
inFlight.delete(settledIndex)
throwSchedulerFailure()
await commitReady()
throwSchedulerFailure()
// Abort may arrive while a tool or ordered commit awaits.
if (signal.aborted) aborted = true
await fillPool()
}
} catch (error: unknown) {
schedulerFailure ??= { error }
await Promise.allSettled(inFlight.values())
throw schedulerFailure.error
}
if (aborted) {

View File

@@ -9,7 +9,7 @@ import { createUserMessage, CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import LlmService from '@deepseek-ai/dsh-llm'
import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH, TOOL_REGISTRY_SCHEDULER, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -613,3 +613,66 @@ describe('tool-call scheduler: abort handling', () => {
})
})
})
describe('tool-call scheduler: failure quiescence', () => {
it('stops new dispatches and drains started bodies before surfacing the first failure', async () => {
const adapter = new MockAdapter([
multiCall([
{ id: 'c1', name: 'p', args: { id: '1' } },
{ id: 'c2', name: 'p', args: { id: '2' } },
{ id: 'c3', name: 'p', args: { id: '3' } },
]),
])
const ctx = await harness(adapter, 3)
const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool)
// The registry contains expected failures as results; replace its internal
// view only to inject the invariant violation this boundary must contain.
const scheduler = ctx.tools[TOOL_REGISTRY_SCHEDULER]
const prepare = scheduler.prepare.bind(scheduler)
const dispatch = scheduler.dispatch.bind(scheduler)
const prepareGate = Promise.withResolvers<undefined>()
let thirdPrepareEntered = false
scheduler.prepare = async (exec) => {
const prepared = await prepare(exec)
if (exec.callId === CallId('c3')) {
thirdPrepareEntered = true
await prepareGate.promise
}
return prepared
}
const schedulerError = new Error('scheduler exploded')
const drainedError = new Error('sibling failed while draining')
let rejectFirst: ((error: Error) => void) | undefined
scheduler.dispatch = exec => exec.callId === CallId('c1')
? new Promise((_resolve, reject) => { rejectFirst = reject })
: dispatch(exec).then(() => { throw drainedError })
const agent = ctx.agentLoop.create(SessionId('scheduler-failure'), { provider: 'mock', model: 'mock' })
const errors: unknown[] = []
ctx.on('agent/error', (subject, _turn, _step, error) => {
if (subject === agent) errors.push(error)
})
let idle = false
const idlePromise = waitForIdle(ctx, agent).then(() => { idle = true })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await until(() => gated.started.includes('2') && thirdPrepareEntered && rejectFirst !== undefined)
rejectFirst?.(schedulerError)
await new Promise<void>(resolve => setImmediate(resolve))
prepareGate.resolve(undefined)
await new Promise<void>(resolve => setImmediate(resolve))
const startedBeforeDrain = [...gated.started]
const idleBeforeDrain = idle
const errorsBeforeDrain = [...errors]
for (const id of gated.pending()) gated.release(id)
await idlePromise
expect(startedBeforeDrain).toEqual(['2'])
expect(idleBeforeDrain).toBe(false)
expect(errorsBeforeDrain).toEqual([])
expect(gated.pending()).toEqual([])
expect(errors).toEqual([schedulerError])
expect(errors[0]).toBe(schedulerError)
})
})

View File

@@ -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/lsp/lsp-local/README.md
README.md: 85254eea2bb74df277be6fd5de1529b5da4ea178
README.zh.md: 2390b02371980da39cd8c801197b7df41571e127
README.md: 40515ad173dec8cfe6a0937525612b7f18d22fb8
README.zh.md: f8b17957dbd0d239aa2c0b0aeadf6136427788aa

View File

@@ -23,7 +23,7 @@ The `servers` record key is the stable provider id reserved on `ctx.lsp`; each v
|---|---|---|
| `command` | (required) | Executable to spawn — absolute, or resolved on the child PATH at load. Launch uses no shell. |
| `args` | `[]` | Arguments passed to the executable. |
| `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`SECRET`/`TOKEN` are not forwarded); an explicit `DSH_*` entry merges after the seam's scrub of ambient ones. |
| `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`PASSWORD`/`SECRET`/`TOKEN` are not forwarded); an explicit `DSH_*` entry merges after the seam's scrub of ambient ones. |
| `extensionToLanguage` | (required) | Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). |
| `initializationOptions` | `null` | Static `initialize` options forwarded to the server. |
| `configuration` | `null` | Static answer to every `workspace/configuration` item. |

View File

@@ -23,7 +23,7 @@ Namespace 插件(`name``inject``Config``apply`,无默认导出)
|---|---|---|
| `command` | (必填) | 要 spawn 的可执行文件:绝对路径,或在加载时从子进程 PATH 解析。不使用 shell 启动。 |
| `args` | `[]` | 传给可执行文件的参数。 |
| `env` | `{}` | 合并到已清理 credential 的环境之上的额外 env匹配 `KEY``SECRET``TOKEN` 的变量不会转发);显式 `DSH_*` 条目在 seam 清除环境中同名值之后合并。 |
| `env` | `{}` | 合并到已清理 credential 的环境之上的额外 env匹配 `KEY``PASSWORD``SECRET``TOKEN` 的变量不会转发);显式 `DSH_*` 条目在 seam 清除环境中同名值之后合并。 |
| `extensionToLanguage` | (必填) | 小写、以点开头的扩展名 → LSP language id例如 `{ '.ts': 'typescript' }`)。 |
| `initializationOptions` | `null` | 转发给服务器的静态 `initialize` 选项。 |
| `configuration` | `null` | 每个 `workspace/configuration` 配置项的静态答案。 |

View File

@@ -5,7 +5,7 @@
*/
import { execFile, spawn } from 'node:child_process'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import { scrubbedParentEnv, SENSITIVE_ENV_PATTERN } from '@deepseek-ai/dsh-subprocess'
import { promisify } from 'node:util'
import type { PackageJsonFile } from '../documents/package-json-file.ts'
import { PnpmWorkspaceFile } from '../documents/pnpm-workspace-file.ts'
@@ -60,7 +60,7 @@ export async function probePackageManagerVersion(name: PackageManagerName, cwd:
*/
export function scrubEnvironment(environment?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
if (environment === undefined) return scrubbedParentEnv()
return Object.fromEntries(Object.entries(environment).filter(([name]) => !/(?:KEY|SECRET|TOKEN)/i.test(name)))
return Object.fromEntries(Object.entries(environment).filter(([name]) => !SENSITIVE_ENV_PATTERN.test(name)))
}
/** Node child-process command runner with inherited stdio and quiescent completion. */

View File

@@ -309,7 +309,12 @@ describe('package manager strategies', () => {
await expect(npm.install('/tmp', failed)).rejects.toThrow('exited with code 2')
const killed: CommandRunner = { run: async () => ({ exitCode: null, signal: 'SIGTERM' }) }
await expect(npm.build('/tmp', killed)).rejects.toThrow('killed by SIGTERM')
expect(scrubEnvironment({ PATH: '/bin', API_KEY: 'secret', TOKEN_VALUE: 'secret' })).toEqual({ PATH: '/bin' })
expect(scrubEnvironment({
PATH: '/bin',
API_KEY: 'secret',
DB_PASSWORD: 'secret',
TOKEN_VALUE: 'secret',
})).toEqual({ PATH: '/bin' })
})
it('probes versions and runs real child-process boundaries', async () => {

View File

@@ -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/subprocess/subprocess-local/README.md
README.md: a4e20b5c64afd14cb9dce9f8cc46fa96a1f5f19d
README.zh.md: 170ff154864de8634a981d571e3ce1a8d704f546
README.md: 202fc57080400afcbf5a65c17ea6bc8ac758c96f
README.zh.md: c3a00cfd327c6865eda57cc63b87fd5f40e2b24f

View File

@@ -8,7 +8,7 @@ Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README
- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID <pid> /T /F` (injectable for tests). `terminate()` — the handle's only termination verb — sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent) and is a no-op once the tree is gone; `waitForExit()` polls whole-tree liveness so consumer teardown confirms real quiescence. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools.
- **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory.
- **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
- **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
- **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement.
- **Terminate-and-join disposal** — the service retains live handles only so its own disposal can escalate every running tree and await its exit; settled and spawn-failed handles leave the live set on settlement.
@@ -23,7 +23,7 @@ No direct invalidation; the named consumers own any request-prefix changes.
## Known Limitations and Deferred Work
- **Windows tree support is best-effort and untested in CI** — termination routes through `taskkill /PID <pid> /T /F` with all outcomes contained (absent tree, races, missing binary), and liveness falls back to the direct-child boundary; the suites cover the routing through an injected runner only, and `packages/subprocess/*` is excluded from the Windows test matrix.
- **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work.
- **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSPHRASE*`) pass through, and a whitelist for over-scrubbed vars is noted future work.
- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind.
The raw process handling lives in `src/spawn.ts`; `src/index.ts` is the service wiring.

View File

@@ -8,7 +8,7 @@
- **以适合平台的方式发送信号的 detached 进程树**POSIX 子进程使用 `detached` spawn拥有独立进程组信号以负 pgid 发送并以直接子进程作为回退Windows 通过 `taskkill /PID <pid> /T /F` 终止进程树(可为测试注入)。`terminate()`(句柄唯一的终止操作)先发送 SIGTERM经过 spec 的宽限期后再发送 SIGKILL沿用 OpenCode 的升级策略;流水线与子 shell 会随父进程一起结束),进程树消亡后为空操作;`waitForExit()` 轮询整棵进程树的存活状态,使消费方的拆卸能确认真正的完全停稳。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH重新指定父进程并脱离该组的 daemon 仍可能存活,这与所调研工具的局限相同。
- **按流划分的处置方式**`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符收集模式collect在输出超过上限后于内存中保留尾部错误与结果通常聚集在末尾沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留用于诊断的尾部。某条流大于 spill 上限时,会丢弃已不完整的 spill仅返回带截断标记的尾部spill 文件描述符在结算时封存最终关闭失败时则不公布路径以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需创建、权限为 `0700` 的每进程目录之下。
- **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*``*SECRET*``*TOKEN*`)和所有环境中已有的 `DSH_*` 名称spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
- **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*``*PASSWORD*``*SECRET*``*TOKEN*`)和所有环境中已有的 `DSH_*` 名称spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
- **基于偏移量的读取**收集模式的读取器按完整流的字节坐标返回增量服务自身从不持有游标因此消费方自有的游标bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。
- **先终止再等待退出的 dispose资源释放**:服务保留存活句柄,只为让自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。
@@ -23,7 +23,7 @@
## 已知限制与暂缓事项
- **Windows 进程树支持仅为尽力而为,且未经 CI 测试**:终止经由 `taskkill /PID <pid> /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界;测试套件只通过注入的运行器覆盖这条路由,且 `packages/subprocess/*` 被排除在 Windows 测试矩阵之外。
- **凭据清除依赖名称启发式规则**:只匹配 `*KEY*``*SECRET*``*TOKEN*`;名称不同的 secret例如 `*PASSWORD*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。
- **凭据清除依赖名称启发式规则**:只匹配 `*KEY*``*PASSWORD*``*SECRET*``*TOKEN*`;名称不同的 secret例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。
- **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。
原始进程处理位于 `src/spawn.ts``src/index.ts` 负责服务接线。

View File

@@ -255,10 +255,10 @@ describe('stdin and extra env (set by in-process plugins)', () => {
})
it('an explicit extra env entry overrides the credential scrub', async () => {
// EXPLICIT_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit
// EXPLICIT_OVERRIDE_PASSWORD matches the credential scrub pattern, yet an explicit
// entry is still honored — the scrub only drops AMBIENT process.env creds.
const result = await finish(spawnSubprocess(spec('echo "$EXPLICIT_OVERRIDE_KEY"', {
env: { EXPLICIT_OVERRIDE_KEY: 'explicit-wins' },
const result = await finish(spawnSubprocess(spec('echo "$EXPLICIT_OVERRIDE_PASSWORD"', {
env: { EXPLICIT_OVERRIDE_PASSWORD: 'explicit-wins' },
})))
expect(result.stdout.text).toBe('explicit-wins\n')
})
@@ -748,13 +748,17 @@ describe('environment and spill-file hardening', () => {
it('scrubs credential-shaped and ambient DSH env vars from child processes', async () => {
process.env.DSH_TEST_API_KEY = 'super-secret'
process.env.DSH_TEST_TOKEN = 'also-secret'
process.env.SUBPROCESS_TEST_PASSWORD = 'password-secret'
process.env.DSH_TEST_PLAIN = 'visible'
try {
const result = await finish(spawnSubprocess(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')))
expect(result.stdout.text.trim()).toBe('[absent|absent|absent]')
const result = await finish(spawnSubprocess(spec(
'echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${SUBPROCESS_TEST_PASSWORD:-absent}|${DSH_TEST_PLAIN:-absent}]"',
)))
expect(result.stdout.text.trim()).toBe('[absent|absent|absent|absent]')
} finally {
delete process.env.DSH_TEST_API_KEY
delete process.env.DSH_TEST_TOKEN
delete process.env.SUBPROCESS_TEST_PASSWORD
delete process.env.DSH_TEST_PLAIN
}
})

View File

@@ -37,7 +37,7 @@ export type {
* deliberately supplied entry survives because explicit env layers merge
* after the scrub.
*/
export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
export const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i
/**
* The ambient parent environment minus credential-shaped names and minus all

View File

@@ -55,16 +55,19 @@ describe('SubprocessService seam', () => {
it('scrubbedParentEnv drops credential-shaped and DSH_ names but keeps PATH', () => {
process.env.DSH_SCRUB_PROBE = 'stale'
process.env.SCRUB_PROBE_TOKEN = 'secret'
process.env.SCRUB_PROBE_PASSWORD = 'secret'
process.env.SCRUB_PROBE_PLAIN = 'visible'
try {
const env = scrubbedParentEnv()
expect(env.DSH_SCRUB_PROBE).toBeUndefined()
expect(env.SCRUB_PROBE_TOKEN).toBeUndefined()
expect(env.SCRUB_PROBE_PASSWORD).toBeUndefined()
expect(env.SCRUB_PROBE_PLAIN).toBe('visible')
expect(env.PATH).toBeDefined()
} finally {
delete process.env.DSH_SCRUB_PROBE
delete process.env.SCRUB_PROBE_TOKEN
delete process.env.SCRUB_PROBE_PASSWORD
delete process.env.SCRUB_PROBE_PLAIN
}
})

View File

@@ -45,6 +45,7 @@
"@deepseek-ai/dsh-session-reference": "^0.0.1",
"@deepseek-ai/dsh-session-title": "^0.0.1",
"@deepseek-ai/dsh-skill": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-token-meter": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
@@ -82,6 +83,7 @@
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tool-cordis": "workspace:^",

View File

@@ -16,6 +16,7 @@ import {
visibleWidth,
} from '@earendil-works/pi-tui'
import type { Session } from '@deepseek-ai/dsh-session'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
/** Editor that shows a placeholder without making it editable content. */
export class HintEditor extends Editor {
@@ -65,13 +66,10 @@ export function formatCwd(cwd: string | undefined): string {
*/
export function gitBranch(cwd: string): string | undefined {
try {
const env = Object.fromEntries(
Object.entries(process.env).filter(([name]) => !/(?:KEY|SECRET|TOKEN)/iu.test(name)),
)
const branch = execFileSync('git', ['branch', '--show-current'], {
cwd,
encoding: 'utf8',
env,
env: scrubbedParentEnv(),
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 1_000,
}).trim()

View File

@@ -0,0 +1,29 @@
import { execFileSync } from 'node:child_process'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { gitBranch } from '../src/chat/helpers.ts'
vi.mock('node:child_process', () => ({
execFileSync: vi.fn(() => 'main\n'),
}))
afterEach(() => {
vi.unstubAllEnvs()
vi.clearAllMocks()
})
describe('chat helpers', () => {
it('scrubs ambient credentials and DSH names from the Git child', () => {
vi.stubEnv('TUI_TEST_PASSWORD', 'ambient-password')
vi.stubEnv('DSH_TUI_TEST_FLAG', 'ambient-harness-state')
expect(gitBranch('/workspace')).toBe('main')
const call = vi.mocked(execFileSync).mock.calls[0] as unknown as [
string,
string[],
{ env: NodeJS.ProcessEnv },
]
expect(call[0]).toBe('git')
expect(call[1]).toEqual(['branch', '--show-current'])
expect(call[2].env).not.toHaveProperty('TUI_TEST_PASSWORD')
expect(call[2].env).not.toHaveProperty('DSH_TUI_TEST_FLAG')
})
})

View File

@@ -56,6 +56,9 @@
{
"path": "../../skill/skill"
},
{
"path": "../../subprocess/subprocess"
},
{
"path": "../user-interaction"
},