Merge remote-tracking branch 'origin/master' into worktree/web-plugin-config

# Conflicts:
#	docs/event-producer-consumer.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
#	docs/module-graph.i18n.yaml
#	docs/module-graph.md
#	docs/module-graph.zh.md
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.md
#	packages/client/ui-conversation/README.zh.md
#	packages/client/ui-conversation/package.json
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-08-10 15:50:49 +08:00
352 changed files with 7220 additions and 781 deletions
@@ -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: 9b6e15904a684b99f30841188af75da43f0173af
README.zh.md: e1aeba8efdb6836d4d48d5194876d7eb8f448a55
README.md: 22db76ca23aac7ece47686730d5820b4b28c4529
README.zh.md: 645f31cad883395324c19a796702bd131833756b
+1 -1
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 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. 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.
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.
+1 -1
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 发送。主会话运行期间,由 Host settings 支撑的 `ui-conversation.busyEnter` General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;本地 settings 提供方将其存入 `$DSH_HOME/settings.yaml`,因此该选择会跟随同一个用户 home 跨越 Web 端口。Shift+Enter 仍然换行。已寻址 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)拥有。
键盘消息提交会根据所寻址会话的运行状态和 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 提供其余状态和回调。
@@ -43,6 +43,8 @@
},
"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-connection": "^0.0.1",
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
@@ -60,6 +62,8 @@
},
"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:^",
@@ -15,7 +15,7 @@ import type {
} from './contract/slots.ts'
import type { InputNotice } from './input/contract.ts'
import { createChatStore } from './stores.ts'
import { ConversationService } from './service.ts'
import { ConversationService, UnsupportedImageMediaTypeError } from './service.ts'
import type { IConversation } from './service.ts'
import { ComposerBlockRegistry } from './input/blocks.ts'
import type { ComposerBlock } from './input/blocks.ts'
@@ -94,6 +94,13 @@ function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
return conversation
}
/** Resolve package-internal attachment operations from the public service registration. */
function concreteConversation(ctx: Context): ConversationService {
const conversation = ctx.get('conversation') as ConversationService | undefined
if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable')
return conversation
}
/** Chain routing: claim the composer while an approval wait is pending (pure — owner props only). */
function selectApproval({ interactions }: ComposerChainProps): ApprovalWait | null {
return interactions.find((i): i is ApprovalWait => i.kind === 'approval') ?? null
@@ -157,7 +164,7 @@ export function apply(ctx: Context): void {
// The per-session input machine registry (InputService face; published as
// ctx.conversation.input by the service below sharing this one instance).
const inputHub = new InputHub(ctx)
const inputHub = new InputHub(ctx, t)
// The composer-block registry: a plugin that knows a session cannot send —
// ui-model, when no adapter serves the session's route — raises a block
@@ -206,9 +213,16 @@ export function apply(ctx: Context): void {
if (sessionId !== undefined && nextId !== sessionId) {
const from = inputHub.shell(sessionId)
const draft = from.snapshot.draft
if (draft !== '') {
inputHub.shell(nextId).setDraft(draft)
from.setDraft('')
const imageIds = from.snapshot.imageIds
const next = inputHub.shell(nextId)
if (imageIds.length === 0 || next.addImages(imageIds)) {
if (draft !== '') {
next.setDraft(draft)
from.setDraft('')
}
if (imageIds.length > 0) {
for (const id of imageIds) from.removeImage(id)
}
}
}
sessions.open(nextId)
@@ -225,10 +239,14 @@ export function apply(ctx: Context): void {
'conversation.view': { kind: 'list', scope: 'session' },
},
store: chatStore,
inject: (sessionId: SessionId, _actions: BoundActions<typeof chatStore>): ConversationSessionInjected => ({
views,
bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
}),
inject: (sessionId: SessionId, _actions: BoundActions<typeof chatStore>): ConversationSessionInjected => {
const conversation = concreteConversation(ctx)
return {
views,
releaseSessionImages: (id) => { conversation.releaseSessionImages(id) },
bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
}
},
}, ConversationSession)
// Header chrome sits above the resident scrollport but shares the same
@@ -267,6 +285,9 @@ export function apply(ctx: Context): void {
if (sessionId === undefined) {
return {
keyboard: undefined,
addImages: undefined,
removeImage: undefined,
draftImages: undefined,
resolveSubmitMode: (running, gesture, steeringAvailable) =>
submissionPolicy.resolve(running, gesture, steeringAvailable),
toggleCommandMenu: undefined,
@@ -275,10 +296,32 @@ export function apply(ctx: Context): void {
hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON, menuLauncher: ABSENT_MENU_LAUNCHER },
}
}
const conversation = concreteConversation(ctx)
const shell = inputHub.shell(sessionId)
const slash = inputHub.slash(sessionId)
return {
keyboard: shell,
addImages: (files) => {
try {
const images = conversation.createDraftImages(files)
if (!shell.addImages(images.map(image => image.id))) {
conversation.releaseDraftImages(images)
}
return null
} catch (error: unknown) {
if (error instanceof UnsupportedImageMediaTypeError) {
return t('image.unsupportedType', {
type: error.mediaType || t('image.unknownType'),
})
}
return error instanceof Error ? error.message : String(error)
}
},
removeImage: (id) => {
conversation.releaseDraftImage(id)
shell.removeImage(id)
},
draftImages: ids => conversation.draftImages(ids),
resolveSubmitMode: (running, gesture, steeringAvailable) =>
submissionPolicy.resolve(running, gesture, steeringAvailable),
toggleCommandMenu: slash === undefined
@@ -337,6 +380,7 @@ export function apply(ctx: Context): void {
},
store: chatStore,
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {
const conversation = concreteConversation(ctx)
const scoped = scopedConversation(sessions, sessionId)
return {
openDetails: (target) => {
@@ -352,6 +396,7 @@ export function apply(ctx: Context): void {
})
},
loadOlder: () => { void scoped.loadOlder() },
loadImage: attachment => conversation.resolveImage(sessionId, attachment),
// Unregistered 'trajectory' id is safe: the tab ring falls back to
// the first view, and the untouched inspect target stays inert.
inspectCall: (callId) => {
@@ -14,6 +14,7 @@ import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { ImageGallery, type ImageLoader } from './MessageImage.tsx'
import { ReasoningRow } from './ReasoningRow.tsx'
import css from './AssistantMarkdown.module.css'
@@ -22,6 +23,8 @@ export interface AssistantMarkdownProps {
streaming: boolean
/** Frozen partial of an aborted turn: rendered with a stopped marker. */
interrupted?: boolean | undefined
/** Session-authorized durable image loader. */
loadImage?: ImageLoader
/** Resolved prose file mentions for this Assistant's closing turn. */
mentions?: MarkdownFileMentions | undefined
/** The owning view's locale seat, passed down as a plain prop. */
@@ -30,8 +33,9 @@ export interface AssistantMarkdownProps {
/** Reasoning block as the Think variant summary row (figma 39:28304). */
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, mentions, t,
blocks, streaming, interrupted, loadImage, mentions, t,
}: AssistantMarkdownProps) {
const imageLoader = loadImage ?? (() => Promise.reject(new Error(t('image.serviceUnavailable'))))
// Stable per locale revision (t identity changes on switch): a fresh object
// per render would rebuild MarkdownText's component table every chunk.
const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t])
@@ -58,6 +62,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
/>
)
case 'reasoning': return <ReasoningRow key={i} text={block.text} running={streaming && i === last} t={t} />
case 'image': return <ImageGallery key={i} images={[block]} load={imageLoader} align="start" t={t} />
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
case 'tool-call': return null
default: return (
@@ -4,7 +4,7 @@ import { AssistantMarkdown } from './AssistantMarkdown.tsx'
/** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */
export const AssistantNodeView = memo(function AssistantNodeView({
node, useTurnData, openFile, fileMentions, t,
node, useTurnData, openFile, loadImage, fileMentions, t,
}: ChatNodeViewProps<'assistant-step'>) {
const data = node.data
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
@@ -25,6 +25,7 @@ export const AssistantNodeView = memo(function AssistantNodeView({
blocks={data.blocks}
streaming={data.status === 'running'}
interrupted={data.status === 'interrupted'}
loadImage={loadImage}
mentions={mentions}
t={t}
/>
@@ -18,7 +18,7 @@ type RoutedChatNodeOwner = {
/** Subscribe and dispatch one stable Context key without observing sibling Nodes. */
export const ChatNodeSeat = memo(function ChatNodeSeat({
nodeKey, selectedCallId, cwd, openFile, inspectCall, forkAt,
fileMentions, useSession, renderSlot, t,
loadImage, fileMentions, useSession, renderSlot, t,
}: ChatNodeSeatProps) {
const node = useSession(snapshot => snapshot.chat.nodes.get(nodeKey))
const routedNode = node as ChatNode | undefined
@@ -30,8 +30,9 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
openFile,
inspectCall,
forkAt,
loadImage,
fileMentions,
}, [node, selectedCallId, cwd, openFile, inspectCall, forkAt, fileMentions])
}, [node, selectedCallId, cwd, openFile, inspectCall, forkAt, loadImage, fileMentions])
if (routedNode === undefined || owner === null) return null
// Runtime dispatch owns the correlation: every Node's discriminant is the
// keyed-slot entry passed alongside that same Node. TypeScript does not
@@ -144,7 +144,7 @@ function TurnStatus({ startTime, t }: {
* ordered business Node crosses the keyed renderer seat.
*/
export function ChatView({
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt,
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, loadImage, inspectCall, chatScroll, forkAt,
fileMentions, t,
}: ChatViewSlotProps) {
const order = useSession(s => s.chat.order)
@@ -389,6 +389,7 @@ export function ChatView({
openFile={openFile}
inspectCall={inspectCall}
forkAt={forkAt}
loadImage={loadImage}
fileMentions={fileMentions}
renderSlot={renderSlot}
t={t}
@@ -401,7 +402,7 @@ export function ChatView({
wait, tool execution, streaming) so it never flickers per step. */}
{running && <TurnStatus startTime={runningTurnStart} t={t} />}
{pendingSteering.map(item => (
<PendingSteeringBubble key={item.id} content={item.content} t={t} />
<PendingSteeringBubble key={item.id} content={item.content} loadImage={loadImage} t={t} />
))}
</div>
{!atBottom && (
@@ -0,0 +1,53 @@
.gallery {
display: flex;
flex-wrap: wrap;
gap: 8px;
width: min(240px, 100%);
}
.gallery[data-align='end'] {
justify-content: flex-end;
align-self: flex-end;
}
.gallery[data-align='start'] {
justify-content: flex-start;
align-self: flex-start;
}
.frame {
display: grid;
flex: 0 0 auto;
place-items: center;
min-width: 44px;
min-height: 44px;
padding: 0;
overflow: hidden;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 12px;
background: var(--dsw-alias-interactive-bg-hover);
cursor: zoom-in;
}
.frame img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.loading,
.error {
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
}
.error {
max-width: 240px;
padding: 10px 12px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 10px;
background: var(--dsw-alias-interactive-bg-hover-danger);
cursor: pointer;
}
@@ -0,0 +1,72 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { ImageLightbox } from '../skeleton/ImageLightbox.tsx'
import css from './MessageImage.module.css'
/** Loads a session-authorized durable image URL. */
export type ImageLoader = (attachment: ImageAttachmentRef) => Promise<string>
/** Compact history renderer with retryable loading and double-click original preview. */
export function MessageImage({ attachment, load, t }: {
attachment: ImageAttachmentRef
load: ImageLoader
t: ChatViewSlotProps['t']
}) {
const [src, setSrc] = useState<string | null>(null)
const [error, setError] = useState(false)
const [open, setOpen] = useState(false)
const close = useCallback(() => { setOpen(false) }, [])
const size = useMemo(() => {
const scale = Math.min(1, 240 / attachment.width, 240 / attachment.height)
return { width: Math.max(1, Math.round(attachment.width * scale)), height: Math.max(1, Math.round(attachment.height * scale)) }
}, [attachment.height, attachment.width])
const request = useCallback(() => {
setError(false)
setSrc(null)
void load(attachment).then(setSrc).catch(() => { setError(true) })
}, [attachment, load])
useEffect(() => {
let live = true
setError(false)
void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) })
return () => { live = false }
}, [attachment, load])
const label = attachment.name ?? t('image.label')
if (error) return <button type="button" className={css.error} onClick={request}>{t('image.loadFailed')}</button>
return (
<>
<button
type="button"
className={css.frame}
style={size}
title={t('image.openOriginal')}
aria-label={t('image.openOriginalLabel', { label })}
onDoubleClick={() => { if (src !== null) setOpen(true) }}
>
{src === null ? <span className={css.loading}>{t('image.loading')}</span> : <img src={src} alt={label} />}
</button>
{open && src !== null && <ImageLightbox src={src} alt={label} onClose={close} t={t} />}
</>
)
}
/** Wrapping image group shared by user and assistant history. */
export function ImageGallery({ images, load, align, t }: {
images: readonly { attachment: ImageAttachmentRef }[]
load: ImageLoader
align: 'start' | 'end'
t: ChatViewSlotProps['t']
}) {
if (images.length === 0) return null
return (
<div className={css.gallery} data-align={align}>
{images.map((image, index) => (
<MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} t={t} />
))}
</div>
)
}
@@ -8,6 +8,15 @@
gap: 6px;
}
.userStack {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
min-width: 0;
max-width: min(525px, 82%);
}
/* Steering caption above the bubble: mid-turn interjections carry the same
bubble as a turn-opening prompt, so the transcript names which one this is. */
.steeringMark {
@@ -19,7 +28,7 @@
.bubble {
/* 525px cap inside the 736 column; percentage keeps narrow windows sane. */
max-width: min(525px, 82%);
max-width: 100%;
background: var(--dsw-specific-bubble);
border-radius: 22px;
/* 44px single-line bubble: 24 line + 10 vertical padding each side. */
@@ -7,24 +7,35 @@
import { memo, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type {
ModelRetryNode, TurnErrorNode,
ModelRetryNode, TurnErrorNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts'
import { CompactionItem } from './CompactionItem.tsx'
import { ContextInjectionRow } from './ContextInjectionRow.tsx'
import { MessageIconActions } from './MessageIconActions.tsx'
import { ImageGallery, type ImageLoader } from './MessageImage.tsx'
import css from './MessageItem.module.css'
function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } {
type UserImage = Extract<UserMessageNode['content'][number], { type: 'image' }>
function contentParts(content: readonly unknown[]): {
text: string
images: { attachment: UserImage['attachment'] }[]
rest: unknown[]
} {
const texts: string[] = []
const images: { attachment: UserImage['attachment'] }[] = []
const rest: unknown[] = []
for (const block of content) {
const b = block as { type?: string; text?: string }
const b = block as { type?: string; text?: string; attachment?: unknown }
if (b.type === 'text' && typeof b.text === 'string') texts.push(b.text)
else if (b.type === 'image' && b.attachment !== undefined) {
images.push({ attachment: (b as UserImage).attachment })
}
else rest.push(block)
}
return { text: texts.join(''), rest }
return { text: texts.join(''), images, rest }
}
function retrySeconds(milliseconds: number): number {
@@ -151,9 +162,10 @@ function projectUserText(text: string): ReactNode {
/** Right-aligned bubble shared by user and steering rows. */
function UserStyleBubble({
content, actions, pending = false, steering = false, t,
content, imageLoader, actions, pending = false, steering = false, t,
}: {
content: readonly unknown[]
imageLoader: ImageLoader
/** Optional IconActions (or similar) below the bubble; receives the joined text. */
actions?: (text: string) => ReactNode
/** Whether this is the Host-authoritative pre-admission steering projection. */
@@ -162,14 +174,18 @@ function UserStyleBubble({
steering?: boolean
t: ChatViewSlotProps['t']
}): ReactNode {
const { text, rest } = contentText(content)
const { text, images, rest } = contentParts(content)
const truncated = (total: number): string => t('json.truncated', { total })
const showBubble = text !== '' || rest.length > 0
return (
<div className={css.userRow} data-pending-steering={pending || undefined} data-time-hover-root>
{steering && <span className={css.steeringMark} data-steering-mark>{t('message.steering')}</span>}
<div className={css.bubble}>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
<div className={css.userStack}>
<ImageGallery images={images} load={imageLoader} align="end" t={t} />
{showBubble && <div className={css.bubble}>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
</div>}
</div>
{actions?.(text)}
</div>
@@ -182,13 +198,16 @@ function UserStyleBubble({
* @param props - Pending message content and conversation translator.
* @returns the pending steering bubble.
*/
export function PendingSteeringBubble({ content, t }: {
export function PendingSteeringBubble({ content, loadImage, t }: {
content: readonly unknown[]
loadImage?: ImageLoader
t: ChatViewSlotProps['t']
}): ReactNode {
const imageLoader = loadImage ?? (() => Promise.reject(new Error(t('image.serviceUnavailable'))))
return (
<UserStyleBubble
content={content}
imageLoader={imageLoader}
pending
steering
t={t}
@@ -206,12 +225,13 @@ export function PendingSteeringBubble({ content, t }: {
/** User and admitted-steering keyed Chat renderer. */
export const UserMessageNodeView = memo(function UserMessageNodeView({
node, t,
node, loadImage, t,
}: ChatNodeViewProps<'user' | 'steering'>) {
const data = node.data
return (
<UserStyleBubble
content={data.content}
imageLoader={loadImage}
steering={data.kind === 'steering'}
t={t}
actions={text => (
@@ -1,5 +1,6 @@
/** Conversation slot declarations and their composed component props. */
import type { ReactNode, RefObject } from 'react'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type {
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore,
SlotHookFactory, SnapshotSelectorHook,
@@ -12,12 +13,22 @@ import type {
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'
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
import type {
ComposerKeyboard, DraftAttachmentId, EditSelection, InputActions, InputNotice, InputState,
} from '../input/contract.ts'
import type { createChatStore } from '../stores.ts'
import type { ComposerSubmitGesture, InputSubmitMode } from './composer-submission.ts'
import type { ChatNode, ChatNodeKind } from './chat-nodes.ts'
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
/** Browser-owned image that has not crossed the durable host boundary. */
export interface ComposerAttachment {
kind: 'image'
id: DraftAttachmentId
file: File
previewUrl: string
}
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/**
@@ -258,6 +269,8 @@ export interface ChatNodeOwnerProps {
openFile: (path: string) => void
inspectCall: (callId: CallId) => void
forkAt: (seq: number) => void
/** Resolve a session-authorized historical image for inline display. */
loadImage: (attachment: ImageAttachmentRef) => Promise<string>
fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
}
@@ -327,6 +340,8 @@ export interface ConversationSessionInjected {
subscribe: (fn: () => void) => () => void
version: () => number
}
/** Release historical image URLs when this rendered session scope unmounts. */
releaseSessionImages: (sessionId: SessionId) => void
/** Bind the input machine's draft persistence mirror to the session store. */
bindDraftMirror: (write: (text: string) => void) => () => void
}
@@ -383,6 +398,12 @@ export interface ComposerBarOwnerProps {
export interface ComposerBarInjected {
/** The InputBar-exclusive keyboard/DOM command face (private plane); absent with the session. */
keyboard: ComposerKeyboard | undefined
/** Create previews and append image ids to the session input. */
addImages: ((files: readonly File[]) => string | null) | undefined
/** Release one preview and remove its id from session input. */
removeImage: ((id: DraftAttachmentId) => void) | undefined
/** Resolve ordered input ids to browser-owned draft images. */
draftImages: ((ids: readonly DraftAttachmentId[]) => readonly ComposerAttachment[]) | undefined
/** Resolve one keyboard submission gesture against the current running state and persisted preference. */
resolveSubmitMode: (
running: boolean,
@@ -565,6 +586,8 @@ export interface ChatViewInjected {
*/
openFile: (path: string) => void
loadOlder: () => void
/** Resolve a session-authorized historical image for inline display. */
loadImage: (attachment: ImageAttachmentRef) => Promise<string>
/** Hand a call off to the trajectory view: write the one-shot inspect target and switch tabs. */
inspectCall: (callId: CallId) => void
/**
@@ -16,6 +16,7 @@ export type {} from './conversation-nodes/turn-tail.ts'
export { apply, inject } from './apply.ts'
export { ConversationService } from './service.ts'
export type { IConversation } from './service.ts'
export type { DraftAttachmentId } from './input/contract.ts'
export type {
CallId, ChatStoreState, SelectionTarget, ViewTab,
@@ -28,7 +29,7 @@ export type {
export type {
ChatFileMentions, ChatNodeOwnerProps, ChatNodeViewProps,
ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected,
ComposerChainProps, ConversationInjected,
ComposerAttachment, ComposerChainProps, ConversationInjected,
ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps,
ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps,
TurnTailOwnerProps, UseChatNodeTurnData,
@@ -6,6 +6,7 @@
* (machine.ts) is package-private and never exported.
*/
import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type {
ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome,
ReferenceInsert, SubmitOutcome, TokenSpan,
@@ -13,6 +14,9 @@ import type {
import type { QueueRow } from '../contract/queue.ts'
import type { InputSubmitMode } from '../contract/composer-submission.ts'
/** Browser-runtime identity of one unsent image draft. */
export type DraftAttachmentId = Branded<'DraftAttachmentId'>
/**
* The scoped-event application verbs: the hub's bail listeners call these,
* and the boolean answer IS the event's bail value (true ⟺ the machine
@@ -29,6 +33,12 @@ export interface InputTarget {
export interface SessionInput extends InputTarget {
/** Single write path for draft text (all mutation rides machine events). */
setDraft(text: string): void
/** Append ordered browser-owned image ids; busy admission phases refuse. */
addImages(ids: readonly DraftAttachmentId[]): boolean
/** Remove one browser-owned image id. */
removeImage(id: DraftAttachmentId): void
/** Drop ids whose browser-owned objects no longer exist. */
pruneImages(ids: readonly DraftAttachmentId[]): void
/**
* THE complexity sink: enter adjudication, submit transaction, and the default sink live inside.
* @param mode - delivery intent retained through asynchronous adjudication and serialization.
@@ -63,6 +73,12 @@ export interface InputService {
export interface InputActions {
/** Single public draft write path (full next draft; occurrence math via diff scan). */
setDraft(text: string): void
/** Append ordered browser-owned image ids; busy admission phases refuse. */
addImages(ids: readonly DraftAttachmentId[]): boolean
/** Remove one browser-owned image id. */
removeImage(id: DraftAttachmentId): void
/** Drop ids whose browser-owned objects no longer exist. */
pruneImages(ids: readonly DraftAttachmentId[]): void
/** Enter submission (adjudication / claim transaction / default sink inside). */
submit(): void
}
@@ -88,6 +104,12 @@ export interface ComposerKeyboard {
setDraft(text: string, editRange?: EditRange): void
/** Submit with an explicit delivery mode resolved by the keyboard policy. */
submit(mode: InputSubmitMode): void
/**
* Steer every still-pending queued message into the running turn (the
* empty-draft accelerated-Enter gesture; the queue dock's per-row steer
* button is the same operation applied to the whole queue).
*/
steerQueue(): void
undo(): void
redo(): void
/** Paste over the selection (sync components ride the same transaction). */
@@ -186,6 +208,8 @@ export interface InputMachineOptions {
/** Published input state (the currency; per-session). */
export interface InputState {
readonly draft: string
/** Ordered runtime-only image ids; bytes and URLs stay in ConversationService. */
readonly imageIds: readonly DraftAttachmentId[]
/** Monotonic draft revision (span CAS compares against this). */
readonly draftRev: number
readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting'
@@ -13,7 +13,7 @@ import type {
ReferenceInsert, SlashController, TokenSpan,
} from '@deepseek-ai/dsh-client-ui-slash/client'
import type {
EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState,
DraftAttachmentId, EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState,
PasteComponent, QueuedMessage, SessionInput, SubmitAttempt,
} from './contract.ts'
import type { InputSubmitMode } from '../contract/composer-submission.ts'
@@ -39,8 +39,13 @@ export interface SessionInputDeps {
popup?: (() => PopupDismissFace | undefined) | undefined
/** Queue read face; overlaid onto InputState.queue (absent = empty). */
queue?: ObservableSnapshot<readonly QueuedMessage[]> | undefined
/**
* Steer every still-pending queued message into the running turn, in FIFO
* order (the empty-draft accelerated-Enter gesture); absent = unsupported.
*/
steerQueue?: (() => void) | undefined
/** The plain-message sink (send choreography / materialize fork — the hub owns it). */
defaultSink(text: string, mode: InputSubmitMode): void
defaultSink(text: string, imageIds: readonly DraftAttachmentId[], mode: InputSubmitMode): void
}
/** Guard tier from the machine phase. */
@@ -69,6 +74,9 @@ export class SessionInputShell implements SessionInput {
/** The public provide-channel action face (one stable identity per session). */
readonly actions: InputActions = {
setDraft: (text) => { this.setDraft(text) },
addImages: ids => this.addImages(ids),
removeImage: (id) => { this.removeImage(id) },
pruneImages: (ids) => { this.pruneImages(ids) },
submit: () => { this.submit('queue') },
}
@@ -77,6 +85,7 @@ export class SessionInputShell implements SessionInput {
private readonly core = new InputMachine({ now: () => Date.now() })
private noticeSeq = 0
private lastDraft = ''
private imageIds: readonly DraftAttachmentId[] = []
private disposed = false
/** Draft persistence mirror (chat store write; receives the clipboard projection, never raw placeholders). */
private mirrorFn: ((text: string) => void) | undefined
@@ -98,12 +107,54 @@ export class SessionInputShell implements SessionInput {
this.run(this.core.dispatch({ type: 'draft-changed', draft: text, ...(editRange !== undefined ? { editRange } : {}) }))
}
/** Append ordered image ids unless an admission transaction is locked. */
addImages(ids: readonly DraftAttachmentId[]): boolean {
if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return false
if (ids.length === 0) return true
this.imageIds = [...this.imageIds, ...ids]
this.publish()
return true
}
/** Remove one image id from this draft. */
removeImage(id: DraftAttachmentId): void {
const next = this.imageIds.filter(candidate => candidate !== id)
if (next.length === this.imageIds.length) return
this.imageIds = next
this.publish()
}
/**
* Keep only image ids that still resolve in the browser attachment registry.
* @param available - live registry ids.
*/
pruneImages(available: readonly DraftAttachmentId[]): void {
const keep = new Set(available)
const next = this.imageIds.filter(id => keep.has(id))
if (next.length === this.imageIds.length) return
this.imageIds = next
this.publish()
}
/**
* Restore a failed attempt before any images added after its admission.
* @param ids - failed attempt image ids.
*/
restoreImages(ids: readonly DraftAttachmentId[]): void {
const current = new Set(this.imageIds)
this.imageIds = [...ids.filter(id => !current.has(id)), ...this.imageIds]
this.publish()
}
/**
* Clear the draft as a successful-send commit: no undo unit is recorded and
* the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent content
* (the command path gets the same discipline from submit-settled success).
* @param imageIds - admitted image ids to remove from this draft.
*/
commitSend(): void {
commitSend(imageIds: readonly DraftAttachmentId[]): void {
const submitted = new Set(imageIds)
this.imageIds = this.imageIds.filter(id => !submitted.has(id))
this.run(this.core.dispatch({ type: 'send-committed' }))
}
@@ -145,6 +196,10 @@ export class SessionInputShell implements SessionInput {
* dismisses and the menu tracks frozen.
*/
submit(mode: InputSubmitMode = 'queue'): void {
if (this.snapshot.draft.trim() === '' && this.imageIds.length > 0) {
if (this.snapshot.phase === 'plain') this.deps.defaultSink('', [...this.imageIds], mode)
return
}
this.run(this.core.dispatch({ type: 'enter', mode }))
const phase = this.snapshot.phase
if (phase === 'adjudicating' || phase === 'submitting') {
@@ -173,6 +228,16 @@ export class SessionInputShell implements SessionInput {
return this.deps.slash?.()?.arbitrate(key, composing) ?? 'pass'
}
/**
* Steer every still-pending queued message into the running turn (the
* empty-draft accelerated-Enter gesture). Execution belongs to the hub's
* queue choreography; absent dep = the gesture falls back to the machine's
* empty-draft no-op.
*/
steerQueue(): void {
this.deps.steerQueue?.()
}
/**
* Space adjudication over the controller's hot state.
* @returns true = a claim/insert was applied — the caller preventDefaults.
@@ -349,9 +414,10 @@ export class SessionInputShell implements SessionInput {
* the clipboard text. Chip-free drafts skip the async detour.
*/
private sinkSerialized(draft: string, mode: InputSubmitMode): void {
const imageIds = [...this.imageIds]
const occurrences = this.core.state.occurrences
if (occurrences.length === 0) {
this.deps.defaultSink(draft.trim(), mode)
this.deps.defaultSink(draft.trim(), imageIds, mode)
return
}
const slash = this.deps.slash?.()
@@ -371,7 +437,7 @@ export class SessionInputShell implements SessionInput {
cursor = part.offset + 1
}
out += draft.slice(cursor)
this.deps.defaultSink(out.trim(), mode)
this.deps.defaultSink(out.trim(), imageIds, mode)
},
(error: unknown) => {
controller.abort()
@@ -429,7 +495,7 @@ export class SessionInputShell implements SessionInput {
private compose(): InputState {
const core = this.core.state
return { ...core, queue: this.deps.queue?.getSnapshot() ?? EMPTY_QUEUE }
return { ...core, imageIds: this.imageIds, queue: this.deps.queue?.getSnapshot() ?? EMPTY_QUEUE }
}
private publish(): void {
@@ -10,8 +10,9 @@
*/
import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
import { queueReadFaceOf } from '../queue/store.ts'
import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts'
import type { ComposerKeyboard, DraftAttachmentId, InputService, SessionInput } from './contract.ts'
import type { InputSubmitMode } from '../contract/composer-submission.ts'
import type { PopupDismissFace } from './facade.ts'
import { SessionInputShell } from './facade.ts'
@@ -21,12 +22,29 @@ interface CommandFace {
popupFor(actx: ClientContext): PopupDismissFace
}
/** Attachment-send face resolved lazily to keep hub/service construction acyclic. */
interface ConversationAttachmentFace {
sendSession(
session: SessionFace,
text: string,
imageIds: readonly DraftAttachmentId[],
mode: InputSubmitMode,
): Promise<void>
releaseDraftImage(id: DraftAttachmentId): void
}
/** Session-addressed input facade registry (InputService face + composer-layer extras). */
export class InputHub implements InputService {
private readonly shells = new Map<SessionId, SessionInputShell>()
/** @param ctx - client root context (services resolved lazily per call — boot order stays free). */
constructor(private readonly rootCtx: ClientContext) {}
/**
* @param ctx - client root context (services resolved lazily per call — boot order stays free).
* @param t - conversation-namespace translate thunk (reads the active locale at call time).
*/
constructor(
private readonly rootCtx: ClientContext,
private readonly t: TranslateNS<'conversation'>,
) {}
/**
* Resolve the facade for one session-scope ctx (InputService face).
@@ -57,7 +75,8 @@ export class InputHub implements InputService {
slash: () => this.controller(actx),
popup: () => this.popup(actx),
queue: queueReadFaceOf(session),
defaultSink: (text, mode) => { this.sink(session, text, mode) },
defaultSink: (text, imageIds, mode) => { this.sink(session, text, imageIds, mode) },
steerQueue: () => { void this.steerQueue(session, shell) },
})
this.shells.set(id, shell)
// The one teardown axis: listeners, shell, and map entries all ride the
@@ -75,8 +94,11 @@ export class InputHub implements InputService {
]
return () => {
for (const off of offs) off()
const drafts = shell.snapshot.imageIds
shell.dispose()
this.shells.delete(id)
const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined
for (const imageId of drafts) conversation?.releaseDraftImage(imageId)
}
}, 'conversation.input: session shell')
return shell
@@ -124,19 +146,49 @@ export class InputHub implements InputService {
* exactly one path; a failed first prompt is an ordinary prompt failure
* (error strip via promptError, draft restored only while untouched).
*/
private sink(session: SessionFace, text: string, mode: InputSubmitMode): void {
if (text === '') return
private sink(
session: SessionFace,
text: string,
imageIds: readonly DraftAttachmentId[],
mode: InputSubmitMode,
): void {
if (text === '' && imageIds.length === 0) return
const shell = this.shells.get(session.sessionId)
// Commit, not an editable clear: undo must not resurrect sent content.
shell?.commitSend()
void session.prompt([{ type: 'text', text }], mode).then(
(result) => {
if (!result.ok && shell?.snapshot.draft === '') shell.setDraft(text)
},
() => {
shell?.commitSend(imageIds)
void this.conversation().sendSession(session, text, imageIds, mode).catch(() => {
if (this.shells.get(session.sessionId) === shell) {
shell?.restoreImages(imageIds)
if (shell?.snapshot.draft === '') shell.setDraft(text)
},
)
return
}
const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined
for (const id of imageIds) conversation?.releaseDraftImage(id)
})
}
/**
* Steer every still-pending queued message into the running turn, in FIFO
* order — the same strict-steer operation as the queue dock's per-row
* button. A turn closing mid-way (`steer-unavailable`) or a row already
* claimed by the agent (`queue-item-not-found`) converges silently, while a
* genuine failure surfaces as one composer notice. Repeated triggers
* (e.g. two rapid empty-draft chords) rely on that `queue-item-not-found`
* convergence: the snapshot may still list a row the host already steered,
* and the duplicate strict steer is a silent no-op.
* @param session - the addressed host session.
* @param shell - the resident shell (notice outlet).
*/
private async steerQueue(session: SessionFace, shell: SessionInputShell): Promise<void> {
const queued = session.getSnapshot().queue.filter(item => item.placement === 'queued')
if (queued.length === 0) return
for (const item of queued) {
const result = await session.updateQueue(item.id, { kind: 'steer' })
if (result.ok) continue
if (result.error.code === 'steer-unavailable' || result.error.code === 'queue-item-not-found') return
shell.notify('error', this.t('queue.steerFailed'))
return
}
}
private controller(actx: ClientContext): SlashController | undefined {
@@ -154,4 +206,10 @@ export class InputHub implements InputService {
if (sessions === undefined) throw new Error('conversation.input: sessions service unavailable')
return sessions
}
private conversation(): ConversationAttachmentFace {
const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined
if (conversation === undefined) throw new Error('conversation.input: conversation service unavailable')
return conversation
}
}
@@ -133,6 +133,7 @@ export class InputMachine {
const c = this.claim
return {
draft: this.draft,
imageIds: [],
draftRev: this.draftRev,
phase: this.phase,
...(c ? { claim: { token: c.token, ...(c.hint !== undefined ? { hint: c.hint } : {}) } } : {}),
@@ -23,7 +23,22 @@ export const zh = {
'input.commands': '命令',
'input.stop': '停止生成',
'input.send': '发送消息',
'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息',
'input.accessMode': '访问模式,当前:{name}',
'image.dropHint': '松开以添加图片',
'image.pending': '待发送图片',
'image.openOriginal': '双击查看原图',
'image.openOriginalLabel': '{label},双击查看原图',
'image.remove': '移除图片 {name}',
'image.original': '原图',
'image.label': '图片',
'image.loadFailed': '图片加载失败,点击重试',
'image.loading': '图片加载中…',
'image.preview': '原图预览',
'image.closePreview': '关闭原图预览',
'image.serviceUnavailable': '图片读取服务不可用',
'image.unsupportedType': '不支持的图片格式:{type}',
'image.unknownType': '未知格式',
'context.aria': '上下文已用 {percent}',
'context.used': '上下文已用',
'context.system': '系统提示词',
@@ -166,7 +181,22 @@ export const en = {
'input.commands': 'Commands',
'input.stop': 'Stop generating',
'input.send': 'Send message',
'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages',
'input.accessMode': 'Access mode, current: {name}',
'image.dropHint': 'Drop to add images',
'image.pending': 'Pending images',
'image.openOriginal': 'Double-click to view original',
'image.openOriginalLabel': '{label}, double-click to view original',
'image.remove': 'Remove image {name}',
'image.original': 'Original image',
'image.label': 'Image',
'image.loadFailed': 'Image failed to load; click to retry',
'image.loading': 'Loading image…',
'image.preview': 'Original image preview',
'image.closePreview': 'Close original image preview',
'image.serviceUnavailable': 'Image loading service unavailable',
'image.unsupportedType': 'Unsupported image format: {type}',
'image.unknownType': 'unknown format',
'context.aria': '{percent} of context used',
'context.used': 'of context used',
'context.system': 'System prompt',
@@ -13,9 +13,12 @@ import type { Context } from 'cordis'
// error, so scope resolution goes through the sessions service (scopeOf
// method) instead of the standalone helper.
import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
import type { ComposerAttachment } from './contract/slots.ts'
import type { QueueAction, QueueItemId } from './contract/queue.ts'
import type { ComposerBlocks } from './input/blocks.ts'
import type { InputService } from './input/contract.ts'
import type { DraftAttachmentId, InputService } from './input/contract.ts'
import type { InputSubmitMode } from './contract/composer-submission.ts'
/**
* The outward conversation face (`ctx.conversation`): the scope-addressed
@@ -55,12 +58,46 @@ export interface IConversation {
loadOlder(): Promise<void>
}
/** Create one browser-only draft descriptor; only its id enters input state. */
function browserDraftAttachment(file: File): ComposerAttachment {
return {
kind: 'image',
id: crypto.randomUUID() as DraftAttachmentId,
previewUrl: URL.createObjectURL(file),
file,
}
}
interface ImageUrlEntry {
readonly sessionId: SessionId
readonly generation: number
readonly pending: Promise<string>
}
/** Unsupported browser-declared image type, localized by the UI boundary. */
export class UnsupportedImageMediaTypeError extends Error {
/** Browser-declared MIME value, possibly empty. */
readonly mediaType: string
/** @param mediaType - Browser-declared MIME value, possibly empty. */
constructor(mediaType: string) {
super(`unsupported image media type: ${mediaType || '(empty)'}`)
this.name = 'UnsupportedImageMediaTypeError'
this.mediaType = mediaType
}
}
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
export class ConversationService extends Service implements IConversation {
/** The per-session input machine registry (InputService face). */
readonly input: InputService
/** The per-session composer-block registry. */
readonly blocks: ComposerBlocks
private readonly draftAttachments = new Map<DraftAttachmentId, ComposerAttachment>()
private readonly imageUrls = new Map<string, ImageUrlEntry>()
private readonly imageGenerations = new Map<SessionId, number>()
private readonly createdImageUrls = new Set<string>()
private disposed = false
/**
* @param ctx - owning root context (the plugin apply context; the service
@@ -73,6 +110,14 @@ export class ConversationService extends Service implements IConversation {
super(ctx, 'conversation')
this.input = config.input
this.blocks = config.blocks
ctx.effect(() => () => {
this.disposed = true
for (const url of this.createdImageUrls) revokePreview(url)
this.createdImageUrls.clear()
this.draftAttachments.clear()
this.imageUrls.clear()
this.imageGenerations.clear()
}, 'conversation attachment URL cache')
}
/**
@@ -87,6 +132,136 @@ export class ConversationService extends Service implements IConversation {
if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`)
}
/**
* Submit ordered draft images with text through one host admission.
* @param session - target session.
* @param text - serialized prompt text.
* @param imageIds - ordered draft-local attachment ids.
* @param mode - queue or steer delivery selected by composer policy.
*/
async sendSession(
session: SessionFace,
text: string,
imageIds: readonly DraftAttachmentId[],
mode: InputSubmitMode,
): Promise<void> {
const attachments = this.draftImages(imageIds)
if (attachments.length !== imageIds.length) {
throw new Error('conversation.sendSession: one or more draft images are no longer available')
}
const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file))
const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])]
const result = await session.prompt(content, mode)
if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`)
this.releaseDraftImages(attachments)
}
/**
* Create runtime-only draft images and their object URLs.
* @param files - browser files to register after MIME validation.
* @returns ordered draft descriptors.
*/
createDraftImages(files: readonly File[]): readonly ComposerAttachment[] {
for (const file of files) imageMediaType(file.type)
return files.map((file) => {
const attachment = browserDraftAttachment(file)
this.draftAttachments.set(attachment.id, attachment)
this.createdImageUrls.add(attachment.previewUrl)
return attachment
})
}
/**
* Resolve ordered input-state ids to runtime-owned draft images.
* @param ids - draft attachment ids.
* @returns descriptors that remain live, in requested order.
*/
draftImages(ids: readonly DraftAttachmentId[]): readonly ComposerAttachment[] {
const attachments: ComposerAttachment[] = []
for (const id of ids) {
const attachment = this.draftAttachments.get(id)
if (attachment !== undefined) attachments.push(attachment)
}
return attachments
}
/**
* Release one browser-owned draft image and preview URL.
* @param id - draft attachment id.
*/
releaseDraftImage(id: DraftAttachmentId): void {
const attachment = this.draftAttachments.get(id)
if (attachment === undefined) return
this.draftAttachments.delete(id)
this.createdImageUrls.delete(attachment.previewUrl)
revokePreview(attachment.previewUrl)
}
/**
* Release a set of browser-owned draft images.
* @param attachments - descriptors to release.
*/
releaseDraftImages(attachments: readonly ComposerAttachment[]): void {
for (const attachment of attachments) this.releaseDraftImage(attachment.id)
}
/**
* Resolve and cache one session-authorized historical image URL.
* @param sessionId - owning session authorization scope.
* @param attachment - durable image reference.
* @returns browser URL valid until its rendered session is released.
*/
resolveImage(sessionId: SessionId, attachment: ImageAttachmentRef): Promise<string> {
if (this.disposed) return Promise.reject(new Error('conversation.resolveImage: service is disposed'))
const key = `${sessionId}:${attachment.attachmentId}`
const cached = this.imageUrls.get(key)
if (cached !== undefined) return cached.pending
const generation = this.imageGenerations.get(sessionId) ?? 0
const session = this.requireSessions().binding(sessionId)?.session
if (session === undefined) {
return Promise.reject(new Error(`conversation.resolveImage: unknown session "${sessionId}"`))
}
const pending = session.readAttachment(attachment.attachmentId)
.then((result) => {
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
if (this.disposed) throw new Error('conversation.resolveImage: service was disposed before loading completed')
if ((this.imageGenerations.get(sessionId) ?? 0) !== generation) {
throw new Error('historical image scope was released before loading completed')
}
if (typeof URL.createObjectURL !== 'function') {
return `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}`
}
const bytes = Uint8Array.from(result.value.data)
const url = URL.createObjectURL(new Blob([bytes.buffer], { type: result.value.attachment.mediaType }))
this.createdImageUrls.add(url)
return url
})
.catch((error: unknown) => {
if (this.imageUrls.get(key)?.generation === generation) this.imageUrls.delete(key)
throw error
})
this.imageUrls.set(key, { sessionId, generation, pending })
return pending
}
/**
* Release every historical image URL owned by one rendered session.
* @param sessionId - rendered session scope.
*/
releaseSessionImages(sessionId: SessionId): void {
this.imageGenerations.set(sessionId, (this.imageGenerations.get(sessionId) ?? 0) + 1)
for (const [key, entry] of this.imageUrls) {
if (entry.sessionId !== sessionId) continue
this.imageUrls.delete(key)
void entry.pending.then((url) => {
if (!this.createdImageUrls.delete(url)) return
revokePreview(url)
}, () => {
// A failed or invalidated load owns no object URL.
})
}
}
/** Apply one operation to a pending queue occurrence. */
async updateQueue(itemId: QueueItemId, action: QueueAction): Promise<void> {
const session = this.scopedSession('updateQueue')
@@ -136,4 +311,39 @@ export class ConversationService extends Service implements IConversation {
if (sessions === undefined) throw new Error('conversation: sessions service unavailable')
return sessions
}
/** Convert browser files to canonical base64 prompt parts. */
private serializeImages(images: readonly File[]): Promise<Parameters<SessionFace['prompt']>[0]> {
return Promise.all(images.map(async file => ({
type: 'image' as const,
mediaType: imageMediaType(file.type),
data: bytesToBase64(new Uint8Array(await file.arrayBuffer())),
...(file.name === '' ? {} : { name: file.name }),
})))
}
}
function imageMediaType(value: string): ImageMediaType {
switch (value) {
case 'image/png':
case 'image/jpeg':
case 'image/webp':
case 'image/gif':
return value
default:
throw new UnsupportedImageMediaTypeError(value)
}
}
function bytesToBase64(data: Uint8Array): string {
let binary = ''
const chunk = 0x8000
for (let offset = 0; offset < data.length; offset += chunk) {
binary += String.fromCharCode(...data.subarray(offset, offset + chunk))
}
return btoa(binary)
}
function revokePreview(url: string): void {
if (url.startsWith('blob:')) URL.revokeObjectURL(url)
}
@@ -121,8 +121,8 @@ export function ConversationSessionHeader({
* @returns the active view area, or null while the Session remains blank.
*/
export function ConversationSession({
useSession, useInput, inputActions, useStore, actions,
renderSlot, views, bindDraftMirror,
sessionId, useSession, useInput, inputActions, useStore, actions,
renderSlot, views, bindDraftMirror, releaseSessionImages,
}: ConversationSessionProps) {
useSyncExternalStore(views.subscribe, views.version)
const tabs = views.list()
@@ -143,6 +143,10 @@ export function ConversationSession({
// the machine mirror, not this seed effect.
}, [inputActions])
useEffect(() => () => {
releaseSessionImages(sessionId)
}, [releaseSessionImages, sessionId])
if (blank && composerPhase === 'blank') return null
return (
<div className={css.viewArea}>
@@ -0,0 +1,34 @@
.backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
place-items: center;
padding: 40px;
background: color-mix(in srgb, var(--dsw-alias-label-primary) 74%, transparent);
}
.image {
max-width: min(100%, 1600px);
max-height: calc(100vh - 80px);
object-fit: contain;
border-radius: 12px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv3);
}
.close {
position: fixed;
top: 20px;
right: 20px;
display: grid;
place-items: center;
width: 36px;
height: 36px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 999px;
background: var(--dsw-specific-input-major);
color: var(--dsw-alias-label-primary);
font-size: 24px;
cursor: pointer;
}
@@ -0,0 +1,40 @@
import { useEffect, useRef } from 'react'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import css from './ImageLightbox.module.css'
/** Document-level original-image preview opened by an explicit double-click. */
export function ImageLightbox({ src, alt, onClose, t }: {
src: string
alt: string
onClose: () => void
t: ChatViewSlotProps['t']
}) {
const closeRef = useRef<HTMLButtonElement | null>(null)
const restoreRef = useRef<HTMLElement | null>(null)
useEffect(() => {
restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
closeRef.current?.focus()
const onKeyDown = (event: globalThis.KeyboardEvent): void => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
restoreRef.current?.focus()
}
}, [onClose])
return (
<div
className={css.backdrop}
role="dialog"
aria-modal="true"
aria-label={t('image.preview')}
onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}
>
<img className={css.image} src={src} alt={alt} />
<button ref={closeRef} type="button" className={css.close} aria-label={t('image.closePreview')} onClick={onClose}>×</button>
</div>
)
}
@@ -102,6 +102,25 @@
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.dragActive {
border-color: var(--dsw-alias-state-business-primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 24%, transparent), var(--dsw-shadow-lv2);
}
.dropHint {
position: absolute;
z-index: 2;
inset: 4px;
display: grid;
place-items: center;
border-radius: 16px;
background: color-mix(in srgb, var(--dsw-specific-input-major) 88%, var(--dsw-alias-state-business-primary));
color: var(--dsw-alias-state-business-primary);
font-size: 14px;
font-weight: 600;
pointer-events: none;
}
.accessory {
display: flex;
align-items: center;
@@ -109,6 +128,57 @@
padding: 10px 12px 0;
}
.attachments {
display: flex;
gap: 8px;
min-width: 0;
padding: 12px 12px 0;
overflow-x: auto;
overflow-y: hidden;
}
.attachment {
position: relative;
flex: 0 0 72px;
width: 72px;
height: 72px;
}
.thumbnail {
width: 72px;
height: 72px;
padding: 0;
overflow: hidden;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 12px;
background: var(--dsw-alias-interactive-bg-hover);
cursor: zoom-in;
}
.thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.remove {
position: absolute;
top: -6px;
right: -6px;
display: grid;
place-items: center;
width: 22px;
height: 22px;
padding: 0;
border: 1px solid var(--dsw-specific-input-major);
border-radius: 999px;
background: var(--dsw-alias-label-primary);
color: var(--dsw-specific-input-major);
font-size: 16px;
line-height: 1;
cursor: pointer;
}
/* Floating overlay anchor (menu / popupSelect shell): entries position
themselves against the card (bottom: 100% + gap); closed entries render null. */
.overlayAnchor {
@@ -6,8 +6,8 @@
* region-slot content) ride the owner props. Session facts
* (running/removed/promptError) are self-selected via useSession. */
import { useEffect, useRef } from 'react'
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ChangeEvent, DragEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import { IconPlusOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
// Type-only: the `plan` projection key merge (the TodoDock posture — the
@@ -16,10 +16,11 @@ import type {} from '@deepseek-ai/dsh-plan-mode/client'
// Type-only: the `goal` projection key merge (hint disambiguation).
import type {} from '@deepseek-ai/dsh-goal/client'
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
import type { ComposerBarProps } from '../contract/slots.ts'
import type { ComposerAttachment, ComposerBarProps } from '../contract/slots.ts'
import { deriveDecorations } from '../input/decorations.ts'
import type { DraftDecorations } from '../input/decorations.ts'
import { ContextMeter } from './ContextMeter.tsx'
import { ImageLightbox } from './ImageLightbox.tsx'
import { PermissionSelect } from './PermissionSelect.tsx'
import css from './InputBar.module.css'
@@ -35,7 +36,8 @@ export interface InputBarError {
export type InputBarProps = ComposerBarProps
export function InputBar({
useSession, useInput, inputActions, keyboard, resolveSubmitMode, toggleCommandMenu, stop, command, t,
useSession, useInput, inputActions, keyboard, addImages, removeImage, draftImages,
resolveSubmitMode, toggleCommandMenu, stop, command, t,
renderSlot, useNotices, useLexicon, useMenuLauncher,
useProjection, sessionId, variant, disabled: inert = false, blocked, placeholder,
accessory, overlay, leftItems, rightItems, footer,
@@ -63,8 +65,16 @@ export function InputBar({
// current; the bar renders the same DOM inert instead of a parallel tree.
const live = input !== undefined && keyboard !== undefined && inputActions !== undefined
const draft = input?.draft ?? ''
const empty = draft.trim() === ''
const attachments = useMemo(
() => input === undefined || draftImages === undefined ? [] : draftImages(input.imageIds),
[draftImages, input?.imageIds],
)
const empty = draft.trim() === '' && attachments.length === 0
const [preview, setPreview] = useState<ComposerAttachment | null>(null)
const [dragActive, setDragActive] = useState(false)
const [dropError, setDropError] = useState<string | null>(null)
const inputRef = useRef<HTMLTextAreaElement | null>(null)
const dragDepthRef = useRef(0)
const scrollRef = useRef<HTMLDivElement | null>(null)
const mirrorRef = useRef<HTMLDivElement | null>(null)
// IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders;
@@ -99,6 +109,19 @@ export function InputBar({
// be disabled do lock it — there is no session to choose a model for.
const modelSeatLocked = removed || inert || !live
const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting'
const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && subagent === null
&& input.queue.some(row => row.placement === 'queued')
useEffect(() => {
if (input === undefined || inputActions === undefined) return
if (attachments.length !== input.imageIds.length) {
inputActions.pruneImages(attachments.map(attachment => attachment.id))
}
}, [attachments, input?.imageIds, inputActions])
useEffect(() => {
if (preview !== null && !attachments.some(attachment => attachment.id === preview.id)) setPreview(null)
}, [attachments, preview])
// Scroll the draft scrollport the minimum that brings `caret` into view — the
// browser's own behavior for typing, performed for the paths where it does
@@ -257,9 +280,19 @@ export function InputBar({
e.preventDefault()
if (e.repeat) return // held-down Enter must not machine-gun sends
if (locked || machineBusy) return
const accelerated = e.ctrlKey || e.metaKey
// Empty-draft accelerated Enter acts on the queue instead of the (empty)
// draft: the machine rejects empty drafts, so the gesture steers every
// still-pending queued message into the running turn (the dock's per-row
// steer button applied to the whole queue). Steering needs the same
// window as the per-row button: a running ordinary session.
if (accelerated && canSteerQueue) {
keyboard.steerQueue()
return
}
keyboard.submit(resolveSubmitMode(
running,
e.ctrlKey || e.metaKey ? 'accelerated' : 'enter',
accelerated ? 'accelerated' : 'enter',
subagent === null,
))
}
@@ -318,8 +351,16 @@ export function InputBar({
const onPaste = (e: React.ClipboardEvent<HTMLTextAreaElement>): void => {
if (keyboard === undefined) return // absent machine: disabled textarea, no events
if (machineBusy || locked) return
const files = Array.from(e.clipboardData.items)
.filter(item => item.kind === 'file')
.map(item => item.getAsFile())
.filter((file): file is File => file !== null)
if (files.length > 0 && addImages !== undefined) setDropError(addImages(files))
const text = e.clipboardData.getData('text/plain')
if (text === '') return
if (text === '') {
if (files.length > 0) e.preventDefault()
return
}
e.preventDefault()
const el = e.currentTarget
const sel = selectionOf(el)
@@ -333,6 +374,39 @@ export function InputBar({
keyboard.track(keyboard.snapshot.draft, caret)
}
const onDragEnter = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files')) return
event.preventDefault()
if (locked || machineBusy || addImages === undefined) return
dragDepthRef.current += 1
setDropError(null)
setDragActive(true)
}
const onDragOver = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files')) return
event.preventDefault()
event.dataTransfer.dropEffect = locked || machineBusy || addImages === undefined ? 'none' : 'copy'
}
const onDragLeave = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files') || locked || machineBusy) return
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) setDragActive(false)
}
const onDrop = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files')) return
event.preventDefault()
dragDepthRef.current = 0
setDragActive(false)
if (locked || machineBusy || addImages === undefined) return
const dropped = [...event.dataTransfer.files]
if (dropped.length > 0) setDropError(addImages(dropped))
}
const closePreview = useCallback(() => { setPreview(null) }, [])
const onSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>): void => {
// Any caret/selection gesture ends a live paste attempt (the machine
// cannot observe DOM selection). Cheap no-op when none is live.
@@ -465,9 +539,43 @@ export function InputBar({
{notice.text}
</div>
)}
<div className={css.card} data-composer-card>
{dropError !== null && <div className={css.error} role="alert">{dropError}</div>}
<div
className={clsx(css.card, dragActive && css.dragActive)}
data-composer-card
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
{dragActive && <div className={css.dropHint} role="status">{t('image.dropHint')}</div>}
{overlay !== undefined && <div className={css.overlayAnchor}>{overlay}</div>}
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
{attachments.length > 0 && (
<div className={css.attachments} role="group" aria-label={t('image.pending')}>
{attachments.map(attachment => (
<div key={attachment.id} className={css.attachment}>
<button
type="button"
className={css.thumbnail}
title={t('image.openOriginal')}
onDoubleClick={() => { setPreview(attachment) }}
>
<img src={attachment.previewUrl} alt={attachment.file.name || t('image.pending')} />
</button>
<button
type="button"
className={css.remove}
aria-label={t('image.remove', { name: attachment.file.name })}
onClick={() => {
setDropError(null)
removeImage?.(attachment.id)
}}
>×</button>
</div>
))}
</div>
)}
{/* One scrollport, two text layers. The hidden mirror renders draft+'\n' and stretches the
stack to the draft's FULL height (counting rows by '\n' cannot see soft wraps); the
absolutely-positioned backdrop and textarea ride that height, and .scroll — capped at 14
@@ -489,9 +597,17 @@ export function InputBar({
? t('placeholder.parentOffline')
: disabled
? t('placeholder.unavailable')
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
// The steer hint deliberately outranks the plan placeholder:
// while it shows, the whole-queue gesture is genuinely available
// (the gate never consults plan mode), so the actionable hint wins.
: canSteerQueue
? t('placeholder.steerQueue')
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
rows={2}
onChange={onChange}
onChange={(event) => {
setDropError(null)
onChange(event)
}}
onKeyDown={onKeyDown}
onSelect={onSelect}
onCopy={(e) => { onCopyOrCut(e, false) }}
@@ -568,6 +684,14 @@ export function InputBar({
</div>
</div>
</div>
{preview !== null && (
<ImageLightbox
src={preview.previewUrl}
alt={preview.file.name || t('image.original')}
onClose={closePreview}
t={t}
/>
)}
{footer}
</div>
)
@@ -9,8 +9,6 @@ import type { CallId, ChatStoreState, SelectionTarget } from './contract/views.t
type ChatActions = {
select: (draft: ChatStoreState, target: SelectionTarget | null) => void
setDraft: (draft: ChatStoreState, text: string) => void
clearDraft: (draft: ChatStoreState) => void
restoreDraft: (draft: ChatStoreState, text: string) => void
setView: (draft: ChatStoreState, view: string) => void
setInspect: (draft: ChatStoreState, target: { callId: CallId } | null) => void
}
@@ -21,15 +19,14 @@ type ChatActions = {
*/
export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions> {
return defineStore({
// Anchored to the contract shape: consumers read the store through
// PropsStore<ChatStore>'s SnapshotSelectorHook<ChatStoreState>, so init
// and the contract cannot drift.
init: (): ChatStoreState => ({ selection: null, draft: '', view: null, inspect: null }),
persist: 'dsh.conversation.chat',
actions: {
select: (d, target: SelectionTarget | null) => { d.selection = target },
setDraft: (d, text: string) => { d.draft = text },
clearDraft: (d) => { d.draft = '' },
// Optimistic-send failure restore: only when the user typed nothing new
// since the clear (send choreography lives in the inject factory).
restoreDraft: (d, text: string) => { if (d.draft === '') d.draft = text },
setView: (d, view: string) => { d.view = view },
setInspect: (d, target: { callId: CallId } | null) => { d.inspect = target },
},
@@ -25,8 +25,6 @@ describe('createChatStore', () => {
store.actions.setDraft('hello')
expect(store.store.getSnapshot().draft).toBe('hello')
store.actions.clearDraft()
expect(store.store.getSnapshot().draft).toBe('')
store.actions.setView('chat')
expect(store.store.getSnapshot().view).toBe('chat')
@@ -37,17 +35,6 @@ describe('createChatStore', () => {
expect(store.store.getSnapshot().inspect).toBeNull()
})
it('restoreDraft only fills an empty draft (optimistic-send rollback contract)', () => {
const store = createChatStore().create()
// Rollback path: draft was cleared by send, nothing typed since.
store.actions.restoreDraft('failed text')
expect(store.store.getSnapshot().draft).toBe('failed text')
// The user typed something new before the failure landed: keep theirs.
store.actions.setDraft('newer input')
store.actions.restoreDraft('stale text')
expect(store.store.getSnapshot().draft).toBe('newer input')
})
it('persists per scope key and rehydrates a fresh instance', () => {
const handle = createChatStore()
const s1 = handle.create('sess-1')
@@ -261,7 +261,13 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
useWorkspaces: emptyWorkspaces(),
useProjection: (() => undefined),
useInput: (() => { throw new Error('unused') }),
inputActions: { setDraft: () => {}, submit: () => {} },
inputActions: {
setDraft: () => {},
addImages: () => true,
removeImage: () => {},
pruneImages: () => {},
submit: () => {},
},
useStore: bindSnapshotSelector(chat),
actions: chat.actions,
renderSlot,
@@ -269,6 +275,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
openDetails,
openFile,
loadOlder,
loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
inspectCall,
chatScroll,
forkAt,
@@ -122,7 +122,13 @@ describe('render branch tails', () => {
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
useProjection={(() => undefined)}
useInput={(() => { throw new Error('unused') })}
inputActions={{ setDraft: () => {}, submit: () => {} }}
inputActions={{
setDraft: () => {},
addImages: () => true,
removeImage: () => {},
pruneImages: () => {},
submit: () => {},
}}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
@@ -173,7 +179,13 @@ describe('render branch tails', () => {
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
useProjection={(() => undefined)}
useInput={(() => { throw new Error('unused') })}
inputActions={{ setDraft: () => {}, submit: () => {} }}
inputActions={{
setDraft: () => {},
addImages: () => true,
removeImage: () => {},
pruneImages: () => {},
submit: () => {},
}}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
@@ -12,6 +12,8 @@ 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'
import { SessionInputShell } from '../src/client/input/facade.ts'
import type { ComposerAttachment } from '../src/client/contract/slots.ts'
import type { DraftAttachmentId } from '../src/client/input/contract.ts'
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
import { zh } from '../src/client/locales.ts'
@@ -57,6 +59,10 @@ interface BenchOptions {
subagent?: Exclude<ConversationSnapshot['subagent'], null>
disabled?: boolean
promptError?: ConversationSnapshot['promptError']
/** Authoritative queue rows served to the machine overlay (empty = none). */
queue?: ConversationSnapshot['queue']
/** The hub's steer-all face (empty-draft accelerated Enter). */
steerQueue?: () => void
variant?: 'hero' | 'composer'
placeholder?: string
t?: InputBarProps['t']
@@ -65,19 +71,41 @@ interface BenchOptions {
overlay?: React.ReactNode
leftItems?: React.ReactNode
rightItems?: React.ReactNode
attachments?: readonly ComposerAttachment[]
addImages?: (files: readonly File[]) => string | null
commandMenuOpen?: boolean
busyEnter?: 'queue' | 'steer'
toggleCommandMenu?: (selection: { start: number; end: number }) => void
}
/** One pending queue row (the runtime snapshot shape, as the dock tests build it). */
function row(id: string): ConversationSnapshot['queue'][number] {
return {
id: id as never, messageId: `message-${id}` as never, placement: 'queued',
content: [{ type: 'text', text: id }], preview: id, text: id,
}
}
/** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */
function bench(over?: BenchOptions) {
const sink = vi.fn()
const lex = over?.lexicon
const session = createSnapshotStore<ConversationSnapshot>(snapshotOf({
running: over?.running ?? false,
subagent: over?.subagent ?? null,
removed: over?.disabled ?? false,
promptError: over?.promptError ?? null,
queue: over?.queue ?? [],
}))
type ShellDeps = ConstructorParameters<typeof SessionInputShell>[0]
const shell = new SessionInputShell({
actx: SCTX,
defaultSink: sink,
queue: {
getSnapshot: () => session.getSnapshot().queue,
subscribe: fn => session.subscribe(fn),
},
...(over?.steerQueue !== undefined ? { steerQueue: over.steerQueue } : {}),
// Lexicon-only stub: adjudication untouched (undefined slash methods are
// never reached — these benches drive plain-draft flows only).
...(lex !== undefined
@@ -89,13 +117,9 @@ function bench(over?: BenchOptions) {
: {}),
})
if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft)
const session = createSnapshotStore<ConversationSnapshot>(snapshotOf({
running: over?.running ?? false,
subagent: over?.subagent ?? null,
removed: over?.disabled ?? false,
promptError: over?.promptError ?? null,
}))
if (over?.attachments !== undefined) shell.addImages(over.attachments.map(attachment => attachment.id))
const stop = vi.fn()
const removeImage = vi.fn((id: DraftAttachmentId) => { shell.removeImage(id) })
const menuLauncher = createSnapshotStore<string | null>(over?.commandMenuOpen === true ? 'command' : null)
const slotCalls: { key: string; owner: unknown }[] = []
const renderSlot = ((key: string, owner: object) => {
@@ -121,6 +145,12 @@ function bench(over?: BenchOptions) {
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
addImages: over?.addImages ?? (() => null),
removeImage,
draftImages: ids => ids.flatMap((id) => {
const attachment = over?.attachments?.find(candidate => candidate.id === id)
return attachment === undefined ? [] : [attachment]
}),
resolveSubmitMode: (running, gesture, steeringAvailable) => {
if (!running || !steeringAvailable) return 'queue'
const preferred = over?.busyEnter ?? 'queue'
@@ -150,15 +180,122 @@ function bench(over?: BenchOptions) {
)!
const interruptButton = view.container.querySelector<HTMLButtonElement>('button[aria-label="停止生成"]')
return {
view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher,
view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, removeImage, slotCalls,
menuLauncher,
steerQueue: over?.steerQueue,
}
}
describe('image draft rail', () => {
it('collects clipboard files while preserving text from a mixed paste', () => {
const addImages = vi.fn(() => null)
const { textarea, shell } = bench({ addImages })
const image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' })
fireEvent.paste(textarea, {
clipboardData: {
items: [
{ kind: 'string', type: 'text/plain', getAsFile: () => null },
{ kind: 'file', type: 'image/png', getAsFile: () => image },
],
getData: () => '同时粘贴的文字',
},
})
expect(addImages).toHaveBeenCalledWith([image])
expect(shell.snapshot.draft).toBe('同时粘贴的文字')
})
it('accepts file drops and prevents browser navigation', () => {
const addImages = vi.fn(() => null)
const { view } = bench({ addImages })
const card = view.container.querySelector('[class*="card"]')!
const image = new File([Uint8Array.of(1)], 'dropped.png', { type: 'image/png' })
const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' }
expect(fireEvent.dragEnter(card, { dataTransfer })).toBe(false)
expect(view.getByRole('status').textContent).toContain('松开以添加图片')
expect(fireEvent.dragOver(card, { dataTransfer })).toBe(false)
expect(dataTransfer.dropEffect).toBe('copy')
expect(fireEvent.drop(card, { dataTransfer })).toBe(false)
expect(addImages).toHaveBeenCalledWith([image])
})
it('sends an image-only draft and removes its thumbnail', () => {
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }
const { view, textarea, sink, removeImage } = bench({ attachments: [attachment] })
expect((view.getByRole('button', { name: '发送消息' }) as HTMLButtonElement).disabled).toBe(false)
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue')
fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' }))
expect(removeImage).toHaveBeenCalledWith('draft-1')
})
it('opens the original image on double-click and closes it with Escape', () => {
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }
const { view } = bench({ attachments: [attachment] })
fireEvent.doubleClick(view.getByTitle('双击查看原图'))
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
fireEvent.keyDown(window, { key: 'Escape' })
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
})
})
describe('Enter semantics', () => {
it('advertises the empty-draft whole-queue steering gesture when it is available', () => {
const { textarea } = bench({ running: true, queue: [row('q-1')], steerQueue: vi.fn() })
expect(textarea.placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
})
it('keeps the owning placeholder or ordinary guidance when whole-queue steering is unavailable', () => {
expect(bench({ running: true }).textarea.placeholder).toBe('给智能体发消息')
expect(bench({ queue: [row('q-1')] }).textarea.placeholder).toBe('给智能体发消息')
expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).textarea.placeholder).toBe('给智能体发消息')
expect(bench({
running: true,
queue: [row('q-1')],
subagent: {
address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' },
parentAvailable: true,
},
}).textarea.placeholder).toBe('给智能体发消息')
expect(bench({
running: true,
queue: [row('q-1')],
placeholder: '上层指定提示',
}).textarea.placeholder).toBe('上层指定提示')
// The command menu owns Enter while open: neither the hint nor the
// gesture may claim the chord.
expect(bench({
running: true,
queue: [row('q-1')],
commandMenuOpen: true,
}).textarea.placeholder).toBe('给智能体发消息')
// The steer hint intentionally outranks the plan placeholder: while it
// shows, the whole-queue gesture is genuinely available in plan mode.
expect(bench({
running: true,
queue: [row('q-1')],
plan: { active: true, pending: false },
}).textarea.placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
})
it('an open command menu withholds the whole-queue steering gesture', () => {
const steerQueue = vi.fn()
const { textarea, sink } = bench({
running: true,
queue: [row('q-1')],
commandMenuOpen: true,
steerQueue,
})
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
expect(steerQueue).not.toHaveBeenCalled()
expect(sink).not.toHaveBeenCalled()
})
it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => {
const { textarea, sink } = bench({ draft: 'hello' })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('hello', 'queue')
expect(sink).toHaveBeenCalledWith('hello', [], 'queue')
fireEvent.keyDown(textarea, { key: 'Enter', repeat: true })
expect(sink).toHaveBeenCalledTimes(1)
const empty = bench({ draft: ' ' })
@@ -183,15 +320,87 @@ describe('Enter semantics', () => {
it('Ctrl/Meta+Enter sends normally while idle and steers while running', () => {
const idle = bench({ draft: 'hello' })
fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true })
expect(idle.sink).toHaveBeenCalledWith('hello', 'queue')
expect(idle.sink).toHaveBeenCalledWith('hello', [], 'queue')
const busyCtrl = bench({ running: true, draft: 'steer with ctrl' })
fireEvent.keyDown(busyCtrl.textarea, { key: 'Enter', ctrlKey: true })
expect(busyCtrl.sink).toHaveBeenCalledWith('steer with ctrl', 'steer')
expect(busyCtrl.sink).toHaveBeenCalledWith('steer with ctrl', [], 'steer')
const busyMeta = bench({ running: true, draft: 'steer with cmd' })
fireEvent.keyDown(busyMeta.textarea, { key: 'Enter', metaKey: true })
expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', 'steer')
expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', [], 'steer')
})
it('empty-draft Cmd/Ctrl+Enter steers the whole queue instead of submitting', () => {
const steerQueue = vi.fn()
const queue = [row('q-1'), row('q-2')]
const meta = bench({ running: true, queue, steerQueue })
fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true })
expect(meta.steerQueue).toHaveBeenCalledTimes(1)
expect(meta.sink).not.toHaveBeenCalled()
const ctrl = bench({ running: true, queue, steerQueue: vi.fn() })
fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true })
expect(ctrl.steerQueue).toHaveBeenCalledTimes(1)
expect(ctrl.sink).not.toHaveBeenCalled()
})
it('queue steering stays gated: idle, subagent, plain Enter, empty queue, or steering-only rows', () => {
// Idle: the gesture falls through to the machine's empty-draft no-op.
const idle = bench({ queue: [row('q-1')], steerQueue: vi.fn() })
fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true })
expect(idle.steerQueue).not.toHaveBeenCalled()
expect(idle.sink).not.toHaveBeenCalled()
// Plain Enter never steers the queue, even under the busy Steer preference.
const plain = bench({ running: true, busyEnter: 'steer', queue: [row('q-1')], steerQueue: vi.fn() })
fireEvent.keyDown(plain.textarea, { key: 'Enter' })
expect(plain.steerQueue).not.toHaveBeenCalled()
expect(plain.sink).not.toHaveBeenCalled()
// Subagent sessions keep the queue transport (no steering face).
const subagent = {
address: {
parentSessionId: 'parent' as SessionId,
childSessionId: SID,
mode: 'continuable' as const,
},
parentAvailable: true,
}
const child = bench({ running: true, subagent, queue: [row('q-1')], steerQueue: vi.fn() })
fireEvent.keyDown(child.textarea, { key: 'Enter', metaKey: true })
expect(child.steerQueue).not.toHaveBeenCalled()
expect(child.sink).not.toHaveBeenCalled()
// No queued rows: the empty draft stays a no-op.
const none = bench({ running: true, steerQueue: vi.fn() })
fireEvent.keyDown(none.textarea, { key: 'Enter', metaKey: true })
expect(none.steerQueue).not.toHaveBeenCalled()
expect(none.sink).not.toHaveBeenCalled()
// Pending steering rows are not the queue: nothing to flush.
const steering = bench({
running: true,
queue: [{ ...row('s-1'), placement: 'steering' }],
steerQueue: vi.fn(),
})
fireEvent.keyDown(steering.textarea, { key: 'Enter', metaKey: true })
expect(steering.steerQueue).not.toHaveBeenCalled()
expect(steering.sink).not.toHaveBeenCalled()
})
it('draft content outranks the queue: accelerated Enter steers the draft only', () => {
const steerQueue = vi.fn()
const { textarea, sink } = bench({ running: true, queue: [row('q-1')], draft: '插话', steerQueue })
fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true })
expect(sink).toHaveBeenCalledWith('插话', [], 'steer')
expect(steerQueue).not.toHaveBeenCalled()
})
it('empty-draft accelerated Enter without a steerQueue face stays a silent no-op', () => {
const { textarea, sink } = bench({ running: true, queue: [row('q-1')] })
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
expect(sink).not.toHaveBeenCalled()
})
it('platform undo/redo chords route to the machine, never the browser stack', () => {
@@ -232,7 +441,7 @@ describe('running and lock semantics', () => {
expect(textarea.disabled).toBe(false)
fireEvent.change(textarea, { target: { value: '排队消息2' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('排队消息2', 'queue')
expect(sink).toHaveBeenCalledWith('排队消息2', [], 'queue')
expect(button.getAttribute('aria-label')).toBe('停止生成')
fireEvent.click(button)
expect(stop).toHaveBeenCalledTimes(1)
@@ -241,17 +450,17 @@ describe('running and lock semantics', () => {
it('running plain Enter follows the busy-state Steer preference', () => {
const { textarea, sink } = bench({ running: true, busyEnter: 'steer', draft: '直接插话' })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('直接插话', 'steer')
expect(sink).toHaveBeenCalledWith('直接插话', [], 'steer')
})
it('running Cmd/Ctrl+Enter uses the opposite of the busy-state Enter preference', () => {
const meta = bench({ running: true, busyEnter: 'steer', draft: '排到下一轮' })
fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true })
expect(meta.sink).toHaveBeenCalledWith('排到下一轮', 'queue')
expect(meta.sink).toHaveBeenCalledWith('排到下一轮', [], 'queue')
const ctrl = bench({ running: true, busyEnter: 'steer', draft: 'also queue' })
fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true })
expect(ctrl.sink).toHaveBeenCalledWith('also queue', 'queue')
expect(ctrl.sink).toHaveBeenCalledWith('also queue', [], 'queue')
})
it('running continuable subagent keeps Send beside an independent Stop', () => {
@@ -271,7 +480,7 @@ describe('running and lock semantics', () => {
expect(interruptButton).not.toBeNull()
expect(textarea.disabled).toBe(false)
fireEvent.click(button)
expect(sink).toHaveBeenCalledWith('后续消息', 'queue')
expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue')
fireEvent.click(interruptButton!)
expect(stop).toHaveBeenCalledTimes(1)
})
@@ -328,11 +537,11 @@ describe('running and lock semantics', () => {
}
const plain = bench({ running: true, busyEnter: 'steer', draft: 'plain', subagent })
fireEvent.keyDown(plain.textarea, { key: 'Enter' })
expect(plain.sink).toHaveBeenCalledWith('plain', 'queue')
expect(plain.sink).toHaveBeenCalledWith('plain', [], 'queue')
const accelerated = bench({ running: true, draft: 'accelerated', subagent })
fireEvent.keyDown(accelerated.textarea, { key: 'Enter', metaKey: true })
expect(accelerated.sink).toHaveBeenCalledWith('accelerated', 'queue')
expect(accelerated.sink).toHaveBeenCalledWith('accelerated', [], 'queue')
})
it('disabled (session removed) locks the textarea and chrome', () => {
@@ -345,7 +554,7 @@ describe('running and lock semantics', () => {
it('idle primary sends and disables on empty draft', () => {
const { button, sink } = bench({ draft: 'go' })
fireEvent.click(button)
expect(sink).toHaveBeenCalledWith('go', 'queue')
expect(sink).toHaveBeenCalledWith('go', [], 'queue')
const empty = bench()
expect(empty.button.disabled).toBe(true)
})
@@ -470,7 +679,7 @@ describe('running and lock semantics', () => {
}
// Pasted text lands below the fold: scroll down by exactly the overshoot.
caretAt(500)
fireEvent.paste(textarea, { clipboardData: { getData: () => 'pasted' } })
fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'pasted' } })
await settle()
expect(scroll.scrollTop).toBe(88) // 524 - 436
// Measured on the mirror's own text, at the index the paste left the caret
@@ -479,12 +688,12 @@ describe('running and lock semantics', () => {
expect(measured!.offset).toBe('pasted'.length)
// A caret already inside the box does not move it.
caretAt(200)
fireEvent.paste(textarea, { clipboardData: { getData: () => 'more' } })
fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'more' } })
await settle()
expect(scroll.scrollTop).toBe(88)
// Above the fold (a cut can leave it there): scroll back up.
caretAt(60)
fireEvent.paste(textarea, { clipboardData: { getData: () => 'again' } })
fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'again' } })
await settle()
expect(scroll.scrollTop).toBe(48) // 88 - (100 - 60)
// A caret straight after a newline has nothing on its line to measure, so
@@ -492,7 +701,7 @@ describe('running and lock semantics', () => {
// chromium reports no client rects at all for the collapsed position.
mirror.style.lineHeight = '24px'
caretAt(500)
fireEvent.paste(textarea, { clipboardData: { getData: () => 'block\n' } })
fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'block\n' } })
await settle()
// The four pastes accumulate at the draft's head, so the caret is at the
// end of what they inserted — and the measured index is the newline before it.
@@ -48,6 +48,9 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
addImages: () => null,
removeImage: () => {},
draftImages: () => [],
resolveSubmitMode: () => 'queue',
toggleCommandMenu: vi.fn(),
useNotices: bindSnapshotSelector(shell.notices),
@@ -90,7 +93,7 @@ describe('matrix row: plain', () => {
fireEvent.change(textarea, { target: { value: '普通消息' } })
expect(shell.snapshot.claim).toBeUndefined()
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('普通消息', 'queue')
expect(sink).toHaveBeenCalledWith('普通消息', [], 'queue')
expect(shell.snapshot.phase).toBe('plain')
})
})
@@ -189,7 +192,7 @@ describe('matrix row: locked (session disabled)', () => {
expect((textarea).disabled).toBe(false)
fireEvent.change(textarea, { target: { value: '排队' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('排队', 'queue')
expect(sink).toHaveBeenCalledWith('排队', [], 'queue')
})
})
@@ -134,6 +134,9 @@ async function scopedBench(register?: (slash: SlashService) => void) {
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
addImages: () => null,
removeImage: () => {},
draftImages: () => [],
resolveSubmitMode: () => 'queue',
toggleCommandMenu: (selection) => {
const snapshot = shell.snapshot
@@ -237,7 +240,7 @@ describe('scenario D: execute-kind /compact', () => {
act(() => { b2.shell.setDraft('/compact 现在') })
fireEvent.keyDown(b2.textarea, { key: 'Enter' })
// execute with trailing → matchEnter answers undefined → default sink.
await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', 'queue') })
await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', [], 'queue') })
expect(b2.executed).toHaveLength(0)
})
})
@@ -291,7 +294,7 @@ describe('scenario I: unknown /xyz + enter', () => {
const b = await bench()
act(() => { b.shell.setDraft('/xyz 干点啥') })
fireEvent.keyDown(b.textarea, { key: 'Enter' })
await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', 'queue') })
await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', [], 'queue') })
expect(b.shell.snapshot.phase).toBe('plain')
expect(b.execute).not.toHaveBeenCalled()
})
@@ -0,0 +1,81 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { MessageImage } from '../src/client/chat/MessageImage.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { en, zh } from '../src/client/locales.ts'
afterEach(cleanup)
const t = makeTranslate(zh, commonZh)
const enT = makeTranslate(en, commonZh)
const attachment = {
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
mediaType: 'image/png' as const,
bytes: 68,
width: 640,
height: 320,
name: 'history.png',
}
describe('MessageImage', () => {
it('loads a session-authorized URL, bounds the thumbnail, and double-clicks into the original', async () => {
const load = vi.fn().mockResolvedValue('blob:history')
const view = render(<MessageImage attachment={attachment} load={load} t={t} />)
const frame = view.getByRole('button', { name: 'history.png,双击查看原图' })
expect(frame.getAttribute('style')).toContain('width: 240px')
expect(frame.getAttribute('style')).toContain('height: 120px')
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
expect(load).toHaveBeenCalledWith(attachment)
fireEvent.doubleClick(frame)
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
fireEvent.click(view.getByRole('button', { name: '关闭原图预览' }))
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
})
it('surfaces a retry control when durable bytes cannot be read', async () => {
const load = vi.fn()
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce('blob:retry')
const view = render(<MessageImage attachment={attachment} load={load} t={t} />)
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
fireEvent.click(retry)
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
expect(load).toHaveBeenCalledTimes(2)
})
it('renders image controls from the active English dictionary', async () => {
const load = vi.fn().mockResolvedValue('blob:history')
const view = render(<MessageImage attachment={attachment} load={load} t={enT} />)
const frame = view.getByRole('button', { name: 'history.png, double-click to view original' })
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
fireEvent.doubleClick(frame)
expect(view.getByRole('dialog', { name: 'Original image preview' })).toBeTruthy()
expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy()
})
it('keeps assistant images at their original position between text blocks', async () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[
{ kind: 'text', text: 'before' },
{ kind: 'image', attachment },
{ kind: 'text', text: 'after' },
]}
streaming={false}
loadImage={() => Promise.resolve('blob:middle')}
/>,
)
const image = await view.findByAltText('history.png')
const before = view.getByText('before')
const after = view.getByText('after')
expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
expect(image.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
})
})
@@ -61,7 +61,8 @@ function liveSession(initial: ConversationSnapshot) {
}
}
const INPUT_STATE: InputState = { draft: '', draftRev: 0, phase: 'plain', occurrences: [], queue: [] }
/** InputZone owner stub (the dock reads useSession only; the zone fields satisfy the owner share). */
const INPUT_STATE: InputState = { draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [] }
// Standard locale seat stub mirroring the real ns → common → key chain.
const t: QueueDockProps['t'] = makeTranslate(zh, commonZh)
@@ -5,12 +5,15 @@
// tag probe).
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import type { QueuedMessage, SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
import { ComposerBlockRegistry } from '../src/client/input/blocks.ts'
import { InputHub } from '../src/client/input/hub.ts'
import { ConversationService, UnsupportedImageMediaTypeError } from '../src/client/service.ts'
import { zh } from '../src/client/locales.ts'
async function bench() {
async function bench(readAttachment?: SessionFace['readAttachment']) {
const runtime = await SlotTestRuntime.create()
const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
const updateQueue = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
@@ -18,18 +21,20 @@ async function bench() {
const loadOlder = vi.fn(() => Promise.resolve())
await runtime.sessions.add({
id: 's1',
session: { prompt, updateQueue, cancel, loadOlder },
session: { prompt, updateQueue, cancel, loadOlder, ...(readAttachment === undefined ? {} : { readAttachment }) },
})
// config.input is required (the apply shares its hub with the inject
// factories); the bench passes its own instance explicitly.
const hub = new InputHub(runtime.ctx, makeTranslate(zh, {}))
const fiber = runtime.ctx.plugin(ConversationService, {
input: new InputHub(runtime.ctx),
input: hub,
blocks: new ComposerBlockRegistry(),
})
await fiber.await()
const root = runtime.ctx.get('conversation') as ConversationService
const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService
return { runtime, root, scoped, prompt, updateQueue, cancel, loadOlder }
const shell = hub.shellFor(runtime.sessions.binding('s1')!)
return { runtime, fiber, root, scoped, hub, shell, prompt, updateQueue, cancel, loadOlder }
}
describe('ConversationService', () => {
@@ -78,6 +83,52 @@ describe('ConversationService', () => {
await b.runtime.dispose()
})
it('releases draft previews when their session scope is disposed', async () => {
const b = await bench()
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:draft-1')
const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined)
try {
const [attachment] = b.root.createDraftImages([
new File([new Uint8Array(4)], 'a.png', { type: 'image/png' }),
])
if (attachment === undefined) throw new Error('draft attachment missing')
b.root.input.for(b.runtime.sessions.scope('s1')!).addImages([attachment.id])
await b.runtime.sessions.remove('s1')
expect(b.root.draftImages([attachment.id])).toEqual([])
expect(revoked).toHaveBeenCalledWith('blob:draft-1')
} finally {
created.mockRestore()
revoked.mockRestore()
}
await b.runtime.dispose()
})
it('validates every MIME type before allocating previews', async () => {
const b = await bench()
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview')
expect(() => b.root.createDraftImages([
new File([Uint8Array.of(1)], 'valid.png', { type: 'image/png' }),
new File([Uint8Array.of(2)], 'invalid.svg', { type: 'image/svg+xml' }),
])).toThrow(UnsupportedImageMediaTypeError)
expect(created).not.toHaveBeenCalled()
created.mockRestore()
await b.runtime.dispose()
})
it('invalidates pending historical image loads when the rendered session is released', async () => {
const read = Promise.withResolvers<Awaited<ReturnType<SessionFace['readAttachment']>>>()
const b = await bench(() => read.promise)
const sessionId = b.runtime.sessions.behavior('s1').sessionId
const attachment = {
attachmentId: AttachmentId('image-1'), mediaType: 'image/png', bytes: 1, width: 1, height: 1,
} as const
const pending = b.root.resolveImage(sessionId, attachment)
b.root.releaseSessionImages(sessionId)
read.resolve({ ok: true, value: { attachment, data: Uint8Array.of(1) } })
await expect(pending).rejects.toThrow('historical image scope was released')
await b.runtime.dispose()
})
it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => {
const b = await bench()
await expect(b.root.send('x')).rejects.toThrow(/requires a session scope/)
@@ -87,10 +138,88 @@ describe('ConversationService', () => {
// No SessionsService at all: a bare context (the runtime always provides one).
const bare = new Context()
await bare.plugin(ConversationService, {
input: new InputHub(bare),
input: new InputHub(bare, makeTranslate(zh, {})),
blocks: new ComposerBlockRegistry(),
}).await()
const orphan = bare.get('conversation') as ConversationService
await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/)
})
})
describe('InputHub queue steering (empty-draft accelerated Enter)', () => {
const row = (id: string): QueuedMessage => ({
id: id as never,
messageId: `message-${id}` as never,
placement: 'queued',
content: [{ type: 'text', text: id }],
preview: id,
text: id,
})
it('steers every queued row in FIFO order and leaves steering rows alone', async () => {
const b = await bench()
await b.runtime.sessions.updateSnapshot('s1', (draft) => {
draft.queue = [row('q-1'), { ...row('q-2'), placement: 'steering' }, row('q-3')]
})
b.shell.steerQueue()
await vi.waitFor(() => {
expect(b.updateQueue).toHaveBeenCalledTimes(2)
})
expect(b.updateQueue).toHaveBeenNthCalledWith(1, 'q-1', { kind: 'steer' })
expect(b.updateQueue).toHaveBeenNthCalledWith(2, 'q-3', { kind: 'steer' })
expect(b.shell.notices.getSnapshot()).toBeNull()
await b.runtime.dispose()
})
it('converges silently when the turn closes or a row is claimed mid-steer', async () => {
const b = await bench()
await b.runtime.sessions.updateSnapshot('s1', (draft) => {
draft.queue = [row('q-1'), row('q-2')]
})
// The turn closes before the second row: the flush stops, silently.
b.updateQueue.mockResolvedValueOnce({
ok: false, error: { code: 'steer-unavailable', message: 'closed', details: {} },
} as never)
b.shell.steerQueue()
await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(1) })
expect(b.shell.notices.getSnapshot()).toBeNull()
// A row the host already claimed (e.g. a repeated empty-draft chord):
// the duplicate strict steer is a silent no-op.
await b.runtime.sessions.updateSnapshot('s1', (draft) => {
draft.queue = [row('q-3')]
})
b.updateQueue.mockResolvedValueOnce({
ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} },
} as never)
b.shell.steerQueue()
await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(2) })
expect(b.shell.notices.getSnapshot()).toBeNull()
await b.runtime.dispose()
})
it('surfaces one notice on a genuine steer failure and stops', async () => {
const b = await bench()
await b.runtime.sessions.updateSnapshot('s1', (draft) => {
draft.queue = [row('q-1'), row('q-2')]
})
b.updateQueue.mockResolvedValueOnce({
ok: false, error: { code: 'internal', message: 'broken', details: {} },
} as never)
b.shell.steerQueue()
await vi.waitFor(() => {
expect(b.shell.notices.getSnapshot()).toEqual(
expect.objectContaining({ level: 'error', text: '插话发送失败,请重试。' }),
)
})
expect(b.updateQueue).toHaveBeenCalledTimes(1)
await b.runtime.dispose()
})
it('no-ops without queued rows', async () => {
const b = await bench()
b.shell.steerQueue()
expect(b.updateQueue).not.toHaveBeenCalled()
await b.runtime.dispose()
})
})
@@ -179,6 +179,7 @@ function mount(
subscribe: () => () => {},
version: () => 1,
}}
releaseSessionImages={vi.fn()}
bindDraftMirror={write => wiring.bindMirror(write)}
/>
)
@@ -198,6 +199,9 @@ function mount(
useInput={useInput}
inputActions={inputActions}
keyboard={wiring}
addImages={() => null}
removeImage={() => {}}
draftImages={() => []}
resolveSubmitMode={() => 'queue'}
toggleCommandMenu={vi.fn()}
useNotices={bindSnapshotSelector(wiring.notices)}
@@ -301,7 +305,7 @@ describe('ConversationRoot resident composer', () => {
fireEvent.change(box, { target: { value: 'ordinary revised' } })
expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised')
fireEvent.keyDown(box, { key: 'Enter' })
expect(b.sink).toHaveBeenCalledWith('ordinary revised', 'queue')
expect(b.sink).toHaveBeenCalledWith('ordinary revised', [], 'queue')
expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true)
expect(b.view.queryByText('Root')).toBeNull()
})
@@ -8,6 +8,12 @@
"src"
],
"references": [
{
"path": "../../attachment/attachment"
},
{
"path": "../../util/brand"
},
{
"path": "../../../vendor/cordis"
},