Merge remote-tracking branch 'origin/master' into feat/web-message-feedback-ui
Resolve additive conflicts in the api-remotes client assembly by keeping both the message-feedback remote mount and master's forwarded-event allowlist, and regenerate the module graph.
This commit is contained in:
@@ -35,6 +35,8 @@
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-client-ui-layout"
|
||||
],
|
||||
"platform": "web"
|
||||
@@ -51,49 +53,55 @@
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-permission": "workspace:^",
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
bindSettingsScope, resolveWorkspacePath, type ISessions, type SessionId,
|
||||
resolveWorkspacePath, type ISessions, type SessionId,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: the ctx.settingsScope Context merge. Cross-plugin collaboration
|
||||
// goes through the service, never a value import (client bundle purity gate).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
@@ -46,7 +49,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
|
||||
/** Services required by the conversation plugin. */
|
||||
export const inject = [
|
||||
'slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection',
|
||||
'slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection', 'remote', 'settingsScope',
|
||||
'conversationEvents', 'conversationViews',
|
||||
]
|
||||
|
||||
@@ -128,7 +131,7 @@ export function apply(ctx: Context): void {
|
||||
// Apply-time construction keeps store identity bound to this fiber.
|
||||
const chatStore = createChatStore()
|
||||
const submissionPolicy = new ComposerSubmissionPolicy(
|
||||
bindSettingsScope<ConversationSettings>(ctx, { namespace: CONVERSATION_SETTINGS_NAMESPACE }),
|
||||
ctx.settingsScope.bind<ConversationSettings>({ namespace: CONVERSATION_SETTINGS_NAMESPACE }),
|
||||
)
|
||||
|
||||
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
||||
|
||||
@@ -13,8 +13,9 @@ import { memo, useMemo } from 'react'
|
||||
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 { ImageGallery, type ImageLoader } from '@deepseek-ai/dsh-client-ui-attachment'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { ImageGallery, type ImageLoader } from './MessageImage.tsx'
|
||||
import { messageImageLabels } from '../image-labels.ts'
|
||||
import { ReasoningRow } from './ReasoningRow.tsx'
|
||||
import css from './AssistantMarkdown.module.css'
|
||||
|
||||
@@ -62,7 +63,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} />
|
||||
case 'image': return <ImageGallery key={i} images={[block]} load={imageLoader} align="start" labels={messageImageLabels(t)} />
|
||||
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
|
||||
case 'tool-call': return null
|
||||
default: return (
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -10,10 +10,11 @@ import type {
|
||||
} 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 { ImageGallery, type ImageLoader } from '@deepseek-ai/dsh-client-ui-attachment'
|
||||
import { messageImageLabels } from '../image-labels.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'
|
||||
|
||||
type UserImage = Extract<UserMessageNode['content'][number], { type: 'image' }>
|
||||
@@ -177,7 +178,7 @@ function UserStyleBubble({
|
||||
return (
|
||||
<div className={css.userRow} data-pending-steering={pending || undefined} data-time-hover-root>
|
||||
<div className={css.userStack}>
|
||||
<ImageGallery images={images} load={imageLoader} align="end" t={t} />
|
||||
<ImageGallery images={images} load={imageLoader} align="end" labels={messageImageLabels(t)} />
|
||||
{showBubble && <div className={css.bubble}>
|
||||
{projectUserText(text)}
|
||||
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
|
||||
|
||||
48
packages/client/ui-conversation/src/client/image-labels.ts
Normal file
48
packages/client/ui-conversation/src/client/image-labels.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/** Bridges the `conversation` locale namespace to the zero-cordis attachment
|
||||
* atoms' label props (`@deepseek-ai/dsh-client-ui-attachment` reads no
|
||||
* application state; owners resolve every string). */
|
||||
|
||||
import type {
|
||||
AttachmentRailLabels, ImageLightboxLabels, MessageImageLabels,
|
||||
} from '@deepseek-ai/dsh-client-ui-attachment'
|
||||
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ConversationKey } from './locales.ts'
|
||||
|
||||
/**
|
||||
* Resolve the original-image lightbox strings.
|
||||
* @param t - the conversation-namespace translate.
|
||||
* @returns the lightbox dialog and close-control labels.
|
||||
*/
|
||||
export function lightboxLabels(t: Translate<ConversationKey>): ImageLightboxLabels {
|
||||
return { dialog: t('image.preview'), close: t('image.closePreview') }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the chat-history image strings.
|
||||
* @param t - the conversation-namespace translate.
|
||||
* @returns the message-image labels including the forwarded lightbox strings.
|
||||
*/
|
||||
export function messageImageLabels(t: Translate<ConversationKey>): MessageImageLabels {
|
||||
return {
|
||||
image: t('image.label'),
|
||||
open: t('image.openOriginal'),
|
||||
openNamed: label => t('image.openOriginalLabel', { label }),
|
||||
loading: t('image.loading'),
|
||||
loadFailed: t('image.loadFailed'),
|
||||
lightbox: lightboxLabels(t),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the composer draft-image rail strings.
|
||||
* @param t - the conversation-namespace translate.
|
||||
* @returns the rail group, open-tooltip, and paging-arrow labels.
|
||||
*/
|
||||
export function attachmentRailLabels(t: Translate<ConversationKey>): AttachmentRailLabels {
|
||||
return {
|
||||
group: t('image.pending'),
|
||||
open: t('image.openOriginal'),
|
||||
scrollLeft: t('image.scrollLeft'),
|
||||
scrollRight: t('image.scrollRight'),
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,11 @@ export const zh = {
|
||||
'input.accessMode': '访问模式,当前:{name}',
|
||||
'image.dropHint': '松开以添加图片',
|
||||
'image.pending': '待发送图片',
|
||||
'image.openOriginal': '双击查看原图',
|
||||
'image.openOriginalLabel': '{label},双击查看原图',
|
||||
'image.openOriginal': '查看原图',
|
||||
'image.openOriginalLabel': '{label},点击查看原图',
|
||||
'image.remove': '移除图片 {name}',
|
||||
'image.scrollLeft': '向左滚动图片',
|
||||
'image.scrollRight': '向右滚动图片',
|
||||
'image.original': '原图',
|
||||
'image.label': '图片',
|
||||
'image.loadFailed': '图片加载失败,点击重试',
|
||||
@@ -184,9 +186,11 @@ export const en = {
|
||||
'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.openOriginal': 'View original',
|
||||
'image.openOriginalLabel': '{label}, click to view original',
|
||||
'image.remove': 'Remove image {name}',
|
||||
'image.scrollLeft': 'Scroll images left',
|
||||
'image.scrollRight': 'Scroll images right',
|
||||
'image.original': 'Original image',
|
||||
'image.label': 'Image',
|
||||
'image.loadFailed': 'Image failed to load; click to retry',
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -35,22 +35,6 @@
|
||||
padding: 0 var(--dsh-composer-side-clearance);
|
||||
}
|
||||
|
||||
.error,
|
||||
.status {
|
||||
width: 100%;
|
||||
max-width: var(--dsh-composer-card-max-width);
|
||||
margin-bottom: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.status {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.notice {
|
||||
width: 100%;
|
||||
max-width: var(--dsh-composer-card-max-width);
|
||||
@@ -68,11 +52,6 @@
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.error {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.card {
|
||||
box-sizing: border-box;
|
||||
position: relative; /* overlay anchor positioning context */
|
||||
@@ -162,55 +141,13 @@
|
||||
padding: 10px 12px 0;
|
||||
}
|
||||
|
||||
/* Rail seat: the card's top padding (10px) plus this 4px matches DeepSeek
|
||||
Chat's spacing above the thumbnails; the card's 12px flex gap owns the space
|
||||
below. The rail itself (arrows, hidden scrollbar, card geometry) is the
|
||||
ui-attachment atom's. */
|
||||
.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;
|
||||
padding: 4px 12px 0;
|
||||
}
|
||||
|
||||
/* Floating overlay anchor (menu / popupSelect shell): entries position
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
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'
|
||||
import {
|
||||
IconPlusOutline16, IconWarningOutline16, Toast, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { AttachmentRail, ImageLightbox } from '@deepseek-ai/dsh-client-ui-attachment'
|
||||
import type { AttachmentRailItem } from '@deepseek-ai/dsh-client-ui-attachment'
|
||||
// Type-only: the `plan` projection key merge (the TodoDock posture — the
|
||||
// composer reads a host-computed value; the domain owns the key).
|
||||
import type {} from '@deepseek-ai/dsh-plan-mode/client'
|
||||
@@ -19,18 +23,17 @@ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ComposerAttachment, ComposerBarProps } from '../contract/slots.ts'
|
||||
import { deriveDecorations } from '../input/decorations.ts'
|
||||
import type { DraftDecorations } from '../input/decorations.ts'
|
||||
import { attachmentRailLabels, lightboxLabels } from '../image-labels.ts'
|
||||
import { ContextMeter } from './ContextMeter.tsx'
|
||||
import { ImageLightbox } from './ImageLightbox.tsx'
|
||||
import { PermissionSelect } from './PermissionSelect.tsx'
|
||||
import css from './InputBar.module.css'
|
||||
|
||||
/** Decoration product of the no-session state (no machine, empty draft). */
|
||||
const INERT_DECORATIONS: DraftDecorations = { token: null, chips: [], textRefs: [], hint: null }
|
||||
|
||||
/** Prompt failure surface (derived from promptError). */
|
||||
export interface InputBarError {
|
||||
op: 'send' | 'stop'
|
||||
message: string
|
||||
/** Rail thumbnail carrying its source attachment for the open/remove callbacks. */
|
||||
interface ComposerRailItem extends AttachmentRailItem {
|
||||
attachment: ComposerAttachment
|
||||
}
|
||||
|
||||
export type InputBarProps = ComposerBarProps
|
||||
@@ -56,12 +59,6 @@ export function InputBar({
|
||||
const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active))
|
||||
// Absent (undefined: no frame yet) and cleared (null) both mean no goal.
|
||||
const hasGoal = useProjection('goal', goal => goal != null)
|
||||
// Prompt failures are ordinary failures (no create/attach transaction
|
||||
// exists anymore): the strip renders promptError, the draft stays in the
|
||||
// machine, and the user resubmits.
|
||||
const error: InputBarError | null = promptError === null
|
||||
? null
|
||||
: { op: promptError.op, message: `${promptError.error.message} (${promptError.error.code})` }
|
||||
// Session-maybe: the machine faces are absent together while no session is
|
||||
// current; the bar renders the same DOM inert instead of a parallel tree.
|
||||
const live = input !== undefined && keyboard !== undefined && inputActions !== undefined
|
||||
@@ -73,8 +70,26 @@ export function InputBar({
|
||||
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)
|
||||
// Transient error banner (image-intake rejections and prompt failures): the
|
||||
// seq keys the Toast so an identical repeated message restarts the
|
||||
// hold-then-fade cycle instead of silently reusing the faded one.
|
||||
const [toast, setToast] = useState<{ seq: number; text: string } | null>(null)
|
||||
const toastSeq = useRef(0)
|
||||
const showToast = useCallback((text: string) => {
|
||||
toastSeq.current += 1
|
||||
setToast({ seq: toastSeq.current, text })
|
||||
}, [])
|
||||
const dismissToast = useCallback(() => { setToast(null) }, [])
|
||||
// Prompt failures are ordinary failures (no create/attach transaction exists
|
||||
// anymore): the toast announces promptError, the draft stays in the machine,
|
||||
// and the user resubmits. A remount over a session whose machine still holds
|
||||
// an unresolved promptError deliberately re-announces it once — the failure
|
||||
// is still pending, and a transient banner is its only surface.
|
||||
useEffect(() => {
|
||||
if (promptError !== null) showToast(`${promptError.error.message} (${promptError.error.code})`)
|
||||
}, [promptError, showToast])
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const cardRef = useRef<HTMLDivElement | null>(null)
|
||||
const dragDepthRef = useRef(0)
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const mirrorRef = useRef<HTMLDivElement | null>(null)
|
||||
@@ -369,7 +384,10 @@ export function InputBar({
|
||||
.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))
|
||||
if (files.length > 0 && addImages !== undefined) {
|
||||
const rejected = addImages(files)
|
||||
if (rejected !== null) showToast(rejected)
|
||||
}
|
||||
const text = e.clipboardData.getData('text/plain')
|
||||
if (text === '') {
|
||||
if (files.length > 0) e.preventDefault()
|
||||
@@ -393,7 +411,6 @@ export function InputBar({
|
||||
event.preventDefault()
|
||||
if (locked || machineBusy || addImages === undefined) return
|
||||
dragDepthRef.current += 1
|
||||
setDropError(null)
|
||||
setDragActive(true)
|
||||
}
|
||||
|
||||
@@ -416,11 +433,24 @@ export function InputBar({
|
||||
setDragActive(false)
|
||||
if (locked || machineBusy || addImages === undefined) return
|
||||
const dropped = [...event.dataTransfer.files]
|
||||
if (dropped.length > 0) setDropError(addImages(dropped))
|
||||
if (dropped.length > 0) {
|
||||
const rejected = addImages(dropped)
|
||||
if (rejected !== null) showToast(rejected)
|
||||
}
|
||||
}
|
||||
|
||||
const closePreview = useCallback(() => { setPreview(null) }, [])
|
||||
|
||||
// Rail thumbnails with their strings resolved here: the attachment atoms are
|
||||
// zero-cordis and read no locale.
|
||||
const railItems = useMemo<ComposerRailItem[]>(() => attachments.map(attachment => ({
|
||||
id: attachment.id,
|
||||
previewUrl: attachment.previewUrl,
|
||||
alt: attachment.file.name || t('image.pending'),
|
||||
removeLabel: t('image.remove', { name: attachment.file.name }),
|
||||
attachment,
|
||||
})), [attachments, t])
|
||||
|
||||
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.
|
||||
@@ -543,10 +573,14 @@ export function InputBar({
|
||||
|
||||
return (
|
||||
<div className={clsx(css.root, variant === 'hero' && css.hero)}>
|
||||
{error !== null && (
|
||||
<div className={css.error} role="alert">
|
||||
{error.message}
|
||||
</div>
|
||||
{toast !== null && (
|
||||
<Toast
|
||||
key={toast.seq}
|
||||
text={toast.text}
|
||||
icon={<IconWarningOutline16 />}
|
||||
anchor={cardRef.current}
|
||||
onDone={dismissToast}
|
||||
/>
|
||||
)}
|
||||
{notice !== null && (
|
||||
<div className={clsx(css.notice, notice.level === 'error' && css.noticeError)} role="status">
|
||||
@@ -558,8 +592,8 @@ export function InputBar({
|
||||
their pointer events), so the WHOLE capsule is the pick target.
|
||||
pointerdown stops here so the Menu's outside-close cannot race the
|
||||
click's reopen (close-then-open flickers the chip's open echo). */}
|
||||
{dropError !== null && <div className={css.error} role="alert">{dropError}</div>}
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={clsx(css.card, workspaceTrigger && css.cardWorkspaceTrigger, dragActive && css.dragActive)}
|
||||
data-composer-card
|
||||
onClick={workspaceTrigger ? onRequestWorkspace : undefined}
|
||||
@@ -572,29 +606,14 @@ export function InputBar({
|
||||
{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>
|
||||
))}
|
||||
{railItems.length > 0 && (
|
||||
<div className={css.attachments}>
|
||||
<AttachmentRail
|
||||
items={railItems}
|
||||
labels={attachmentRailLabels(t)}
|
||||
onOpen={(item) => { setPreview(item.attachment) }}
|
||||
onRemove={(item) => { removeImage?.(item.attachment.id) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* One scrollport, two text layers. The hidden mirror renders draft+'\n' and stretches the
|
||||
@@ -628,10 +647,7 @@ export function InputBar({
|
||||
? t('placeholder.steerQueue')
|
||||
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
|
||||
rows={2}
|
||||
onChange={(event) => {
|
||||
setDropError(null)
|
||||
onChange(event)
|
||||
}}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onSelect={onSelect}
|
||||
onCopy={(e) => { onCopyOrCut(e, false) }}
|
||||
@@ -712,8 +728,8 @@ export function InputBar({
|
||||
<ImageLightbox
|
||||
src={preview.previewUrl}
|
||||
alt={preview.file.name || t('image.original')}
|
||||
labels={lightboxLabels(t)}
|
||||
onClose={closePreview}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{footer}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
// chat-toolview-slot.spec.tsx.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -48,6 +48,9 @@ function sessionFakeFor() {
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
// The plugin injects both; these specs exercise no settings path.
|
||||
runtime.provide('remote', { $on: () => () => {} })
|
||||
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
const sessionFake = sessionFakeFor()
|
||||
await runtime.sessions.add({
|
||||
id: ROOT,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useState } from 'react'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
usePinnedBrowserLanguages('zh-CN')
|
||||
@@ -51,6 +51,9 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
|
||||
async function bench(opts?: { blank?: boolean }) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
// The plugin injects both; these specs exercise no settings path.
|
||||
runtime.provide('remote', { $on: () => () => {} })
|
||||
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
@@ -76,6 +79,9 @@ describe('resident composer', () => {
|
||||
it('renders the locked view state while no session exists at all', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
// The plugin injects both; these specs exercise no settings path.
|
||||
runtime.provide('remote', { $on: () => () => {} })
|
||||
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
@@ -103,6 +109,9 @@ describe('resident composer', () => {
|
||||
it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
// The plugin injects both; these specs exercise no settings path.
|
||||
runtime.provide('remote', { $on: () => () => {} })
|
||||
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
@@ -169,6 +178,9 @@ describe('prompt rejection through the assembled composer', () => {
|
||||
it('renders the promptError alert strip and keeps the draft in the machine', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
// The plugin injects both; these specs exercise no settings path.
|
||||
runtime.provide('remote', { $on: () => () => {} })
|
||||
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// entries. Tool composition belongs to ui-tool and its machinery spec.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -22,6 +22,9 @@ const CHILD = 'child-1' as SessionId
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
// The plugin injects both; these specs exercise no settings path.
|
||||
runtime.provide('remote', { $on: () => () => {} })
|
||||
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
await runtime.sessions.add({ id: ROOT, summary: { title: 'R', displayTitle: 'R' } }, { current: false })
|
||||
await runtime.sessions.add(
|
||||
{ id: CHILD, summary: { title: 'C', displayTitle: 'C', parentId: ROOT } }, { current: false })
|
||||
|
||||
82
packages/client/ui-conversation/tests/image-labels.spec.tsx
Normal file
82
packages/client/ui-conversation/tests/image-labels.spec.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment jsdom
|
||||
// The conversation-side bridge to the ui-attachment atoms: dictionary strings
|
||||
// flow through image-labels into the gallery, and assistant images keep their
|
||||
// block position between text blocks.
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
const enT = makeTranslate(en, commonZh)
|
||||
|
||||
const attachment = {
|
||||
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
|
||||
mediaType: 'image/png' as const,
|
||||
bytes: 68,
|
||||
width: 640,
|
||||
height: 320,
|
||||
name: 'history.png',
|
||||
}
|
||||
|
||||
describe('assistant images through the label bridge', () => {
|
||||
it('resolves zh dictionary strings and opens the lightbox on a single click', async () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'image', attachment }]}
|
||||
streaming={false}
|
||||
loadImage={() => Promise.resolve('blob:history')}
|
||||
/>,
|
||||
)
|
||||
const frame = await view.findByRole('button', { name: 'history.png,点击查看原图' })
|
||||
expect(frame.getAttribute('title')).toBe('查看原图')
|
||||
await view.findByAltText('history.png')
|
||||
fireEvent.click(frame)
|
||||
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
|
||||
fireEvent.click(view.getByRole('button', { name: '关闭原图预览' }))
|
||||
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves the active English dictionary', async () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
t={enT}
|
||||
blocks={[{ kind: 'image', attachment }]}
|
||||
streaming={false}
|
||||
loadImage={() => Promise.resolve('blob:history')}
|
||||
/>,
|
||||
)
|
||||
const frame = await view.findByRole('button', { name: 'history.png, click to view original' })
|
||||
await view.findByAltText('history.png')
|
||||
fireEvent.click(frame)
|
||||
expect(view.getByRole('dialog', { name: 'Original image preview' })).toBeTruthy()
|
||||
expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps assistant images at their original position between text blocks', async () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[
|
||||
{ kind: 'text', text: 'before' },
|
||||
{ kind: 'image', attachment },
|
||||
{ kind: 'text', text: 'after' },
|
||||
]}
|
||||
streaming={false}
|
||||
loadImage={() => Promise.resolve('blob:middle')}
|
||||
/>,
|
||||
)
|
||||
const image = await view.findByAltText('history.png')
|
||||
const before = view.getByText('before')
|
||||
const after = view.getByText('after')
|
||||
expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
||||
expect(image.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -237,15 +237,49 @@ describe('image draft rail', () => {
|
||||
expect(removeImage).toHaveBeenCalledWith('draft-1')
|
||||
})
|
||||
|
||||
it('opens the original image on double-click and closes it with Escape', () => {
|
||||
it('opens the original image on a single click and closes it with Escape', () => {
|
||||
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
|
||||
const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }
|
||||
const { view } = bench({ attachments: [attachment] })
|
||||
fireEvent.doubleClick(view.getByTitle('双击查看原图'))
|
||||
fireEvent.click(view.getByTitle('查看原图'))
|
||||
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
|
||||
})
|
||||
|
||||
it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const addImages = vi.fn(() => '不支持的图片格式:text/plain')
|
||||
const { view, textarea } = bench({ addImages })
|
||||
const paste = () => {
|
||||
fireEvent.paste(textarea, {
|
||||
clipboardData: {
|
||||
items: [{ kind: 'file', type: 'text/plain', getAsFile: () => new File(['x'], 'note.txt', { type: 'text/plain' }) }],
|
||||
getData: () => '',
|
||||
},
|
||||
})
|
||||
}
|
||||
paste()
|
||||
expect(view.getByRole('alert').textContent).toContain('不支持的图片格式:text/plain')
|
||||
act(() => { vi.advanceTimersByTime(4000) })
|
||||
expect(view.queryByRole('alert')).toBeNull()
|
||||
// The identical rejection re-announces: the toast is keyed per show.
|
||||
paste()
|
||||
expect(view.getByRole('alert').textContent).toContain('不支持的图片格式:text/plain')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('announces a rejected drop through the same toast', () => {
|
||||
const addImages = vi.fn(() => '图片读取服务不可用')
|
||||
const { view } = bench({ addImages })
|
||||
const card = view.container.querySelector('[class*="card"]')!
|
||||
const dataTransfer = { types: ['Files'], files: [new File([Uint8Array.of(1)], 'x.png', { type: 'image/png' })], dropEffect: 'none' }
|
||||
fireEvent.drop(card, { dataTransfer })
|
||||
expect(view.getByRole('alert').textContent).toContain('图片读取服务不可用')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Enter semantics', () => {
|
||||
@@ -941,10 +975,19 @@ describe('insertText (scoped event body)', () => {
|
||||
})
|
||||
|
||||
describe('strips and variants', () => {
|
||||
it('derives the failure strip from promptError (ordinary failure — no transaction UI, no Retry)', () => {
|
||||
const send = bench({ promptError: { op: 'send', error: { code: 'agent-busy', message: 'boom', details: { reason: 'boom' } } } })
|
||||
expect(send.view.container.querySelector('[role="alert"]')?.textContent).toBe('boom (agent-busy)')
|
||||
expect(send.view.queryByRole('button', { name: 'Retry' })).toBeNull()
|
||||
it('announces promptError as a fading toast (ordinary failure — no transaction UI, no Retry)', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const send = bench({ promptError: { op: 'send', error: { code: 'agent-busy', message: 'boom', details: { reason: 'boom' } } } })
|
||||
// The toast body-portals (transformed ancestors must not trap it), so
|
||||
// queries go through the view's document-bound helpers.
|
||||
expect(send.view.getByRole('alert').textContent).toContain('boom (agent-busy)')
|
||||
expect(send.view.queryByRole('button', { name: 'Retry' })).toBeNull()
|
||||
act(() => { vi.advanceTimersByTime(4000) })
|
||||
expect(send.view.queryByRole('alert')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders the notice strip from the machine notice store', () => {
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { MessageImage } from '../src/client/chat/MessageImage.tsx'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
const enT = makeTranslate(en, commonZh)
|
||||
|
||||
const attachment = {
|
||||
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
|
||||
mediaType: 'image/png' as const,
|
||||
bytes: 68,
|
||||
width: 640,
|
||||
height: 320,
|
||||
name: 'history.png',
|
||||
}
|
||||
|
||||
describe('MessageImage', () => {
|
||||
it('loads a session-authorized URL, bounds the thumbnail, and double-clicks into the original', async () => {
|
||||
const load = vi.fn().mockResolvedValue('blob:history')
|
||||
const view = render(<MessageImage attachment={attachment} load={load} t={t} />)
|
||||
const frame = view.getByRole('button', { name: 'history.png,双击查看原图' })
|
||||
expect(frame.getAttribute('style')).toContain('width: 240px')
|
||||
expect(frame.getAttribute('style')).toContain('height: 120px')
|
||||
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
|
||||
expect(load).toHaveBeenCalledWith(attachment)
|
||||
fireEvent.doubleClick(frame)
|
||||
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
|
||||
fireEvent.click(view.getByRole('button', { name: '关闭原图预览' }))
|
||||
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
|
||||
})
|
||||
|
||||
it('surfaces a retry control when durable bytes cannot be read', async () => {
|
||||
const load = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValueOnce('blob:retry')
|
||||
const view = render(<MessageImage attachment={attachment} load={load} t={t} />)
|
||||
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
|
||||
fireEvent.click(retry)
|
||||
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('renders image controls from the active English dictionary', async () => {
|
||||
const load = vi.fn().mockResolvedValue('blob:history')
|
||||
const view = render(<MessageImage attachment={attachment} load={load} t={enT} />)
|
||||
const frame = view.getByRole('button', { name: 'history.png, double-click to view original' })
|
||||
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
|
||||
fireEvent.doubleClick(frame)
|
||||
expect(view.getByRole('dialog', { name: 'Original image preview' })).toBeTruthy()
|
||||
expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps assistant images at their original position between text blocks', async () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[
|
||||
{ kind: 'text', text: 'before' },
|
||||
{ kind: 'image', attachment },
|
||||
{ kind: 'text', text: 'after' },
|
||||
]}
|
||||
streaming={false}
|
||||
loadImage={() => Promise.resolve('blob:middle')}
|
||||
/>,
|
||||
)
|
||||
const image = await view.findByAltText('history.png')
|
||||
const before = view.getByText('before')
|
||||
const after = view.getByText('after')
|
||||
expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
||||
expect(image.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,9 @@
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../ui-attachment"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
@@ -76,6 +79,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/permission"
|
||||
},
|
||||
{
|
||||
"path": "../ui-settings"
|
||||
}
|
||||
],
|
||||
"exclude": [
|
||||
|
||||
Reference in New Issue
Block a user