Merge remote-tracking branch 'origin/master' into worktree/web-theme-settings-integration-fde706

# Conflicts:
#	docs/module-graph.i18n.yaml
#	docs/module-graph.md
#	docs/module-graph.zh.md
#	packages/client/ui-conversation/package.json
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-08-10 15:58:52 +08:00
193 changed files with 5815 additions and 507 deletions

View File

@@ -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
@@ -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) => {

View File

@@ -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 (

View File

@@ -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}
/>

View File

@@ -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

View File

@@ -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 && (

View File

@@ -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;
}

View File

@@ -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>
)
}

View File

@@ -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. */

View File

@@ -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 => (

View File

@@ -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
/**

View File

@@ -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,

View File

@@ -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
}
@@ -192,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'

View File

@@ -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'
@@ -45,7 +45,7 @@ export interface SessionInputDeps {
*/
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. */
@@ -74,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') },
}
@@ -82,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
@@ -103,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' }))
}
@@ -150,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') {
@@ -364,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?.()
@@ -386,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()
@@ -444,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 {

View File

@@ -12,7 +12,7 @@ import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId }
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'
@@ -22,6 +22,17 @@ 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>()
@@ -64,7 +75,7 @@ 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)
@@ -83,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
@@ -132,19 +146,25 @@ 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)
})
}
/**
@@ -186,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
}
}

View File

@@ -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 } : {}) } } : {}),

View File

@@ -25,6 +25,20 @@ export const zh = {
'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': '系统提示词',
@@ -169,6 +183,20 @@ export const en = {
'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',

View File

@@ -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)
}

View File

@@ -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}>

View File

@@ -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;
}

View File

@@ -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>
)
}

View File

@@ -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 {

View File

@@ -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;
@@ -102,6 +112,17 @@ export function InputBar({
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
// not act.
@@ -330,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)
@@ -345,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.
@@ -477,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
@@ -508,7 +604,10 @@ export function InputBar({
? 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) }}
@@ -585,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>
)

View File

@@ -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 },
},