Merge remote-tracking branch 'origin/master' into feat/web-message-feedback-ui
This commit is contained in:
@@ -313,9 +313,9 @@ export function apply(ctx: Context): void {
|
||||
return null
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof UnsupportedImageMediaTypeError) {
|
||||
return t('image.unsupportedType', {
|
||||
type: error.mediaType || t('image.unknownType'),
|
||||
})
|
||||
// Positive copy: the supported list is fixed in imageMediaType,
|
||||
// and naming it beats echoing the rejected MIME type back.
|
||||
return t('image.unsupportedType')
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// turn's transcript tail. Think / tool-head-only nodes stay chrome-free.
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import type { ReactNode } 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'
|
||||
@@ -48,34 +49,60 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
|| interrupted === true
|
||||
|| blocks.some(block => block.kind !== 'tool-call')
|
||||
if (!hasVisible) return null
|
||||
const rendered: ReactNode[] = []
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const block = blocks[i]
|
||||
if (block === undefined) continue
|
||||
switch (block.kind) {
|
||||
case 'text':
|
||||
rendered.push(
|
||||
<MarkdownText
|
||||
key={i}
|
||||
text={block.text}
|
||||
streaming={streaming}
|
||||
codeLabels={codeLabels}
|
||||
fileMentions={mentions}
|
||||
/>,
|
||||
)
|
||||
break
|
||||
case 'reasoning':
|
||||
rendered.push(<ReasoningRow key={i} text={block.text} running={streaming && i === last} t={t} />)
|
||||
break
|
||||
case 'image': {
|
||||
// Consecutive image blocks share one gallery so several images tile
|
||||
// into rows instead of each opening a one-image group of its own.
|
||||
// Keyed by the group's FIRST block index: a streaming append that
|
||||
// extends the group then only grows `images` instead of remounting
|
||||
// the gallery under a shifted key.
|
||||
const start = i
|
||||
const group = [block]
|
||||
while (i + 1 < blocks.length) {
|
||||
const next = blocks[i + 1]
|
||||
if (next === undefined || next.kind !== 'image') break
|
||||
group.push(next)
|
||||
i += 1
|
||||
}
|
||||
rendered.push(<ImageGallery key={start} images={group} load={imageLoader} align="start" labels={messageImageLabels(t)} />)
|
||||
break
|
||||
}
|
||||
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
|
||||
case 'tool-call':
|
||||
break
|
||||
default:
|
||||
rendered.push(
|
||||
<JsonBlock
|
||||
key={i}
|
||||
label={t('message.unknownBlock')}
|
||||
payload={block.block}
|
||||
truncatedLabel={total => t('json.truncated', { total })}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className={css.root} data-streaming={streaming || undefined}>
|
||||
<div className={css.body}>
|
||||
{blocks.map((block, i) => {
|
||||
switch (block.kind) {
|
||||
case 'text': return (
|
||||
<MarkdownText
|
||||
key={i}
|
||||
text={block.text}
|
||||
streaming={streaming}
|
||||
codeLabels={codeLabels}
|
||||
fileMentions={mentions}
|
||||
/>
|
||||
)
|
||||
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" labels={messageImageLabels(t)} />
|
||||
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
|
||||
case 'tool-call': return null
|
||||
default: return (
|
||||
<JsonBlock
|
||||
key={i}
|
||||
label={t('message.unknownBlock')}
|
||||
payload={block.block}
|
||||
truncatedLabel={total => t('json.truncated', { total })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})}
|
||||
{rendered}
|
||||
{interrupted && <span className={css.stopped}>{t('message.stopped')}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,60 @@
|
||||
* application state; owners resolve every string). */
|
||||
|
||||
import type {
|
||||
AttachmentRailLabels, ImageLightboxLabels, MessageImageLabels,
|
||||
AttachmentRailLabels, DropOverlayLabels, ImageLightboxLabels, MessageImageLabels,
|
||||
} from '@deepseek-ai/dsh-client-ui-attachment'
|
||||
import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
|
||||
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ConversationKey } from './locales.ts'
|
||||
|
||||
/**
|
||||
* Byte count as user-facing megabytes (`10MB`, `2.5MB`).
|
||||
* @param bytes - the byte count.
|
||||
* @returns the rounded megabyte text.
|
||||
*/
|
||||
export function imageSizeText(bytes: number): string {
|
||||
const mb = bytes / (1024 * 1024)
|
||||
return `${Number.isInteger(mb) ? String(mb) : mb.toFixed(1)}MB`
|
||||
}
|
||||
|
||||
/**
|
||||
* Product copy for a host attachment rejection (the `attachment-error`
|
||||
* `details.reason`). User-solvable reasons name the limit and the way out;
|
||||
* reasons the user cannot act on fold into one send-failed line carrying the
|
||||
* reason code for a bug report.
|
||||
* @param t - the conversation-namespace translate.
|
||||
* @param reason - the wire `details.reason` code.
|
||||
* @param limits - projected limits interpolated into count/size copy, when known.
|
||||
* @returns the banner text.
|
||||
*/
|
||||
export function attachmentErrorText(
|
||||
t: Translate<ConversationKey>,
|
||||
reason: string,
|
||||
limits?: ImageAttachmentLimits,
|
||||
): string {
|
||||
switch (reason) {
|
||||
case 'MODEL_DOES_NOT_SUPPORT_IMAGES': return t('image.modelUnsupported')
|
||||
case 'SUBAGENT_IMAGE_UNSUPPORTED': return t('image.subagentUnsupported')
|
||||
case 'IMAGE_TOO_MANY_PIXELS': return t('image.tooManyPixels')
|
||||
// Undecodable bytes or a declared type its bytes contradict: solvable by
|
||||
// replacing or re-exporting the file, so it reads as a format problem.
|
||||
case 'INVALID_IMAGE':
|
||||
case 'IMAGE_TYPE_MISMATCH':
|
||||
return t('image.unsupportedType')
|
||||
case 'TOO_MANY_IMAGES':
|
||||
if (limits !== undefined) return t('image.tooMany', { count: limits.maxImagesPerMessage })
|
||||
break
|
||||
case 'IMAGE_TOO_LARGE':
|
||||
if (limits !== undefined) return t('image.fileTooLarge', { size: imageSizeText(limits.maxImageBytes) })
|
||||
break
|
||||
case 'IMAGES_TOO_LARGE':
|
||||
if (limits !== undefined) return t('image.totalTooLarge', { size: imageSizeText(limits.maxMessageImageBytes) })
|
||||
break
|
||||
default: break
|
||||
}
|
||||
return t('image.sendFailed', { reason })
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the original-image lightbox strings.
|
||||
* @param t - the conversation-namespace translate.
|
||||
@@ -33,6 +82,25 @@ export function messageImageLabels(t: Translate<ConversationKey>): MessageImageL
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the full-page drop overlay strings.
|
||||
* @param t - the conversation-namespace translate.
|
||||
* @param accepting - whether drops are currently accepted.
|
||||
* @param limits - per-message limits for the desc line, when known.
|
||||
* @returns the overlay title, with the limits desc while accepting.
|
||||
*/
|
||||
export function dropOverlayLabels(
|
||||
t: Translate<ConversationKey>,
|
||||
accepting: boolean,
|
||||
limits?: { count: number; size: string },
|
||||
): DropOverlayLabels {
|
||||
if (!accepting) return { title: t('image.dropBlocked') }
|
||||
return {
|
||||
title: t('image.dropTitle'),
|
||||
desc: limits === undefined ? undefined : t('image.dropDesc', { count: limits.count, size: limits.size }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the composer draft-image rail strings.
|
||||
* @param t - the conversation-namespace translate.
|
||||
|
||||
@@ -25,7 +25,9 @@ export const zh = {
|
||||
'input.send': '发送消息',
|
||||
'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息',
|
||||
'input.accessMode': '访问模式,当前:{name}',
|
||||
'image.dropHint': '松开以添加图片',
|
||||
'image.dropTitle': '图片拖动到此处即可添加',
|
||||
'image.dropDesc': '最多 {count} 张,每张 {size}',
|
||||
'image.dropBlocked': '当前无法添加图片',
|
||||
'image.pending': '待发送图片',
|
||||
'image.openOriginal': '查看原图',
|
||||
'image.openOriginalLabel': '{label},点击查看原图',
|
||||
@@ -39,8 +41,14 @@ export const zh = {
|
||||
'image.preview': '原图预览',
|
||||
'image.closePreview': '关闭原图预览',
|
||||
'image.serviceUnavailable': '图片读取服务不可用',
|
||||
'image.unsupportedType': '不支持的图片格式:{type}',
|
||||
'image.unknownType': '未知格式',
|
||||
'image.unsupportedType': '仅支持 PNG、JPG、WebP、GIF 格式的图片',
|
||||
'image.tooMany': '一条消息最多添加 {count} 张图片',
|
||||
'image.fileTooLarge': '单张图片不能超过 {size}',
|
||||
'image.totalTooLarge': '图片总大小超过 {size},请移除部分图片',
|
||||
'image.tooManyPixels': '图片分辨率过大,请压缩后重试',
|
||||
'image.modelUnsupported': '当前模型不支持图片,请切换支持图片的模型',
|
||||
'image.subagentUnsupported': '子智能体会话暂不支持图片',
|
||||
'image.sendFailed': '图片发送失败({reason}),请重新添加图片后再试',
|
||||
'context.aria': '上下文已用 {percent}',
|
||||
'context.used': '上下文已用',
|
||||
'context.system': '系统提示词',
|
||||
@@ -184,7 +192,9 @@ 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.dropTitle': 'Drag images here to add them',
|
||||
'image.dropDesc': 'Up to {count} images, {size} each',
|
||||
'image.dropBlocked': 'Images cannot be added right now',
|
||||
'image.pending': 'Pending images',
|
||||
'image.openOriginal': 'View original',
|
||||
'image.openOriginalLabel': '{label}, click to view original',
|
||||
@@ -198,8 +208,14 @@ export const en = {
|
||||
'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',
|
||||
'image.unsupportedType': 'Only PNG, JPG, WebP, and GIF images are supported',
|
||||
'image.tooMany': 'A message can include up to {count} images',
|
||||
'image.fileTooLarge': 'Each image must be smaller than {size}',
|
||||
'image.totalTooLarge': 'Images exceed {size} in total; remove some and try again',
|
||||
'image.tooManyPixels': 'Image resolution is too high; compress it and try again',
|
||||
'image.modelUnsupported': 'The current model does not support images; switch to a model that does',
|
||||
'image.subagentUnsupported': 'Subagent sessions do not support images yet',
|
||||
'image.sendFailed': 'Sending images failed ({reason}); re-add them and try again',
|
||||
'context.aria': '{percent} of context used',
|
||||
'context.used': 'of context used',
|
||||
'context.system': 'System prompt',
|
||||
|
||||
@@ -115,25 +115,6 @@
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
@@ -7,23 +7,28 @@
|
||||
* (running/removed/promptError) are self-selected via useSession. */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ChangeEvent, DragEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
|
||||
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconPlusOutline16, IconWarningOutline16, Toast, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { AttachmentRail, ImageLightbox } from '@deepseek-ai/dsh-client-ui-attachment'
|
||||
import { AttachmentRail, DropOverlay, 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'
|
||||
// Type-only: the `goal` projection key merge (hint disambiguation).
|
||||
import type {} from '@deepseek-ai/dsh-goal/client'
|
||||
// The `imageLimits` projection key merge (intake pre-check) arrives with the
|
||||
// wire types: apiproxy's sessions contract declares it, and client-runtime's
|
||||
// api-remotes import already places it in every client program.
|
||||
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 {
|
||||
attachmentErrorText, attachmentRailLabels, dropOverlayLabels, imageSizeText, lightboxLabels,
|
||||
} from '../image-labels.ts'
|
||||
import { ContextMeter } from './ContextMeter.tsx'
|
||||
import { PermissionSelect } from './PermissionSelect.tsx'
|
||||
import css from './InputBar.module.css'
|
||||
@@ -80,14 +85,22 @@ export function InputBar({
|
||||
setToast({ seq: toastSeq.current, text })
|
||||
}, [])
|
||||
const dismissToast = useCallback(() => { setToast(null) }, [])
|
||||
// The deployment's image-intake limits (absent while no attachment service
|
||||
// is composed — the pre-check below then defers entirely to the host).
|
||||
const imageLimits = useProjection('imageLimits')
|
||||
// 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.
|
||||
// is still pending, and a transient banner is its only surface. Attachment
|
||||
// rejections show product copy keyed by the wire reason; other codes are
|
||||
// developer-facing and keep the raw message plus code.
|
||||
useEffect(() => {
|
||||
if (promptError !== null) showToast(`${promptError.error.message} (${promptError.error.code})`)
|
||||
}, [promptError, showToast])
|
||||
if (promptError === null) return
|
||||
showToast(promptError.error.code === 'attachment-error'
|
||||
? attachmentErrorText(t, promptError.error.details.reason, imageLimits)
|
||||
: `${promptError.error.message} (${promptError.error.code})`)
|
||||
}, [promptError, showToast, t, imageLimits])
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const cardRef = useRef<HTMLDivElement | null>(null)
|
||||
const dragDepthRef = useRef(0)
|
||||
@@ -384,10 +397,7 @@ 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) {
|
||||
const rejected = addImages(files)
|
||||
if (rejected !== null) showToast(rejected)
|
||||
}
|
||||
if (files.length > 0) intakeImages(files)
|
||||
const text = e.clipboardData.getData('text/plain')
|
||||
if (text === '') {
|
||||
if (files.length > 0) e.preventDefault()
|
||||
@@ -406,38 +416,94 @@ 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
|
||||
setDragActive(true)
|
||||
}
|
||||
// Intake pre-check (DeepSeek Chat semantics): an addition that would break
|
||||
// a projected limit is refused as a whole batch, announced immediately, and
|
||||
// never enters the rail — no more submit-time failure rolling the rail
|
||||
// back. The host enforces the same limits at submit for callers that bypass
|
||||
// this composer.
|
||||
const intakeImages = useCallback((files: readonly File[]): void => {
|
||||
if (addImages === undefined || files.length === 0) return
|
||||
const rejected = ((): string | null => {
|
||||
if (imageLimits !== undefined) {
|
||||
// Format precedes limits (DeepSeek Chat's filter order): a batch with
|
||||
// a non-image must announce the format problem, not a count or size
|
||||
// it could never pass anyway — addImages rejects it authoritatively.
|
||||
if (files.some(file => !(imageLimits.mediaTypes as readonly string[]).includes(file.type))) {
|
||||
return addImages(files)
|
||||
}
|
||||
if (attachments.length + files.length > imageLimits.maxImagesPerMessage) {
|
||||
return t('image.tooMany', { count: imageLimits.maxImagesPerMessage })
|
||||
}
|
||||
if (files.some(file => file.size > imageLimits.maxImageBytes)) {
|
||||
return t('image.fileTooLarge', { size: imageSizeText(imageLimits.maxImageBytes) })
|
||||
}
|
||||
const total = attachments.reduce((sum, attachment) => sum + attachment.file.size, 0)
|
||||
+ files.reduce((sum, file) => sum + file.size, 0)
|
||||
if (total > imageLimits.maxMessageImageBytes) {
|
||||
return t('image.totalTooLarge', { size: imageSizeText(imageLimits.maxMessageImageBytes) })
|
||||
}
|
||||
}
|
||||
return addImages(files)
|
||||
})()
|
||||
if (rejected !== null) showToast(rejected)
|
||||
}, [addImages, attachments, imageLimits, showToast, t])
|
||||
|
||||
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) {
|
||||
const rejected = addImages(dropped)
|
||||
if (rejected !== null) showToast(rejected)
|
||||
// Whole-page file-drop intake (DeepSeek Chat behavior): the listeners live
|
||||
// on the document so a drop anywhere over the window adds images, not only
|
||||
// over the composer card. Safe as document-level state: the composer-bar
|
||||
// slot is `kind: 'single'`, so at most one bar is mounted to bind these.
|
||||
// Text drags carry no 'Files' type and pass through untouched, keeping the
|
||||
// native drop-text-into-textarea path. The overlay layer itself is
|
||||
// pointer-inert, so it never disturbs the enter/leave count.
|
||||
const canAcceptDrop = !locked && !machineBusy && addImages !== undefined
|
||||
useEffect(() => {
|
||||
const hasFiles = (event: globalThis.DragEvent): boolean =>
|
||||
event.dataTransfer?.types.includes('Files') ?? false
|
||||
const reset = (): void => {
|
||||
dragDepthRef.current = 0
|
||||
setDragActive(false)
|
||||
}
|
||||
}
|
||||
const onDragEnter = (event: globalThis.DragEvent): void => {
|
||||
if (!hasFiles(event)) return
|
||||
event.preventDefault()
|
||||
dragDepthRef.current += 1
|
||||
setDragActive(true)
|
||||
}
|
||||
const onDragOver = (event: globalThis.DragEvent): void => {
|
||||
if (!hasFiles(event) || event.dataTransfer === null) return
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = canAcceptDrop ? 'copy' : 'none'
|
||||
}
|
||||
const onDragLeave = (event: globalThis.DragEvent): void => {
|
||||
if (!hasFiles(event)) return
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
||||
if (dragDepthRef.current === 0) setDragActive(false)
|
||||
// Leaving through the viewport edge does not balance the count on every
|
||||
// engine; a page-root leave at the border means the drag left the window.
|
||||
const leavingViewport = event.clientX <= 0 || event.clientY <= 0
|
||||
|| event.clientX >= window.innerWidth || event.clientY >= window.innerHeight
|
||||
if ((event.target === document.documentElement || event.target === document.body) && leavingViewport) reset()
|
||||
}
|
||||
const onDrop = (event: globalThis.DragEvent): void => {
|
||||
if (!hasFiles(event)) return
|
||||
event.preventDefault()
|
||||
reset()
|
||||
if (!canAcceptDrop) return
|
||||
intakeImages([...(event.dataTransfer?.files ?? [])])
|
||||
}
|
||||
document.addEventListener('dragenter', onDragEnter)
|
||||
document.addEventListener('dragover', onDragOver)
|
||||
document.addEventListener('dragleave', onDragLeave)
|
||||
document.addEventListener('drop', onDrop)
|
||||
window.addEventListener('dragend', reset)
|
||||
return () => {
|
||||
document.removeEventListener('dragenter', onDragEnter)
|
||||
document.removeEventListener('dragover', onDragOver)
|
||||
document.removeEventListener('dragleave', onDragLeave)
|
||||
document.removeEventListener('drop', onDrop)
|
||||
window.removeEventListener('dragend', reset)
|
||||
}
|
||||
}, [canAcceptDrop, intakeImages])
|
||||
|
||||
const closePreview = useCallback(() => { setPreview(null) }, [])
|
||||
|
||||
@@ -573,6 +639,15 @@ export function InputBar({
|
||||
|
||||
return (
|
||||
<div className={clsx(css.root, variant === 'hero' && css.hero)}>
|
||||
{dragActive && (
|
||||
<DropOverlay
|
||||
disabled={!canAcceptDrop}
|
||||
labels={dropOverlayLabels(t, canAcceptDrop, imageLimits === undefined ? undefined : {
|
||||
count: imageLimits.maxImagesPerMessage,
|
||||
size: imageSizeText(imageLimits.maxImageBytes),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{toast !== null && (
|
||||
<Toast
|
||||
key={toast.seq}
|
||||
@@ -594,16 +669,11 @@ export function InputBar({
|
||||
click's reopen (close-then-open flickers the chip's open echo). */}
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={clsx(css.card, workspaceTrigger && css.cardWorkspaceTrigger, dragActive && css.dragActive)}
|
||||
className={clsx(css.card, workspaceTrigger && css.cardWorkspaceTrigger)}
|
||||
data-composer-card
|
||||
onClick={workspaceTrigger ? onRequestWorkspace : undefined}
|
||||
onPointerDown={workspaceTrigger ? (e) => { e.stopPropagation() } : undefined}
|
||||
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>}
|
||||
{railItems.length > 0 && (
|
||||
|
||||
Reference in New Issue
Block a user