Merge remote-tracking branch 'origin/master' into feat/web-diff-card
# Conflicts: # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/README.md # packages/client/ui-conversation/README.zh.md
This commit is contained in:
@@ -1150,6 +1150,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
let failNextHistory = false
|
||||
/** Force-enders for currently open stream generators (timing hook: simulated connection loss). */
|
||||
const streamBreakers = new Set<() => void>()
|
||||
/** Retry scenarios opened by timing hooks and completed in a later browser assertion phase. */
|
||||
const retryScenarios = new Map<SessionId, { turn: number; stepStarted: boolean }>()
|
||||
|
||||
// Timing-acceptance hooks (browser test backdoor): the in-memory fixture is ideally timed, which
|
||||
// is exactly what masked the open-window and reconnect-gap bugs (audit S1/S3). These let
|
||||
@@ -1173,6 +1175,89 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
const messageSeqs = log.filter(event => event.type === 'user/message').map(event => event.seq)
|
||||
append(sid(id), { type: 'session/title', data: { title, messageSeqs, source: { kind: 'provider', provider: 'fixture' } } })
|
||||
},
|
||||
/** Open one failed model step whose partial remains visible until llm/retry arrives. */
|
||||
beginModelRetry(id: string): void {
|
||||
const sessionId = sid(id)
|
||||
const turn = nextTurn.get(sessionId) ?? 0
|
||||
nextTurn.set(sessionId, turn + 1)
|
||||
retryScenarios.set(sessionId, { turn, stepStarted: true })
|
||||
setRunning(sessionId, true)
|
||||
append(sessionId, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
append(sessionId, { type: 'user/message', surfaceOp: 'append', data: { content: text('请重试这个请求'), source: { kind: 'user' } } })
|
||||
append(sessionId, { type: 'step/start', data: { turn, step: 1 } })
|
||||
append(sessionId, { type: 'assistant/chunk', data: { turn, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
|
||||
append(sessionId, { type: 'assistant/chunk', data: { turn, step: 1, chunk: { type: 'text-delta', index: 0, text: '应撤回的半截回复' } } })
|
||||
append(sessionId, { type: 'step/end', data: { turn, step: 1 } })
|
||||
},
|
||||
/** Record one retry decision, then open the next retry turn. */
|
||||
scheduleModelRetry(id: string, retry = 1, delayMs = 450): void {
|
||||
const sessionId = sid(id)
|
||||
const scenario = retryScenarios.get(sessionId)
|
||||
if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
|
||||
if (!scenario.stepStarted) {
|
||||
append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: 1 } })
|
||||
append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
|
||||
append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: 1, chunk: { type: 'text-delta', index: 0, text: `第 ${String(retry)} 次应撤回的回复` } } })
|
||||
append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } })
|
||||
scenario.stepStarted = true
|
||||
}
|
||||
const failure = { code: 'TRANSPORT', message: '连接被重置' }
|
||||
append(sessionId, {
|
||||
type: 'llm/retry',
|
||||
data: {
|
||||
turn: scenario.turn, step: 1,
|
||||
provider: 'fixture', mode: 'normal', policyKey: 'fixture-normal',
|
||||
retry, maxRetries: 2, delayMs, failure,
|
||||
},
|
||||
})
|
||||
append(sessionId, {
|
||||
type: 'turn/end',
|
||||
data: { turn: scenario.turn, reason: { kind: 'error', step: 1, failure } },
|
||||
})
|
||||
const next = nextTurn.get(sessionId) ?? scenario.turn + 1
|
||||
nextTurn.set(sessionId, next + 1)
|
||||
append(sessionId, { type: 'turn/start', data: { turn: next, trigger: { kind: 'retry' } } })
|
||||
scenario.turn = next
|
||||
scenario.stepStarted = false
|
||||
},
|
||||
/** Record one retry decision, then cancel its source turn before the retry starts. */
|
||||
cancelModelRetryDuringBackoff(id: string, delayMs = 450): void {
|
||||
const sessionId = sid(id)
|
||||
const scenario = retryScenarios.get(sessionId)
|
||||
if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
|
||||
const failure = { code: 'TRANSPORT', message: '连接被重置' }
|
||||
append(sessionId, {
|
||||
type: 'llm/retry',
|
||||
data: {
|
||||
turn: scenario.turn, step: 1,
|
||||
provider: 'fixture', mode: 'normal', policyKey: 'fixture-normal',
|
||||
retry: 1, maxRetries: 2, delayMs, failure,
|
||||
},
|
||||
})
|
||||
append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'aborted' } } })
|
||||
retryScenarios.delete(sessionId)
|
||||
setRunning(sessionId, false)
|
||||
},
|
||||
/** Finish the timing-hook retry with a finalized response in the open retry turn. */
|
||||
completeModelRetry(id: string): void {
|
||||
const sessionId = sid(id)
|
||||
const scenario = retryScenarios.get(sessionId)
|
||||
if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
|
||||
retryScenarios.delete(sessionId)
|
||||
append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: 1 } })
|
||||
append(sessionId, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: 'append',
|
||||
data: {
|
||||
turn: scenario.turn,
|
||||
step: 1,
|
||||
message: assistantMessage(text('重试后的完整回复')),
|
||||
},
|
||||
})
|
||||
append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } })
|
||||
append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'completed' } } })
|
||||
setRunning(sessionId, false)
|
||||
},
|
||||
/** Log append WITHOUT the mux emit: a frame lost in transit — history still serves it, the client must repull. */
|
||||
appendSilent(id: string, msg: string): void {
|
||||
const log = logOf(sid(id))
|
||||
|
||||
@@ -19,6 +19,10 @@ interface TimingHooks {
|
||||
failNextHistory(): void
|
||||
appendUser(id: string, msg: string): void
|
||||
appendTitle(id: string, title: string): void
|
||||
beginModelRetry(id: string): void
|
||||
scheduleModelRetry(id: string, retry?: number, delayMs?: number): void
|
||||
cancelModelRetryDuringBackoff(id: string, delayMs?: number): void
|
||||
completeModelRetry(id: string): void
|
||||
appendSilent(id: string, msg: string): void
|
||||
breakStreams(): void
|
||||
}
|
||||
@@ -814,8 +818,18 @@ describe('createFixtureApi', () => {
|
||||
hooks.appendSilent('fx-alpha', '静默丢帧')
|
||||
hooks.appendUser('fx-alpha', '正常直播')
|
||||
hooks.appendTitle('fx-alpha', 'Fixture 修订标题')
|
||||
hooks.beginModelRetry('fx-alpha')
|
||||
hooks.scheduleModelRetry('fx-alpha')
|
||||
hooks.completeModelRetry('fx-alpha')
|
||||
hooks.beginModelRetry('fx-alpha')
|
||||
hooks.cancelModelRetryDuringBackoff('fx-alpha')
|
||||
await vi.waitFor(() => {
|
||||
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('正常直播'))).toBe(true)
|
||||
expect(seen.some(f => f.type === 'session/event' && (f.event as { type: string }).type === 'llm/retry')).toBe(true)
|
||||
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('重试后的完整回复'))).toBe(true)
|
||||
expect(seen.some(f => f.type === 'session/event'
|
||||
&& f.event.type === 'turn/end'
|
||||
&& f.event.data.reason.kind === 'aborted')).toBe(true)
|
||||
expect(seen.some(f => f.type === 'session/projection' && f.key === 'title' && f.value === 'Fixture 修订标题')).toBe(true)
|
||||
})
|
||||
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('静默丢帧'))).toBe(false)
|
||||
|
||||
@@ -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/runtime/README.md
|
||||
README.md: 12023868c577ebcae6898d13358a2456295496c2
|
||||
README.zh.md: 7ef4c93d36b3f0b32c0bfcf8a38892260240c74f
|
||||
README.md: 9f2b165f1a98dcecfa3ab82386da9b094cfd2f54
|
||||
README.zh.md: 3ed047e65d3bddc14c3b6b84f327bbeebf805d4b
|
||||
|
||||
@@ -30,6 +30,10 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
|
||||
|
||||
`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. `ISession.rename` settles the `title` projection cell directly from the unary response's `{title, seq}` under the same higher-seq-wins rule — the list row and every `useProjection('title')` reader update ahead of the push frame, whose later replay of the same seq is a no-op.
|
||||
|
||||
## Model retry projection
|
||||
|
||||
The Session object validates plugin-owned, provider-routed `llm/retry` payloads at the event wire boundary against the producer's complete field contract, including timer, integer, status, provider-delay, and non-empty diagnostic bounds. A valid event removes the matching failed step's streaming partial and inserts a durable retry notice at the event's sequence position. The notice is `scheduled` until a following retry turn starts; an aborted or disposed source turn marks it `cancelled`, while the retry turn marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. Window rebuild and history replay apply the same projection, so logged chunks from the discarded attempt never reappear as an interrupted reply after refresh. A terminal turn without `llm/retry` retains the existing behavior: visible unfinalized output is frozen as an interrupted assistant node.
|
||||
|
||||
## Session forking
|
||||
|
||||
`ISessions.fork({sessionId, atSeq?, increaseTitle?})` resolves only after the child summary is locally addressable, carrying source lineage and cwd with `blank: false`; callers choose whether to open it. With `increaseTitle: true`, the client renames the child from the source session's persisted title: a trailing `(N)` or `(N)` is incremented without changing bracket style, while any other title gets ` (1)` appended; the rename is skipped when the source has no persisted title, and a rename failure rejects the promise but leaves the created child in place. This option is not sent in the Host fork request. A `workspace-attach-failed` response still identifies a child already published by the Host, so `SessionManager` reconciles that partial success before `SessionForkError` reaches the caller instead of making a retry create a duplicate child.
|
||||
|
||||
@@ -30,6 +30,10 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
|
||||
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更高的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实际的持久化标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷态持久化会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影由日志支撑的标题。`ISession.rename` 用 unary 响应中的 `{title, seq}` 直接结算 `title` 投影格,遵循同一 seq 高者胜规则——列表行和所有 `useProjection('title')` 读者在推送帧到达前即更新;推送帧随后重放同一 seq 时为无操作。
|
||||
|
||||
## 模型重试投影
|
||||
|
||||
Session 对象会在事件 wire 边界依据生产方的完整字段契约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或释放会将其标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限;always mode 提示则保持显式无界。窗口重建与历史回放应用相同的投影,因此刷新后,来自已丢弃尝试的日志分片绝不会重新显示为中断回复。没有 `llm/retry` 的终止轮次保留现有行为:可见但尚未定稿的输出会冻结为中断的 assistant 节点。
|
||||
|
||||
## 会话 fork
|
||||
|
||||
`ISessions.fork({sessionId, atSeq?, increaseTitle?})` 只在子会话摘要已能在本地寻址后才完成;该摘要携带源会话的谱系和 cwd,且 `blank: false`,由调用方决定是否打开。`increaseTitle: true` 会在 client 端把源会话的持久化标题改名到子会话:尾部 `(N)` 或 `(N)` 递增并保留括号样式,其余标题追加 ` (1)`;源会话没有持久化标题时跳过改名,改名失败时拒绝 promise 但保留已创建的子会话。该选项不会进入 Host fork 请求。即使响应为 `workspace-attach-failed`,其中仍会标识 Host 已发布的子会话,因此 `SessionManager` 会先将这一部分成功对账,再让 `SessionForkError` 到达调用方,避免重试创建重复的子会话。
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
@@ -49,6 +50,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
|
||||
@@ -45,7 +45,7 @@ export type {
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
|
||||
AssistantTiming, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode,
|
||||
ConversationSnapshot, QueuedMessage, RunningToolCall,
|
||||
ConversationSnapshot, ModelRetryNode, QueuedMessage, RunningToolCall,
|
||||
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from './sessions/conversation.ts'
|
||||
export type {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
InboxItemId, RpcError, SessionId, ToolCallView, ToolResultView,
|
||||
@@ -121,6 +122,19 @@ export interface ContextMessageNode {
|
||||
source: unknown
|
||||
}
|
||||
|
||||
/** Durable notice that a closed failed step is waiting for a model-request retry. */
|
||||
export type ModelRetryNode = LlmRetryEventData & {
|
||||
kind: 'model-retry'
|
||||
seq: number
|
||||
/** Unix epoch ms from the llm/retry session event. */
|
||||
time: number
|
||||
/**
|
||||
* Client-derived lifecycle: scheduled until a retry turn starts, started
|
||||
* once it does, or cancelled when the failed turn aborts first.
|
||||
*/
|
||||
retryState: 'scheduled' | 'started' | 'cancelled'
|
||||
}
|
||||
|
||||
/** A tool result paired (when in-window) with its call head. */
|
||||
export interface ToolResultNode {
|
||||
kind: 'tool-result'
|
||||
@@ -183,6 +197,7 @@ export type ConversationNode =
|
||||
| AssistantMessageNode
|
||||
| SteeringMessageNode
|
||||
| ContextMessageNode
|
||||
| ModelRetryNode
|
||||
| ToolResultNode
|
||||
| CommandNode
|
||||
| UnknownSurfaceNode
|
||||
@@ -265,7 +280,7 @@ export interface PromptError {
|
||||
/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */
|
||||
export interface ConversationSnapshot {
|
||||
sessionId: SessionId
|
||||
/** Surface fold product (finalized conversation nodes in surface order). */
|
||||
/** Finalized surface events and durable operational notices in event order. */
|
||||
nodes: readonly ConversationNode[]
|
||||
/** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */
|
||||
foldDegraded: boolean
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
HistoryEntry, IApiClient, InboxItemId, MuxFrame, QueueAction, RpcError,
|
||||
@@ -12,8 +13,8 @@ import type {
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { SessionFace } from '../contract/session.ts'
|
||||
import type {
|
||||
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState,
|
||||
PromptError, QueuedMessage, RunningToolCall,
|
||||
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, ModelRetryNode,
|
||||
OpenState, PromptError, QueuedMessage, RunningToolCall,
|
||||
} from './conversation.ts'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
import { PendingWait } from './pending.ts'
|
||||
@@ -26,6 +27,10 @@ import type { ProjectionsBaseline } from './projection-store.ts'
|
||||
/** Messages requested per history page. */
|
||||
export const PAGE_MESSAGES = 50
|
||||
|
||||
// Browser bundles cannot value-import the host timeout library. This protocol
|
||||
// bound is pinned to @deepseek-ai/dsh-timeout's MAX_TIMER_DELAY_MS in tests.
|
||||
const MAX_RETRY_DELAY_MS = 2_147_483_647
|
||||
|
||||
/** Manager-owned observers of a Session object's local state edges. */
|
||||
export interface SessionOptions {
|
||||
/**
|
||||
@@ -88,9 +93,9 @@ export class Session implements SessionFace {
|
||||
private readonly foldAdapter = new FoldAdapter()
|
||||
private partial: PartialAccumulator | null = null
|
||||
private openCalls = new Map<string, RunningToolCall>()
|
||||
/** Interrupted-turn terminal nodes (frozen partial text / aborted tool cards), merged into the flow by seq.
|
||||
* Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */
|
||||
private frozenNodes: ConversationNode[] = []
|
||||
/** Operational notices and interrupted-turn terminal nodes merged into the flow by seq.
|
||||
* Derived from window events — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */
|
||||
private derivedNodes: ConversationNode[] = []
|
||||
private pending = new Map<string, PendingInteraction>()
|
||||
// Revision counters preserve array identity when derived content is unchanged, so
|
||||
// React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every
|
||||
@@ -100,12 +105,12 @@ export class Session implements SessionFace {
|
||||
private callsCache: { rev: number; value: RunningToolCall[] } | null = null
|
||||
private pendingRev = 0
|
||||
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
|
||||
private derivedRev = 0
|
||||
private nodesCache: { folded: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null
|
||||
/** Authoritative stream-only inbox snapshot; pending work never hits history. */
|
||||
private queued: QueuedMessage[] = []
|
||||
private queueRev = 0
|
||||
private queueCache: { rev: number; value: QueuedMessage[] } | null = null
|
||||
private frozenRev = 0
|
||||
private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
|
||||
/** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends
|
||||
* copy-on-write the per-parent array so published snapshot references never mutate. */
|
||||
private codeDispatches = new Map<string, readonly CodeSubCall[]>()
|
||||
@@ -625,8 +630,28 @@ export class Session implements SessionFace {
|
||||
}
|
||||
|
||||
/** Per-event side effects (right column of the §A.9 dispatch table):
|
||||
* chunk accumulation / partial clear on finalize / openCalls add-remove. */
|
||||
* chunk/retry projection and openCalls add-remove. */
|
||||
private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void {
|
||||
const eventType = event.type as string
|
||||
if (eventType === 'llm/retry') {
|
||||
const data = parseRetryEventData(event.data)
|
||||
if (data === null) {
|
||||
console.error(`[web-runtime] ignored malformed llm/retry event at seq ${event.seq}`)
|
||||
return
|
||||
}
|
||||
if (this.partial !== null && this.partial.turn === data.turn && this.partial.step === data.step) {
|
||||
this.partial = null
|
||||
}
|
||||
this.derivedNodes.push({
|
||||
kind: 'model-retry',
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
retryState: 'scheduled',
|
||||
...data,
|
||||
})
|
||||
this.derivedRev++
|
||||
return
|
||||
}
|
||||
// The `tool/code-dispatch-start`/`tool/code-dispatch` pair is declared by
|
||||
// the host-side dsh-tools plugin whose types cannot enter the client
|
||||
// program (its host Context merges collide with the client's), so this
|
||||
@@ -687,6 +712,10 @@ export class Session implements SessionFace {
|
||||
return
|
||||
}
|
||||
switch (event.type) {
|
||||
case 'turn/start': {
|
||||
if (event.data.trigger.kind === 'retry') this.settleScheduledRetry('started')
|
||||
return
|
||||
}
|
||||
case 'assistant/chunk': {
|
||||
const { turn, step, chunk } = event.data
|
||||
if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) {
|
||||
@@ -715,6 +744,9 @@ export class Session implements SessionFace {
|
||||
return
|
||||
}
|
||||
case 'turn/end': {
|
||||
if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'disposed') {
|
||||
this.settleScheduledRetry('cancelled', event.data.turn)
|
||||
}
|
||||
// Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it
|
||||
// into an interrupted terminal node (pulse stops, text survives) instead of deleting it.
|
||||
// Shared by live and window-replay paths, so a refresh reconstructs the same frozen node
|
||||
@@ -724,12 +756,12 @@ export class Session implements SessionFace {
|
||||
const visible = blocks.some(b => (b.kind === 'text' || b.kind === 'reasoning' ? b.text !== '' : true))
|
||||
if (visible) {
|
||||
// Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn.
|
||||
this.frozenNodes.push({
|
||||
this.derivedNodes.push({
|
||||
kind: 'assistant', seq: event.seq - 0.9, time: event.time,
|
||||
turn: this.partial.turn, step: this.partial.step,
|
||||
blocks, interrupted: true,
|
||||
})
|
||||
this.frozenRev++
|
||||
this.derivedRev++
|
||||
}
|
||||
this.partial = null
|
||||
}
|
||||
@@ -739,7 +771,7 @@ export class Session implements SessionFace {
|
||||
this.openCalls.delete(callId)
|
||||
this.callsRev++
|
||||
// The spinner card becomes an interrupted terminal card (never vanishes mid-flow).
|
||||
this.frozenNodes.push({
|
||||
this.derivedNodes.push({
|
||||
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, time: event.time,
|
||||
callId,
|
||||
call: { name: call.name, argsRaw: call.argsRaw },
|
||||
@@ -747,7 +779,7 @@ export class Session implements SessionFace {
|
||||
content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' },
|
||||
callView: call.callView, resultView: null,
|
||||
})
|
||||
this.frozenRev++
|
||||
this.derivedRev++
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -756,15 +788,36 @@ export class Session implements SessionFace {
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps
|
||||
* paging/stitching consistent, and makes the live freeze and the history replay converge on the
|
||||
* same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */
|
||||
/**
|
||||
* Settle the newest scheduled retry, optionally restricted to its failed turn.
|
||||
* @param retryState - next client projection state to publish.
|
||||
* @param turn - failed turn required for cancellation; omitted for the next retry turn start.
|
||||
*/
|
||||
private settleScheduledRetry(
|
||||
retryState: Exclude<ModelRetryNode['retryState'], 'scheduled'>,
|
||||
turn?: number,
|
||||
): void {
|
||||
const index = this.derivedNodes.findLastIndex(node =>
|
||||
node.kind === 'model-retry'
|
||||
&& node.retryState === 'scheduled'
|
||||
&& (turn === undefined || node.turn === turn))
|
||||
if (index < 0) return
|
||||
const node = this.derivedNodes[index]
|
||||
/* v8 ignore next -- findLastIndex's predicate narrows the indexed node only at runtime. */
|
||||
if (node?.kind !== 'model-retry') return
|
||||
this.derivedNodes[index] = { ...node, retryState }
|
||||
this.derivedRev++
|
||||
}
|
||||
|
||||
/** Re-derive state (partial/openCalls/derivedNodes) from raw window events after a rebuild — keeps
|
||||
* paging/stitching consistent, and makes live handling and history replay converge on the same
|
||||
* retry notices and interrupted nodes. */
|
||||
private rebuildDerivedFromWindow(): void {
|
||||
this.partial = null
|
||||
this.openCalls.clear()
|
||||
this.callsRev++
|
||||
this.frozenNodes = []
|
||||
this.frozenRev++
|
||||
this.derivedNodes = []
|
||||
this.derivedRev++
|
||||
this.codeDispatches = new Map()
|
||||
this.dispatchesRev++
|
||||
for (let i = 0; i < this.events.length; i++) {
|
||||
@@ -781,17 +834,17 @@ export class Session implements SessionFace {
|
||||
|
||||
private buildSnapshot(): ConversationSnapshot {
|
||||
const { nodes: folded, degraded } = this.foldAdapter.nodes()
|
||||
// Frozen interrupted nodes ride fractional seqs: a stable merge keeps them in flow order.
|
||||
// The merged array is cached on (folded reference, frozenRev) so an unchanged flow keeps its
|
||||
// Derived nodes use their event seq or a nearby fractional seq: a stable merge keeps flow order.
|
||||
// The merged array is cached on (folded reference, derivedRev) so an unchanged flow keeps its
|
||||
// reference across snapshot swaps (§A.9.4).
|
||||
let nodes: readonly ConversationNode[]
|
||||
if (this.nodesCache !== null && this.nodesCache.folded === folded && this.nodesCache.frozenRev === this.frozenRev) {
|
||||
if (this.nodesCache !== null && this.nodesCache.folded === folded && this.nodesCache.derivedRev === this.derivedRev) {
|
||||
nodes = this.nodesCache.value
|
||||
} else {
|
||||
nodes = this.frozenNodes.length === 0
|
||||
nodes = this.derivedNodes.length === 0
|
||||
? folded
|
||||
: [...folded, ...this.frozenNodes].sort((a, b) => a.seq - b.seq)
|
||||
this.nodesCache = { folded, frozenRev: this.frozenRev, value: nodes }
|
||||
: [...folded, ...this.derivedNodes].sort((a, b) => a.seq - b.seq)
|
||||
this.nodesCache = { folded, derivedRev: this.derivedRev, value: nodes }
|
||||
}
|
||||
if (this.callsCache === null || this.callsCache.rev !== this.callsRev) {
|
||||
this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] }
|
||||
@@ -835,6 +888,58 @@ export class Session implements SessionFace {
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate the plugin-owned payload at the session-event wire boundary. */
|
||||
function parseRetryEventData(value: unknown): LlmRetryEventData | null {
|
||||
if (value === null || typeof value !== 'object') return null
|
||||
const data = value as Record<string, unknown>
|
||||
const failure = data.failure
|
||||
if (failure === null || typeof failure !== 'object') return null
|
||||
const failureData = failure as Record<string, unknown>
|
||||
if (!nonNegativeSafeInteger(data.turn)
|
||||
|| !nonNegativeSafeInteger(data.step)
|
||||
|| typeof data.provider !== 'string'
|
||||
|| data.provider.length === 0
|
||||
|| typeof data.policyKey !== 'string'
|
||||
|| data.policyKey.length === 0
|
||||
|| !positiveSafeInteger(data.retry)
|
||||
|| typeof data.delayMs !== 'number'
|
||||
|| !Number.isFinite(data.delayMs)
|
||||
|| data.delayMs < 0
|
||||
|| data.delayMs > MAX_RETRY_DELAY_MS
|
||||
|| typeof failureData.message !== 'string'
|
||||
|| failureData.message.length === 0
|
||||
|| typeof failureData.code !== 'string'
|
||||
|| failureData.code.length === 0) return null
|
||||
if (data.mode === 'normal') {
|
||||
if (!positiveSafeInteger(data.maxRetries) || data.retry > data.maxRetries) return null
|
||||
} else if (data.mode === 'always') {
|
||||
if ('maxRetries' in data) return null
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
if (failureData.status !== undefined
|
||||
&& (typeof failureData.status !== 'number'
|
||||
|| !Number.isInteger(failureData.status)
|
||||
|| failureData.status < 100
|
||||
|| failureData.status > 599)) return null
|
||||
if (failureData.providerRetryAfterMs !== undefined
|
||||
&& (typeof failureData.providerRetryAfterMs !== 'number'
|
||||
|| !Number.isFinite(failureData.providerRetryAfterMs)
|
||||
|| failureData.providerRetryAfterMs <= 0)) return null
|
||||
if (failureData.requestId !== undefined
|
||||
&& (typeof failureData.requestId !== 'string'
|
||||
|| failureData.requestId.length === 0)) return null
|
||||
return data as unknown as LlmRetryEventData
|
||||
}
|
||||
|
||||
function nonNegativeSafeInteger(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
|
||||
}
|
||||
|
||||
function positiveSafeInteger(value: unknown): value is number {
|
||||
return nonNegativeSafeInteger(value) && value > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The composerPhase judgment — the single site that knows the predicate
|
||||
* (consumers switch on the result, never re-derive). Monotone per session
|
||||
|
||||
@@ -63,7 +63,25 @@ export const ev = {
|
||||
}),
|
||||
stepEnd: (seq: number, turn: number, step = 0): SessionEvent =>
|
||||
at(seq, { type: 'step/end', data: { turn, step } }),
|
||||
turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent =>
|
||||
retry: (
|
||||
seq: number,
|
||||
turn: number,
|
||||
step = 0,
|
||||
retry = 1,
|
||||
maxRetries = 2,
|
||||
delayMs = 500,
|
||||
message = 'temporary transport failure',
|
||||
): SessionEvent =>
|
||||
at(seq, {
|
||||
type: 'llm/retry',
|
||||
data: {
|
||||
turn, step,
|
||||
provider: 'fake', mode: 'normal', policyKey: 'fake-normal',
|
||||
retry, maxRetries, delayMs,
|
||||
failure: { code: 'TRANSPORT', message },
|
||||
},
|
||||
}),
|
||||
turnEnd: (seq: number, turn: number, reason: 'completed' | 'aborted' | 'disposed' = 'completed'): SessionEvent =>
|
||||
at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
|
||||
commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent =>
|
||||
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
@@ -161,6 +162,214 @@ describe('live event path', () => {
|
||||
expect((last as { interrupted?: true }).interrupted).toBeUndefined()
|
||||
})
|
||||
|
||||
it('retracts the failed step partial on retry and keeps a replayable notice before the recovered response', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
const retryTurn = [
|
||||
ev.turnStart(6, 1),
|
||||
ev.user(7, '请重试'),
|
||||
ev.stepStart(8, 1),
|
||||
ev.chunkStart(9, 1),
|
||||
ev.chunkText(10, 1, '不完整回复'),
|
||||
ev.stepEnd(11, 1),
|
||||
ev.retry(12, 1, 0, 1, 2, 450, '连接被重置'),
|
||||
at(13, {
|
||||
type: 'turn/end',
|
||||
data: {
|
||||
turn: 1,
|
||||
reason: {
|
||||
kind: 'error', step: 0,
|
||||
failure: { code: 'TRANSPORT', message: '连接被重置' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
at(14, { type: 'turn/start', data: { turn: 2, trigger: { kind: 'retry' } } }),
|
||||
ev.stepStart(15, 2),
|
||||
ev.assistant(16, 2, '完整回复'),
|
||||
ev.stepEnd(17, 2),
|
||||
ev.turnEnd(18, 2),
|
||||
]
|
||||
for (const event of retryTurn.slice(0, 7)) feed(event)
|
||||
|
||||
let snapshot = session.getSnapshot()
|
||||
expect(snapshot.partial).toBeNull()
|
||||
expect(snapshot.nodes.at(-1)).toMatchObject({
|
||||
kind: 'model-retry',
|
||||
retryState: 'scheduled',
|
||||
turn: 1,
|
||||
step: 0,
|
||||
provider: 'fake',
|
||||
mode: 'normal',
|
||||
policyKey: 'fake-normal',
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 450,
|
||||
failure: { code: 'TRANSPORT', message: '连接被重置' },
|
||||
})
|
||||
expect(JSON.stringify(snapshot.nodes)).not.toContain('不完整回复')
|
||||
|
||||
for (const event of retryTurn.slice(7)) feed(event)
|
||||
snapshot = session.getSnapshot()
|
||||
expect(snapshot.nodes.slice(-2).map(node => node.kind)).toEqual(['model-retry', 'assistant'])
|
||||
expect(snapshot.nodes.at(-2)).toMatchObject({ kind: 'model-retry', retryState: 'started' })
|
||||
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '完整回复' }] })
|
||||
|
||||
const replay = makeSession()
|
||||
replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...retryTurn])
|
||||
await replay.session.open()
|
||||
expect(replay.session.getSnapshot().nodes).toEqual(snapshot.nodes)
|
||||
expect(replay.session.getSnapshot().partial).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects retry payloads outside the producer contract without retracting the current partial', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.chunkStart(7, 1))
|
||||
feed(ev.chunkText(8, 1, '仍在生成'))
|
||||
const valid = {
|
||||
turn: 1, step: 0,
|
||||
provider: 'fake', mode: 'normal', policyKey: 'fake-normal',
|
||||
retry: 1, maxRetries: 2, delayMs: 500,
|
||||
failure: { code: 'TRANSPORT', message: 'temporary failure' },
|
||||
}
|
||||
const invalid = [
|
||||
{ ...valid, turn: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ ...valid, step: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ ...valid, provider: '' },
|
||||
{ ...valid, policyKey: '' },
|
||||
{ ...valid, retry: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ ...valid, maxRetries: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ ...valid, delayMs: -1 },
|
||||
{ ...valid, delayMs: Number.POSITIVE_INFINITY },
|
||||
{ ...valid, delayMs: MAX_TIMER_DELAY_MS + 1 },
|
||||
{ ...valid, failure: { ...valid.failure, message: '' } },
|
||||
{ ...valid, failure: { ...valid.failure, code: '' } },
|
||||
{ ...valid, failure: { ...valid.failure, status: '429' } },
|
||||
{ ...valid, failure: { ...valid.failure, status: 99 } },
|
||||
{ ...valid, failure: { ...valid.failure, status: 429.5 } },
|
||||
{ ...valid, failure: { ...valid.failure, status: 600 } },
|
||||
{ ...valid, failure: { ...valid.failure, providerRetryAfterMs: 0 } },
|
||||
{ ...valid, failure: { ...valid.failure, providerRetryAfterMs: Number.POSITIVE_INFINITY } },
|
||||
{ ...valid, failure: { ...valid.failure, requestId: 1 } },
|
||||
{ ...valid, failure: { ...valid.failure, requestId: '' } },
|
||||
]
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
for (const [index, data] of invalid.entries()) {
|
||||
feed(at(9 + index, { type: 'llm/retry', data }))
|
||||
}
|
||||
expect(session.getSnapshot().partial?.blocks).toEqual([{ kind: 'text', text: '仍在生成' }])
|
||||
expect(session.getSnapshot().nodes.filter(node => node.kind === 'model-retry')).toEqual([])
|
||||
expect(errorSpy).toHaveBeenCalledTimes(invalid.length)
|
||||
expect(errorSpy).toHaveBeenCalledWith('[web-runtime] ignored malformed llm/retry event at seq 9')
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts complete retry payloads at the producer field boundaries', async () => {
|
||||
const { session } = await opened()
|
||||
session.handleMuxEnvelope('r' as never, {
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event: at(6, {
|
||||
type: 'llm/retry',
|
||||
data: {
|
||||
turn: Number.MAX_SAFE_INTEGER,
|
||||
step: Number.MAX_SAFE_INTEGER,
|
||||
provider: 'fake',
|
||||
mode: 'normal',
|
||||
policyKey: 'fake-normal',
|
||||
retry: Number.MAX_SAFE_INTEGER,
|
||||
maxRetries: Number.MAX_SAFE_INTEGER,
|
||||
delayMs: MAX_TIMER_DELAY_MS,
|
||||
failure: {
|
||||
code: 'RATE_LIMIT',
|
||||
message: 'provider busy',
|
||||
status: 599,
|
||||
providerRetryAfterMs: Number.MIN_VALUE,
|
||||
requestId: 'req-1',
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'model-retry',
|
||||
retryState: 'scheduled',
|
||||
retry: Number.MAX_SAFE_INTEGER,
|
||||
delayMs: MAX_TIMER_DELAY_MS,
|
||||
failure: { status: 599, providerRetryAfterMs: Number.MIN_VALUE, requestId: 'req-1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('projects always-mode retries and rejects mode-specific maximums or unknown modes', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
feed(at(6, {
|
||||
type: 'llm/retry',
|
||||
data: {
|
||||
turn: 1, step: 0,
|
||||
provider: 'fake', mode: 'always', policyKey: 'fake-always',
|
||||
retry: 3, delayMs: 500,
|
||||
failure: { code: 'TRANSPORT', message: 'retry forever' },
|
||||
},
|
||||
}))
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'model-retry',
|
||||
retryState: 'scheduled',
|
||||
mode: 'always',
|
||||
retry: 3,
|
||||
})
|
||||
|
||||
feed(at(7, {
|
||||
type: 'llm/retry',
|
||||
data: {
|
||||
turn: 2, step: 0,
|
||||
provider: 'fake', mode: 'always', policyKey: 'fake-always',
|
||||
retry: 4, maxRetries: 4, delayMs: 500,
|
||||
failure: { code: 'TRANSPORT', message: 'unexpected maximum' },
|
||||
},
|
||||
}))
|
||||
feed(at(8, {
|
||||
type: 'llm/retry',
|
||||
data: {
|
||||
turn: 2, step: 0,
|
||||
provider: 'fake', mode: 'sometimes', policyKey: 'fake-unknown',
|
||||
retry: 4, delayMs: 500,
|
||||
failure: { code: 'TRANSPORT', message: 'unknown mode' },
|
||||
},
|
||||
}))
|
||||
expect(session.getSnapshot().nodes.filter(node => node.kind === 'model-retry')).toHaveLength(1)
|
||||
expect(errorSpy).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['aborted', 'disposed'] as const)(
|
||||
'marks a scheduled retry as cancelled when its failed turn ends %s',
|
||||
async (reason) => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => {
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
|
||||
}
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.retry(7, 1))
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'model-retry',
|
||||
retryState: 'scheduled',
|
||||
})
|
||||
feed(ev.turnEnd(8, 1, reason))
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'model-retry',
|
||||
retryState: 'cancelled',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
@@ -168,7 +377,7 @@ describe('live event path', () => {
|
||||
feed(ev.user(7, '要被打断的'))
|
||||
feed(ev.chunkStart(8, 1))
|
||||
feed(ev.chunkText(9, 1, '说到一半'))
|
||||
feed(ev.turnEnd(10, 1, 'cancelled')) // no assistant/message ever arrives
|
||||
feed(ev.turnEnd(10, 1, 'aborted')) // no assistant/message ever arrives
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.partial).toBeNull()
|
||||
const frozen = snapshot.nodes.at(-1)
|
||||
@@ -187,7 +396,7 @@ describe('live event path', () => {
|
||||
expect(session.getSnapshot().runningCalls).toEqual([])
|
||||
// Second call never resolves: turn/end freezes it as an error card.
|
||||
feed(ev.toolCall(9, 1, 'c2', 'slow_tool', '{}'))
|
||||
feed(ev.turnEnd(10, 1, 'cancelled'))
|
||||
feed(ev.turnEnd(10, 1, 'aborted'))
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.runningCalls).toEqual([])
|
||||
expect(snapshot.nodes.at(-1)).toMatchObject({
|
||||
@@ -529,7 +738,7 @@ describe('remaining branches', () => {
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.chunkStart(7, 1)) // empty text block only, no delta
|
||||
feed(ev.turnEnd(8, 1, 'cancelled'))
|
||||
feed(ev.turnEnd(8, 1, 'aborted'))
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.partial).toBeNull()
|
||||
expect(snapshot.nodes.filter(n => n.kind === 'assistant' && (n as { interrupted?: true }).interrupted)).toEqual([])
|
||||
@@ -543,7 +752,7 @@ describe('remaining branches', () => {
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.toolCall(7, 1, 'turn1-call', 'echo', '{}'))
|
||||
feed(ev.toolCall(8, 2, 'turn2-call', 'echo', '{}')) // stray call attributed to a later turn
|
||||
feed(ev.turnEnd(9, 1, 'cancelled'))
|
||||
feed(ev.turnEnd(9, 1, 'aborted'))
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.runningCalls.map(c => c.callId)).toEqual(['turn2-call'])
|
||||
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'tool-result', callId: 'turn1-call', isError: true })
|
||||
@@ -637,7 +846,7 @@ describe('remaining branches', () => {
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(at(7, { type: 'assistant/chunk', data: { turn: 1, step: 0, chunk: { type: 'tool-call-delta', index: 0, id: 'c1', name: 'echo', argumentsDelta: '{' } } }))
|
||||
feed(ev.turnEnd(8, 1, 'cancelled'))
|
||||
feed(ev.turnEnd(8, 1, 'aborted'))
|
||||
const frozen = session.getSnapshot().nodes.at(-1)
|
||||
expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'tool-call', callId: 'c1' }] })
|
||||
})
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm-retry"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCheckOutline16, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconCheckOutline16, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { filterOptions } from './popup.ts'
|
||||
import type { PopupSelectController } from './popup.ts'
|
||||
@@ -60,23 +60,24 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) {
|
||||
// closes the shell before its own handlers run; that click's target then
|
||||
// takes focus naturally, so no focusComposer here.
|
||||
useEffect(() => {
|
||||
if (!state.open) return
|
||||
if (!state.open || state.confirming !== null) return
|
||||
const onPointerDown = (ev: PointerEvent): void => {
|
||||
if (cardRef.current !== null && ev.target instanceof Node && cardRef.current.contains(ev.target)) return
|
||||
popup.dismiss()
|
||||
}
|
||||
document.addEventListener('pointerdown', onPointerDown, true)
|
||||
return () => { document.removeEventListener('pointerdown', onPointerDown, true) }
|
||||
}, [state.open, popup])
|
||||
}, [state.open, state.confirming, 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 && state.confirming === null) searchRef.current?.focus()
|
||||
}, [state.open, state.confirming])
|
||||
|
||||
if (!state.open) return null
|
||||
|
||||
const rows = filterOptions(state.options, state.search)
|
||||
const confirmation = state.confirming?.confirmation
|
||||
|
||||
const onKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||
// ArrowLeft/ArrowRight fall through on purpose: the search input keeps
|
||||
@@ -103,55 +104,73 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={css.card}
|
||||
style={{ maxHeight }}
|
||||
aria-label={t('overlay.aria', { command: String(state.command) })}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<input
|
||||
ref={searchRef}
|
||||
className={css.search}
|
||||
type="text"
|
||||
placeholder={t('search.placeholder')}
|
||||
aria-label={t('search.aria')}
|
||||
value={state.search}
|
||||
readOnly={state.submitting}
|
||||
onChange={(ev) => { popup.setSearch(ev.currentTarget.value) }}
|
||||
/>
|
||||
{state.error !== null && (
|
||||
<div className={css.error} role="alert">
|
||||
<span className={css.errorText}>{state.error}</span>
|
||||
{state.status === 'failed' && (
|
||||
<button type="button" className={css.retry} onClick={() => { popup.retry() }}>{t('retry')}</button>
|
||||
<>
|
||||
{state.confirming === null && (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={css.card}
|
||||
style={{ maxHeight }}
|
||||
aria-label={t('overlay.aria', { command: String(state.command) })}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<input
|
||||
ref={searchRef}
|
||||
className={css.search}
|
||||
type="text"
|
||||
placeholder={t('search.placeholder')}
|
||||
aria-label={t('search.aria')}
|
||||
value={state.search}
|
||||
readOnly={state.submitting}
|
||||
onChange={(ev) => { popup.setSearch(ev.currentTarget.value) }}
|
||||
/>
|
||||
{state.error !== null && (
|
||||
<div className={css.error} role="alert">
|
||||
<span className={css.errorText}>{state.error}</span>
|
||||
{state.status === 'failed' && (
|
||||
<button type="button" className={css.retry} onClick={() => { popup.retry() }}>{t('retry')}</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{state.status === 'pending' && <div className={css.status}>{t('status.loading')}</div>}
|
||||
{state.submitting && <div className={css.status}>{t('status.applying')}</div>}
|
||||
{state.status === 'ready' && rows.length === 0 && <div className={css.status}>{t('status.empty')}</div>}
|
||||
{state.status === 'ready' && (
|
||||
<div role="listbox" aria-label={t('listbox.aria', { command: String(state.command) })} className={css.viewport}>
|
||||
{rows.map((option, index) => (
|
||||
<div
|
||||
key={option.id}
|
||||
role="option"
|
||||
aria-selected={index === state.active}
|
||||
className={clsx(css.row, index === state.active && css.rowActive)}
|
||||
// mousedown would race the document capture listener; the shell
|
||||
// owns focus anyway, so a plain click (inside the card → no
|
||||
// dismiss) works.
|
||||
onClick={() => { void popup.select(index) }}
|
||||
onMouseEnter={() => { popup.highlight(index) }}
|
||||
>
|
||||
<span className={css.label}>{option.label}</span>
|
||||
{option.detail !== undefined && <span className={css.detail}>{option.detail}</span>}
|
||||
{option.active === true && <span className={css.check}><IconCheckOutline16 /></span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{state.status === 'pending' && <div className={css.status}>{t('status.loading')}</div>}
|
||||
{state.submitting && <div className={css.status}>{t('status.applying')}</div>}
|
||||
{state.status === 'ready' && rows.length === 0 && <div className={css.status}>{t('status.empty')}</div>}
|
||||
{state.status === 'ready' && (
|
||||
<div role="listbox" aria-label={t('listbox.aria', { command: String(state.command) })} className={css.viewport}>
|
||||
{rows.map((option, index) => (
|
||||
<div
|
||||
key={option.id}
|
||||
role="option"
|
||||
aria-selected={index === state.active}
|
||||
className={clsx(css.row, index === state.active && css.rowActive)}
|
||||
// mousedown would race the document capture listener; the shell
|
||||
// owns focus anyway, so a plain click (inside the card → no
|
||||
// dismiss) works.
|
||||
onClick={() => { void popup.select(index) }}
|
||||
onMouseEnter={() => { popup.highlight(index) }}
|
||||
>
|
||||
<span className={css.label}>{option.label}</span>
|
||||
{option.detail !== undefined && <span className={css.detail}>{option.detail}</span>}
|
||||
{option.active === true && <span className={css.check}><IconCheckOutline16 /></span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{confirmation !== undefined && (
|
||||
<RiskConfirmation
|
||||
open
|
||||
title={confirmation.title}
|
||||
description={confirmation.description}
|
||||
acknowledgeLabel={confirmation.acknowledgeLabel}
|
||||
cancelLabel={confirmation.cancelLabel}
|
||||
confirmLabel={confirmation.confirmLabel}
|
||||
acknowledged={state.acknowledged}
|
||||
onAcknowledgedChange={(value) => { popup.acknowledge(value) }}
|
||||
onCancel={() => { popup.cancelConfirmation() }}
|
||||
onConfirm={() => { void popup.confirm() }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,12 +6,23 @@
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
|
||||
/** Copy for an option that must be acknowledged before onSelect can run. */
|
||||
export interface SelectConfirmation {
|
||||
readonly title: string
|
||||
readonly description: string
|
||||
readonly acknowledgeLabel: string
|
||||
readonly cancelLabel: string
|
||||
readonly confirmLabel: string
|
||||
}
|
||||
|
||||
/** One option row of a popupSelect shell. */
|
||||
export interface SelectOption {
|
||||
readonly id: string
|
||||
readonly label: string
|
||||
readonly detail?: string
|
||||
readonly active?: boolean
|
||||
/** Optional in-page risk gate owned by the shared popup shell. */
|
||||
readonly confirmation?: SelectConfirmation
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,7 +24,7 @@ export { filterOptions, PopupSelectController } from './popup.ts'
|
||||
export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts'
|
||||
export type { PopupSelectInjected, PopupSelectViewProps } from './PopupSelectView.tsx'
|
||||
export type {
|
||||
CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectOption,
|
||||
CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectConfirmation, SelectOption,
|
||||
} from './contract.ts'
|
||||
export type { CommandKey } from './locales.ts'
|
||||
|
||||
|
||||
@@ -67,12 +67,17 @@ export interface PopupState {
|
||||
readonly active: number
|
||||
/** A select() settlement is in flight: further select/search/highlight no-op until it settles. */
|
||||
readonly submitting: boolean
|
||||
/** Option waiting for explicit risk acknowledgement; null during normal selection. */
|
||||
readonly confirming: SelectOption | null
|
||||
/** Caller-controlled checkbox state for the pending confirmation. */
|
||||
readonly acknowledged: boolean
|
||||
/** Surfaced settlement failure (options load or onSelect); null when none. */
|
||||
readonly error: string | null
|
||||
}
|
||||
|
||||
const CLOSED: PopupState = {
|
||||
open: false, command: null, status: 'pending', options: [], search: '', active: 0, submitting: false, error: null,
|
||||
open: false, command: null, status: 'pending', options: [], search: '', active: 0,
|
||||
submitting: false, confirming: null, acknowledged: false, error: null,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,7 +171,7 @@ export class PopupSelectController<TCtx = unknown> {
|
||||
*/
|
||||
setSearch(search: string): void {
|
||||
const s = this.state.getSnapshot()
|
||||
if (!s.open || s.submitting || search === s.search) return
|
||||
if (!s.open || s.submitting || s.confirming !== null || search === s.search) return
|
||||
this.state.set({ ...s, search, active: 0 })
|
||||
}
|
||||
|
||||
@@ -177,7 +182,7 @@ export class PopupSelectController<TCtx = unknown> {
|
||||
*/
|
||||
move(dir: 1 | -1): void {
|
||||
const s = this.state.getSnapshot()
|
||||
if (!s.open || s.status !== 'ready' || s.submitting) return
|
||||
if (!s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return
|
||||
const rows = filterOptions(s.options, s.search)
|
||||
if (rows.length === 0) return
|
||||
const active = (s.active + dir + rows.length) % rows.length
|
||||
@@ -191,7 +196,7 @@ export class PopupSelectController<TCtx = unknown> {
|
||||
*/
|
||||
highlight(index: number): void {
|
||||
const s = this.state.getSnapshot()
|
||||
if (!s.open || s.status !== 'ready' || s.submitting) return
|
||||
if (!s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return
|
||||
if (index < 0 || index >= filterOptions(s.options, s.search).length || index === s.active) return
|
||||
this.state.set({ ...s, active: index })
|
||||
}
|
||||
@@ -209,10 +214,46 @@ export class PopupSelectController<TCtx = unknown> {
|
||||
async select(index: number): Promise<void> {
|
||||
const binding = this.binding
|
||||
const s = this.state.getSnapshot()
|
||||
if (binding === null || !s.open || s.status !== 'ready' || s.submitting) return
|
||||
if (binding === null || !s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return
|
||||
const option = filterOptions(s.options, s.search)[index]
|
||||
if (option === undefined) return
|
||||
this.state.set({ ...s, submitting: true, error: null })
|
||||
if (option.confirmation !== undefined) {
|
||||
this.state.set({ ...s, confirming: option, acknowledged: false, error: null })
|
||||
return
|
||||
}
|
||||
await this.settle(binding, option)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the explicit checkbox for the currently pending risk gate.
|
||||
* @param acknowledged - whether the user has acknowledged the displayed risk.
|
||||
*/
|
||||
acknowledge(acknowledged: boolean): void {
|
||||
const s = this.state.getSnapshot()
|
||||
if (!s.open || s.submitting || s.confirming === null || s.acknowledged === acknowledged) return
|
||||
this.state.set({ ...s, acknowledged })
|
||||
}
|
||||
|
||||
/** Cancel only the risk gate and return to the still-open option picker. */
|
||||
cancelConfirmation(): void {
|
||||
const s = this.state.getSnapshot()
|
||||
if (!s.open || s.submitting || s.confirming === null) return
|
||||
this.state.set({ ...s, confirming: null, acknowledged: false })
|
||||
}
|
||||
|
||||
/** Settle the gated option only after the checkbox is acknowledged. */
|
||||
async confirm(): Promise<void> {
|
||||
const binding = this.binding
|
||||
const s = this.state.getSnapshot()
|
||||
if (binding === null || !s.open || s.submitting || s.confirming === null || !s.acknowledged) return
|
||||
await this.settle(binding, s.confirming)
|
||||
}
|
||||
|
||||
/** Run the business settlement for an already admitted option. */
|
||||
private async settle(binding: OpenBinding<TCtx>, option: SelectOption): Promise<void> {
|
||||
const s = this.state.getSnapshot()
|
||||
if (this.binding !== binding || !s.open || s.submitting) return
|
||||
this.state.set({ ...s, submitting: true, confirming: null, acknowledged: false, error: null })
|
||||
try {
|
||||
await binding.spec.onSelect(option, binding.context)
|
||||
} catch (error) {
|
||||
|
||||
@@ -38,6 +38,17 @@ const OPTIONS: SelectOption[] = [
|
||||
{ id: 'light', label: 'Light', active: true },
|
||||
{ id: 'sepia', label: 'Sepia', detail: 'warm' },
|
||||
]
|
||||
const GATED: SelectOption = {
|
||||
id: 'full',
|
||||
label: 'Full access',
|
||||
confirmation: {
|
||||
title: 'Enable Full access?',
|
||||
description: 'Sensitive operations.',
|
||||
acknowledgeLabel: 'I understand the risks',
|
||||
cancelLabel: 'Cancel',
|
||||
confirmLabel: 'Enable Full access',
|
||||
},
|
||||
}
|
||||
|
||||
const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' }
|
||||
|
||||
@@ -149,6 +160,37 @@ describe('PopupSelectView', () => {
|
||||
expect(view.container.childElementCount).toBe(0)
|
||||
})
|
||||
|
||||
it('renders a gated option as an in-page modal and requires the checkbox before onSelect', async () => {
|
||||
const onSelect = vi.fn()
|
||||
const { popup, consume } = await mountOpen({
|
||||
options: () => Promise.resolve([GATED]),
|
||||
onSelect,
|
||||
})
|
||||
await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) })
|
||||
expect(screen.queryByLabelText('/theme 选项')).toBeNull()
|
||||
expect(screen.getByRole('dialog', { name: 'Enable Full access?' })).toBeTruthy()
|
||||
const enable = screen.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement
|
||||
expect(enable.disabled).toBe(true)
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'I understand the risks' }))
|
||||
expect(enable.disabled).toBe(false)
|
||||
await act(async () => { fireEvent.click(enable) })
|
||||
expect(onSelect).toHaveBeenCalledExactlyOnceWith(GATED, 'ctx-A')
|
||||
expect(consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)
|
||||
expect(popup.state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('canceling a gated option returns to the picker with acknowledgement reset', async () => {
|
||||
await mountOpen({ options: () => Promise.resolve([GATED]) })
|
||||
await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) })
|
||||
fireEvent.click(screen.getByRole('checkbox'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(screen.getByLabelText('/theme 选项')).toBeTruthy()
|
||||
await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) })
|
||||
expect(screen.getByRole<HTMLInputElement>('checkbox').checked).toBe(false)
|
||||
})
|
||||
|
||||
it('submitting shows pending, locks the search input, and further Enter/click no-op', async () => {
|
||||
let release!: () => void
|
||||
const onSelect = vi.fn(() => new Promise<void>((resolve) => { release = resolve }))
|
||||
|
||||
@@ -19,6 +19,17 @@ const OPTIONS: SelectOption[] = [
|
||||
{ id: 'light', label: 'Light', active: true },
|
||||
{ id: 'sepia', label: 'Sepia', detail: 'warm' },
|
||||
]
|
||||
const GATED: SelectOption = {
|
||||
id: 'full',
|
||||
label: 'Full access',
|
||||
confirmation: {
|
||||
title: 'Enable Full access?',
|
||||
description: 'Sensitive operations.',
|
||||
acknowledgeLabel: 'I understand',
|
||||
cancelLabel: 'Cancel',
|
||||
confirmLabel: 'Enable Full access',
|
||||
},
|
||||
}
|
||||
|
||||
const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' }
|
||||
|
||||
@@ -200,6 +211,38 @@ describe('search / move / highlight over the filtered list', () => {
|
||||
})
|
||||
|
||||
describe('select', () => {
|
||||
it('gates a confirmed option until acknowledgement, then settles through the original binding', async () => {
|
||||
const onSelect = vi.fn()
|
||||
const deps = makeDeps()
|
||||
const { popup } = await readyPopup({ options: () => Promise.resolve([GATED]), onSelect }, deps)
|
||||
await popup.select(0)
|
||||
expect(popup.state.getSnapshot()).toMatchObject({
|
||||
open: true, confirming: GATED, acknowledged: false, submitting: false,
|
||||
})
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
await popup.confirm()
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
popup.acknowledge(true)
|
||||
await popup.confirm()
|
||||
expect(onSelect).toHaveBeenCalledExactlyOnceWith(GATED, CTX_A)
|
||||
expect(deps.consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)
|
||||
expect(popup.state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('cancels a confirmation back to the picker without selecting or consuming', async () => {
|
||||
const onSelect = vi.fn()
|
||||
const deps = makeDeps()
|
||||
const { popup } = await readyPopup({ options: () => Promise.resolve([GATED]), onSelect }, deps)
|
||||
await popup.select(0)
|
||||
popup.acknowledge(true)
|
||||
popup.cancelConfirmation()
|
||||
expect(popup.state.getSnapshot()).toMatchObject({
|
||||
open: true, confirming: null, acknowledged: false, submitting: false,
|
||||
})
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
expect(deps.consume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs onSelect with the filtered option and the open-time context, consumes, closes, refocuses', async () => {
|
||||
const seen: Array<{ option: SelectOption; context: Ctx }> = []
|
||||
const deps = makeDeps()
|
||||
|
||||
@@ -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: d8f369231de1341450049b2f0cf1acc01d42b9ad
|
||||
README.zh.md: 49275390c30993e659afdda6aa14ef196b7fa859
|
||||
README.md: 7c6e36409efd5f2f9224e85a9cbd3a5e515833c1
|
||||
README.zh.md: 7661826153bc44ff47a660fa49a4ffd902d93bdd
|
||||
|
||||
@@ -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); 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.
|
||||
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. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
|
||||
|
||||
Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded 141px scrollport shows bounded inline JSON for both `content` and `source`, and no tool state, summary, or keyed toolview dispatch is synthesized ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)).
|
||||
|
||||
@@ -20,6 +20,8 @@ A tool call declaring the `web` render intent renders its web retrieval inline,
|
||||
|
||||
A tool call declaring the `diff` render intent (the `write`/`edit` tools) renders its applied change inline through ui-primitives' `DiffBlock`, the same four-layer shape. `contract/diff-card-model.ts` is the single derivation from the `callView`/`resultView` pair; the settled result's hunks replace the call-time diff, and it yields null — the generic path — for any other card tag or a generic result view (write/edit's execution errors). The keyed `FileMutationRow` (registered under both `write` and `edit`) carries the card resident below its summary, whose path link still opens the file through the host; the render-site fallback and the details panel are diff-aware too. Rows cap at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)).
|
||||
|
||||
The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds.
|
||||
|
||||
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).
|
||||
|
||||
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.
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
|
||||
声明 `diff` 渲染意图的工具调用(`write`/`edit` 工具),通过 ui-primitives 的 `DiffBlock` 内联渲染其已应用的改动,采用同一套四层结构。`contract/diff-card-model.ts` 是从 `callView`/`resultView` 对推导的唯一位置;已结算 result 的 hunk 替换 call 时 diff,对任何其他 card 标签或 generic result view(write/edit 的执行错误)它返回 null,落回通用路径。键控的 `FileMutationRow`(在 `write` 与 `edit` 下都注册)把卡片常驻在摘要之下,其路径链接仍经 host 打开文件;渲染点兜底行与详情面板同样感知 diff。行的上限是 `CHAT_DIFF_MAX_LINES`(8),面板为 16([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md))。
|
||||
|
||||
聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。
|
||||
|
||||
工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `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']` 作为加载顺序 seam(apply 在聊天注册后挂载 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);chip 打开 Menu 原语下拉,kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `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 缺席即隐藏 chip);chip 打开 Menu 原语下拉,普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。
|
||||
|
||||
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,包括这条计划条。
|
||||
|
||||
|
||||
@@ -55,6 +55,17 @@ type RenderToolRow = ChatViewSlotProps['renderSlot']
|
||||
* chat view narrows once to the runtime snapshot the binding actually feeds. */
|
||||
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
|
||||
|
||||
function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): number | null {
|
||||
if (!running) return null
|
||||
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
||||
const node = nodes[index]
|
||||
if (node === undefined) continue
|
||||
if (node.kind === 'model-retry') return node.retryState === 'cancelled' ? null : node.seq
|
||||
if (node.kind === 'assistant' || node.kind === 'user') return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** One `run_code` sub-dispatch row: the identical keyed-slot dispatch as a
|
||||
* top-level call (same registrations, same fallback), nested by the parent.
|
||||
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and
|
||||
@@ -262,6 +273,7 @@ export function ChatView({
|
||||
const selectedCallId = useStore(s => s.selection?.callId)
|
||||
|
||||
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
|
||||
const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running])
|
||||
// Only the last content assistant of each turn owns IconActions; mid-turn
|
||||
// text (before tools) omits `time` so AssistantMarkdown stays chrome-free.
|
||||
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes])
|
||||
@@ -424,7 +436,15 @@ export function ChatView({
|
||||
}
|
||||
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
|
||||
if (node.kind === 'tool-result') return null
|
||||
return <MessageItem key={item.key} node={node} onFork={forkAt} t={t} />
|
||||
return (
|
||||
<MessageItem
|
||||
key={item.key}
|
||||
node={node}
|
||||
retryActive={node.kind === 'model-retry' && node.seq === activeRetry}
|
||||
onFork={forkAt}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -34,6 +34,106 @@
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.retryRow {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.retrySummary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 2px 0;
|
||||
gap: 7px;
|
||||
border-radius: 3px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.retrySummary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.retrySummary::after {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-right: 1.5px solid currentcolor;
|
||||
border-bottom: 1.5px solid currentcolor;
|
||||
content: '';
|
||||
opacity: 0.8;
|
||||
transform: rotate(-45deg);
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.retrySummary:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.retrySummary:focus-visible {
|
||||
outline: 1.5px solid var(--dsw-alias-button-info-fill);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.retryText {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.retryRow[data-active] .retryText {
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
var(--dsw-alias-label-tertiary) 0%,
|
||||
var(--dsw-alias-label-tertiary) 40%,
|
||||
var(--dsw-alias-label-secondary) 50%,
|
||||
var(--dsw-alias-label-tertiary) 60%,
|
||||
var(--dsw-alias-label-tertiary) 100%
|
||||
);
|
||||
background-position: 100% 50%;
|
||||
background-size: 200% 100%;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: retry-shimmer 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.retryRow[open] .retrySummary::after {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.retryDetails {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
margin-top: 3px;
|
||||
padding-left: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.retryDetailLabel {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
@keyframes retry-shimmer {
|
||||
from {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
|
||||
to {
|
||||
background-position: 0 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.retryRow[data-active] .retryText {
|
||||
background: none;
|
||||
color: inherit;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reference chip projection inside a user bubble (`<skill>name</skill>` model
|
||||
spans render as chips; free geometry — no textarea pairing here). */
|
||||
.refChip {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// MessageItem: the four simple node kinds — user bubble (right-aligned, with
|
||||
// MessageItem: simple chat nodes — user bubble (right-aligned, with
|
||||
// clock + copy / branch / edit IconActions), steering (badged bubble), context
|
||||
// injection and unknown-surface JSON rows. Props are frozen node slices off
|
||||
// the snapshot cache; memo holds across streaming because unchanged nodes
|
||||
// keep their references.
|
||||
// injection, retry disclosure, and unknown-surface JSON rows.
|
||||
|
||||
import { memo } from 'react'
|
||||
import { memo, useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
|
||||
ContextMessageNode, ModelRetryNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
@@ -16,7 +14,8 @@ import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import css from './MessageItem.module.css'
|
||||
|
||||
export interface MessageItemProps {
|
||||
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode
|
||||
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | ModelRetryNode | UnknownSurfaceNode
|
||||
retryActive?: boolean
|
||||
/** Fork the session through the turn containing this message (user-bubble branch action). */
|
||||
onFork?: (seq: number) => void
|
||||
/** The owning view's locale seat, passed down as a plain prop. */
|
||||
@@ -34,6 +33,80 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
|
||||
return { text: texts.join(''), rest }
|
||||
}
|
||||
|
||||
function retrySeconds(milliseconds: number): number {
|
||||
return Math.max(1, Math.ceil(milliseconds / 1_000))
|
||||
}
|
||||
|
||||
interface RetryCountdown {
|
||||
deadline: number
|
||||
seconds: number
|
||||
}
|
||||
|
||||
function ModelRetryItem({ node, active, t }: {
|
||||
node: ModelRetryNode
|
||||
active: boolean
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
// Anchor the host-scheduled delay to this browser's first render of the
|
||||
// retry node. Host event time and Date.now() may belong to different clocks.
|
||||
const deadline = useMemo(() => Date.now() + node.delayMs, [node.delayMs, node.seq])
|
||||
const scheduledSeconds = retrySeconds(node.delayMs)
|
||||
const maximum = node.mode === 'normal' ? node.maxRetries : '∞'
|
||||
const [countdown, setCountdown] = useState<RetryCountdown>(() => ({
|
||||
deadline,
|
||||
seconds: retrySeconds(deadline - Date.now()),
|
||||
}))
|
||||
const remainingSeconds = countdown.deadline === deadline
|
||||
? countdown.seconds
|
||||
: retrySeconds(deadline - Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const updateCountdown = (): number => {
|
||||
const next = retrySeconds(deadline - Date.now())
|
||||
setCountdown(current => (
|
||||
current.deadline === deadline && current.seconds === next
|
||||
? current
|
||||
: { deadline, seconds: next }
|
||||
))
|
||||
return next
|
||||
}
|
||||
if (updateCountdown() === 1) return
|
||||
const timer = window.setInterval(() => {
|
||||
if (updateCountdown() === 1) window.clearInterval(timer)
|
||||
}, 250)
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [active, deadline])
|
||||
|
||||
const label = active
|
||||
? t('message.retry.active')
|
||||
: node.retryState === 'cancelled'
|
||||
? t('message.retry.cancelled')
|
||||
: node.retryState === 'started'
|
||||
? t('message.retry.started')
|
||||
: t('message.retry.scheduled')
|
||||
const seconds = active ? remainingSeconds : scheduledSeconds
|
||||
|
||||
return (
|
||||
<details className={css.retryRow} data-active={active || undefined}>
|
||||
<summary className={css.retrySummary}>
|
||||
<span className={css.retryText} role="status">
|
||||
{t('message.retry.status', { label, retry: node.retry, maximum, seconds })}
|
||||
</span>
|
||||
</summary>
|
||||
<div className={css.retryDetails}>
|
||||
<div>
|
||||
<span className={css.retryDetailLabel}>{t('message.retry.delay')}</span>
|
||||
{Math.round(node.delayMs)}ms
|
||||
</div>
|
||||
<div>
|
||||
<span className={css.retryDetailLabel}>{t('message.retry.failure')}</span>
|
||||
{node.failure.message}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Display projection of reference forms in a user bubble (free geometry — no
|
||||
* textarea alignment constraint here); everything else stays plain text. The
|
||||
@@ -66,7 +139,9 @@ function projectUserText(text: string): ReactNode {
|
||||
return <>{parts}</>
|
||||
}
|
||||
|
||||
export const MessageItem = memo(function MessageItem({ node, onFork, t }: MessageItemProps) {
|
||||
export const MessageItem = memo(function MessageItem({
|
||||
node, retryActive = false, onFork, t,
|
||||
}: MessageItemProps) {
|
||||
const truncated = (total: number): string => t('json.truncated', { total })
|
||||
switch (node.kind) {
|
||||
case 'user': {
|
||||
@@ -105,6 +180,8 @@ export const MessageItem = memo(function MessageItem({ node, onFork, t }: Messag
|
||||
return (
|
||||
<ContextInjectionRow content={node.content} source={node.source} t={t} />
|
||||
)
|
||||
case 'model-retry':
|
||||
return <ModelRetryItem node={node} active={retryActive} t={t} />
|
||||
default:
|
||||
return (
|
||||
<div className={css.contextRow}>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Chat flow derivation: ConversationSnapshot nodes -> render items. Tool
|
||||
* results group into consecutive-run tool groups (figma step-summary flow,
|
||||
* VERTICAL gap10) alternating with narration; everything else passes through.
|
||||
* VERTICAL gap10) alternating with narration. Consecutive retry notices
|
||||
* reuse the first notice's row while projecting the latest retry turn.
|
||||
* Item identity keys are stable across snapshots so the list parent can
|
||||
* subscribe to keys only while rows subscribe to content. IconActions ownership
|
||||
* (last content assistant per turn) is derived here too so ChatView and the
|
||||
@@ -49,7 +50,7 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon
|
||||
/**
|
||||
* Group finalized nodes into the step-summary flow.
|
||||
* @param nodes - snapshot nodes (surface order).
|
||||
* @returns flow items; consecutive tool-results merged into one group keyed by the first seq.
|
||||
* @returns flow items; consecutive tool results and retry notices reuse their first key.
|
||||
*/
|
||||
export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] {
|
||||
const items: ChatFlowItem[] = []
|
||||
@@ -63,6 +64,17 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem
|
||||
} else {
|
||||
group.push(node)
|
||||
}
|
||||
} else if (node.kind === 'model-retry') {
|
||||
group = null
|
||||
const previous = items[items.length - 1]
|
||||
if (
|
||||
previous?.kind === 'node'
|
||||
&& previous.node.kind === 'model-retry'
|
||||
) {
|
||||
items[items.length - 1] = { ...previous, node }
|
||||
} else {
|
||||
items.push({ kind: 'node', key: `n${node.seq}`, node })
|
||||
}
|
||||
} else {
|
||||
group = null
|
||||
items.push({ kind: 'node', key: `n${node.seq}`, node })
|
||||
|
||||
@@ -23,6 +23,11 @@ export const zh = {
|
||||
'input.stop': '停止生成',
|
||||
'input.send': '发送消息',
|
||||
'input.accessMode': '访问模式,当前:{name}',
|
||||
'access.confirm.title': '确认启用 Full access?',
|
||||
'access.confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
|
||||
'access.confirm.acknowledge': '我已了解风险,并愿意继续',
|
||||
'access.confirm.cancel': '取消',
|
||||
'access.confirm.enable': '启用 Full access',
|
||||
'hero.headline': '开始构建吧',
|
||||
'hero.chooseWorkspace': '选择工作区',
|
||||
'session.hierarchy': '会话层级',
|
||||
@@ -48,6 +53,13 @@ export const zh = {
|
||||
'message.unknownBlock': '未知内容块',
|
||||
'message.stopped': '已停止',
|
||||
'message.branch': '在新对话中分支',
|
||||
'message.retry.active': '正在重试模型请求',
|
||||
'message.retry.cancelled': '模型请求重试已取消',
|
||||
'message.retry.started': '已重试模型请求',
|
||||
'message.retry.scheduled': '等待重试模型请求',
|
||||
'message.retry.status': '{label}({retry}/{maximum}) · {seconds}s',
|
||||
'message.retry.delay': '重试延迟:',
|
||||
'message.retry.failure': '失败原因:',
|
||||
'command.running': '执行中…',
|
||||
'command.failed': '命令失败',
|
||||
'command.done': '已完成',
|
||||
@@ -105,6 +117,11 @@ export const en = {
|
||||
'input.stop': 'Stop generating',
|
||||
'input.send': 'Send message',
|
||||
'input.accessMode': 'Access mode, current: {name}',
|
||||
'access.confirm.title': 'Enable Full access?',
|
||||
'access.confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.',
|
||||
'access.confirm.acknowledge': 'I understand the risks and want to continue',
|
||||
'access.confirm.cancel': 'Cancel',
|
||||
'access.confirm.enable': 'Enable Full access',
|
||||
'hero.headline': 'Let\'s start building',
|
||||
'hero.chooseWorkspace': 'Choose workspace',
|
||||
'session.hierarchy': 'Session hierarchy',
|
||||
@@ -130,6 +147,13 @@ export const en = {
|
||||
'message.unknownBlock': 'Unknown content block',
|
||||
'message.stopped': 'Stopped',
|
||||
'message.branch': 'Branch into a new conversation',
|
||||
'message.retry.active': 'Retrying model request',
|
||||
'message.retry.cancelled': 'Model request retry cancelled',
|
||||
'message.retry.started': 'Retried model request',
|
||||
'message.retry.scheduled': 'Waiting to retry model request',
|
||||
'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s',
|
||||
'message.retry.delay': 'Retry delay: ',
|
||||
'message.retry.failure': 'Failure reason: ',
|
||||
'command.running': 'Running…',
|
||||
'command.failed': 'Command failed',
|
||||
'command.done': 'Completed',
|
||||
|
||||
@@ -279,7 +279,7 @@ export function InputBar({
|
||||
// or while the command face is absent with the session).
|
||||
const accessSelect: ReactNode = command === undefined
|
||||
? null
|
||||
: <PermissionSelect value={permissions} locked={locked} command={command} t={t} />
|
||||
: <PermissionSelect key={sessionId} value={permissions} locked={locked} command={command} t={t} />
|
||||
|
||||
// Mirror-layer decorations: a visible backdrop with transparent text. The
|
||||
// claim token highlights through behind the textarea glyphs; each U+FFFC
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client'
|
||||
import { Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { Menu, RiskConfirmation } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ComposerBarProps } from '../contract/slots.ts'
|
||||
import css from './PermissionSelect.module.css'
|
||||
|
||||
const FULL_ACCESS = 'danger-full-access'
|
||||
|
||||
/**
|
||||
* Display transform: kebab-case machine names render as title-case labels
|
||||
* (`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.
|
||||
* pass through. Full access intentionally overrides the machine-name
|
||||
* transform so both permission surfaces use the product label `Full access`;
|
||||
* the warning body remains locale-aware.
|
||||
*/
|
||||
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(' ')
|
||||
}
|
||||
|
||||
function optionLabel(option: PermissionSelectValue['options'][number]): string {
|
||||
return option.value === FULL_ACCESS ? 'Full access' : displayName(option.name)
|
||||
}
|
||||
|
||||
export interface PermissionSelectProps {
|
||||
value: PermissionSelectValue | undefined
|
||||
locked: boolean
|
||||
@@ -27,49 +34,94 @@ export interface PermissionSelectProps {
|
||||
export function PermissionSelect({ value, locked, command, t }: PermissionSelectProps) {
|
||||
const [pick, setPick] = useState<string | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [confirmation, setConfirmation] = useState<string | null>(null)
|
||||
const [acknowledged, setAcknowledged] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!locked && value !== undefined) return
|
||||
setOpen(false)
|
||||
setAcknowledged(false)
|
||||
setConfirmation(null)
|
||||
}, [locked, value])
|
||||
|
||||
if (value === undefined) return null
|
||||
|
||||
const currentValue = pick ?? value.currentValue
|
||||
const current = value.options.find(option => option.value === currentValue)
|
||||
const busy = pick !== null
|
||||
const busy = pick !== null || confirmation !== null
|
||||
|
||||
const items: MenuEntry[] = value.options
|
||||
.filter(o => o.value !== 'custom')
|
||||
.map(option => ({ id: option.value, label: displayName(option.name) }))
|
||||
.map(option => ({ id: option.value, label: optionLabel(option) }))
|
||||
|
||||
const choose = (id: string): void => {
|
||||
setOpen(false)
|
||||
if (id === value.currentValue) return
|
||||
const submit = (id: string): void => {
|
||||
setPick(id)
|
||||
void command(`/permission ${id}`)
|
||||
.catch(() => false)
|
||||
.then(() => { setPick(null) })
|
||||
}
|
||||
|
||||
const choose = (id: string): void => {
|
||||
setOpen(false)
|
||||
if (id === value.currentValue) return
|
||||
if (id === FULL_ACCESS) {
|
||||
setAcknowledged(false)
|
||||
setConfirmation(id)
|
||||
return
|
||||
}
|
||||
submit(id)
|
||||
}
|
||||
|
||||
const closeConfirmation = (): void => {
|
||||
setAcknowledged(false)
|
||||
setConfirmation(null)
|
||||
}
|
||||
|
||||
const confirmFullAccess = (): void => {
|
||||
if (locked || !acknowledged || confirmation === null) return
|
||||
const id = confirmation
|
||||
closeConfirmation()
|
||||
submit(id)
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
items={items}
|
||||
selectedId={currentValue}
|
||||
onSelect={choose}
|
||||
onClose={() => { setOpen(false) }}
|
||||
side="top"
|
||||
anchor={
|
||||
<button
|
||||
type="button"
|
||||
className={css.trigger}
|
||||
aria-label={t('input.accessMode', { name: 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>
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<Menu
|
||||
open={open}
|
||||
items={items}
|
||||
selectedId={currentValue}
|
||||
onSelect={choose}
|
||||
onClose={() => { setOpen(false) }}
|
||||
side="top"
|
||||
anchor={
|
||||
<button
|
||||
type="button"
|
||||
className={css.trigger}
|
||||
aria-label={t('input.accessMode', { name: current === undefined ? displayName(currentValue) : optionLabel(current) })}
|
||||
title={current?.description}
|
||||
disabled={locked || busy}
|
||||
onClick={() => { setOpen(!open) }}
|
||||
>
|
||||
<span className={css.triggerLabel}>{current === undefined ? displayName(currentValue) : optionLabel(current)}</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>
|
||||
}
|
||||
/>
|
||||
<RiskConfirmation
|
||||
open={confirmation !== null}
|
||||
title={t('access.confirm.title')}
|
||||
description={t('access.confirm.description')}
|
||||
acknowledgeLabel={t('access.confirm.acknowledge')}
|
||||
cancelLabel={t('access.confirm.cancel')}
|
||||
confirmLabel={t('access.confirm.enable')}
|
||||
acknowledged={acknowledged}
|
||||
disabled={locked}
|
||||
onAcknowledgedChange={setAcknowledged}
|
||||
onCancel={closeConfirmation}
|
||||
onConfirm={confirmFullAccess}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: MessageItemProps['t'] = makeTranslate(zh, commonZh)
|
||||
@@ -160,6 +163,157 @@ describe('MessageItem arms', () => {
|
||||
)
|
||||
expect(unknownView.getByText(/未知 surface 事件:surface\/next/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('collapses retry details behind the durable model retry status', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(10_000)
|
||||
const view = render(
|
||||
<MessageItem
|
||||
t={t}
|
||||
retryActive
|
||||
node={{
|
||||
kind: 'model-retry',
|
||||
seq: 5,
|
||||
time: 10_000,
|
||||
retryState: 'scheduled',
|
||||
turn: 1,
|
||||
step: 0,
|
||||
provider: 'mock',
|
||||
mode: 'normal',
|
||||
policyKey: 'mock-normal',
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 2_500.4,
|
||||
failure: { code: 'TRANSPORT', message: '连接被重置' },
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
const details = view.container.querySelector('details')
|
||||
const summary = view.container.querySelector('summary')
|
||||
expect(details?.open).toBe(false)
|
||||
expect(details?.dataset.active).toBe('true')
|
||||
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 3s')
|
||||
expect(view.getByText('重试延迟:').parentElement?.textContent).toBe('重试延迟:2500ms')
|
||||
expect(view.getByText('失败原因:').parentElement?.textContent).toBe('失败原因:连接被重置')
|
||||
|
||||
act(() => { vi.advanceTimersByTime(1_100) })
|
||||
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 2s')
|
||||
act(() => { vi.advanceTimersByTime(1_000) })
|
||||
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
|
||||
|
||||
view.rerender(
|
||||
<MessageItem
|
||||
t={t}
|
||||
retryActive
|
||||
node={{
|
||||
kind: 'model-retry',
|
||||
seq: 6,
|
||||
time: 12_100,
|
||||
retryState: 'scheduled',
|
||||
turn: 2,
|
||||
step: 0,
|
||||
provider: 'mock',
|
||||
mode: 'normal',
|
||||
policyKey: 'mock-normal',
|
||||
retry: 2,
|
||||
maxRetries: 2,
|
||||
delayMs: 3_500.4,
|
||||
failure: { code: 'TRANSPORT', message: '再次断开' },
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 4s')
|
||||
|
||||
if (summary === null) throw new Error('retry summary missing')
|
||||
fireEvent.click(summary)
|
||||
expect(details?.open).toBe(true)
|
||||
|
||||
view.rerender(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'model-retry',
|
||||
seq: 6,
|
||||
time: 12_100,
|
||||
retryState: 'started',
|
||||
turn: 2,
|
||||
step: 0,
|
||||
provider: 'mock',
|
||||
mode: 'normal',
|
||||
policyKey: 'mock-normal',
|
||||
retry: 2,
|
||||
maxRetries: 2,
|
||||
delayMs: 3_500.4,
|
||||
failure: { code: 'TRANSPORT', message: '再次断开' },
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(details?.dataset.active).toBeUndefined()
|
||||
expect(view.getByRole('status').textContent).toBe('已重试模型请求(2/2) · 4s')
|
||||
|
||||
view.rerender(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'model-retry',
|
||||
seq: 7,
|
||||
time: 12_100,
|
||||
retryState: 'started',
|
||||
turn: 3,
|
||||
step: 0,
|
||||
provider: 'mock',
|
||||
mode: 'always',
|
||||
policyKey: 'mock-always',
|
||||
retry: 3,
|
||||
delayMs: 3_500.4,
|
||||
failure: { code: 'TRANSPORT', message: '继续重试' },
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(view.getByRole('status').textContent).toBe('已重试模型请求(3/∞) · 4s')
|
||||
|
||||
view.rerender(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'model-retry',
|
||||
seq: 8,
|
||||
time: 12_100,
|
||||
retryState: 'cancelled',
|
||||
turn: 4,
|
||||
step: 0,
|
||||
provider: 'mock',
|
||||
mode: 'normal',
|
||||
policyKey: 'mock-normal',
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 3_500.4,
|
||||
failure: { code: 'TRANSPORT', message: '用户取消' },
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(view.getByRole('status').textContent).toBe('模型请求重试已取消(1/2) · 4s')
|
||||
})
|
||||
|
||||
it('synchronizes the countdown when an inactive retry becomes active at the one-second floor', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(10_000)
|
||||
const node = {
|
||||
kind: 'model-retry',
|
||||
seq: 5,
|
||||
time: 10_000,
|
||||
retryState: 'scheduled',
|
||||
turn: 1,
|
||||
step: 0,
|
||||
provider: 'mock',
|
||||
mode: 'normal',
|
||||
policyKey: 'mock-normal',
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 5_000,
|
||||
failure: { code: 'TRANSPORT', message: '连接被重置' },
|
||||
} as const
|
||||
const view = render(<MessageItem t={t} node={node} />)
|
||||
expect(view.getByRole('status').textContent).toBe('等待重试模型请求(1/2) · 5s')
|
||||
|
||||
act(() => { vi.advanceTimersByTime(4_200) })
|
||||
view.rerender(<MessageItem t={t} node={node} retryActive />)
|
||||
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatMessageClock', () => {
|
||||
|
||||
@@ -7,8 +7,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Profiler } from 'react'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import type {
|
||||
AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId,
|
||||
SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
|
||||
AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot,
|
||||
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode,
|
||||
UserMessageNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -67,6 +68,13 @@ const user = (seq: number, text: string): UserMessageNode => ({
|
||||
const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => ({
|
||||
kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }],
|
||||
})
|
||||
const retry = (seq: number): ModelRetryNode => ({
|
||||
kind: 'model-retry', seq, time: seq * 1_000, turn: 1, step: 0,
|
||||
retryState: 'scheduled',
|
||||
provider: 'mock', mode: 'normal', policyKey: 'mock-normal',
|
||||
retry: 1, maxRetries: 2, delayMs: 450,
|
||||
failure: { code: 'TRANSPORT', message: '连接被重置' },
|
||||
})
|
||||
const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
|
||||
kind: 'tool-result', seq, time: seq * 1_000, callId,
|
||||
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
|
||||
@@ -155,6 +163,17 @@ describe('chat-flow derivation', () => {
|
||||
expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6')
|
||||
})
|
||||
|
||||
it('reuses one stable row for consecutive retry turns', () => {
|
||||
const first = retry(2)
|
||||
const second = { ...retry(3), turn: 2, retry: 2 }
|
||||
const initial = deriveChatFlow([user(1, 'try'), first])
|
||||
const updated = deriveChatFlow([user(1, 'try'), first, second])
|
||||
expect(flowKeys(initial)).toBe('n1|n2')
|
||||
expect(flowKeys(updated)).toBe('n1|n2')
|
||||
expect(updated).toHaveLength(2)
|
||||
expect(updated[1]?.kind === 'node' && updated[1].node).toBe(second)
|
||||
})
|
||||
|
||||
it('skips render-nothing assistant nodes so tool runs stay one group', () => {
|
||||
// A tool-call-only step message (and blank text/reasoning) renders nothing:
|
||||
// it must not split the run into two groups with an empty line between.
|
||||
@@ -227,6 +246,47 @@ describe('ChatView', () => {
|
||||
expect(view.getByText('run a')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('animates only the latest unresolved model retry', () => {
|
||||
const retryNode = retry(2)
|
||||
const nextRetry = { ...retry(3), turn: 2, retry: 2 }
|
||||
const context = {
|
||||
kind: 'context', seq: 4, time: 4_000, content: [], source: null,
|
||||
} as const satisfies ConversationNode
|
||||
const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const disclosure = view.container.querySelector('details')
|
||||
expect(disclosure?.dataset.active).toBe('true')
|
||||
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
|
||||
|
||||
act(() => {
|
||||
h.set({ nodes: [user(1, 'try'), retryNode, nextRetry] })
|
||||
})
|
||||
expect(view.getAllByRole('status')).toHaveLength(1)
|
||||
expect(view.container.querySelector('details')).toBe(disclosure)
|
||||
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 1s')
|
||||
|
||||
act(() => {
|
||||
h.set({
|
||||
nodes: [
|
||||
user(1, 'try'),
|
||||
retryNode,
|
||||
{ ...nextRetry, retryState: 'started' },
|
||||
context,
|
||||
assistant(5, 'done'),
|
||||
],
|
||||
running: false,
|
||||
})
|
||||
})
|
||||
expect(disclosure?.dataset.active).toBeUndefined()
|
||||
expect(view.getByRole('status').textContent).toBe('已重试模型请求(2/2) · 1s')
|
||||
|
||||
act(() => {
|
||||
h.set({ nodes: [user(1, 'try'), { ...retry(6), retryState: 'cancelled' }], running: true })
|
||||
})
|
||||
expect(disclosure?.dataset.active).toBeUndefined()
|
||||
expect(view.getByRole('status').textContent).toContain('重试已取消')
|
||||
})
|
||||
|
||||
it('the expanded row Inspect pill hands the call id to inspectCall', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [toolResult(3, 'a')],
|
||||
|
||||
@@ -46,6 +46,7 @@ interface BenchOptions {
|
||||
variant?: 'hero' | 'composer'
|
||||
placeholder?: string
|
||||
t?: InputBarProps['t']
|
||||
command?: (line: string) => Promise<boolean>
|
||||
accessory?: React.ReactNode
|
||||
overlay?: React.ReactNode
|
||||
leftItems?: React.ReactNode
|
||||
@@ -108,7 +109,7 @@ function bench(over?: BenchOptions) {
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
useMenuLauncher: bindSnapshotSelector(menuLauncher),
|
||||
stop,
|
||||
command: () => Promise.resolve(true),
|
||||
command: over?.command ?? (() => Promise.resolve(true)),
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
t: over?.t ?? makeTranslate(zh, commonZh),
|
||||
renderSlot,
|
||||
@@ -467,7 +468,35 @@ describe('command launcher chrome and control seats', () => {
|
||||
expect(launcher.getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
|
||||
it('the Access chip renders the projection value and submits /permission on pick', async () => {
|
||||
it('the Access chip renders the projection value and submits a non-Full-access pick directly', async () => {
|
||||
const command = vi.fn(() => Promise.resolve(true))
|
||||
const permissions = {
|
||||
options: [
|
||||
{ value: 'read-only', name: 'read-only' },
|
||||
{ value: 'workspace-write', name: 'workspace-write' },
|
||||
{ value: 'danger-full-access', name: 'danger-full-access' },
|
||||
],
|
||||
currentValue: 'read-only',
|
||||
}
|
||||
const { view } = bench({ permissions, command })
|
||||
const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement
|
||||
// Title-case display is presentation only; the menu ids stay machine names.
|
||||
expect(trigger.textContent).toBe('Read Only')
|
||||
fireEvent.click(trigger)
|
||||
const items = view.getAllByRole('menuitem')
|
||||
expect(items.map(o => o.textContent)).toEqual(['Read Only', 'Workspace Write', 'Full access'])
|
||||
fireEvent.click(items[1]!)
|
||||
// Optimistic pick + disable until admission resolves (command stub resolves true).
|
||||
const busy = view.getByLabelText(/^访问模式/) as HTMLButtonElement
|
||||
expect(busy.textContent).toBe('Workspace Write')
|
||||
expect(busy.disabled).toBe(true)
|
||||
expect(command).toHaveBeenCalledWith('/permission workspace-write')
|
||||
await act(async () => {})
|
||||
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('requires explicit risk acknowledgement before submitting Full access', async () => {
|
||||
const command = vi.fn(() => Promise.resolve(true))
|
||||
const permissions = {
|
||||
options: [
|
||||
{ value: 'workspace-write', name: 'workspace-write' },
|
||||
@@ -475,20 +504,86 @@ describe('command launcher chrome and control seats', () => {
|
||||
],
|
||||
currentValue: 'workspace-write',
|
||||
}
|
||||
const { view } = bench({ permissions })
|
||||
const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement
|
||||
// Title-case display is presentation only; the menu ids stay machine names.
|
||||
expect(trigger.textContent).toBe('Workspace Write')
|
||||
fireEvent.click(trigger)
|
||||
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).
|
||||
const busy = view.getByLabelText(/^访问模式/) as HTMLButtonElement
|
||||
expect(busy.textContent).toBe('Danger Full Access')
|
||||
expect(busy.disabled).toBe(true)
|
||||
const { view } = bench({ permissions, command })
|
||||
fireEvent.click(view.getByLabelText(/^访问模式/))
|
||||
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
|
||||
|
||||
expect(command).not.toHaveBeenCalled()
|
||||
expect(view.getByRole('dialog', { name: '确认启用 Full access?' })).toBeTruthy()
|
||||
const enable = view.getByRole('button', { name: '启用 Full access' }) as HTMLButtonElement
|
||||
expect(enable.disabled).toBe(true)
|
||||
|
||||
fireEvent.click(view.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }))
|
||||
expect(enable.disabled).toBe(false)
|
||||
fireEvent.click(enable)
|
||||
|
||||
expect(command).toHaveBeenCalledOnce()
|
||||
expect(command).toHaveBeenCalledWith('/permission danger-full-access')
|
||||
expect(view.queryByRole('dialog')).toBeNull()
|
||||
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('Full access')
|
||||
await act(async () => {})
|
||||
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('cancels a Full access selection without changing permission and resets acknowledgement', () => {
|
||||
const command = vi.fn(() => Promise.resolve(true))
|
||||
const permissions = {
|
||||
options: [
|
||||
{ value: 'workspace-write', name: 'workspace-write' },
|
||||
{ value: 'danger-full-access', name: 'danger-full-access' },
|
||||
],
|
||||
currentValue: 'workspace-write',
|
||||
}
|
||||
const { view } = bench({ permissions, command })
|
||||
const openConfirmation = () => {
|
||||
fireEvent.click(view.getByLabelText(/^访问模式/))
|
||||
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
|
||||
}
|
||||
|
||||
openConfirmation()
|
||||
fireEvent.click(view.getByRole('checkbox'))
|
||||
fireEvent.click(view.getByRole('button', { name: '取消' }))
|
||||
expect(command).not.toHaveBeenCalled()
|
||||
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('Workspace Write')
|
||||
|
||||
openConfirmation()
|
||||
expect((view.getByRole('checkbox') as HTMLInputElement).checked).toBe(false)
|
||||
expect((view.getByRole('button', { name: '启用 Full access' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('revokes an open Full access confirmation when the task locks', () => {
|
||||
const command = vi.fn(() => Promise.resolve(true))
|
||||
const permissions = {
|
||||
options: [
|
||||
{ value: 'workspace-write', name: 'workspace-write' },
|
||||
{ value: 'danger-full-access', name: 'danger-full-access' },
|
||||
],
|
||||
currentValue: 'workspace-write',
|
||||
}
|
||||
const { view, session } = bench({ permissions, command })
|
||||
fireEvent.click(view.getByLabelText(/^访问模式/))
|
||||
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
|
||||
fireEvent.click(view.getByRole('checkbox'))
|
||||
act(() => { session.set(snapshotOf({ removed: true })) })
|
||||
expect(view.queryByRole('dialog')).toBeNull()
|
||||
expect(command).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets an open Full access confirmation when switching tasks', () => {
|
||||
const command = vi.fn(() => Promise.resolve(true))
|
||||
const permissions = {
|
||||
options: [
|
||||
{ value: 'workspace-write', name: 'workspace-write' },
|
||||
{ value: 'danger-full-access', name: 'danger-full-access' },
|
||||
],
|
||||
currentValue: 'workspace-write',
|
||||
}
|
||||
const { view, props } = bench({ permissions, command })
|
||||
fireEvent.click(view.getByLabelText(/^访问模式/))
|
||||
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
|
||||
fireEvent.click(view.getByRole('checkbox'))
|
||||
view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />)
|
||||
expect(view.queryByRole('dialog')).toBeNull()
|
||||
expect(command).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a registered entry fills its seat and receives the locked owner prop', () => {
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-command"
|
||||
],
|
||||
@@ -35,6 +36,7 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-command": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
|
||||
@@ -43,6 +45,7 @@
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
|
||||
@@ -8,15 +8,21 @@
|
||||
* projection (the same host-computed select the composer chip renders); a
|
||||
* pick submits the `/permission <preset>` command line, so both surfaces
|
||||
* write through one path and the pushed projection frame is the one
|
||||
* confirmation.
|
||||
* confirmation. The Full access row carries the same explicit risk gate as
|
||||
* the composer chip; the shared popup shell owns the modal mechanics.
|
||||
*/
|
||||
import type { ClientContext, SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
|
||||
import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client'
|
||||
|
||||
/** Required services (cordis fiber inject). */
|
||||
export const inject = ['command', 'sessions']
|
||||
export const inject = ['command', 'sessions', 'locale']
|
||||
|
||||
const FULL_ACCESS = 'danger-full-access'
|
||||
const ACCESS_NS = 'permission.access'
|
||||
|
||||
/** Read one session's current permissions projection value (undefined = capability absent). */
|
||||
function selectOf(session: SessionFace | undefined): PermissionSelect | undefined {
|
||||
@@ -26,8 +32,9 @@ function selectOf(session: SessionFace | undefined): PermissionSelect | undefine
|
||||
/**
|
||||
* 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.
|
||||
* (`workspace-write` → `Workspace Write`); non-kebab host-configured names
|
||||
* pass through. Full access intentionally uses the product label rather than
|
||||
* a title-cased machine value; its warning body remains locale-aware.
|
||||
*/
|
||||
function displayName(name: string): string {
|
||||
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name
|
||||
@@ -35,14 +42,25 @@ function displayName(name: string): string {
|
||||
}
|
||||
|
||||
/** Flatten the projection select into popup rows; `custom` is display state, never a target. */
|
||||
function optionsOf(value: PermissionSelect): SelectOption[] {
|
||||
function optionsOf(value: PermissionSelect, t: (key: string) => string): SelectOption[] {
|
||||
return value.options
|
||||
.filter(option => option.value !== 'custom')
|
||||
.map(option => ({
|
||||
id: option.value,
|
||||
label: displayName(option.name),
|
||||
label: option.value === FULL_ACCESS ? 'Full access' : displayName(option.name),
|
||||
...(option.description !== undefined ? { detail: option.description } : {}),
|
||||
...(option.value === value.currentValue ? { active: true } : {}),
|
||||
...(option.value === FULL_ACCESS
|
||||
? {
|
||||
confirmation: {
|
||||
title: t('confirm.title'),
|
||||
description: t('confirm.description'),
|
||||
acknowledgeLabel: t('confirm.acknowledge'),
|
||||
cancelLabel: t('confirm.cancel'),
|
||||
confirmLabel: t('confirm.enable'),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -54,6 +72,30 @@ function optionsOf(value: PermissionSelect): SelectOption[] {
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const command = ctx.get('command') as CommandServiceContract
|
||||
const sessions = ctx.sessions
|
||||
// This optional bundle and ui-conversation can load independently, so each
|
||||
// owns the same safety copy under its own locale namespace.
|
||||
/* jscpd:ignore-start */
|
||||
ctx.effect(() => {
|
||||
const disposers = [
|
||||
ctx.locale.register(ACCESS_NS, 'zh', {
|
||||
'confirm.title': '确认启用 Full access?',
|
||||
'confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
|
||||
'confirm.acknowledge': '我已了解风险,并愿意继续',
|
||||
'confirm.cancel': '取消',
|
||||
'confirm.enable': '启用 Full access',
|
||||
}),
|
||||
ctx.locale.register(ACCESS_NS, 'en', {
|
||||
'confirm.title': 'Enable Full access?',
|
||||
'confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.',
|
||||
'confirm.acknowledge': 'I understand the risks and want to continue',
|
||||
'confirm.cancel': 'Cancel',
|
||||
'confirm.enable': 'Enable Full access',
|
||||
}),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-permission: Full access confirmation dictionaries')
|
||||
/* jscpd:ignore-end */
|
||||
const t = ctx.locale.bind(ACCESS_NS)
|
||||
const sessionFor = (session: ClientSessionContext): SessionFace | undefined =>
|
||||
sessions.binding(session.sessionId)?.session
|
||||
ctx.effect(() => command.decorate({
|
||||
@@ -67,7 +109,7 @@ export function apply(ctx: ClientContext): void {
|
||||
options: (session) => {
|
||||
const value = selectOf(sessionFor(session))
|
||||
if (value === undefined) throw new Error('permission presets are not available on this host')
|
||||
return Promise.resolve(optionsOf(value))
|
||||
return Promise.resolve(optionsOf(value, t))
|
||||
},
|
||||
onSelect: async (option, session) => {
|
||||
const live = sessionFor(session)
|
||||
|
||||
@@ -54,6 +54,17 @@ async function bench() {
|
||||
ctx.provide('sessions', {
|
||||
binding: (id: SessionId) => (values.has(id) ? { sessionId: id, session: session(id) } : undefined),
|
||||
})
|
||||
const en = {
|
||||
'confirm.title': 'Enable Full access?',
|
||||
'confirm.description': 'Full access can perform sensitive operations.',
|
||||
'confirm.acknowledge': 'I understand the risks and want to continue',
|
||||
'confirm.cancel': 'Cancel',
|
||||
'confirm.enable': 'Enable Full access',
|
||||
} as Record<string, string>
|
||||
ctx.provide('locale', {
|
||||
register: () => () => {},
|
||||
bind: () => (key: string) => en[key] ?? key,
|
||||
})
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
return {
|
||||
@@ -86,7 +97,14 @@ describe('ui-permission browser plugin', () => {
|
||||
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'])
|
||||
expect(again.map(option => option.label)).toEqual(['Read Only', 'Workspace Write', 'Full access'])
|
||||
expect(again.find(option => option.id === 'danger-full-access')?.confirmation).toEqual({
|
||||
title: 'Enable Full access?',
|
||||
description: 'Full access can perform sensitive operations.',
|
||||
acknowledgeLabel: 'I understand the risks and want to continue',
|
||||
cancelLabel: 'Cancel',
|
||||
confirmLabel: 'Enable 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')
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// Modal: controlled full-viewport dialog (create-workspace and similar).
|
||||
// Fixed overlay in the React tree (no react-dom portal) so ui-primitives
|
||||
// stays free of a react-dom dependency; mask tokens match figma 451:18655.
|
||||
// The overlay portals to this document's body so ancestor stacking contexts
|
||||
// cannot leave sticky page controls above the mask. This is still an in-page
|
||||
// WebUI dialog; it never creates or targets another browser/native window.
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import clsx from 'clsx'
|
||||
import { IconCloseOutline16 } from './icons/index.tsx'
|
||||
import css from './Modal.module.css'
|
||||
@@ -17,6 +19,7 @@ import css from './Modal.module.css'
|
||||
* @param props.description - optional supporting sentence under the title.
|
||||
* @param props.children - body (inputs, etc.).
|
||||
* @param props.footer - action row (Cancel / Create).
|
||||
* @param props.contentClassName - optional class for a scrollable content region.
|
||||
* @param props.headless - render children directly in the card (no default
|
||||
* header/close/body chrome) for dialogs whose figma frame owns its own
|
||||
* header structure; mask, card, Escape, and aria-label remain.
|
||||
@@ -25,7 +28,7 @@ import css from './Modal.module.css'
|
||||
* @returns null when closed; otherwise the overlay tree.
|
||||
*/
|
||||
export function Modal({
|
||||
open, onClose, title, closeLabel = 'Close', description, children, footer, className, headless = false,
|
||||
open, onClose, title, closeLabel = 'Close', description, children, footer, className, contentClassName, headless = false,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
@@ -35,6 +38,7 @@ export function Modal({
|
||||
children?: ReactNode
|
||||
footer?: ReactNode
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
headless?: boolean
|
||||
}) {
|
||||
useEffect(() => {
|
||||
@@ -48,7 +52,7 @@ export function Modal({
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
return createPortal((
|
||||
<div className={css.root} role="presentation">
|
||||
<div className={css.mask} aria-hidden="true" onClick={onClose} />
|
||||
<div
|
||||
@@ -61,7 +65,7 @@ export function Modal({
|
||||
? children
|
||||
: (
|
||||
<>
|
||||
<div className={css.content}>
|
||||
<div className={clsx(css.content, contentClassName)}>
|
||||
<div className={css.header}>
|
||||
<h2 className={css.title}>{title}</h2>
|
||||
<button type="button" className={css.close} aria-label={closeLabel} onClick={onClose}>
|
||||
@@ -78,5 +82,5 @@ export function Modal({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
), document.body)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
.confirmation {
|
||||
width: min(440px, 100%);
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.confirmationContent {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
.confirmation {
|
||||
max-height: calc(100dvh - 48px);
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.warning p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.warningIcon {
|
||||
flex: none;
|
||||
margin-top: 2px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.acknowledgement {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.acknowledgement input {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 3px 0 0;
|
||||
accent-color: var(--dsw-alias-button-primary-fill);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.acknowledgement input:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.acknowledgement input:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.modalAction {
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.confirmAction {
|
||||
min-width: 136px;
|
||||
}
|
||||
80
packages/client/ui-primitives/src/RiskConfirmation.tsx
Normal file
80
packages/client/ui-primitives/src/RiskConfirmation.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Controlled risk acknowledgement dialog shared by product surfaces that
|
||||
* must gate a sensitive action behind an explicit checkbox.
|
||||
*/
|
||||
import { Button } from './Button.tsx'
|
||||
import { IconWarningOutline16 } from './icons/index.tsx'
|
||||
import { Modal } from './Modal.tsx'
|
||||
import css from './RiskConfirmation.module.css'
|
||||
|
||||
export interface RiskConfirmationProps {
|
||||
open: boolean
|
||||
title: string
|
||||
description: string
|
||||
acknowledgeLabel: string
|
||||
cancelLabel: string
|
||||
confirmLabel: string
|
||||
acknowledged: boolean
|
||||
disabled?: boolean
|
||||
onAcknowledgedChange: (acknowledged: boolean) => void
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one in-page confirmation whose primary action is unavailable until
|
||||
* the caller-controlled acknowledgement is checked.
|
||||
*/
|
||||
export function RiskConfirmation({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
acknowledgeLabel,
|
||||
cancelLabel,
|
||||
confirmLabel,
|
||||
acknowledged,
|
||||
disabled = false,
|
||||
onAcknowledgedChange,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: RiskConfirmationProps) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onCancel}
|
||||
title={title}
|
||||
className={css.confirmation ?? ''}
|
||||
contentClassName={css.confirmationContent ?? ''}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" className={css.modalAction} onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={css.confirmAction}
|
||||
disabled={disabled || !acknowledged}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className={css.warning}>
|
||||
<IconWarningOutline16 size={18} className={css.warningIcon} />
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<label className={css.acknowledgement}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acknowledged}
|
||||
disabled={disabled}
|
||||
autoFocus
|
||||
onChange={(event) => { onAcknowledgedChange(event.currentTarget.checked) }}
|
||||
/>
|
||||
<span>{acknowledgeLabel}</span>
|
||||
</label>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,8 @@ 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 { RiskConfirmation } from './RiskConfirmation.tsx'
|
||||
export type { RiskConfirmationProps } from './RiskConfirmation.tsx'
|
||||
export { ConnectionBanner } from './ConnectionBanner.tsx'
|
||||
export { FishLogo } from './FishLogo.tsx'
|
||||
export { BrandWordmark } from './BrandWordmark.tsx'
|
||||
|
||||
@@ -324,12 +324,17 @@ describe('Modal', () => {
|
||||
<Modal open={false} onClose={onClose} title="Create new workspace">body</Modal>)
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
rerender(
|
||||
<Modal open onClose={onClose} title="Create new workspace" closeLabel="Configure later" description="Name it." footer={<button type="button">Create</button>}>
|
||||
<Modal open onClose={onClose} title="Create new workspace" closeLabel="Configure later" description="Name it." contentClassName="scrolling-content" footer={<button type="button">Create</button>}>
|
||||
<input aria-label="name" />
|
||||
</Modal>)
|
||||
expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeDefined()
|
||||
const dialog = screen.getByRole('dialog', { name: 'Create new workspace' })
|
||||
expect(dialog).toBeDefined()
|
||||
// The full-page layer escapes caller stacking contexts but remains in
|
||||
// this document/current WebUI window.
|
||||
expect(dialog.parentElement?.parentElement).toBe(document.body)
|
||||
expect(screen.getByRole('button', { name: 'Configure later' })).toBeDefined()
|
||||
expect(screen.getByText('Name it.')).toBeDefined()
|
||||
expect(screen.getByText('Name it.').parentElement?.className).toContain('scrolling-content')
|
||||
fireEvent.keyDown(document, { key: 'a' })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
|
||||
README.md: b9c8b849b3454fe46e1fc37713d9d3b9449734cf
|
||||
README.zh.md: 19ae5050a4c4f7dfe80de0ab58e772e9d26e3f6a
|
||||
README.zh.md: 6ddc32f2f27c93f8ccc80b3d9b31d56d3cf4dd94
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。runtime 的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -10,7 +10,7 @@ Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包(package)既不组装也不发送提供方请求。
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
|
||||
Reference in New Issue
Block a user