Merge master into feature/workspace-picker-composer

This commit is contained in:
NI0317
2026-08-11 10:32:28 +08:00
2096 changed files with 28409 additions and 15503 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 323c968b3333c91e106a2962838447a6cdd4c6a6
README.zh.md: 03865e25be62911e14d996ad6a28bae02fde9bdd
README.md: 605bba15d704c0c6e9f28abb3cddeb68bdd7e0d8
README.zh.md: e6a2dd0b545b66ab01b213b5ebc937e22af8ac1a

View File

@@ -32,7 +32,7 @@ The chat flow projects consecutive model-retry nodes across retry turns into one
The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; non-user next-step items (injected context) carry the `context` placement instead and render nowhere until claimed. Fork is absent here as on every user-style bubble. The Host delays steering retirement until the durable `user/message` carrying the steering has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the clock from the durable node — a steering bubble, like a user bubble, carries no branch action ([decision](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md)) — and survives reconnect from the same authority.
Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. With an empty draft, Cmd/Ctrl+Enter instead steers every still-pending queued message into the running turn in FIFO order (the dock's per-row strict-steer action applied to the whole queue); plain Enter with an empty draft remains a no-op. While this whole-queue gesture is available, the textarea placeholder advertises it; a placeholder supplied by the owning surface still takes precedence. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction.
Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the Host-backed `ui-conversation.busyEnter` General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; the local settings provider stores it in `$DSH_HOME/settings.yaml`, so the choice follows the same user home across Web ports. Shift+Enter remains a newline. With an empty draft, Cmd/Ctrl+Enter instead steers every still-pending queued message into the running turn in FIFO order (the dock's per-row strict-steer action applied to the whole queue); plain Enter with an empty draft remains a no-op. While this whole-queue gesture is available, the textarea placeholder advertises it; a placeholder supplied by the owning surface still takes precedence. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary.
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.

View File

@@ -32,7 +32,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。与所有用户样式气泡一样,这里不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与时钟——steering 气泡与 user 气泡一样不带分支操作([决策](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md))——并能在重连后从同一权威恢复。
键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。草稿为空时,Cmd/Ctrl+Enter 改为按 FIFO 顺序把仍在排队的消息全部插话进运行中的轮次(把 dock 的逐条严格 steer 操作应用于整个队列);空草稿 + 普通 Enter 仍是无操作。这个整队列手势可用时,文本框 placeholder 会提示该手势;owner 提供的 placeholder 仍然优先。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。
键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,由 Host settings 支撑的 `ui-conversation.busyEnter` General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;本地 settings 提供方将其存入 `$DSH_HOME/settings.yaml`,因此该选择会跟随同一个用户 home 跨越 Web 端口。Shift+Enter 仍然换行。草稿为空时,Cmd/Ctrl+Enter 改为按 FIFO 顺序把仍在排队的消息全部插话进运行中的轮次(把 dock 的逐条严格 steer 操作应用于整个队列);空草稿 + 普通 Enter 仍是无操作。这个整队列手势可用时,文本框 placeholder 会提示该手势;owner 提供的 placeholder 仍然优先。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 约定:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。

View File

@@ -1,8 +1,15 @@
{
"name": "@deepseek-ai/dsh-client-ui-conversation",
"description": "Conversation domain: skeleton, ordered chat flow, composer, and details host",
"version": "0.0.1",
"private": true,
"description": "Conversation domain: skeleton, ordered chat flow, composer with the Host-backed busy-Enter preference, and details host",
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/ui-conversation"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
@@ -22,13 +29,16 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-layout"
],
"platform": "web"
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-layout"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",
@@ -36,30 +46,34 @@
},
"license": "BSD-3-Clause",
"dependencies": {
"clsx": "^2.0.0"
"@deepseek-ai/dsh-settings": "workspace:^",
"clsx": "^2.0.0",
"@deepseek-ai/schemastery": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-attachment": "^0.0.1",
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-compact": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-token-meter": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
@@ -79,7 +93,7 @@
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"files": [

View File

@@ -1,7 +1,9 @@
/** Registers the conversation components, shared store, and service callbacks. */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import { resolveWorkspacePath, type ISessions, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import {
bindSettingsScope, resolveWorkspacePath, type ISessions, type SessionId,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
@@ -33,6 +35,7 @@ import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
import { en, NS, zh, type ConversationKey } from './locales.ts'
import { registerConversationNodes } from './conversation-nodes/register.ts'
import { registerChatNodeRenderers } from './chat/register-node-renderers.ts'
import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
@@ -43,7 +46,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Services required by the conversation plugin. */
export const inject = [
'slots', 'layout', 'sessions', 'workspaces', 'locale',
'slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection',
'conversationEvents', 'conversationViews',
]
@@ -124,7 +127,9 @@ export function apply(ctx: Context): void {
// Apply-time construction keeps store identity bound to this fiber.
const chatStore = createChatStore()
const submissionPolicy = new ComposerSubmissionPolicy()
const submissionPolicy = new ComposerSubmissionPolicy(
bindSettingsScope<ConversationSettings>(ctx, { namespace: CONVERSATION_SETTINGS_NAMESPACE }),
)
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
name: 'settings.general.item',

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import { NS } from '../locales.ts'
import { AssistantNodeView } from './AssistantNodeView.tsx'
import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx'

View File

@@ -1,10 +1,11 @@
/** Composer submission vocabulary shared by the input and settings domains. */
/** Delivery mode requested for one ordinary composer message. */
export type InputSubmitMode = 'queue' | 'steer'
import type { BusyEnterBehavior } from '../../submission-settings.ts'
/** Configurable meaning of plain Enter while the addressed agent is busy. */
export type BusyEnterBehavior = InputSubmitMode
export type { BusyEnterBehavior } from '../../submission-settings.ts'
/** Delivery mode requested for one ordinary composer message. */
export type InputSubmitMode = BusyEnterBehavior
/** Keyboard gesture whose delivery mode the submission policy resolves. */
export type ComposerSubmitGesture = 'enter' | 'accelerated'

View File

@@ -38,7 +38,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
'conversation.session': { kind: 'single'; scope: 'session' }
/** Strict-session header above the resident conversation scrollport. */
'conversation.session.header': { kind: 'single'; scope: 'session' }
/** Session-header actions contributed by feature plugins. */
/**
* Session-header actions contributed by feature plugins. Entries render
* by ascending `order`; negative values are reserved for static session
* context that precedes interactive actions.
*/
'conversation.session.header.actions': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps }
/**
* The conversation view ring: one list entry per view tab (chat here;
@@ -225,7 +229,7 @@ export interface ChatFileMentions {
forClosing(owner: TurnTailOwnerProps): MarkdownFileMentions | undefined
}
declare module 'cordis' {
declare module '@deepseek-ai/cordis' {
interface Context {
/** Prose file-mention provider (ui-deliverables); reach via ctx.get — optional. */
chatFileMentions: ChatFileMentions

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
AssistantBlock, AssistantMessageNode, ConversationLocation, ConversationMatch,
ConversationNodeContext, ConversationNodeDefinition,
@@ -242,6 +242,7 @@ function projectAssistant(context: ConversationNodeContext<AssistantState>): Ass
/** Per-step Assistant streaming/final/interruption Definition. */
export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
kind: 'assistant-step',
target: 'chat',
match: (event) => {
if (event.type === 'step/start') return { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
if (event.type === 'assistant/chunk'
@@ -291,8 +292,7 @@ export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
value: projected.data,
}
},
buildViewNode: (context, target) => {
if (target !== 'chat') return null
buildViewNode: (context) => {
const projected = projectAssistant(context)
if (projected === undefined) return null
if (projected.settled === undefined && !projected.visible) {

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
ChatConversationViewNode, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot,
ConversationLocation, ConversationNode, ConversationTimelineSnapshot,

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
CommandNode, CompactionSummaryNode, ConversationMatch, ConversationNodeContext,
ConversationNodeDefinition,
@@ -175,6 +175,7 @@ export function updateCompactionState<State extends CompactionEvidence>(
/** Slash-command lifecycle, including integrated manual compaction, Definition. */
export const commandDefinition: ConversationNodeDefinition<CommandState> = {
kind: 'command',
target: 'chat',
match: (event) => {
if (event.type === 'command/run') {
return { id: String(event.data.commandId), role: 'start' }
@@ -202,8 +203,7 @@ export const commandDefinition: ConversationNodeDefinition<CommandState> = {
}
return updateCompactionState(context.state, match)
},
buildViewNode: (context, target) => {
if (target !== 'chat') return null
buildViewNode: (context) => {
const state = context.state ?? fallbackState(context)
if (state === undefined) return null
if (state.command.name !== 'compact') {

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -30,6 +30,7 @@ function fallbackState(context: ConversationNodeContext<CompactionState>): Compa
/** Automatic compaction lifecycle and landed checkpoint Definition. */
export const compactionDefinition: ConversationNodeDefinition<CompactionState> = {
kind: 'compaction',
target: 'chat',
match: (event) => {
const checkpoint = compactSource(event)
if (checkpoint !== undefined && checkpoint.sourceCommandId === undefined) {
@@ -47,8 +48,7 @@ export const compactionDefinition: ConversationNodeDefinition<CompactionState> =
},
start: () => ({}),
update: (context, match) => updateCompactionState(context.state, match),
buildViewNode: (context, target) => {
if (target !== 'chat') return null
buildViewNode: (context) => {
const state = context.state ?? fallbackState(context)
if (state.checkpoint === undefined) return null
const marker = compactSummary(state.summary, state.checkpoint)

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
ConversationNodeDefinition, UnknownSurfaceNode,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -15,6 +15,7 @@ declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
/** Unclaimed append-surface fallback Definition. */
export const unknownFallbackDefinition: ConversationNodeDefinition<UnknownSurfaceNode> = {
kind: 'unknown-surface',
target: 'chat',
match: event => isAppendSurfaceEvent(event)
? { id: String(event.seq), role: 'start' }
: null,
@@ -26,7 +27,7 @@ export const unknownFallbackDefinition: ConversationNodeDefinition<UnknownSurfac
data: match.event.data,
}),
update: context => context.state,
buildViewNode: (context, target) => target !== 'chat' || context.state === undefined
buildViewNode: context => context.state === undefined
? null
: chatNode(context, 'unknown', context.state.seq, context.state),
}

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
ConversationNodeDefinition, ConversationPreviousContext,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -50,7 +50,6 @@ function inboxDefinition(target: InboxTarget): ConversationNodeDefinition<InboxS
},
update: context => context.state,
publication: () => 'none',
buildViewNode: () => null,
}
}

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
ContextMessageNode, ConversationNodeDefinition, SteeringMessageNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -30,6 +30,7 @@ function isCompactionCheckpoint(event: Parameters<ConversationNodeDefinition['ma
/** User, steering, and injected-context message classification Definition. */
export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
kind: 'input-message',
target: 'chat',
match: event => event.type === 'user/message'
&& isAppendSurfaceEvent(event)
&& !isCompactionCheckpoint(event)
@@ -68,8 +69,8 @@ export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
}
},
update: context => context.state,
buildViewNode: (context, target) => {
if (target !== 'chat' || context.state === undefined) return null
buildViewNode: (context) => {
if (context.state === undefined) return null
return chatNode(context, context.state.kind, context.state.seq, context.state)
},
}

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import { registerAssistantConversationNode } from './assistant.ts'
import { registerChatConversationView } from './chat-snapshot-builder.ts'
import { registerCommandConversationNode } from './command.ts'

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
ConversationLocation, ConversationNodeDefinition, ModelRetryNode,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -40,6 +40,7 @@ function isClosed(location: ConversationLocation): boolean {
/** Producer-correlated model retry chain Definition. */
export const retryDefinition: ConversationNodeDefinition<RetryState> = {
kind: 'model-retry',
target: 'chat',
match: (event) => {
if (event.type === 'llm/retry') {
const retryId: unknown = event.data.retryId
@@ -70,8 +71,8 @@ export const retryDefinition: ConversationNodeDefinition<RetryState> = {
attempt.retry === retry ? { ...attempt, retryState: 'started' } : attempt),
}
},
buildViewNode: (context, target) => {
if (target !== 'chat' || context.state === undefined || context.state.attempts.length === 0) return null
buildViewNode: (context) => {
if (context.state === undefined || context.state.attempts.length === 0) return null
const location = context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' as const }
const stateAttempts = context.state.attempts
const attempts = stateAttempts.map((attempt, index) =>

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
RunningToolCall, ToolCallBlock, ToolResultNode,
@@ -235,6 +235,7 @@ function fallbackState(context: ConversationNodeContext<ToolState>): ToolState |
/** Root Tool lifecycle and nested Code Dispatch Definition. */
export const toolDefinition: ConversationNodeDefinition<ToolState> = {
kind: 'tool-call',
target: 'chat',
match: (event) => {
if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' }
if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) {
@@ -257,8 +258,7 @@ export const toolDefinition: ConversationNodeDefinition<ToolState> = {
}
return updateDispatch(context.state, match)
},
buildViewNode: (context, target) => {
if (target !== 'chat') return null
buildViewNode: (context) => {
const state = context.state ?? fallbackState(context)
if (state === undefined) return null
const projected = projectBlock(state.root, state, interruption(context))

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnErrorNode,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -63,6 +63,7 @@ function fallbackState(context: ConversationNodeContext<TurnErrorState>): TurnEr
/** Terminal turn failure Definition, suppressed when the turn owns a retry chain. */
export const turnErrorDefinition: ConversationNodeDefinition<TurnErrorState> = {
kind: 'turn-error',
target: 'chat',
match: (event) => {
if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' }
if (event.type === 'turn/end' && event.data.reason.kind === 'error') {
@@ -82,8 +83,7 @@ export const turnErrorDefinition: ConversationNodeDefinition<TurnErrorState> = {
? { ...context.state, hidden: true }
: context.state
},
buildViewNode: (context, target) => {
if (target !== 'chat') return null
buildViewNode: (context) => {
const state = context.state ?? fallbackState(context)
if (state?.failure === undefined) return null
const failure = state.failure

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnLocation,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -151,6 +151,7 @@ function tailData(context: ConversationNodeContext<TurnTailState>): TurnTailChat
/** Completed-turn footer Definition independent of any Assistant row. */
export const turnTailDefinition: ConversationNodeDefinition<TurnTailState> = {
kind: 'turn-tail',
target: 'chat',
match: (event) => {
if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' }
if (event.type === 'turn/end') return { id: String(event.data.turn), role: 'update' }
@@ -179,8 +180,7 @@ export const turnTailDefinition: ConversationNodeDefinition<TurnTailState> = {
value,
}
},
buildViewNode: (context, target) => {
if (target !== 'chat') return null
buildViewNode: (context) => {
const turn = turnLocation(context)
const data = turn?.data.get('turn-tail')
return data === undefined ? null : chatNode(context, 'turn-tail', closingAnchor(context), data)

View File

@@ -36,7 +36,7 @@ export type {
} from './contract/slots.ts'
// Export discipline: packages/client/AGENTS.md.
declare module 'cordis' {
declare module '@deepseek-ai/cordis' {
interface Context {
/** The outward face only; the concrete service stays inside this plugin. */
conversation: import('./service.ts').IConversation

View File

@@ -1,27 +1,42 @@
/**
* Browser-local Composer submission policy. It owns the persisted busy-Enter
* Composer submission policy. It owns the live busy-Enter
* preference and resolves keyboard gestures into queue/steer delivery modes;
* Host and Agent keep the actual delivery-window authority.
*/
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import {
createSnapshotStore, type SettingsScope, type SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {
BusyEnterBehavior, ComposerSubmitGesture, InputSubmitMode,
} from '../contract/composer-submission.ts'
import { BUSY_ENTER_FIELD, DEFAULT_BUSY_ENTER_BEHAVIOR } from '../../submission-settings.ts'
import type { ConversationSettings } from '../../submission-settings.ts'
/** localStorage key holding the busy-Enter preference. */
export const BUSY_ENTER_STORAGE_KEY = 'dsh.conversation.busyEnter'
/** Default preserves Enter-as-Queue for running conversations. */
export const DEFAULT_BUSY_ENTER_BEHAVIOR: BusyEnterBehavior = 'queue'
export { DEFAULT_BUSY_ENTER_BEHAVIOR } from '../../submission-settings.ts'
/**
* Persisted policy used by both the composer inject face and its Settings row.
* Busy-Enter policy used by both the composer inject face and its Settings row.
* Direct `steer` is intentionally best-effort: AgentLoop turns a closed-window
* submission into the next waking Queue item.
*/
export class ComposerSubmissionPolicy {
/** Reactive preference source for the Settings row. */
readonly busyEnter: SnapshotStore<BusyEnterBehavior> = createSnapshotStore(restoreBusyEnter())
readonly busyEnter: SnapshotStore<BusyEnterBehavior> = createSnapshotStore(DEFAULT_BUSY_ENTER_BEHAVIOR)
private readonly host: SettingsScope<ConversationSettings> | undefined
/**
* @param host - durable preference scope owned by the providing plugin;
* absent compositions stay process-local. The adoption subscription shares
* the scope's plugin lifetime — a disposed scope never publishes again, so
* the policy needs no release hook.
*/
constructor(host?: SettingsScope<ConversationSettings>) {
this.host = host
if (host !== undefined) {
host.subscribe(() => { this.adopt(host) })
this.adopt(host)
}
}
/**
* Resolve one keyboard gesture without changing state.
@@ -42,36 +57,23 @@ export class ComposerSubmissionPolicy {
}
/**
* Change and persist the plain-Enter behavior used during busy state.
* Change the plain-Enter behavior used during busy state; the live value
* publishes before the durable write starts.
* @param behavior - Queue or Steer.
*/
setBusyEnter(behavior: BusyEnterBehavior): void {
if (this.busyEnter.getSnapshot() === behavior) return
this.busyEnter.set(behavior)
persistBusyEnter(behavior)
void this.host?.set(BUSY_ENTER_FIELD, behavior)
}
}
/** Restore a valid preference; unavailable or corrupt storage uses Queue. */
function restoreBusyEnter(): BusyEnterBehavior {
if (typeof localStorage === 'undefined') return DEFAULT_BUSY_ENTER_BEHAVIOR
let stored: string | null
try {
stored = localStorage.getItem(BUSY_ENTER_STORAGE_KEY)
} catch {
// Storage access can fail in privacy modes; the default remains usable.
return DEFAULT_BUSY_ENTER_BEHAVIOR
}
if (stored === 'queue' || stored === 'steer') return stored
return DEFAULT_BUSY_ENTER_BEHAVIOR
}
/** Persist a preference when browser storage is available. */
function persistBusyEnter(behavior: BusyEnterBehavior): void {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(BUSY_ENTER_STORAGE_KEY, behavior)
} catch {
// A storage failure makes the preference session-only; input stays usable.
/**
* Adopt the scope's accepted durable behavior without writing it back.
* @param host - the constructor-narrowed scope driving this adoption.
*/
private adopt(host: SettingsScope<ConversationSettings>): void {
const section = host.getSnapshot().value
if (section === undefined || this.busyEnter.getSnapshot() === section.busyEnter) return
this.busyEnter.set(section.busyEnter)
}
}

View File

@@ -3,7 +3,7 @@
//
// The 'conversation.input.dock' SlotMap declaration lives in
// ../contract/slots.ts beside the other input-region slots.
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import { useEffect, useId, useMemo, useState } from 'react'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'

View File

@@ -7,8 +7,8 @@
* through one property read; assignment through the tracker proxy and `#`
* private fields bypass that rebinding.
*/
import { Service } from 'cordis'
import type { Context } from 'cordis'
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
// Type-only imports: a plugin-to-plugin value import is a bundle purity
// error, so scope resolution goes through the sessions service (scopeOf
// method) instead of the standalone helper.

View File

@@ -6,7 +6,7 @@
// framework-free. Visual: figma 772:51905 / 772:52972 / 772:53419.
import { useId, useState } from 'react'
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// The domain's client-namespace pure-type outlet: one import edge delivers
// the `todos` projection-key merge (single source, no consumer-side restated

View File

@@ -1,4 +1,23 @@
/** Host loader entry for the browser-only conversation plugin. */
/** Host registration for browser conversation preferences. */
/** Provides no host-side behavior. */
export function apply(): void {}
import type { Context } from '@deepseek-ai/cordis'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { CONVERSATION_SETTINGS_NAMESPACE, ConversationSettingsSchema } from './submission-settings.ts'
export {
BUSY_ENTER_BEHAVIORS, BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE,
DEFAULT_BUSY_ENTER_BEHAVIOR, type BusyEnterBehavior, type ConversationSettings,
} from './submission-settings.ts'
/**
* Register the durable conversation section when a settings provider exists.
* @param ctx - Host context whose optional settings service owns the section.
*/
export function apply(ctx: Context): void {
ctx.inject(['settings'], (settingsCtx) => {
settingsCtx.settings.register(
settingsNamespace(CONVERSATION_SETTINGS_NAMESPACE),
ConversationSettingsSchema,
)
})
}

View File

@@ -4,7 +4,7 @@
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-conversation'

View File

@@ -0,0 +1,29 @@
/** Busy-Enter preference stored in the Host user-settings document. */
import z from '@deepseek-ai/schemastery'
/** Settings namespace owned by the conversation plugin. */
export const CONVERSATION_SETTINGS_NAMESPACE = 'ui-conversation'
/** Field carrying the delivery mode for plain Enter while an agent is busy. */
export const BUSY_ENTER_FIELD = 'busyEnter'
/** Busy-Enter behaviors accepted at settings and input boundaries. */
export const BUSY_ENTER_BEHAVIORS = ['queue', 'steer'] as const
/** Configurable meaning of plain Enter while the addressed agent is busy. */
export type BusyEnterBehavior = typeof BUSY_ENTER_BEHAVIORS[number]
/** Default preserves Enter-as-Queue for running conversations. */
export const DEFAULT_BUSY_ENTER_BEHAVIOR: BusyEnterBehavior = 'queue'
/** Durable conversation section shared by the Host schema and the browser scope. */
export interface ConversationSettings {
/** Delivery mode for plain Enter while the addressed agent is busy. */
busyEnter: BusyEnterBehavior
}
/** Durable conversation schema; also the wire envelope the browser scope validates against. */
export const ConversationSettingsSchema: z<ConversationSettings> = z.object({
[BUSY_ENTER_FIELD]: z.union([...BUSY_ENTER_BEHAVIORS]).default(DEFAULT_BUSY_ENTER_BEHAVIOR),
})

View File

@@ -47,6 +47,7 @@ function sessionFakeFor() {
async function bench() {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
const sessionFake = sessionFakeFor()
await runtime.sessions.add({
id: ROOT,

View File

@@ -50,6 +50,7 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
async function bench(opts?: { blank?: boolean }) {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
@@ -74,6 +75,7 @@ async function bench(opts?: { blank?: boolean }) {
describe('resident composer', () => {
it('renders the locked view state while no session exists at all', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
@@ -100,6 +102,7 @@ describe('resident composer', () => {
it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
@@ -165,6 +168,7 @@ describe('resident composer', () => {
describe('prompt rejection through the assembled composer', () => {
it('renders the promptError alert strip and keeps the draft in the machine', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)

View File

@@ -21,6 +21,7 @@ const CHILD = 'child-1' as SessionId
async function bench() {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
await runtime.sessions.add({ id: ROOT, summary: { title: 'R', displayTitle: 'R' } }, { current: false })
await runtime.sessions.add(
{ id: CHILD, summary: { title: 'C', displayTitle: 'C', parentId: ROOT } }, { current: false })

View File

@@ -7,6 +7,7 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
AssistantMessageNode, ConversationSnapshot, SessionId, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { EMPTY_CONVERSATION_VIEWS } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
@@ -43,7 +44,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, chat: chatSnapshotFixture(),
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: chatSnapshotFixture(),
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,

View File

@@ -12,7 +12,9 @@ import type {
UserMessageNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import {
createSnapshotStore, EMPTY_CONVERSATION_VIEWS, PendingWait,
} from '@deepseek-ai/dsh-client-runtime/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type {
ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps, SelectionTarget, UseChatNodeTurnData,
@@ -47,7 +49,8 @@ type RoutedChatNodeOwner = ChatNodeOwnerProps & { readonly node: ChatNode }
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, chat: chatSnapshotFixture(), nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: chatSnapshotFixture(), nodes: [],
turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}

View File

@@ -1,8 +1,9 @@
// @vitest-environment jsdom
// Branch tails the acceptance specs do not reach: the node-half empty apply
// and AssistantMarkdown reasoning/unknown block arms.
// Branch tails the acceptance specs do not reach: the node-half apply
// without a settings service and AssistantMarkdown reasoning/unknown block arms.
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { cleanup, render } from '@testing-library/react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
@@ -16,8 +17,8 @@ const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh)
afterEach(cleanup)
describe('tails', () => {
it('node-half apply is an intentional no-op', () => {
expect(() => { nodeApply() }).not.toThrow()
it('node-half apply tolerates a Host without settings', () => {
expect(() => { nodeApply(new Context()) }).not.toThrow()
})
it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => {

View File

@@ -3,7 +3,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
import {
createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionProviderComponent } from '@deepseek-ai/dsh-client-ui-slots'
@@ -48,7 +50,7 @@ function renderToolDetailsProbe(owners?: DetailsToolOwnerProps[]): DetailsSlotPr
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT,
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT,
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,

View File

@@ -0,0 +1,30 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
import {
CONVERSATION_SETTINGS_NAMESPACE, DEFAULT_BUSY_ENTER_BEHAVIOR, apply,
} from '@deepseek-ai/dsh-client-ui-conversation'
class MemorySettings extends Settings {
readonly writable = true
protected load(): Promise<Record<string, unknown>> { return Promise.resolve({}) }
protected persist(_ns: SettingsNamespace, _section: Record<string, unknown>): Promise<void> {
return Promise.resolve()
}
}
describe('ui-conversation host', () => {
it('registers, validates, and disposes the durable busy-Enter preference', async () => {
const ctx = new Context()
await ctx.plugin(MemorySettings).await()
const fiber = ctx.plugin({ apply })
await fiber.await()
const ns = settingsNamespace(CONVERSATION_SETTINGS_NAMESPACE)
expect(ctx.settings.get(ns)).toEqual({ busyEnter: DEFAULT_BUSY_ENTER_BEHAVIOR })
await ctx.settings.update(ns, { busyEnter: 'steer' })
expect(ctx.settings.get(ns)).toEqual({ busyEnter: 'steer' })
await expect(ctx.settings.update(ns, { busyEnter: 'invalid' })).rejects.toThrow()
await fiber.dispose()
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns)
})
})

View File

@@ -7,7 +7,9 @@
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
import {
createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS,
} from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
@@ -37,7 +39,7 @@ const SID = 's1' as SessionId
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT,
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT,
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,

View File

@@ -8,7 +8,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
import {
createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
@@ -26,7 +28,7 @@ const SID = 's1' as SessionId
/** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
const session = createSnapshotStore<ConversationSnapshot>({
sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT,
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT,
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,

View File

@@ -8,10 +8,12 @@
* itself is not a dependency of this package; the source below is the
* decision-table contract at the `SlashSource` boundary.
*/
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { EMPTY_CHAT_SNAPSHOT, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import {
EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, SessionsService,
} from '@deepseek-ai/dsh-client-runtime/client'
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
@@ -112,7 +114,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined)
const wiring = shell
const sessionStore = createSnapshotStore<ConversationSnapshot>({
sessionId, chat: EMPTY_CHAT_SNAPSHOT,
sessionId, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT,
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,

View File

@@ -6,7 +6,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { useSyncExternalStore } from 'react'
import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
import {
EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, QueuedMessage, SessionId, SessionListState,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -33,7 +35,7 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
return {
sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT,
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT,
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,

View File

@@ -3,7 +3,7 @@
// TestSessions mints tagged scopes through the production createScope, so the
// service's scopeOf/binding path runs against production resolution (no local
// tag probe).
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'

View File

@@ -5,7 +5,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
import {
createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -70,7 +72,7 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT,
sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT,
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,

View File

@@ -1,13 +1,10 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import {
BUSY_ENTER_STORAGE_KEY, ComposerSubmissionPolicy, DEFAULT_BUSY_ENTER_BEHAVIOR,
ComposerSubmissionPolicy, DEFAULT_BUSY_ENTER_BEHAVIOR,
} from '../src/client/input/submission-policy.ts'
afterEach(() => {
vi.unstubAllGlobals()
localStorage.clear()
})
import type { ConversationSettings } from '../src/submission-settings.ts'
describe('ComposerSubmissionPolicy', () => {
it('defaults to Queue and only applies the preference while running', () => {
@@ -28,40 +25,42 @@ describe('ComposerSubmissionPolicy', () => {
expect(policy.resolve(true, 'accelerated', true)).toBe('queue')
expect(policy.resolve(false, 'enter', true)).toBe('queue')
expect(policy.resolve(false, 'accelerated', true)).toBe('queue')
expect(localStorage.getItem(BUSY_ENTER_STORAGE_KEY)).toBe('steer')
})
it('restores a valid preference and leaves an identical write untouched', () => {
localStorage.setItem(BUSY_ENTER_STORAGE_KEY, 'steer')
const write = vi.spyOn(Storage.prototype, 'setItem')
const policy = new ComposerSubmissionPolicy()
it('writes an explicit change through the scope after publishing it locally', () => {
const host = stubSettingsScope<ConversationSettings>()
const observed: string[] = []
let liveBehavior = (): string => 'unconstructed'
const scope: typeof host.scope = {
...host.scope,
set: (field, value) => {
observed.push(`${field}=${String(value)}:${liveBehavior()}`)
return host.scope.set(field, value)
},
}
const policy = new ComposerSubmissionPolicy(scope)
liveBehavior = () => policy.busyEnter.getSnapshot()
policy.setBusyEnter('steer')
expect(observed).toEqual(['busyEnter=steer:steer'])
expect(host.set).toHaveBeenCalledWith('busyEnter', 'steer')
expect(host.set).toHaveBeenCalledOnce()
})
it('adopts a Host preference without writing it back and leaves an identical write untouched', () => {
const host = stubSettingsScope<ConversationSettings>()
const policy = new ComposerSubmissionPolicy(host.scope)
host.publish({ status: 'ready', value: { busyEnter: 'steer' }, revision: 1, writable: true })
expect(policy.busyEnter.getSnapshot()).toBe('steer')
policy.setBusyEnter('steer')
expect(write).not.toHaveBeenCalled()
write.mockRestore()
expect(host.set).not.toHaveBeenCalled()
host.publish({ value: { busyEnter: 'steer' }, revision: 2 })
expect(policy.busyEnter.getSnapshot()).toBe('steer')
})
it('uses Queue for invalid, unavailable, or unreadable storage', () => {
localStorage.setItem(BUSY_ENTER_STORAGE_KEY, 'invalid')
expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue')
vi.stubGlobal('localStorage', undefined)
expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue')
vi.stubGlobal('localStorage', {
getItem: () => { throw new Error('blocked') },
setItem: vi.fn(),
})
expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue')
})
it('keeps the in-memory preference when persistence throws', () => {
vi.stubGlobal('localStorage', {
getItem: () => null,
setItem: () => { throw new Error('quota') },
})
const policy = new ComposerSubmissionPolicy()
policy.setBusyEnter('steer')
it('adopts a section already standing at construction', () => {
const host = stubSettingsScope<ConversationSettings>()
host.publish({ status: 'ready', value: { busyEnter: 'steer' }, revision: 1, writable: true })
const policy = new ComposerSubmissionPolicy(host.scope)
expect(policy.busyEnter.getSnapshot()).toBe('steer')
})
})

View File

@@ -1,7 +1,7 @@
// View-ring type-chain samples. This spec pins the conversation-owned SlotMap
// row, list-kind registration shape, composed view props, and the runtime
// ledger projection consumed by ConversationRoot.
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import type { ReactNode } from 'react'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'

View File

@@ -17,6 +17,9 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../connection"
},
{
"path": "../ui-slots"
},
@@ -65,6 +68,9 @@
{
"path": "../locale"
},
{
"path": "../../settings/settings"
},
{
"path": "../../support/invariants"
},