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:
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } : {}) } } : {}),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user