feat(schedule): add durable after reminders
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 缺席即隐藏 chip);chip 打开 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 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'>
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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} />)
|
||||
|
||||
Reference in New Issue
Block a user