feat(schedule): add durable after reminders

This commit is contained in:
pku-xht
2026-08-05 19:00:02 +08:00
committed by Tianyi Cui
parent a229b42e24
commit f7e7851e3f
102 changed files with 2619 additions and 122 deletions

View File

@@ -23,6 +23,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
| [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces. |
| [`ui-conversation/`](ui-conversation/README.md) | Presents the active conversation and its input surface. |
| [`ui-tool/`](ui-tool/README.md) | Composes Tool call trees and keyed per-Tool views. |
| [`ui-schedule/`](ui-schedule/README.md) | Presents durable Schedule reminder receipts. |
| [`ui-goal/`](ui-goal/README.md) | Presents and manages the current goal. |
| [`ui-trajectory/`](ui-trajectory/README.md) | Presents alternate views of agent activity. |
| [`ui-command/`](ui-command/README.md) | Provides session-aware command discovery and dispatch. |

View File

@@ -23,6 +23,7 @@ dsh web GUI 的浏览器侧shell 启动、浏览器与宿主通信、共享 U
| [`ui-workspace/`](ui-workspace/README.md) | 提供 Workspace 选择与创建界面。 |
| [`ui-conversation/`](ui-conversation/README.md) | 展示当前会话及其输入界面。 |
| [`ui-tool/`](ui-tool/README.md) | 编排工具调用树和按工具键控的视图。 |
| [`ui-schedule/`](ui-schedule/README.md) | 展示持久的 Schedule 提醒回执。 |
| [`ui-goal/`](ui-goal/README.md) | 展示和管理当前目标。 |
| [`ui-trajectory/`](ui-trajectory/README.md) | 提供 agent智能体活动的其他视图。 |
| [`ui-command/`](ui-command/README.md) | 提供会话感知的命令发现与分发。 |

View File

@@ -12,6 +12,12 @@ The node half guards every entry under `/api` before bridging or upgrading (`src
`/api/events.mux` and `/api/events.host` each accept a WebSocket upgrade and send only the corresponding `ServerRequest` text messages to the browser; the client sends no application data over these sockets. If either socket ends, the current connection generation fails and rebuilds both streams; readiness still requires both sockets to be open and the `host.describe` HTTP call to succeed. Host teardown terminates both sockets, aborts their sources, and waits for source cleanup before returning. Ordinary network GETs to these paths return 426 with no SSE fallback; `toFetchHandler`'s SSE codec serves only the isomorphic in-process carrier.
`SessionEventView` is an optional non-persistent sidecar on both `session.history` entries and live `session/event` frames. Tool views keep their closed call/result shapes; a presented durable event instead carries `{ for: 'event', presentationKey, view }`, leaving the key space and JSON-compatible payload open to domain plugins. The same Session event may be delivered again with a new or changed sidecar, so consumers merge it by exact event identity and seq rather than treating the second frame as another log append.
## Keyless fixture
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. Fixture content search preserves the production-facing `unicode61`-style case, diacritic, and token-phrase behavior and returns a match-centered snippet of at most 120 Unicode code points.
## Model Experience
None, as the wire consumer layer moves already-composed messages between browser and host; nothing here reaches a model request.
@@ -23,3 +29,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **History resumes an unattached session** — opening history may create the host-side agent and add latency to the first open; there is no persistence-only read path.
- **Attached history may omit commit-aware event views** — when persistence inspection is unavailable, fails, or cannot prove an identity-matching prefix, the Host still serves raw live events and withholds only those sidecars. A later durable live redelivery or history read can add them.
- **Tool-specific view types remain transitional** — `ToolEventView`/`ToolCallView`/`ToolResultView` stay exported while the Host's tool `viewFor` presenter exists. The generic presented-event branch is independent and remains the domain-plugin extension shape.

View File

@@ -12,6 +12,12 @@ node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-r
`/api/events.mux``/api/events.host` 各接受一条 WebSocket upgrade并只向浏览器发送对应的 `ServerRequest` text message客户端不会在这些 socket 上发送业务数据。任一 socket 结束都会使当前 connection generation 失败并重建两条流,连接就绪仍要求两条 socket open 且 `host.describe` HTTP 调用成功。Host teardown 会终止两条 socket、中止各自的 source并等待 source 清理完成后再返回。普通网络 GET 这些路径会返回 426不保留 SSE 回退;`toFetchHandler` 的 SSE 编解码只服务进程内同构载体。
`SessionEventView``session.history` 条目与实时 `session/event` 帧上的可选、非持久 sidecar。工具 view 保持封闭的 callresult 形状;由 Host presentation 的持久事件则携带 `{ for: 'event', presentationKey, view }`,把 key 空间与兼容 JSON 的 payload 开放给领域插件。同一个 Session event 可以再次投递并带有新增或变化的 sidecar因此消费方会按完全一致的事件身份与 seq 合并,而不会把第二个帧当作另一次日志 append。
## 无密钥 fixture
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId均具有足够的确定性组装后的 Web 测试可以据此协调列表与帧的到达。fixture 内容搜索会保留面向生产环境的 `unicode61` 式大小写、变音符号和 token短语行为并返回以匹配位置为中心、最多包含 120 个 Unicode 码点的 snippet。
## 模型体验
无。协议消费层只在浏览器与主机之间搬运已经组合好的消息;这里没有任何内容进入模型请求。
@@ -23,3 +29,5 @@ node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-r
## 已知限制与暂缓事项
- **History 会恢复未附加的会话**:打开 history 可能创建宿主侧 agent并增加首次打开的延迟没有仅从持久化读取的路径。
- **已附加 history 可能省略 commit-aware event view**:当 persistence inspect 不可用、失败或无法证明 identity-matching prefix 时Host 仍会返回原始 live event只会省略这些 sidecar。之后的持久 live 重投或 history 读取仍可补上它们。
- **工具专属 view 类型仍是过渡表面**:只要 Host 的工具 `viewFor` presenter 仍存在,`ToolEventView``ToolCallView``ToolResultView` 就继续导出。通用 presented-event 分支与此独立,并保持为领域插件的扩展形状。

View File

@@ -7,7 +7,8 @@
export type {
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, PresentedEventView,
SessionEventView, ToolEventView,
DirectoryEntry, DirectoryListing,
WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,

View File

@@ -15,7 +15,8 @@ import type { ClientConnectionRpc } from '../rpc.ts'
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type {
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, PresentedEventView,
SessionEventView, ToolEventView,
DirectoryEntry, DirectoryListing,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,

View File

@@ -40,6 +40,8 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text, replaced-item count, and estimated shadowed-token count come from the checkpoint's cited `compact/summary` event; a window cut that left that event outside makes those fields unavailable, and a later page that supplies it resolves them. `CommandNode.outcome.sourceEventSeq` preserves a successful command's explicit reference to that summary event, allowing the presentation layer to pair `/compact` with its checkpoint without parsing settlement copy or assuming the two rows are adjacent. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity.
A Host may redeliver the same Session event seq with a new or changed non-persistent view after the event reaches its presentation commit point. `Session` first requires deep event identity, then upgrades only the sidecar; a generic event view becomes one `PresentedEventNode` keyed by its `presentationKey`. The existing `liveBuffer` is the sole rendezvous during tail loading, gap stitching, and `loadOlder`. One merge path upgrades overlaps, consumes covered entries, and attaches only a contiguous suffix on every current-generation settlement, including rejected, empty, and discontinuous page responses. Reconnect advances the generation and clears its loading ownership, so an older request's result or `finally` cannot mutate or block the rebuilt window.
## Request inspection
`SessionHistoryInspection.requests` is one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan.

View File

@@ -40,6 +40,8 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本、被替换条目数量和估算的被遮蔽 token 数量都来自检查点引用的 `compact/summary` 事件;窗口切分把该事件留在窗口外时这些字段不可用,后续包含该事件的分页会解析出它们。`CommandNode.outcome.sourceEventSeq` 保留成功命令对该摘要事件的显式引用,使呈现层能够配对 `/compact` 与其检查点,而无须解析结算文案或假定两行相邻。性能约定:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
一个 Session event 到达其 presentation 提交点后Host 可以用同一 seq 重新投递完全相同的事件,并携带新增或变化的非持久 view。`Session` 会先要求事件深度一致,再只升级 sidecar通用 event view 会按 `presentationKey` 形成一个 `PresentedEventNode`。既有 `liveBuffer` 是尾部加载、gap stitching 与 `loadOlder` 期间唯一的汇合点。每个当前 generation 的结算出口都使用同一条 merge 路径升级窗口重叠项、消费已覆盖项并只接入连续后缀RPC 拒绝、空页和不连续页同样如此。重连会推进 generation 并清除其 loading 所有权,因此旧请求的结果或 `finally` 既不能改写,也不能阻塞重建后的窗口。
## 请求检查
`SessionHistoryInspection.requests` 是一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn``step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。

View File

@@ -50,7 +50,7 @@ export type {
export type {
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
AssistantTiming, CommandNode, CompactionSummaryNode, ComposerPhase,
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, PresentedEventNode, QueuedMessage,
RunningToolCall,
SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'

View File

@@ -252,6 +252,23 @@ export interface CommandNode {
} | null
}
/**
* Host-computed presentation for one durable non-surface event. The generic
* runtime carries the keyed JSON-compatible payload without importing the
* producing domain; a client plugin owns the keyed renderer.
*/
export interface PresentedEventNode {
kind: 'presented-event'
/** Seq of the durable event whose sidecar produced this node. */
seq: number
/** Unix epoch ms from the source Session event. */
time: number
/** Open runtime key selecting an optional domain renderer. */
presentationKey: string
/** Domain-owned JSON-compatible presentation payload. */
view: unknown
}
/** Finalized conversation node union (kind discriminates; seq is the React key). */
export type ConversationNode =
| UserMessageNode
@@ -262,6 +279,7 @@ export type ConversationNode =
| TurnErrorNode
| ToolResultNode
| CommandNode
| PresentedEventNode
| CompactionSummaryNode
| UnknownSurfaceNode

View File

@@ -6,7 +6,7 @@ import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
HistoryEntry, IApiClient, MessageId, MuxFrame, QueueAction, RpcError,
RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView,
RpcId, RpcResponse, RpcResult, SessionEventView, SessionId, SubagentAddress,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
@@ -74,6 +74,30 @@ function queueTextOf(content: readonly ContentBlock[]): string | null {
return content.map(block => block.text).join('')
}
/** Browser-safe structural equality for JSON-compatible wire values. */
function sameWireValue(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true
if (left === null || right === null || typeof left !== 'object' || typeof right !== 'object') return false
if (Array.isArray(left) || Array.isArray(right)) {
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false
return left.every((value, index) => sameWireValue(value, right[index]))
}
const leftRecord = left as Record<string, unknown>
const rightRecord = right as Record<string, unknown>
const leftKeys = Object.keys(leftRecord).sort()
const rightKeys = Object.keys(rightRecord).sort()
return leftKeys.length === rightKeys.length
&& leftKeys.every((key, index) =>
key === rightKeys[index] && sameWireValue(leftRecord[key], rightRecord[key]))
}
/** Same-seq deliveries may add a sidecar, but must carry the identical durable event. */
function assertSameEvent(left: SessionEvent, right: SessionEvent): void {
if (!sameWireValue(left, right)) {
throw new Error(`session event identity mismatch at seq ${left.seq}`)
}
}
/**
* Owns a session's event window, derived conversation state, and observable
* snapshot. React bindings remain outside this data layer. Features see only
@@ -85,7 +109,7 @@ export class Session implements SessionFace {
private events: SessionEvent[] = []
/** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view).
* Kept parallel rather than merged so `events` stays the raw log slice (model-visible ⟺ logged). */
private views: (ToolEventView | undefined)[] = []
private views: (SessionEventView | undefined)[] = []
private baseSeq = 0
private hasMore = false
private openState: OpenState = 'cold'
@@ -147,7 +171,7 @@ export class Session implements SessionFace {
private promptError: PromptError | null = null
private lastAgentError: string | null = null
/** Live events buffered during open/resync and stitched by sequence once history lands. */
private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = []
private liveBuffer: { event: SessionEvent; view: SessionEventView | undefined }[] = []
/** Gap repair in flight; live events detour to the buffer until the tail page lands. */
private stitching = false
/** subscribed.lastSeq baseline (gap detection; null when no subscribed frame arrived — degrade to the liveBuffer dedup path). */
@@ -367,10 +391,12 @@ export class Session implements SessionFace {
/** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend (§D.2). */
async loadOlder(): Promise<void> {
if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return
const generation = this.openGeneration
this.loadingOlder = true
this.notifier.markDirty()
try {
const { result } = await this.history({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES })
if (generation !== this.openGeneration) return
if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded)
const older = result.value.events
if (older.length === 0) {
@@ -384,18 +410,30 @@ export class Session implements SessionFace {
this.hasMore = false
return
}
this.events = [...older.map(e => e.event), ...this.events]
this.views = [...older.map(e => e.view), ...this.views]
/* v8 ignore next -- the ?? arm needs older[0] undefined, but the empty-page branch above already returned. */
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
this.hasMore = result.value.hasMore
this.transcript.reset(this.events, this.views) // prepend forces a rebuild (the window grew at the head)
this.rebuildDerivedFromWindow()
this.installWindow([
...older,
...this.events.map((event, index): HistoryEntry => {
const view = this.views[index]
return view === undefined ? { event } : { event, view }
}),
], result.value.hasMore)
} catch (error) {
console.error('[web-runtime] loadOlder failed:', error)
if (generation === this.openGeneration) {
console.error('[web-runtime] loadOlder failed:', error)
}
} finally {
this.loadingOlder = false
this.notifier.markDirty()
if (generation === this.openGeneration) {
try {
const { hasGap } = this.mergeWindow()
// oxlint-disable-next-line typescript/no-unnecessary-condition -- resync can close the window while the page request is awaited.
if (hasGap && this.openState === 'open') void this.repairGap()
} catch (error) {
console.error('[web-runtime] loadOlder buffer merge failed:', error)
void this.resync()
}
this.loadingOlder = false
this.notifier.markDirty()
}
}
}
@@ -423,6 +461,8 @@ export class Session implements SessionFace {
this.pendingRev++
this.subscribedLastSeq = null
this.liveBuffer = []
this.loadingOlder = false
this.stitching = false
this.notifier.markDirty()
await this.open()
}
@@ -627,7 +667,9 @@ export class Session implements SessionFace {
if (generation !== this.openGeneration) return
if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
}
const { hasGap } = this.mergeWindow()
this.openState = 'open'
if (hasGap) void this.repairGap()
} catch (error) {
if (generation !== this.openGeneration) return
this.openState = 'error'
@@ -639,29 +681,132 @@ export class Session implements SessionFace {
}
}
/** Install the history window + stitch the liveBuffer (seq is the sole dedup key).
* Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here
* (doOpen flips it after install), so recursing would push every buffered event straight
* back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1).
* A carried projections block seeds the value store (higher seq wins, so a stale
* baseline cannot overwrite a newer push frame); the window events themselves are
* never folded — the host is the only computation site. */
/**
* Install one history window and settle every buffered overlap or safe
* contiguous suffix through {@link mergeWindow}. A carried projections
* block seeds the value store (higher seq wins, so a stale baseline cannot
* overwrite a newer push frame); the window events themselves are never
* folded — the host is the only computation site.
*/
private installWindow(entries: HistoryEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
this.events = entries.map(e => e.event)
this.views = entries.map(e => e.view)
this.baseSeq = this.events[0]?.seq ?? 0
this.mergeWindow(entries)
this.hasMore = hasMore
this.transcript.reset(this.events, this.views)
this.rebuildDerivedFromWindow()
if (projections !== undefined) this.projections.seed(projections)
const buffered = this.liveBuffer
this.liveBuffer = []
for (const item of buffered) this.appendLive(item.event, item.view)
this.notifier.markDirty()
}
/**
* Reconcile a history snapshot (when supplied), the current window, and
* buffered live deliveries by seq. Same-seq events must be identical;
* defined late sidecars upgrade but an absent sidecar never erases an
* existing one. Only the contiguous suffix joins the window, leaving a real
* gap buffered for the existing repair path.
* @param entries - replacement/prepended history window, or undefined to
* settle the current window after an RPC failure or empty page.
* @returns whether the visible window changed and whether a true gap remains.
*/
private mergeWindow(entries?: readonly HistoryEntry[]): { changed: boolean; hasGap: boolean } {
const current = new Map<number, { event: SessionEvent; view: SessionEventView | undefined }>()
for (let index = 0; index < this.events.length; index++) {
const event = this.events[index]
/* v8 ignore next -- dense-array guard: index stays within events.length. */
if (event !== undefined) current.set(event.seq, { event, view: this.views[index] })
}
const events: SessionEvent[] = []
const views: (SessionEventView | undefined)[] = []
if (entries === undefined) {
events.push(...this.events)
views.push(...this.views)
} else {
let previousSeq: number | undefined
for (const entry of entries) {
if (previousSeq !== undefined && entry.event.seq !== previousSeq + 1) {
throw new Error(`history window is not contiguous at seq ${entry.event.seq}`)
}
previousSeq = entry.event.seq
const retained = current.get(entry.event.seq)
if (retained !== undefined) assertSameEvent(retained.event, entry.event)
events.push(entry.event)
views.push(entry.view ?? retained?.view)
}
}
const buffered = new Map<number, { event: SessionEvent; view: SessionEventView | undefined }>()
for (const item of this.liveBuffer) {
const retained = buffered.get(item.event.seq)
if (retained !== undefined) {
assertSameEvent(retained.event, item.event)
if (item.view !== undefined) retained.view = item.view
} else {
buffered.set(item.event.seq, { ...item })
}
}
const bySeq = new Map<number, number>()
for (let index = 0; index < events.length; index++) {
const event = events[index]
/* v8 ignore next -- dense-array guard: index stays within events.length. */
if (event !== undefined) bySeq.set(event.seq, index)
}
const consumed = new Set<number>()
let viewChanged = false
const baseSeq = events[0]?.seq
const tailSeq = events.at(-1)?.seq
for (const [seq, item] of buffered) {
const index = bySeq.get(seq)
if (index !== undefined) {
const event = events[index]
/* v8 ignore next -- bySeq indexes the dense events array. */
if (event === undefined) continue
assertSameEvent(event, item.event)
if (item.view !== undefined && !sameWireValue(views[index], item.view)) {
views[index] = item.view
viewChanged = true
}
consumed.add(seq)
continue
}
// A replay older than the retained tail window is irrelevant to this
// page and cannot become a future suffix.
if (baseSeq !== undefined && seq < baseSeq) {
consumed.add(seq)
continue
}
if (tailSeq !== undefined && seq <= tailSeq) {
throw new Error(`history window is missing buffered seq ${seq}`)
}
}
const appended: SessionEvent[] = []
let expectedSeq = tailSeq === undefined ? 0 : tailSeq + 1
for (let item = buffered.get(expectedSeq); item !== undefined; item = buffered.get(++expectedSeq)) {
events.push(item.event)
views.push(item.view)
appended.push(item.event)
consumed.add(expectedSeq)
}
const remaining = [...buffered.entries()]
.filter(([seq]) => !consumed.has(seq))
.sort(([left], [right]) => left - right)
.map(([, item]) => item)
this.liveBuffer = remaining
const changed = entries !== undefined || viewChanged || appended.length > 0
if (changed) {
this.events = events
this.views = views
this.baseSeq = events[0]?.seq ?? 0
this.transcript.reset(events, views)
this.rebuildDerivedFromWindow()
for (const event of appended) this.handoffPendingSteering(event)
}
return { changed, hasGap: remaining.length > 0 }
}
/** Seq-guarded append shared by stitching and the open-state live path. */
private appendLive(event: SessionEvent, view?: ToolEventView): void {
private appendLive(event: SessionEvent, view?: SessionEventView): void {
const tailSeq = this.windowTailSeq()
if (tailSeq !== null && event.seq <= tailSeq) return // replay overlap, drop
this.events.push(event)
@@ -687,18 +832,34 @@ export class Session implements SessionFace {
* expected reconnect-window artifact, repaired by refetch). The window stays one contiguous
* raw range, which is what lets the transcript render every event between its ends and lets a
* compaction checkpoint find its cited summary event. */
private acceptLiveEvent(event: SessionEvent, view?: ToolEventView): void {
private acceptLiveEvent(event: SessionEvent, view?: SessionEventView): void {
if (this.openState === 'loading' || this.stitching) {
this.liveBuffer.push({ event, view })
return
}
if (this.openState !== 'open') return // cold/error: no window upkeep (history fully backfills on open)
const tailSeq = this.windowTailSeq()
if (tailSeq !== null && event.seq <= tailSeq) {
this.liveBuffer.push({ event, view })
try {
const { changed } = this.mergeWindow()
if (changed) this.notifier.markDirty()
} catch (error) {
console.error('[web-runtime] duplicate session event failed identity validation:', error)
void this.resync()
}
return
}
if (tailSeq !== null && event.seq > tailSeq + 1) {
this.liveBuffer.push({ event, view })
void this.repairGap()
return
}
if (tailSeq === null && event.seq !== 0) {
this.liveBuffer.push({ event, view })
void this.repairGap()
return
}
this.appendLive(event, view)
if (event.type === 'assistant/chunk') {
if (isVisibleAssistantChunk(event.data.chunk.type)) this.notifier.markFrameDirty()
@@ -717,20 +878,33 @@ export class Session implements SessionFace {
const generation = this.openGeneration
try {
const { result } = await this.history({ maxMessages: PAGE_MESSAGES })
// Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
if (result.ok && generation === this.openGeneration && this.openState === 'open') {
if (generation !== this.openGeneration || this.openState !== 'open') return
if (result.ok) {
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
} else {
this.mergeWindow()
}
} catch (error) {
console.error('[web-runtime] gap repair failed:', error)
if (generation === this.openGeneration) {
console.error('[web-runtime] gap repair failed:', error)
try {
this.mergeWindow()
} catch (mergeError) {
console.error('[web-runtime] gap repair buffer merge failed:', mergeError)
void this.resync()
}
}
} finally {
this.stitching = false
if (generation === this.openGeneration) {
this.stitching = false
this.notifier.markDirty()
}
}
}
/** Per-event side effects (right column of the §A.9 dispatch table):
* chunk/retry projection and openCalls add-remove. */
private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void {
private applyEventSideEffects(event: SessionEvent, view?: SessionEventView): void {
const eventType = event.type as string
if (eventType === 'llm/retry') {
const data = parseRetryEventData(event.data)

View File

@@ -19,7 +19,9 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
// `sessions: ISessions` (TS2717, the one-program-per-side rule in
// docs/development.md).
import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint'
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type {
PresentedEventView, SessionEventView, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts'
import { toAssistantBlocks } from './conversation.ts'
import { contextForm, contextProvenance } from './context-provenance.ts'
@@ -48,7 +50,7 @@ interface CallIndexEntry {
callView: ToolCallView | null
}
/** One event -> UI node (pure function; the ten-variant ConversationNode union). */
/** One ordinary surface event -> UI node. */
function materializeNode(
event: SessionEvent,
callIndex: ReadonlyMap<string, CallIndexEntry>,
@@ -117,6 +119,17 @@ function materializeNode(
}
}
/** One host-presented non-surface event -> generic keyed conversation node. */
function materializePresented(event: SessionEvent, sidecar: PresentedEventView): ConversationNode {
return {
kind: 'presented-event',
seq: event.seq,
time: event.time,
presentationKey: sidecar.presentationKey,
view: sidecar.view,
}
}
/**
* Whether an event is a landed compaction checkpoint — all three conditions,
* matching the terminal's `isCompactCheckpoint`: a `user/message`, carrying the
@@ -255,7 +268,7 @@ export class TranscriptAdapter {
* @param events - the new window contents (seq-ascending).
* @param views - per-event wire views aligned with `events` by index (undefined slots for view-less events).
*/
reset(events: readonly SessionEvent[], views?: readonly (ToolEventView | undefined)[]): void {
reset(events: readonly SessionEvent[], views?: readonly (SessionEventView | undefined)[]): void {
this.rev++
this.eventIndex = new Map()
this.callIdx = new Map()
@@ -277,8 +290,13 @@ export class TranscriptAdapter {
// Indexes first, then project: a tool/result materializes against the
// complete call index, and a checkpoint against the complete event index.
const projected: ConversationNode[] = []
for (const event of events) {
if (isTranscriptEvent(event)) projected.push(this.materialize(event, steeringSeqs.has(event.seq)))
for (let index = 0; index < events.length; index++) {
const event = events[index]
/* v8 ignore next -- dense-array guard: index stays within events.length. */
if (event === undefined) continue
const view = views?.[index]
if (view?.for === 'event') projected.push(materializePresented(event, view))
else if (isTranscriptEvent(event)) projected.push(this.materialize(event, steeringSeqs.has(event.seq)))
}
this.projected = projected
}
@@ -292,12 +310,17 @@ export class TranscriptAdapter {
* @param event - the live event (seq = window tail + 1).
* @param view - host-computed tool view paired with the event when it is a tool call/result; indexed for card rendering.
*/
append(event: SessionEvent, view?: ToolEventView): void {
append(event: SessionEvent, view?: SessionEventView): void {
this.eventIndex.set(event.seq, event)
this.indexCall(event, view)
const steering = this.steeringHistory.apply(event)
indexAssistantStepTiming(this.stepTimings, event)
if (this.indexCommand(event)) this.rev++
if (view?.for === 'event') {
this.projected = [...this.projected, materializePresented(event, view)]
this.rev++
return
}
if (!isTranscriptEvent(event)) return
this.projected = [...this.projected, this.materialize(event, steering)]
this.rev++
@@ -392,7 +415,7 @@ export class TranscriptAdapter {
return true
}
private indexCall(event: SessionEvent, view?: ToolEventView): void {
private indexCall(event: SessionEvent, view?: SessionEventView): void {
if (event.type === 'tool/result') {
if (view?.for === 'result') this.resultViews.set(event.seq, view.view)
return

View File

@@ -33,6 +33,23 @@ function histResponse(events: SessionEvent[], hasMore = false) {
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
}
function logRange(start: number, end: number, label = 'fixture/log'): SessionEvent[] {
return Array.from({ length: end - start }, (_value, offset) =>
at(start + offset, { type: label, data: { index: start + offset } }))
}
function reminderEvent(seq: number, id: string): SessionEvent {
return at(seq, { type: 'schedule/change', data: { version: 1, operation: 'dispatch', id } })
}
function reminderView(id: string, prompt = '检查日志') {
return {
for: 'event' as const,
presentationKey: 'schedule/reminder',
view: { id, prompt },
}
}
describe('open', () => {
it('installs the tail page: cold → loading → open with window and nodes in place', async () => {
const { api, session } = makeSession()
@@ -82,9 +99,9 @@ describe('open', () => {
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => gate.promise
const opening = session.open()
// Three live frames land mid-open; seq 15 overlaps the page tail (page covers 10..15).
const page = plainTurn(10, 0, '早', '安')
session.handleMuxEnvelope('r1' as never, { type: 'session/event', sessionId: SID, event: ev.turnStart(15, 1) })
// Three live frames land mid-open; seq 15 overlaps the page tail (page covers 10..15).
session.handleMuxEnvelope('r1' as never, { type: 'session/event', sessionId: SID, event: page[5]! })
session.handleMuxEnvelope('r2' as never, { type: 'session/event', sessionId: SID, event: ev.user(16, '插进来的') })
gate.resolve(ok({
events: entries(page) as never[],
@@ -98,6 +115,117 @@ describe('open', () => {
})
})
describe('late event views', () => {
it('upgrades an already-open raw event without duplicating it or letting an absent sidecar erase it', async () => {
const { api, session } = makeSession()
const event = reminderEvent(0, 'schedule-1')
api.onHistory = () => histResponse([event])
await session.open()
expect(session.getSnapshot().nodes).toEqual([])
session.handleMuxEnvelope('rv1' as never, {
type: 'session/event', sessionId: SID, event, view: reminderView('schedule-1'),
})
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', seq: 0, presentationKey: 'schedule/reminder',
view: { id: 'schedule-1', prompt: '检查日志' },
}])
session.handleMuxEnvelope('rv2' as never, {
type: 'session/event', sessionId: SID, event,
})
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', view: { prompt: '检查日志' },
}])
session.handleMuxEnvelope('rv3' as never, {
type: 'session/event', sessionId: SID, event, view: reminderView('schedule-1', '检查发布'),
})
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', view: { prompt: '检查发布' },
}])
})
it('merges a view delivered while the tail history is loading', async () => {
const { api, session } = makeSession()
const event = reminderEvent(0, 'schedule-loading')
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => gate.promise
const opening = session.open()
session.handleMuxEnvelope('rv' as never, {
type: 'session/event', sessionId: SID, event, view: reminderView('schedule-loading'),
})
gate.resolve(ok({ events: [{ event }] as never[], hasMore: false }))
await opening
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', seq: 0, view: { id: 'schedule-loading' },
}])
})
it('merges a late view buffered behind a gap repair snapshot', async () => {
const { api, session } = makeSession()
const first = logRange(0, 6)
api.onHistory = () => histResponse(first)
await session.open()
const due = reminderEvent(9, 'schedule-gap')
const full = [...first, ...logRange(6, 9), due]
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => gate.promise
session.handleMuxEnvelope('raw' as never, {
type: 'session/event', sessionId: SID, event: due,
})
session.handleMuxEnvelope('late' as never, {
type: 'session/event', sessionId: SID, event: due, view: reminderView('schedule-gap'),
})
gate.resolve(ok({ events: entries(full) as never[], hasMore: false }))
await vi.waitFor(() => {
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', seq: 9, view: { id: 'schedule-gap' },
}])
})
})
it.each(['success', 'rejection', 'empty', 'discontinuous'] as const)(
'settles a late overlap after loadOlder %s',
async (outcome) => {
const { api, session } = makeSession()
const newer = logRange(6, 12)
const target = newer[3]!
api.onHistory = () => histResponse(newer, true)
await session.open()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => gate.promise
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
const loading = session.loadOlder()
session.handleMuxEnvelope('late' as never, {
type: 'session/event', sessionId: SID, event: target,
view: reminderView(`schedule-${outcome}`),
})
if (outcome === 'success') {
gate.resolve(ok({ events: entries(logRange(0, 6)) as never[], hasMore: false }))
} else if (outcome === 'rejection') {
gate.resolve(err({ code: 'internal', message: 'page rejected', details: {} }))
} else if (outcome === 'empty') {
gate.resolve(ok({ events: [], hasMore: false }))
} else {
gate.resolve(ok({ events: entries(logRange(0, 2)) as never[], hasMore: true }))
}
await loading
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', seq: target.seq,
view: { id: `schedule-${outcome}` },
}])
} finally {
errorSpy.mockRestore()
}
},
)
})
describe('live event path', () => {
async function opened(events: SessionEvent[] = plainTurn(0, 0, 'a', 'b')) {
@@ -548,11 +676,12 @@ describe('live event path', () => {
})
it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => {
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5
const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]
const first = plainTurn(0, 0, 'a', 'b')
const { api, session } = await opened(first) // tail seq = 5
const repaired = [...first, ...plainTurn(6, 1, 'c', 'd')]
api.onHistory = () => histResponse(repaired)
// seq 9 with tail 5 → gap; the event detours to the buffer and one history refetch fires.
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.assistant(9, 1, 'd') })
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: repaired[9]! })
await vi.waitFor(() => {
expect(api.callsOf('session.history').length).toBe(2)
})
@@ -883,11 +1012,12 @@ describe('remaining branches', () => {
it('subscribed baseline past the window tail triggers the second stitch pull in doOpen', async () => {
const { api, session } = makeSession()
const full = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]
const first = plainTurn(0, 0, 'a', 'b')
const full = [...first, ...plainTurn(6, 1, 'c', 'd')]
let call = 0
api.onHistory = () => {
call++
return histResponse(call === 1 ? plainTurn(0, 0, 'a', 'b') : full)
return histResponse(call === 1 ? first : full)
}
// Baseline arrives before open: lastSeq 11 > first page tail 5 → doOpen repulls once.
session.handleMuxEnvelope('rs' as never, { type: 'session/subscribed', sessionId: SID, lastSeq: 11 })
@@ -1175,6 +1305,107 @@ describe('resync', () => {
expect(snapshot.nodes.map(n => n.seq)).toEqual([7, 9])
})
it('a stale loadOlder success and finally cannot mutate or clear a fresh-generation page request', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(logRange(6, 12), true)
await session.open()
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => stale.promise
const staleLoad = session.loadOlder()
api.onHistory = () => histResponse(logRange(12, 18), true)
await session.resync()
const fresh = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => fresh.promise
const freshLoad = session.loadOlder()
expect(session.getSnapshot()).toMatchObject({ loadingOlder: true, hasMore: true })
stale.resolve(ok({ events: entries(logRange(0, 6)) as never[], hasMore: false }))
await staleLoad
expect(session.getSnapshot()).toMatchObject({ loadingOlder: true, hasMore: true })
fresh.resolve(ok({ events: entries(logRange(6, 12)) as never[], hasMore: false }))
await freshLoad
expect(session.getSnapshot()).toMatchObject({ loadingOlder: false, hasMore: false })
})
it('a stale rejected or never-settled loadOlder cannot freeze the new generation', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(logRange(6, 12), true)
await session.open()
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => stale.promise
const staleLoad = session.loadOlder()
api.onHistory = () => histResponse(logRange(12, 18), false)
await session.resync()
expect(session.getSnapshot()).toMatchObject({ openState: 'open', loadingOlder: false })
stale.reject(new Error('old page connection closed'))
await staleLoad
expect(session.getSnapshot()).toMatchObject({ openState: 'open', loadingOlder: false })
const never = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
// Re-open a pageable generation and park a request that never settles.
api.onHistory = () => histResponse(logRange(18, 24), true)
await session.resync()
api.onHistory = () => never.promise
void session.loadOlder()
expect(session.getSnapshot().loadingOlder).toBe(true)
api.onHistory = () => histResponse(logRange(24, 30), false)
await session.resync()
expect(session.getSnapshot()).toMatchObject({ openState: 'open', loadingOlder: false })
})
it('stale gap success, rejection, and finally cannot clear a fresh repair owner', async () => {
const { api, session } = makeSession()
const initial = logRange(0, 6)
api.onHistory = () => histResponse(initial)
await session.open()
const staleRepair = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => staleRepair.promise
session.handleMuxEnvelope('old-gap' as never, {
type: 'session/event', sessionId: SID, event: reminderEvent(9, 'old-gap'),
})
const freshBase = logRange(10, 16)
api.onHistory = () => histResponse(freshBase)
await session.resync()
const freshRepair = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
let freshRepairCalls = 0
api.onHistory = () => {
freshRepairCalls++
return freshRepair.promise
}
const due = reminderEvent(18, 'fresh-gap')
session.handleMuxEnvelope('fresh-gap' as never, {
type: 'session/event', sessionId: SID, event: due, view: reminderView('fresh-gap'),
})
expect(freshRepairCalls).toBe(1)
staleRepair.reject(new Error('stale gap connection closed'))
await Promise.resolve()
await Promise.resolve()
const trailing = at(19, { type: 'fixture/log', data: { index: 19 } })
session.handleMuxEnvelope('fresh-trailing' as never, {
type: 'session/event', sessionId: SID, event: trailing,
})
expect(freshRepairCalls).toBe(1)
freshRepair.resolve(ok({
events: entries([...freshBase, ...logRange(16, 18), due, trailing]) as never[],
hasMore: false,
}))
await vi.waitFor(() => {
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', seq: 18, view: { id: 'fresh-gap' },
}])
})
})
})
describe('nested run_code sub-dispatches', () => {

View File

@@ -434,6 +434,34 @@ describe('TranscriptAdapter', () => {
})
})
it('materializes generic presented-event nodes on replay and live append', () => {
const replayed = at(0, { type: 'schedule/change', data: { operation: 'dispatch', id: 'schedule-1' } })
const live = at(1, { type: 'schedule/change', data: { operation: 'dispatch', id: 'schedule-2' } })
const adapter = new TranscriptAdapter()
adapter.reset([replayed], [{
for: 'event',
presentationKey: 'schedule/reminder',
view: { id: 'schedule-1', prompt: '检查日志' },
}])
adapter.append(live, {
for: 'event',
presentationKey: 'schedule/reminder',
view: { id: 'schedule-2', prompt: '检查发布' },
})
expect(adapter.nodes()).toEqual([
{
kind: 'presented-event', seq: 0, time: 1_700_000_000_000,
presentationKey: 'schedule/reminder',
view: { id: 'schedule-1', prompt: '检查日志' },
},
{
kind: 'presented-event', seq: 1, time: 1_700_000_000_001,
presentationKey: 'schedule/reminder',
view: { id: 'schedule-2', prompt: '检查发布' },
},
])
})
it('leaves callView null when the paired call fell outside the window (cross-page break)', () => {
const adapter = new TranscriptAdapter()
const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '孤儿' } }

View File

@@ -24,6 +24,8 @@ The chat view keeps Tool placement but delegates Tool presentation. It passes ea
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. An unretried terminal failure renders as a persistent inline status at its turn boundary, showing the display-safe durable message and optional error code without offering an action the Host cannot fulfill; AUTH copy never echoes provider-supplied credential fragments.
Host-presented durable events use the keyed `'conversation.chat.eventview'` seat alongside whole-Tool presentation. The React-free runtime turns a generic `{ presentationKey, view }` sidecar into a `PresentedEventNode`; Chat dispatches on that open key, and a domain UI plugin may register its own row without adding domain vocabulary here. When no registrant is loaded, `GenericEventCard` keeps the presentation key and JSON payload visible in an expandable disclosure rather than dropping the durable event.
`TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — 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 its own `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted). The dock adapter owns selection so the panel stays a pure function of its props. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. The `todo_write` Tool row belongs to [`ui-tool`](../ui-tool/README.md).
`QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `"<n> 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible ordinary-session row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; addressed subagents retain the rows as a read-only projection because their continuation transport does not expose queue mutation. If strict steer loses to a closed window, the original occurrence remains queued for normal delivery; if the driver already claimed it, normal delivery is already underway. Neither converged race displays a failure, while transport and unknown failures do.

View File

@@ -24,6 +24,8 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 会将所有审批或问题等待通过 `SessionSummary.pendingInteraction` 投影出来,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流问题ui-question与审批ApprovalPanel都经编辑器接管作答不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数key 缺席即隐藏 chipchip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用取消、Escape、关闭按钮与点击遮罩都不会提交命令。
由 Host presentation 的持久事件使用键控的 `'conversation.chat.eventview'` 座位,与整体 Tool presentation 并行。无 React 的 runtime 会把通用 `{ presentationKey, view }` sidecar 转为 `PresentedEventNode`Chat 按开放 key 分发,领域 UI 插件无需在本包增加领域词汇即可注册自己的行。没有 registrant 被加载时,`GenericEventCard` 会在可展开 disclosure 中保留可见的 presentation key 与 JSON payload而不会丢弃该持久事件。
`TodoDock``order: 0` 占用 `'conversation.input.dock'` 列表 slot位于 Goal 与 Queue 之前),作为计划条读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`。面板接收纯列表,列表为空时自我隐藏;列表非空时默认折叠,表头显示标题及以 `·` 连接的各状态计数(如 `1 已完成 · 2 进行中 · 1 待处理`省略零计数。dock adapter 拥有 selection因此面板保持为 props 的纯函数。输入区 composer 链隐藏的一切也会隐藏整个 dock。`todo_write` Tool 行属于 [`ui-tool`](../ui-tool/README.md)。
`QueueDock``order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded``aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering 操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。

View File

@@ -306,6 +306,7 @@ export function apply(ctx: Context): void {
locale: NS,
children: {
'conversation.chat.tool': { kind: 'single', scope: 'session' },
'conversation.chat.eventview': { kind: 'keyed', scope: 'session' },
'conversation.chat.commandview': { kind: 'keyed', scope: 'session' },
'conversation.chat.turnTail': { kind: 'chain', scope: 'session' },
},

View File

@@ -24,7 +24,7 @@ import {
memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
} from 'react'
import type {
CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode,
CommandNode, ConversationNode, ConversationSnapshot, PresentedEventNode, RunningToolCall, ToolCallBlock, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -33,6 +33,7 @@ import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, runningTurnS
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { CompactionCommandCard } from './CompactionCommandCard.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericEventCard } from './GenericEventCard.tsx'
import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx'
import { formatRunDuration } from './message-chrome.ts'
import { deriveTurnMetrics } from './turn-metrics.ts'
@@ -211,6 +212,24 @@ const CommandRow = memo(function CommandRow({ renderSlot, node, compaction, t }:
)
})
/** One Host-presented durable event: keyed dispatch on its open presentation
* key, with a visible JSON disclosure when no domain renderer is loaded. */
const EventRow = memo(function EventRow({ renderSlot, node, t }: {
renderSlot: RenderChatSlot
node: PresentedEventNode
t: ChatViewSlotProps['t']
}) {
const owner = useMemo(() => ({ node }), [node])
return (
<div className={css.callRow}>
{renderSlot('conversation.chat.eventview', owner, {
entryKey: node.presentationKey,
fallback: <GenericEventCard {...owner} t={t} />,
})}
</div>
)
})
/** Turn-level model activity label retained across first-token, tool, and streaming phases. */
function TurnStatus({ startTime, t }: {
/** The running turn's logged `turn/start` time; null falls back to mount
@@ -544,6 +563,9 @@ export function ChatView({
if (node.kind === 'command') {
return <CommandRow renderSlot={renderSlot} node={node} t={t} />
}
if (node.kind === 'presented-event') {
return <EventRow renderSlot={renderSlot} node={node} t={t} />
}
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null
return (

View File

@@ -0,0 +1,34 @@
// GenericEventCard: the visible fallback for a Host-presented durable event.
// A domain plugin may replace it through the keyed eventview slot; without
// one, the presentation key and JSON sidecar remain inspectable in the flow.
import { useMemo, useState } from 'react'
import { IconSparkle16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps, EventRowOwnerProps } from '../contract/slots.ts'
import { DisclosureRow } from './DisclosureRow.tsx'
import css from './ContextInjectionRow.module.css'
/** Card props: the event owner payload plus the render site's locale seat. */
export interface GenericEventCardProps extends EventRowOwnerProps {
t: ChatViewSlotProps['t']
}
/** Render an unregistered event presentation as a visible JSON disclosure. */
export function GenericEventCard({ node, t }: GenericEventCardProps) {
const [open, setOpen] = useState(false)
const body = useMemo(() => open ? JSON.stringify(node.view, null, 2) : '', [node.view, open])
return (
<DisclosureRow
className={css.root}
icon={<IconSparkle16 size={14} />}
chevronClassName={css.chevron}
title={t('message.presentedEvent', { key: node.presentationKey })}
open={open}
expandable
expandOnRowClick
onToggle={() => { setOpen(value => !value) }}
>
<pre className={css.body} data-presented-event-body>{body}</pre>
</DisclosureRow>
)
}

View File

@@ -3,7 +3,10 @@ import type { ReactNode, RefObject } from 'react'
import type {
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type {
CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction,
PendingWait, PresentedEventNode, SessionId, ToolCallBlock, WorkspaceId,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerBlock } from '../input/blocks.ts'
@@ -38,6 +41,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* {@link ToolTreeOwnerProps} for every root and child wrapper.
*/
'conversation.chat.tool': { kind: 'single'; scope: 'session'; owner: ToolTreeOwnerProps }
/**
* The chat view's per-event presentation hole: keyed dispatch on the
* Host-provided presentation key. The durable event remains in the
* runtime node; a feature plugin may replace the visible JSON fallback
* with a domain renderer without entering ui-conversation.
*/
'conversation.chat.eventview': { kind: 'keyed'; scope: 'session'; owner: EventRowOwnerProps }
/**
* The chat view's per-command row hole: keyed dispatch on the command
* name (`command/run.name`; a run-less cross-window node has none and
@@ -239,6 +249,15 @@ export interface DetailsToolOwnerProps {
cwd?: string | undefined
}
/** Owner share for one Host-presented durable event. */
export interface EventRowOwnerProps {
/** Generic runtime node carrying the durable event identity and keyed sidecar. */
node: PresentedEventNode
}
/** Full props of a registered event-presentation row component. */
export type EventRowProps = PropsRuntime<'conversation.chat.eventview'>
/**
* Owner share of the per-command row slot: the frozen {@link CommandNode}
* slice off the snapshot (cache-stable reference — memo premise). The node
@@ -553,9 +572,10 @@ export interface ChatViewInjected {
fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
}
/** Full chat-view component props: runtime & its Tool/command/tail render shares & store & injected & locale seat. */
/** Full chat-view component props: runtime plus Tool, event, command, and turn-tail render shares. */
export type ChatViewSlotProps =
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.tool' | 'conversation.chat.commandview' | 'conversation.chat.turnTail'>
PropsRuntime<'conversation.view'>
& PropsRenderSlots<'conversation.chat.tool' | 'conversation.chat.eventview' | 'conversation.chat.commandview' | 'conversation.chat.turnTail'>
& PropsStore<ChatStore> & ChatViewInjected & PropsLocale<'conversation'>
/**

View File

@@ -17,7 +17,7 @@ export type {
ComposerChainProps, ConversationInjected,
ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps,
ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps,
ToolTreeOwnerProps, TurnTailOwnerProps,
EventRowOwnerProps, EventRowProps, ToolTreeOwnerProps, TurnTailOwnerProps,
} from './contract/slots.ts'
// Export discipline: packages/client/AGENTS.md.

View File

@@ -68,6 +68,7 @@ export const zh = {
'chat.toBottom': '回到底部',
'message.extraBlock': '附加内容块',
'message.contextInjection': '上下文注入',
'message.presentedEvent': '事件:{key}',
'message.contextRecall': '跨会话召回',
'message.context.instructions.loaded': '已载入',
'message.context.instructions.added': '已新增',
@@ -211,6 +212,7 @@ export const en = {
'chat.toBottom': 'Back to bottom',
'message.extraBlock': 'Extra content block',
'message.contextInjection': 'Context injection',
'message.presentedEvent': 'Event: {key}',
'message.contextRecall': 'Session recall',
'message.context.instructions.loaded': 'loaded',
'message.context.instructions.added': 'added',

View File

@@ -53,7 +53,7 @@ describe('apply wiring', () => {
await b.runtime.dispose()
})
it('registers the chat view as the first ring entry, declaring the whole-Tool seat', async () => {
it('registers the chat view as the first ring entry with Tool and event seats', async () => {
const b = await bench()
const entries = b.slots.entries('conversation.view')
expect(entries.map(e => e.options.id)).toEqual(['chat'])
@@ -63,6 +63,7 @@ describe('apply wiring', () => {
// Declaring is claiming: the chat entry's registration put the hole on
// the ledger with the contract's kind/scope.
expect(b.slots.spec('conversation.chat.tool')).toEqual({ kind: 'single', scope: 'session' })
expect(b.slots.spec('conversation.chat.eventview')).toEqual({ kind: 'keyed', scope: 'session' })
await b.runtime.dispose()
})
@@ -110,6 +111,8 @@ describe('apply wiring', () => {
expect(b.slots.entries('conversation.view')).toHaveLength(0)
expect(b.slots.entries('conversation.chat.tool')).toHaveLength(0)
expect(b.slots.spec('conversation.chat.tool')).toBeUndefined()
expect(b.slots.entries('conversation.chat.eventview')).toHaveLength(0)
expect(b.slots.spec('conversation.chat.eventview')).toBeUndefined()
expect(b.slots.entries('details')).toHaveLength(0)
expect(b.slots.entries('settings.general.item')).toHaveLength(0)
expect(b.runtime.ctx.get('conversation')).toBeUndefined()

View File

@@ -8,7 +8,7 @@ import { Profiler } from 'react'
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
import type {
AssistantMessageNode, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot,
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode, TurnErrorNode,
ModelRetryNode, PresentedEventNode, RunningToolCall, SessionId, SessionListState, ToolResultNode, TurnErrorNode,
UserMessageNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
@@ -106,6 +106,13 @@ const compaction = (over: Partial<CompactionSummaryNode> = {}): CompactionSummar
shadowedTokenCount: 11_309,
...over,
})
const presentedEvent = (seq: number): PresentedEventNode => ({
kind: 'presented-event',
seq,
time: seq * 1_000,
presentationKey: 'schedule/reminder',
view: { prompt: 'check logs', scheduleId: 'schedule-1' },
})
/** Empty sessions-list hook for the global standard-kit seat. */
function emptySessions() {
@@ -953,6 +960,21 @@ describe('ChatView', () => {
expect(calls[0]?.entryKey).toBeUndefined()
})
it('dispatches presented events by key and keeps a visible JSON fallback', () => {
const node = presentedEvent(3)
const h = makeHarness({ nodes: [node] })
const calls: { key: string; entryKey?: string }[] = []
h.props.renderSlot = ((key: string, _owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
calls.push({ key, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) })
return opts?.fallback ?? null
})
const view = render(<h.ChatView {...h.props} />)
expect(calls).toEqual([{ key: 'conversation.chat.eventview', entryKey: 'schedule/reminder' }])
fireEvent.click(view.getByText('事件schedule/reminder'))
expect(view.getByText(/"prompt": "check logs"/)).toBeTruthy()
expect(view.getByText(/"scheduleId": "schedule-1"/)).toBeTruthy()
})
it('prepend preserves a semantic row; a trailing user node force-scrolls', () => {
const h = makeHarness({ nodes: [user(5, 'later'), assistant(6, 'a')], hasMore: true })
const view = render(<h.ChatView {...h.props} />)

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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-schedule/README.md
README.md: 7a37baf92c6e050e628d97d5926b08bae295137b
README.zh.md: 8acc33cc397f8e0ac4bee016007d635d0021c309

View File

@@ -0,0 +1,20 @@
# @deepseek-ai/dsh-client-ui-schedule
English | [中文](README.zh.md)
Browser-only renderer for durable Schedule reminder receipts. The plugin registers the `schedule/reminder` key in the conversation-owned `conversation.chat.eventview` slot. The generic runtime continues to carry the durable event identity and its Host-computed JSON sidecar; this package owns only the Schedule card.
The card displays the reminder prompt, Session-local Schedule ID, exact UTC occurrence, and the `session-local` delivery boundary. A malformed or incompatible sidecar remains visible as a contained unavailable receipt instead of crashing the conversation. Unloading the plugin removes only the keyed renderer; `ui-conversation` then shows its generic visible JSON fallback for the same durable event.
## Model Experience
None, as this browser-only renderer registers no model surface; Schedule tools and reminder framing belong to `@deepseek-ai/dsh-tool-schedule`.
#### KV Cache effect
None. The renderer consumes a browser-side presentation sidecar after the durable event is committed.
## Known Limitations and Deferred Work
- **Receipt-only UI** — creating, listing, and deleting reminders remains model-driven through the Schedule tools; this package does not add a management page.
- **Session-local delivery** — the card records a receipt in the original Session. It does not imply a system, browser, email, or other external notification.

View File

@@ -0,0 +1,20 @@
# @deepseek-ai/dsh-client-ui-schedule
[English](README.md) | 中文
用于渲染持久 Schedule 提醒回执的纯浏览器插件。插件在会话拥有的 `conversation.chat.eventview` slot 中注册 `schedule/reminder` key。通用 runtime 继续携带持久事件身份与 Host 计算的 JSON sidecar本包只拥有 Schedule 卡片。
卡片显示提醒原文、Session 内的 Schedule ID、精确 UTC 发生时刻,以及 `session-local` 交付边界。若 sidecar 损坏或版本不兼容,组件会显示受控的不可用回执,而不会让会话崩溃。卸载插件只会移除该键控 renderer`ui-conversation` 随后仍会为同一个持久事件显示通用且可见的 JSON fallback。
## 模型体验
无,因为这个纯浏览器 renderer 不注册模型 surfaceSchedule 工具与提醒 framing 由 `@deepseek-ai/dsh-tool-schedule` 拥有。
#### KV Cache 影响
无。renderer 只在持久事件提交后消费浏览器侧 presentation sidecar。
## 已知限制与暂缓事项
- **仅提供回执 UI**:创建、列出和删除提醒仍由模型通过 Schedule 工具完成;本包不增加管理页面。
- **仅在 Session 内交付**:卡片记录的是原 Session 中的回执,并不表示系统、浏览器、邮件或其他外部通知。

View File

@@ -0,0 +1,70 @@
{
"name": "@deepseek-ai/dsh-client-ui-schedule",
"description": "Web renderer for durable Schedule reminder receipts in the conversation flow",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"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-conversation": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@testing-library/react": "^16.1.0",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}

View File

@@ -0,0 +1,63 @@
.root {
display: grid;
min-width: 0;
gap: 8px;
padding: 12px 14px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 10px;
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-label-primary);
}
.header {
display: flex;
min-width: 0;
align-items: center;
gap: 7px;
}
.icon {
display: inline-flex;
flex: none;
color: var(--dsw-alias-brand-text);
}
.title {
min-width: 0;
flex: 1;
font: 600 13px/18px var(--ds-font-family);
}
.delivery {
flex: none;
color: var(--dsw-alias-label-tertiary);
font: 400 11px/16px var(--ds-font-family);
}
.prompt {
margin: 0;
color: var(--dsw-alias-label-primary);
font: 400 14px/21px var(--ds-font-family);
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.meta {
display: flex;
min-width: 0;
flex-wrap: wrap;
gap: 4px 12px;
color: var(--dsw-alias-label-tertiary);
font: 400 11px/16px var(--ds-font-family);
}
.id {
font-family: var(--ds-font-family-code);
overflow-wrap: anywhere;
}
.invalid {
margin: 0;
color: var(--dsw-alias-label-secondary);
font: 400 13px/18px var(--ds-font-family);
}

View File

@@ -0,0 +1,61 @@
import { IconSparkle16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { EventRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import css from './ReminderRow.module.css'
interface ReminderPresentation {
scheduleId: string
prompt: string
occurrenceAt: string
deliveryMode: 'session-local'
}
/** Full Schedule row props: event owner/runtime share plus the locale seat. */
export type ReminderRowProps = EventRowProps & PropsLocale<'schedule'>
/** Narrow the domain-owned JSON sidecar without trusting its unknown carrier type. */
function reminderPresentation(value: unknown): ReminderPresentation | null {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null
const record = value as Record<string, unknown>
if (typeof record['scheduleId'] !== 'string' || record['scheduleId'].length === 0) return null
if (typeof record['prompt'] !== 'string') return null
if (typeof record['occurrenceAt'] !== 'string' || record['occurrenceAt'].length === 0) return null
if (record['deliveryMode'] !== 'session-local') return null
return {
scheduleId: record['scheduleId'],
prompt: record['prompt'],
occurrenceAt: record['occurrenceAt'],
deliveryMode: record['deliveryMode'],
}
}
/**
* Render one durable reminder dispatch carried by the generic event sidecar.
* @param props - Keyed event owner payload and the Schedule translator.
* @returns A visible reminder receipt, or a contained invalid-payload row.
*/
export function ReminderRow({ node, t }: ReminderRowProps) {
const reminder = reminderPresentation(node.view)
return (
<section className={css.root} role="note" data-schedule-reminder>
<header className={css.header}>
<span className={css.icon} aria-hidden><IconSparkle16 size={14} /></span>
<span className={css.title}>{t('reminder.title')}</span>
{reminder !== null && <span className={css.delivery}>{t('reminder.delivery')}</span>}
</header>
{reminder === null
? <p className={css.invalid}>{t('reminder.invalid')} · {node.presentationKey}</p>
: (
<>
<p className={css.prompt}>{reminder.prompt}</p>
<footer className={css.meta}>
<span className={css.id}>{t('reminder.id', { id: reminder.scheduleId })}</span>
<time dateTime={reminder.occurrenceAt}>
{t('reminder.occurrence', { time: reminder.occurrenceAt })}
</time>
</footer>
</>
)}
</section>
)
}

View File

@@ -0,0 +1,38 @@
/** Register the Schedule durable-reminder renderer into the conversation event slot. */
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { ReminderRow } from './ReminderRow.tsx'
import { en, NS, zh, type ScheduleKey } from './locales.ts'
export type { ReminderRowProps } from './ReminderRow.tsx'
export type { ScheduleKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** Copy for durable Schedule reminder receipts. */
schedule: ScheduleKey
}
}
/**
* `conversation` is an ordering edge: its service is published after the chat
* entry has declared `conversation.chat.eventview`.
*/
export const inject = ['slots', 'conversation', 'locale']
/**
* Register bilingual copy and the Schedule reminder keyed row.
* @param ctx - Client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-schedule: dictionaries')
ctx.effect(
() => ctx.slots.register({
name: 'conversation.chat.eventview',
key: 'schedule/reminder',
locale: NS,
}, ReminderRow),
'ui-schedule: reminder row registration',
)
}

View File

@@ -0,0 +1,25 @@
/** `schedule` namespace dictionaries. */
/** Dictionary namespace owned by this plugin. */
export const NS = 'schedule'
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'reminder.title': '定时提醒',
'reminder.delivery': '仅在当前会话中交付',
'reminder.invalid': '提醒回执不可用',
'reminder.id': '编号 {id}',
'reminder.occurrence': '触发时间 {time}',
} satisfies Record<string, string>
/** The Schedule namespace key union. */
export type ScheduleKey = keyof typeof zh
/** English dictionary, checked complete against the Chinese key set. */
export const en = {
'reminder.title': 'Scheduled reminder',
'reminder.delivery': 'Delivered in this session only',
'reminder.invalid': 'Reminder receipt unavailable',
'reminder.id': 'ID {id}',
'reminder.occurrence': 'Due at {time}',
} satisfies Record<ScheduleKey, string>

View File

@@ -0,0 +1,4 @@
declare module '*.module.css' {
const classes: Readonly<Record<string, string>>
export default classes
}

View File

@@ -0,0 +1,4 @@
/** Host loader entry for the browser-only Schedule receipt renderer. */
/** Provides no host-side behavior. */
export function apply(): void {}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-schedule`.
* @module @deepseek-ai/dsh-client-ui-schedule/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-schedule'
/** Cordis companion plugin name. */
export const name = 'client-ui-schedule-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the keyed slot registry owns contribution lifecycle,
* and the component has no state outside its immutable owner payload.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns The installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,75 @@
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '../src/client/index.ts'
import { ReminderRow } from '../src/client/ReminderRow.tsx'
import { apply as nodeApply } from '../src/index.ts'
import {
apply as invariantApply,
inject as invariantInject,
name as invariantName,
} from '../src/invariant.ts'
interface CapturedEntry {
name: string
key?: string
locale?: string
component: unknown
}
function bench() {
const ctx = new Context()
let entry: CapturedEntry | undefined
ctx.provide('slots', {
register(options: Omit<CapturedEntry, 'component'>, component: unknown) {
entry = { ...options, component }
return () => { entry = undefined }
},
})
ctx.provide('conversation', {})
ctx.provide('locale', new LocaleService(ctx))
const fiber = ctx.plugin({ inject: [...inject], apply })
return { ctx, fiber, entry: () => entry }
}
describe('ui-schedule browser plugin', () => {
it('registers the keyed reminder renderer and unloads it with the fiber', async () => {
const b = bench()
await b.fiber.await()
expect(b.entry()).toEqual({
name: 'conversation.chat.eventview',
key: 'schedule/reminder',
locale: 'schedule',
component: ReminderRow,
})
await b.fiber.dispose()
expect(b.entry()).toBeUndefined()
})
})
describe('ui-schedule node and invariant companions', () => {
it('keeps the node half inert', () => {
expect(() => { nodeApply() }).not.toThrow()
})
it('registers exact package ownership and returns its disposer', async () => {
const ctx = new Context()
let owner: string | undefined
let disposed = false
ctx.provide('invariants', {
register(packageName: string, install: unknown) {
expect(install).toBeTypeOf('function')
owner = packageName
return () => { disposed = true }
},
})
expect(invariantName).toBe('client-ui-schedule-invariant')
expect(invariantInject).toEqual(['invariants'])
const dispose = await invariantApply(ctx)
expect(owner).toBe('@deepseek-ai/dsh-client-ui-schedule')
dispose()
expect(disposed).toBe(true)
})
})

View File

@@ -0,0 +1,55 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import type { PresentedEventNode } from '@deepseek-ai/dsh-client-runtime/client'
import { ReminderRow, type ReminderRowProps } from '../src/client/ReminderRow.tsx'
import { zh } from '../src/client/locales.ts'
const t: ReminderRowProps['t'] = makeTranslate(zh)
afterEach(cleanup)
function props(view: unknown): ReminderRowProps {
const node: PresentedEventNode = {
kind: 'presented-event',
seq: 4,
time: Date.parse('2026-08-05T08:00:00.000Z'),
presentationKey: 'schedule/reminder',
view,
}
return { node, t } as ReminderRowProps
}
describe('ReminderRow', () => {
it('shows the durable reminder payload and its session-local boundary', () => {
render(<ReminderRow {...props({
scheduleId: 'schedule-7',
prompt: 'Check the deploy',
occurrenceAt: '2026-08-05T08:00:00.000Z',
deliveryMode: 'session-local',
})} />)
expect(screen.getByRole('note')).toBeTruthy()
expect(screen.getByText('定时提醒')).toBeTruthy()
expect(screen.getByText('仅在当前会话中交付')).toBeTruthy()
expect(screen.getByText('Check the deploy')).toBeTruthy()
expect(screen.getByText('编号 schedule-7')).toBeTruthy()
const time = screen.getByText('触发时间 2026-08-05T08:00:00.000Z')
expect(time.getAttribute('datetime')).toBe('2026-08-05T08:00:00.000Z')
})
it('contains an incompatible sidecar as a visible unavailable receipt', () => {
render(<ReminderRow {...props({
scheduleId: '',
prompt: 'not trusted',
occurrenceAt: 123,
deliveryMode: 'external',
})} />)
expect(screen.getByText('提醒回执不可用 · schedule/reminder')).toBeTruthy()
expect(screen.queryByText('not trusted')).toBeNull()
expect(screen.queryByText('仅在当前会话中交付')).toBeNull()
})
})

View File

@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../locale"
},
{
"path": "../runtime"
},
{
"path": "../ui-conversation"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slots"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-schedule', ['lib/types/index.js', 'lib/types/invariant.js'])