Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

# Conflicts:
#	.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md
#	.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md
#	docs/config-catalog.md
#	packages/client/ui-goal/README.i18n.yaml
#	packages/host/apiproxy/tests/api-proxy-fork.spec.ts
#	packages/ui/tui/README.i18n.yaml
This commit is contained in:
_Kerman
2026-08-02 00:59:11 +08:00
153 changed files with 1768 additions and 540 deletions

View File

@@ -388,6 +388,9 @@ export class SessionsService implements ISessions {
* cut (the boundary is the first turn/end at or after it; an in-log
* anchor in an open turn is unavailable rather than clipped backward),
* and whether to increment an inherited durable title before resolving.
* A fractional anchor floors to a real event seq: the frozen nodes of an
* interrupted turn carry flow-ordering seqs between two events, and the
* wire takes integers only.
* @returns the child session id.
* @throws {SessionForkError} with the source id.
* @throws {Error} when a requested child-title rename fails after creation.
@@ -402,7 +405,10 @@ export class SessionsService implements ISessions {
: undefined
const result = await this.manager.fork({
sessionId: opts.sessionId,
...(opts.atSeq === undefined ? {} : { atSeq: opts.atSeq }),
// Flooring lands inside the anchor's own turn (every turn opens with a
// turn/start), so the host's first-turn/end-at-or-after cut still ends
// on that turn — never clipped back to the previous one.
...(opts.atSeq === undefined ? {} : { atSeq: Math.floor(opts.atSeq) }),
})
if (!result.ok) throw new SessionForkError(result.error, opts.sessionId)
this.projectList()

View File

@@ -455,6 +455,17 @@ describe('fork', () => {
})
})
it('floors a fractional anchor to the real event seq the wire accepts', async () => {
const b = bench()
await feedList(b, [{ id: 'source', cwd: '/work' }])
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
// The frozen node of an interrupted turn carries turnEnd.seq - 0.9.
await expect(b.svc.fork({ sessionId: sid('source'), atSeq: 41.1 })).resolves.toBe('child')
expect(b.api.callsOf('session.fork')).toEqual([{ sessionId: 'source', atSeq: 41 }])
})
it('does not rename without the title policy or a durable source title', async () => {
const b = bench()
await feedList(b, [{ id: 'source', cwd: '/work' }])

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: b61a70fb079eb6a1bc2a67b682a337ffdf708b79
README.zh.md: 0bb1740b166cfacc2bc79fe2f49793796f66c365
README.md: 845e12d760326b97a7e1fbffbd1655a73c5b5174
README.zh.md: eab8d6663848b1130e41e2d581f13ad93a50b545

View File

@@ -28,7 +28,7 @@ The chat flow projects consecutive model-retry nodes across retry turns into one
A `grep`/`glob` call declaring the `search` render intent renders its result inline, at the same render sites, through ui-primitives' `SearchBlock` — grep's matches grouped by file (each a collapsible header of `lineNumber: line` rows), glob's flat path list. `contract/search-card-model.ts` is the single derivation from the snapshot's `resultView`; unlike the terminal card it reads no `callView`, since a search has no matches or paths before `execute`, so a running search shows its summary alone. It yields null — the generic path — for any non-search result view, a `card` or `kind` this client version does not compile, and (because those ride the untrusted wire frame) a known kind whose `files`/`paths` is malformed. The keyed `SearchRow`, registered under both `grep` and `glob` since the derived `kind` decides the shape, composes the shared `ToolRow`, feeding the card as ToolRow's `search` body, so it is the row's collapsed-by-default expanded card; the render-site fallback routes it the same way. Both cap at `CHAT_SEARCH_MAX_LINES` (8) against the panel's 16. A capped search drops rows from the card, but the locator to the rest — grep/glob's `Full … stored at …` footer — lives only in the result text, so the derivation surfaces that as a recovery footer below the card when (and only when) the result was truncated; a settled call with no card at all (an errored search, a nested `run_code` sub-dispatch, a legacy generic result) surfaces its flattened result text through ToolRow's Output section so nothing is lost behind a bare summary ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)).
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); the bash sample is the third-party-posture exemplar. Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.

View File

@@ -26,7 +26,7 @@
声明 `search` 渲染意图的 `grep``glob` 调用,会在同样的渲染点上通过 ui-primitives 的 `SearchBlock` 内联渲染其结果——grep 的匹配按文件分组(每个是一个可折叠的头,下辖 `lineNumber: line`glob 是扁平路径列表。`contract/search-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;与终端卡片不同,它不读 `callView`,因为搜索在 `execute` 前没有匹配或路径,所以运行中的搜索只显示摘要。对任何非搜索的结果视图、当前客户端版本无法编译的 `card``kind`、以及(因为这些都与不可信的 wire 帧同行)一个 `files``paths` 格式错误的已知 kind它都返回 null落回通用路径。键控的 `SearchRow` 因推导出的 `kind` 决定形态而同时注册在 `grep``glob` 下,组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `search` body 传入,因此它是该行默认折叠的展开卡片;渲染点兜底行以同样方式渲染它。两者上限都是 `CHAT_SEARCH_MAX_LINES`8面板为 16。被截断的搜索会从卡片里丢掉一些行但通往其余部分的定位符——grep/glob 的 `Full … stored at …` 脚注——只存在于结果文本里,因此推导在(且仅在)结果被截断时把它作为恢复脚注画在卡片下方;一个完全没有卡片的已结算调用(出错的搜索、嵌套 `run_code` 子派发、旧日志的 generic 结果)则经 ToolRow 的 Output 区呈现其压平后的结果文本,从而不让任何内容丢失在一个光秃秃的摘要之后([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md))。
工具行同样是 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 会拒绝没有任何渲染方的声明)。
工具行同样是 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 已声明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 缺席即隐藏 chipchip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用取消、Escape、关闭按钮与点击遮罩都不会提交命令。

View File

@@ -317,7 +317,7 @@ export function apply(ctx: Context): void {
ctx.plugin(ConversationService, { input: inputHub })
// The bash sample rides that exact seam, in third-party posture
// (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions).
// (ToolRow-matching Bash · {description} chrome).
ctx.plugin(bashToolviewSample)
// The read row rides the same seam (a product registration, not a sample):

View File

@@ -99,17 +99,6 @@
opacity: 1;
}
.scopeBadge {
flex: none;
margin-right: 8px;
padding: 0 6px;
border-radius: 6px;
font-size: 11px;
line-height: 18px;
color: var(--dsw-alias-label-primary-foreground);
background: var(--dsw-alias-state-business-primary);
}
.title {
flex: none;
font-size: 14px;

View File

@@ -1,8 +1,6 @@
// Bash toolview registrant: third-party posture over the keyed toolview hole
// (ctx.slots.register + ToolRowProps only — never imports the chat domain).
// Product chrome matches ToolRow / Think (figma: Bash · {description}).
// Child sessions keep a scoped badge so session-dimension differentiation stays
// observable inside the component (no parallel registry).
//
// A bash call declares the terminal render intent, so this row renders the
// command's own output through TerminalBlock — expand-gated exactly like
@@ -64,7 +62,6 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal)
? 'error'
: model.state
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
const status = stateStatus(state, t)
const [expanded, setExpanded] = useState(false)
const expandable = terminal !== null
@@ -92,7 +89,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
<div className={css.card}>
<div
className={css.root}
data-sample={isChild ? 'bash-scoped' : 'bash-global'}
data-sample="bash"
data-variant="bash"
data-state={state}
data-expandable={expandable || undefined}
@@ -104,7 +101,6 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
>
<span className={css.leading}>{leading}</span>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
{isChild && <span className={css.scopeBadge}>scoped</span>}
<span className={css.title}>{model.title}</span>
<span className={css.sep} aria-hidden />
{/* The terminal presenter's description is the contractual

View File

@@ -149,7 +149,7 @@ describe('terminal card assembly', () => {
const view = runtime.renderRoot()
// Keyed BashRow: collapsed by default, the whole summary row is the toggle.
const keyedRow = view.container.querySelector('[data-sample="bash-global"]')
const keyedRow = view.container.querySelector('[data-sample="bash"]')
const keyed = keyedRow?.parentElement
expect(keyed?.querySelector('[data-terminal]')).toBeNull()
fireEvent.click(keyedRow!)

View File

@@ -181,7 +181,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
// sub-tool fell back to GenericToolCard at the same render site.
const nest = view.container.querySelector('[data-subcalls]')
expect(nest).not.toBeNull()
expect(nest!.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(nest!.querySelector('[data-sample="bash"]')).not.toBeNull()
expect(view.getByText('Bash')).toBeTruthy()
expect(view.getByText('List notes')).toBeTruthy()
expect(view.getByText('Tool call')).toBeTruthy()
@@ -264,7 +264,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
expect(running).not.toBeNull()
const nest = view.container.querySelector('[data-subcalls]')
expect(nest).not.toBeNull()
expect(nest!.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(nest!.querySelector('[data-sample="bash"]')).not.toBeNull()
})
it('a started-but-unsettled sub-call renders the running state exactly like a native in-flight row', async () => {

View File

@@ -1,8 +1,7 @@
// @vitest-environment jsdom
// StatsLine (composer.dock entry): totals derivation + the RFC
// hard acceptance — zero renders during streaming. Bash sample row: the
// canonical sub-agent differential decided INSIDE the component off the
// standard useSessions kit (no registry predicates — tool ring dissolved).
// hard acceptance — zero renders during streaming. Bash sample row: ToolRow
// chrome (Bash · description) without a row click target.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
@@ -232,8 +231,7 @@ describe('StatsLine', () => {
})
describe('bash sample row', () => {
const ROOT = 'root-1' as SessionId
const CHILD = 'child-1' as SessionId
const SID = 'root-1' as SessionId
const result = (callId: string): ToolResultNode => ({
kind: 'tool-result', seq: 3, time: 3_000, callId,
@@ -242,68 +240,30 @@ describe('bash sample row', () => {
content: [], isError: false, callView: null, resultView: null,
})
/** Real list-store engine: the family fixture the in-component parentId branch reads. */
function listStore() {
return createSnapshotStore<SessionListState>({
ids: [ROOT, CHILD],
ids: [SID],
byId: {
[ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 },
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, waitingApproval: false, blank: false, updatedAt: 0 },
[SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 },
},
current: undefined,
phase: 'ready',
})
}
const rowProps = (sessionId: SessionId, over?: {
store?: ReturnType<typeof listStore>
}): BashRowProps => ({
const rowProps = (): BashRowProps => ({
callId: 'c1', toolName: 'bash', block: result('c1'),
openFile: vi.fn(),
sessionId,
useSessions: bindSnapshotSelector(over?.store ?? listStore()),
sessionId: SID,
useSessions: bindSnapshotSelector(listStore()),
t,
} as unknown as BashRowProps)
it('differential rendering: the scoped variant in sub-sessions, global at roots', () => {
const scoped = render(<BashRow {...rowProps(CHILD)} />)
expect(scoped.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
expect(scoped.getByText('scoped')).toBeTruthy()
const plain = render(<BashRow {...rowProps(ROOT)} />)
expect(plain.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
})
it('a session outside the list renders the global arm (no parent known)', () => {
const view = render(<BashRow {...rowProps('gone' as SessionId)} />)
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
})
it('a live parentId write flips the row to the scoped variant (store subscription)', () => {
const store = listStore()
const orphan = 'late-child' as SessionId
store.update((d) => {
d.ids.push(orphan)
d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, waitingApproval: false, blank: false, updatedAt: 0 }
})
const view = render(<BashRow {...rowProps(orphan, { store })} />)
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
act(() => {
store.update((d) => { d.byId[orphan]!.parentId = ROOT })
})
expect(view.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
})
it('summarizes as Bash · description on both arms without row click targets', () => {
const global = render(<BashRow {...rowProps(ROOT)} />)
// Two renders share document.body: query inside each container.
const globalRow = global.container.querySelector('[data-sample="bash-global"]')!
expect(globalRow.textContent).toContain('Bash')
expect(globalRow.textContent).toContain('Build')
expect(globalRow.getAttribute('data-clickable')).toBeNull()
const scoped = render(<BashRow {...rowProps(CHILD)} />)
const scopedRow = scoped.container.querySelector('[data-sample="bash-scoped"]')!
expect(scopedRow.textContent).toContain('Bash')
expect(scopedRow.textContent).toContain('Build')
expect(scopedRow.getAttribute('data-clickable')).toBeNull()
it('summarizes as Bash · description without a row click target', () => {
const view = render(<BashRow {...rowProps()} />)
const row = view.container.querySelector('[data-sample="bash"]')!
expect(row.textContent).toContain('Bash')
expect(row.textContent).toContain('Build')
expect(row.getAttribute('data-clickable')).toBeNull()
})
})

View File

@@ -91,7 +91,7 @@ describe('keyed toolview hole through the real machinery', () => {
const view = b.runtime.renderRoot()
// bash: the sample plugin's keyed registration took the row (root
// session → global arm, decided inside the component off useSessions).
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(view.container.querySelector('[data-sample="bash"]')).not.toBeNull()
expect(view.getByText('Bash')).toBeTruthy()
expect(view.getByText('Build')).toBeTruthy()
// mystery: no registration under that key → render-site fallback.

View File

@@ -89,7 +89,7 @@ describe('tails', () => {
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
it('BashRow carries data-state for running (row sweep) and StateDots for error/stopped (root session arm)', () => {
it('BashRow carries data-state for running (row sweep) and StateDots for error/stopped', () => {
const sid = 'root-1' as SessionId
const list = createSnapshotStore<SessionListState>({
ids: [sid],
@@ -125,7 +125,7 @@ describe('tails', () => {
runningView.unmount()
const errorView = render(<BashRow {...props(errorResult)} />)
expect(errorView.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(errorView.container.querySelector('[data-sample="bash"]')).not.toBeNull()
expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull()
expect(errorView.getByText('失败')).toBeTruthy()
errorView.unmount()

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: caeaef4db9b1bd82090f1897c1a470a835294a39
README.zh.md: 60dd661b640d04917ac93c9930e1a4755a5817ff
README.md: 5df2df3a4d0814b55066a096263a9ff382498083
README.zh.md: 6ab7ff00ff2682324f78249b9865a913a622139e

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Goal surface plugin, browser half: the `GoalBar` strip is the first standalone card in the `conversation.input.dock` composer-context stack (order 0, before Todo and Queue). 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.
Goal surface plugin, browser half: the `GoalBar` strip is the first standalone card in the `conversation.input.dock` composer-context stack (order 0, before Todo and Queue). 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 domain store, refresh chain, or 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 strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing.
The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types.
@@ -17,4 +17,3 @@ None unless the queued goal context is admitted. An admitted context extends the
## Known Limitations and Deferred Work
- **Durable phase only** — the projection value deliberately omits process-local activation (armed/disarmed), so the strip cannot distinguish an active-but-disarmed goal from an armed one; resume re-arms through the RPC side. A host-live-value channel is deferred until a real consumer needs it.
- **No keyless snapshot yet** — the assembled-application transcript (boot → projection → GoalBar) is deferred to the post-review cleanup pass recorded on the landing PR.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第一张独立卡片order 0位于 Todo 和 Queue 之前)。活值经 `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、已完成三种状态一律不渲染。
Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第一张独立卡片order 0位于 Todo 和 Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词edit / pause / resume / clear`goal.*` 协议域——active 的 goal 提供暂停动作paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref并把结算后的 RPC 错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。
`/client` 出口面为插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。
@@ -17,4 +17,3 @@ Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input
## Known Limitations and Deferred Work
- **只反映持久 phase** —— 投影值有意省略进程本地的 activationarmed/disarmed条带无法区分 active-but-disarmed 与 armed 状态resume 经 RPC 侧重新武装。host 活值通道待出现真实消费方后再议。
- **暂缺 keyless 快照** —— 组装应用级 transcriptboot → 投影 → GoalBar推迟到落地 PR 记录的评审后收口批次。

View File

@@ -8,7 +8,7 @@
* the injected face.
*/
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { GoalSnapshot } from '@deepseek-ai/dsh-goal/client'
import {
IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconPauseOutline16, IconPlayOutline16, IconSparkle16, IconTrashOutline16,
@@ -35,6 +35,8 @@ export function GoalBar({ goal, onEdit, onPause, onResume, onClear, t }: GoalBar
const [draft, setDraft] = useState('')
const [pending, setPending] = useState(false)
const [actionError, setActionError] = useState<string | null>(null)
const [clearedGoalId, setClearedGoalId] = useState<GoalSnapshot['id'] | null>(null)
const pendingRef = useRef(false)
// A new goal identity (cleared/completed/replaced externally) invalidates the local edit
// state: without the reset a surviving draft's Enter would write over the NEW goal.
@@ -42,32 +44,37 @@ export function GoalBar({ goal, onEdit, onPause, onResume, onClear, t }: GoalBar
useEffect(() => {
setEditing(false)
setActionError(null)
setClearedGoalId(null)
}, [goalId])
// React state disables the controls on the next render; the ref closes the
// same-render window so rapid clicks cannot submit the same CAS twice.
const runAction = useCallback(async (action: () => Promise<GoalActionResult>): Promise<GoalActionResult | undefined> => {
if (pendingRef.current) return undefined
pendingRef.current = true
setPending(true)
setActionError(null)
const result = await action()
pendingRef.current = false
setPending(false)
if (!result.ok) setActionError(`${result.error.message} (${result.error.code})`)
return result
}, [])
const handleEdit = useCallback(async () => {
const trimmed = draft.trim()
if (trimmed === '') return
setPending(true)
setActionError(null)
const result = await onEdit(trimmed)
setPending(false)
if (result.ok) {
setEditing(false)
} else {
setActionError(`${result.error.message} (${result.error.code})`)
}
}, [draft, onEdit])
const result = await runAction(() => onEdit(trimmed))
if (result?.ok) setEditing(false)
}, [draft, onEdit, runAction])
const runAction = useCallback(async (action: () => Promise<GoalActionResult>) => {
setPending(true)
setActionError(null)
const result = await action()
setPending(false)
if (!result.ok) setActionError(`${result.error.message} (${result.error.code})`)
}, [])
const handleClear = useCallback(async (clearedId: GoalSnapshot['id']) => {
const result = await runAction(onClear)
if (result?.ok) setClearedGoalId(clearedId)
}, [onClear, runAction])
// Loading, absent, and complete goals have no strip at all.
if (goal === undefined || goal === null || goal.phase === 'complete') return null
if (goal === undefined || goal === null || goal.phase === 'complete' || goal.id === clearedGoalId) return null
if (editing) {
return (
@@ -142,7 +149,7 @@ export function GoalBar({ goal, onEdit, onPause, onResume, onClear, t }: GoalBar
>
<IconEditOutline16 />
</button>
<button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void runAction(onClear) }} title={t('action.clear')} aria-label={t('action.clear')}>
<button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void handleClear(goal.id) }} title={t('action.clear')} aria-label={t('action.clear')}>
<IconTrashOutline16 />
</button>
</div>

View File

@@ -3,13 +3,13 @@
// inline edit form, and resume/clear icon actions — driven purely through
// props, no wire. Loading, absent, and complete goals render nothing.
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { GoalSnapshot } from '@deepseek-ai/dsh-goal/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { GoalBar } from '../src/client/GoalBar.tsx'
import type { GoalBarActions } from '../src/client/slots.ts'
import type { GoalActionResult, GoalBarActions } from '../src/client/slots.ts'
import { zh } from '../src/client/locales.ts'
// The framework-injected t seat, stubbed over the zh dictionaries (the default locale).
@@ -61,6 +61,27 @@ describe('GoalBar', () => {
expect(actions.onClear).toHaveBeenCalledTimes(1)
})
it('single-flights rapid clear clicks and hides the committed goal before its projection catches up', async () => {
const actions = makeActions()
let resolveClear!: (result: GoalActionResult) => void
actions.onClear.mockImplementation(() => new Promise((resolve) => { resolveClear = resolve }))
const { container, rerender } = render(<GoalBar goal={makeGoal()} {...actions} t={t} />)
const clear = screen.getByRole<HTMLButtonElement>('button', { name: '清除目标' })
act(() => {
clear.click()
clear.click()
})
expect(actions.onClear).toHaveBeenCalledTimes(1)
expect(clear.disabled).toBe(true)
await act(async () => { resolveClear({ ok: true }) })
expect(container.firstChild).toBeNull()
rerender(<GoalBar goal={makeGoal({ id: 'g2' as GoalSnapshot['id'], objective: 'Next goal' })} {...actions} t={t} />)
expect(screen.getByText('Next goal')).toBeTruthy()
})
it('edit swaps the strip for a prefilled form; Enter saves, empty stays disabled', async () => {
const actions = makeActions()
render(<GoalBar goal={makeGoal()} {...actions} t={t} />)
@@ -180,5 +201,7 @@ describe('GoalBar', () => {
fireEvent.click(screen.getByRole('button', { name: '清除目标' }))
expect((await screen.findByRole('alert')).textContent).toBe('clear failed (agent-busy)')
expect(screen.getByText('Ship the redesign')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '清除目标' }))
await waitFor(() => { expect(actions.onClear).toHaveBeenCalledTimes(2) })
})
})

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: f06e8c014c19d17197980a232e8b42080aac2901
README.zh.md: feb97ee9fa834fbff10052909630ffcbbce9c78a
README.md: 8bdc3f043488631424c85a4319020c3d8ba5437b
README.zh.md: 52b507bb924ca4c05e92ed09d819339621c3bc20

View File

@@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
## Markdown rendering
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
`MarkdownText` renders GFM and `$…$` / `$$…$$` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
## Terminal output

View File

@@ -10,7 +10,7 @@
## Markdown 渲染
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM`$…$` / `$$…$$` TeX 公式,公式由 KaTeX 排版并禁用受信任命令。它会省略原始 HTML使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki
## 终端输出

View File

@@ -23,13 +23,16 @@
"@shikijs/langs": "^4.3.1",
"anser": "^2.3.5",
"clsx": "^2.0.0",
"katex": "^0.16.47",
"mdast-util-from-markdown": "^2.0.3",
"mdast-util-gfm": "^3.1.0",
"micromark-extension-gfm": "^3.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"shiki": "^4.3.1"
},
"devDependencies": {

View File

@@ -1,5 +1,5 @@
/* Visual baseline: deepsuite `@deepseek/md` markdown.css, adapted to CSS
Modules. Cite pills, KaTeX, header anchors, and thinking-small variants are
Modules. Cite pills, header anchors, and thinking-small variants are
intentionally absent (no matching DOM). Token names match that sheet. */
.markdown {
@@ -160,6 +160,12 @@
font-family: var(--ds-font-family-code);
}
.markdown :global(.katex-display) {
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
}
.markdown input[type='checkbox'] {
margin: 0 8px 0 0;
accent-color: var(--dsw-alias-label-secondary);

View File

@@ -1,11 +1,16 @@
import { isValidElement, useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import type { Components, UrlTransform } from 'react-markdown'
import rehypeKatex from 'rehype-katex'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import { CodeBlock } from './CodeBlock.tsx'
import 'katex/dist/katex.min.css'
import css from './MarkdownText.module.css'
const remarkPlugins = [remarkGfm]
const streamingRemarkPlugins = [remarkGfm]
const settledRemarkPlugins = [remarkGfm, remarkMath]
const settledRehypePlugins = [rehypeKatex]
function sanitizeUrl(url: string): string {
try {
@@ -88,12 +93,12 @@ const streamingComponents = buildComponents(true)
/**
* Render untrusted assistant-authored Markdown as semantic React elements.
* @param props - Markdown source text preserved by the session projection;
* `streaming` renders fences plain (highlighting lands on the finalize swap);
* `streaming` renders fences and TeX plain (highlighting and KaTeX land on the finalize swap);
* `codeLabels` forwards localized copy-button labels to fence CodeBlocks —
* pass a reference-stable object (memoized per locale revision), because the
* component table memoizes on its identity and a fresh literal per render
* would rebuild it every streaming chunk.
* @returns A GFM document with raw HTML, relative links, unsafe protocols, and remote images disabled.
* @returns A GFM document with TeX math rendered through KaTeX and raw HTML, relative links, unsafe protocols, and remote images disabled.
*/
export function MarkdownText({ text, streaming = false, codeLabels }: {
text: string
@@ -109,7 +114,8 @@ export function MarkdownText({ text, streaming = false, codeLabels }: {
return (
<div className={css.markdown}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
remarkPlugins={streaming ? streamingRemarkPlugins : settledRemarkPlugins}
rehypePlugins={streaming ? undefined : settledRehypePlugins}
components={components}
urlTransform={safeUrl}
>

View File

@@ -94,6 +94,11 @@ describe('MarkdownText', () => {
expect(done.container.querySelector('pre.shiki')).not.toBeNull()
})
it('forwards localized labels to fenced code blocks', () => {
render(<MarkdownText text={'```ts\nconst answer = 42\n```'} codeLabels={{ copyLabel: 'Copy code', copiedLabel: 'Copied' }} />)
expect(screen.getByRole('button', { name: 'Copy code' })).toBeTruthy()
})
it('neutralizes raw HTML, unsafe or relative links, and remote images', () => {
const markdown = [
'<script>globalThis.compromised = true</script>',
@@ -121,6 +126,39 @@ describe('MarkdownText', () => {
expect(container.querySelectorAll('li')).toHaveLength(2)
expect(screen.getByText('**unfinished')).toBeTruthy()
})
it('renders inline and display TeX through KaTeX without enabling trusted commands', () => {
const source = [
'Einstein wrote $E = mc^2$.',
'',
'$$',
'\\frac{\\partial \\mathbf{u}}{\\partial t} + (\\mathbf{u} \\cdot \\nabla)\\mathbf{u} = -\\frac{1}{\\rho}\\nabla p',
'$$',
'',
'$\\href{javascript:alert(1)}{unsafe}$',
].join('\n')
const { container } = render(<MarkdownText text={source} />)
expect(container.querySelectorAll('.katex')).toHaveLength(3)
expect(container.querySelectorAll('.katex-display')).toHaveLength(1)
expect(container.querySelector('.katex-display annotation')?.textContent).toContain('\\frac{\\partial \\mathbf{u}}')
expect(container.querySelector('a')).toBeNull()
})
it('defers TeX rendering while streaming so incomplete formulas never flash KaTeX errors', () => {
const partial = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial'
const complete = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial t}\n$$'
const live = render(<MarkdownText text={partial} streaming />)
expect(live.container.querySelector('.katex')).toBeNull()
expect(live.container.querySelector('.katex-error')).toBeNull()
expect(live.container.textContent).toContain('\\frac{\\partial \\mathbf{u}}{\\partial')
live.rerender(<MarkdownText text={complete} />)
expect(live.container.querySelectorAll('.katex')).toHaveLength(1)
expect(live.container.querySelectorAll('.katex-display')).toHaveLength(1)
expect(live.container.querySelector('.katex-error')).toBeNull()
})
})
describe('JsonBlock', () => {