Revert "fix(web): keep a sent message's attachments out of the next message's limits"

This reverts commit e36c3f9a395e4409b2bb2d29e75d2a9ccee1280d.
This commit is contained in:
Yichen Jiang committed 2026-10-21 13:52:00 +08:00
1 parent 6752c858a7
commit 4804ce4de4
18 files changed
+39 -151

No files matched your search

@@ -18,8 +18,8 @@
en: 15cdd9d52dcc71b8
zh: 53933fbbf5d39100
/deepseek-ai-dsh-client-ui-attachment/use-this-package/draft-attachments:
en: 2c4dc29fc7c54392
zh: 0d27bff308577cef
en: a77c8ec48e219aec
zh: 756d4fe881403006
/deepseek-ai-dsh-client-ui-attachment/use-this-package/message-images-and-the-lightbox:
en: 8b3d7729152d8060
zh: 815f932ccc9653f5
+1 -1
View File
@@ -29,7 +29,7 @@ Mount this plugin alongside [`ui-conversation`](../ui-conversation/README.md) an
### Draft attachments
Images and generic files retain pick order in one non-wrapping horizontal rail. Every item is 64px high: an image is a 64px square thumbnail, while a generic file is a 240px-wide DeepSeek Web card with a 16px radius, blue gradient document glyph, filename, and uppercase extension plus byte size. Edge arrows page hidden overflow, the scrollbar stays hidden, and a newly added item is revealed at the rail's end. Uploading replaces a file glyph with a spinner and shows byte progress when the carrier reports it, with an indeterminate bar before the first report; failure shows retry, and removal controls appear on hover or keyboard focus while remaining visible on touch devices. Attachments of a message that is still being sent have no removal control. Clicking an image opens the original.
Images and generic files retain pick order in one non-wrapping horizontal rail. Every item is 64px high: an image is a 64px square thumbnail, while a generic file is a 240px-wide DeepSeek Web card with a 16px radius, blue gradient document glyph, filename, and uppercase extension plus byte size. Edge arrows page hidden overflow, the scrollbar stays hidden, and a newly added item is revealed at the rail's end. Uploading replaces a file glyph with a spinner and shows byte progress when the carrier reports it, with an indeterminate bar before the first report; failure shows retry, and removal controls appear on hover or keyboard focus while remaining visible on touch devices. Clicking an image opens the original.
### Message images and the lightbox
+1 -1
View File
@@ -29,7 +29,7 @@ kind: "package-reference"
### 草稿附件
图片与通用文件按选择顺序进入同一条不换行的横向附件栏。所有条目均为 64px 高:图片是 64px 方形缩略图,通用文件是 240px 宽的 DeepSeek Web 卡片,带 16px 圆角、蓝色渐变文档图标、文件名,以及大写扩展名与字节大小。溢出隐藏时由边缘箭头翻页,滚动条保持隐藏,新增条目会滚动到栏尾展示。文件上传时图标位置显示 spinner,传输层报告字节时显示进度,首次报告前使用不定态进度条;失败时显示重试。移除按钮在悬停或键盘聚焦时出现,在触摸设备上保持可见。仍在发送的消息的附件没有移除按钮。单击图片会打开原图。
图片与通用文件按选择顺序进入同一条不换行的横向附件栏。所有条目均为 64px 高:图片是 64px 方形缩略图,通用文件是 240px 宽的 DeepSeek Web 卡片,带 16px 圆角、蓝色渐变文档图标、文件名,以及大写扩展名与字节大小。溢出隐藏时由边缘箭头翻页,滚动条保持隐藏,新增条目会滚动到栏尾展示。文件上传时图标位置显示 spinner,传输层报告字节时显示进度,首次报告前使用不定态进度条;失败时显示重试。移除按钮在悬停或键盘聚焦时出现,在触摸设备上保持可见。单击图片会打开原图。
### 消息图片与灯箱
@@ -118,8 +118,8 @@
}
}
.removable:hover .name,
.removable:focus-within .name {
.card:hover .name,
.card:focus-within .name {
padding-right: 18px;
}
+10 -13
View File
@@ -27,8 +27,7 @@ export function FileCard({
state: FileCardState
progress?: number
labels: FileCardLabels
/** Removes the file; a card without it offers no remove action. */
onRemove?: (() => void) | undefined
onRemove: () => void
onRetry: () => void
}) {
const extension = fileExtension(name).toUpperCase().slice(0, 8)
@@ -40,7 +39,7 @@ export function FileCard({
const retryable = state === 'error'
return (
<div
className={`${css.card}${retryable ? ` ${css.failed}` : ''}${onRemove === undefined ? '' : ` ${css.removable}`}`}
className={`${css.card}${retryable ? ` ${css.failed}` : ''}`}
title={name}
>
<span className={css.icon} aria-hidden>
@@ -61,16 +60,14 @@ export function FileCard({
<span className={css.meta}>{meta}</span>
</span>
)}
{onRemove !== undefined && (
<button
type="button"
className={retryable ? `${css.remove} ${css.removeFailed}` : css.remove}
aria-label={labels.remove}
onClick={onRemove}
>
<IconCloseFillRegular size={12} />
</button>
)}
<button
type="button"
className={retryable ? `${css.remove} ${css.removeFailed}` : css.remove}
aria-label={labels.remove}
onClick={onRemove}
>
<IconCloseFillRegular size={12} />
</button>
{state === 'uploading' && (
<span className={css.progressTrack} aria-hidden>
<span
@@ -19,7 +19,7 @@ interface ComposerRailItem extends AttachmentRailItem {
/** Draft image previews, pending-file cards, drop target, and original-image preview. */
export function ComposerAttachments({
attachments, canAcceptDrop, onAddFiles, onRemoveAttachment, sendingAttachmentIds, uploads, onRetryFile, dropLimits, t,
attachments, canAcceptDrop, onAddFiles, onRemoveAttachment, uploads, onRetryFile, dropLimits, t,
}: ComposerAttachmentsProps) {
const [preview, setPreview] = useState<ComposerImageAttachment | null>(null)
const [dragActive, setDragActive] = useState(false)
@@ -53,7 +53,6 @@ export function ComposerAttachments({
labels={attachmentRailLabels(t)}
renderItem={(item) => {
const attachment = item.attachment
const removable = !sendingAttachmentIds.includes(attachment.id)
if (attachment.kind === 'file') {
const upload = uploads[attachment.id]
return (
@@ -67,7 +66,7 @@ export function ComposerAttachments({
? { progress: upload.loaded / upload.total }
: {}}
labels={fileCardLabels(t, attachment.file.name)}
onRemove={removable ? () => { onRemoveAttachment(attachment.id) } : undefined}
onRemove={() => { onRemoveAttachment(attachment.id) }}
onRetry={() => { onRetryFile(attachment.id) }}
/>
)
@@ -82,16 +81,14 @@ export function ComposerAttachments({
>
<img src={attachment.previewUrl} alt={attachment.file.name || t('image.pending')} />
</button>
{removable && (
<button
type="button"
className={css.remove}
aria-label={t('image.remove', { name: attachment.file.name })}
onClick={() => { onRemoveAttachment(attachment.id) }}
>
<IconCloseFillRegular size={12} />
</button>
)}
<button
type="button"
className={css.remove}
aria-label={t('image.remove', { name: attachment.file.name })}
onClick={() => { onRemoveAttachment(attachment.id) }}
>
<IconCloseFillRegular size={12} />
</button>
</div>
)
}}
@@ -80,12 +80,11 @@ function props(overrides: Partial<ComposerAttachmentsOwnerProps> = {}): Composer
canAcceptDrop: true,
onAddFiles: () => {},
onRemoveAttachment: () => {},
sendingAttachmentIds: [],
uploads: {},
onRetryFile: () => {},
t,
...overrides,
} as ComposerAttachmentsProps
} as unknown as ComposerAttachmentsProps
}
describe('ComposerAttachments', () => {
@@ -203,26 +202,6 @@ describe('ComposerAttachments', () => {
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
})
it('keeps the attachments of the message being sent without a remove action', () => {
const onRemoveAttachment = vi.fn()
const view = render(<ComposerAttachments {...props({
attachments: [attachment('sent'), fileDraft('sent-file'), attachment('next')],
uploads: {
'sent-file': {
status: 'ready', receiptId: 'receipt-sent-file' as never,
file: { attachmentId: 'file-sent' as never, name: 'sent-file.pdf', bytes: 3 },
},
},
sendingAttachmentIds: ['sent', 'sent-file'] as ComposerAttachment['id'][],
onRemoveAttachment,
})} />)
expect(view.queryByRole('button', { name: '移除图片 sent.png' })).toBeNull()
expect(view.queryByRole('button', { name: '移除文件 sent-file.pdf' })).toBeNull()
expect(view.getByTitle('sent-file.pdf').textContent).toContain('PDF 3B')
fireEvent.click(view.getByRole('button', { name: '移除图片 next.png' }))
expect(onRemoveAttachment).toHaveBeenCalledWith('next')
})
it('keeps images and files in pick order inside one attachment rail', () => {
const view = render(<ComposerAttachments {...props({
attachments: [attachment('first'), fileDraft('middle'), attachment('last')],
@@ -303,12 +303,6 @@ export interface InputState {
* send waits, and the attachments of the message being sent stay attached.
*/
readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting' | 'sending'
/**
* Attachments of the message being sent, in order; empty unless `phase` is
* `sending`. They stay in `attachmentIds` and cannot be removed until the
* send settles; attachments added meanwhile belong to the next message.
*/
readonly sendingAttachmentIds: readonly DraftAttachmentId[]
/** Present exactly while claimed/submitting (claim snapshot during flight; submit closure withheld). */
readonly claim?: { readonly name: string; readonly token: string; readonly hint?: string; readonly attachments?: boolean }
/** Reference occurrence view of the editor's chips, sorted by offset. */
@@ -70,8 +70,6 @@ export interface ComposerAttachmentsOwnerProps {
onAddFiles: (files: readonly File[], directories?: ReadonlySet<File>) => void
/** Remove one draft attachment through the Conversation service. */
onRemoveAttachment: (id: DraftAttachmentId) => void
/** Attachments of the message being sent, which the rail shows without a remove action. */
sendingAttachmentIds: readonly DraftAttachmentId[]
/** Current per-draft upload states for file-kind attachments. */
uploads: DraftFileUploads
/** Restart one failed file upload. */
@@ -116,8 +116,6 @@ function errorText(error: unknown): string {
const EMPTY_QUEUE: InboxState['next-turn'] = []
const EMPTY_ATTACHMENTS: readonly DraftAttachmentId[] = []
/** Unavailable catalogs contain no named text references. */
const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
@@ -848,7 +846,6 @@ export class SessionInputShell implements SessionInput {
attachmentIds: this.attachmentIds,
draftRev: this.rev,
phase: core.phase,
sendingAttachmentIds: this.core.sendingAttempt?.attachmentIds ?? EMPTY_ATTACHMENTS,
...(core.claim !== undefined ? { claim: core.claim } : {}),
occurrences: this.projection.occurrences,
queue: this.deps.inbox?.getSnapshot()?.['next-turn'] ?? EMPTY_QUEUE,
@@ -28,7 +28,6 @@ import type {} from '@deepseek-ai/dsh-goal/client'
// 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 { DraftAttachmentId } from '../contract/input.ts'
import type { ComposerBarProps } from '../contract/slots.ts'
import { DraftEditor } from '../input/editor/DraftEditor.tsx'
import {
@@ -44,8 +43,6 @@ import css from './InputBar.module.css'
export type InputBarProps = ComposerBarProps
const NO_ATTACHMENTS: readonly DraftAttachmentId[] = []
export const InputBar = memo(function InputBar({
useSession, useInput, inputActions, keyboard, addFiles, removeAttachment, resolveDraftAttachments,
retryFileUpload,
@@ -81,7 +78,6 @@ export const InputBar = memo(function InputBar({
() => input === undefined || resolveDraftAttachments === undefined ? [] : resolveDraftAttachments(input.attachmentIds),
[resolveDraftAttachments, input?.attachmentIds],
)
const sendingAttachmentIds = input?.sendingAttachmentIds ?? NO_ATTACHMENTS
const empty = draft.trim() === '' && attachments.length === 0
const uploads = useFileUploads(snapshot => snapshot)
// Send waits for every picked file: uploading and failed drafts both hold
@@ -237,9 +233,8 @@ export const InputBar = memo(function InputBar({
// refused as a whole batch, announced immediately, and never enters the
// rail. Only the image subset is limit-checked: generic files carry no
// client-side size or count limit and upload as soon as they are picked.
// The images of a message being sent belong to that message, so the limits
// count only the images of the next one. The host enforces the same image
// limits at submit for callers that bypass this composer.
// The host enforces the same image limits at submit for callers that bypass
// this composer.
const intakeFiles = useCallback((files: readonly File[], directories?: ReadonlySet<File>): void => {
if (locked || commandBusy || (subagent !== null && !continuable) || addFiles === undefined || files.length === 0) return
const rejected = ((): string | null => {
@@ -251,8 +246,7 @@ export const InputBar = memo(function InputBar({
if (imageLimits !== undefined) {
const mediaTypes = imageLimits.mediaTypes as readonly string[]
const images = files.filter(file => mediaTypes.includes(file.type))
const imageAttachments = attachments.filter(attachment => attachment.kind === 'image'
&& !sendingAttachmentIds.includes(attachment.id))
const imageAttachments = attachments.filter(attachment => attachment.kind === 'image')
if (imageAttachments.length + images.length > imageLimits.maxImagesPerMessage) {
return t('image.tooMany', { count: imageLimits.maxImagesPerMessage })
}
@@ -268,7 +262,7 @@ export const InputBar = memo(function InputBar({
return addFiles(files, directories)
})()
if (rejected !== null) showToast(rejected)
}, [locked, commandBusy, subagent, continuable, addFiles, attachments, sendingAttachmentIds, imageLimits, showToast, t])
}, [locked, commandBusy, subagent, continuable, addFiles, attachments, imageLimits, showToast, t])
const canAcceptDrop = (subagent === null || continuable) && !locked && !commandBusy && addFiles !== undefined
@@ -444,7 +438,6 @@ export const InputBar = memo(function InputBar({
canAcceptDrop,
onAddFiles: intakeFiles,
onRemoveAttachment: (id) => { removeAttachment?.(id) },
sendingAttachmentIds,
uploads,
onRetryFile: (id) => { retryFileUpload?.(id) },
dropLimits: imageLimits === undefined ? undefined : {
@@ -619,16 +619,10 @@ describe('draft documents in the input shell', () => {
expect(shell.addAttachments([nextAttachment])).toBe(true)
expect(shell.removeAttachment(nextAttachment)).toBe(true)
expect(shell.addAttachments([nextAttachment])).toBe(true)
expect(shell.snapshot).toMatchObject({
attachmentIds: [sentAttachment, nextAttachment], sendingAttachmentIds: [sentAttachment],
})
outcome.resolve({ kind: 'error', text: 'flight rejected' })
await vi.waitFor(() => { expect(shell.notices.getSnapshot()?.text).toBe('flight rejected') })
expect(shell.snapshot).toMatchObject({
phase: 'plain',
draft: attachmentOnly ? '' : 'outbound message',
attachmentIds: [sentAttachment, nextAttachment],
sendingAttachmentIds: [],
phase: 'plain', draft: attachmentOnly ? '' : 'outbound message', attachmentIds: [sentAttachment, nextAttachment],
})
expect(shell.removeAttachment(sentAttachment)).toBe(true)
expect(shell.requestDraftInitialization({ prompt: 'after settlement', clearPreviousDraft: true })).toBe('applied')
@@ -709,7 +703,7 @@ describe('draft documents in the input shell', () => {
expect(shell.addAttachments([nextAttachment])).toBe(true)
outcome.resolve({ kind: 'success' })
await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') })
expect(shell.snapshot).toMatchObject({ draft, attachmentIds: [nextAttachment], sendingAttachmentIds: [] })
expect(shell.snapshot).toMatchObject({ draft, attachmentIds: [nextAttachment] })
expect(shell.caretSpan()).toEqual({ start: caret, end: caret })
shell.editor.dispatchCommand(UNDO_COMMAND, undefined)
shell.editor.update(() => {}, { discrete: true })
@@ -618,66 +618,6 @@ describe('image draft rail', () => {
expect(within.view.queryByRole('alert')).toBeNull()
})
it.each([
['count', { maxImagesPerMessage: 1, maxMessageImageBytes: 4 * 1024 * 1024 }, 16, 16],
['total size', { maxImagesPerMessage: 5, maxMessageImageBytes: 2 * 1024 * 1024 }, 1.5 * 1024 * 1024, 1024 * 1024],
])('checks a new image against the next message\'s %s limit while a message is sent', async (_limit, caps, sentBytes, nextBytes) => {
const limits = {
maxImageBytes: 2 * 1024 * 1024,
...caps,
maxImagePixels: 40_000_000,
maxImageDimension: 2000,
mediaTypes: ['image/png'] as const,
}
const sent = {
kind: 'image' as const, id: 'draft-1' as DraftAttachmentId,
file: new File([new ArrayBuffer(sentBytes)], 'sent.png', { type: 'image/png' }), previewUrl: 'blob:draft-1',
}
const addFiles = vi.fn(() => null)
const result = bench({ attachments: [sent], imageLimits: limits, addFiles })
let settle!: (outcome: SubmitOutcome) => void
result.sink.mockImplementationOnce(() => new Promise<SubmitOutcome>((resolve) => { settle = resolve }))
fireEvent.keyDown(result.textarea, { key: 'Enter' })
expect(attachmentOwner(result.slotCalls).sendingAttachmentIds).toEqual(['draft-1'])
const next = new File([new ArrayBuffer(nextBytes)], 'next.png', { type: 'image/png' })
act(() => { attachmentOwner(result.slotCalls).onAddFiles([next]) })
expect(addFiles).toHaveBeenCalledWith([next], undefined)
expect(result.view.queryByRole('alert')).toBeNull()
await act(async () => { settle({ kind: 'success' }) })
expect(attachmentOwner(result.slotCalls).sendingAttachmentIds).toEqual([])
})
it('counts the images of a refused message against the next addition again', async () => {
const sent = {
kind: 'image' as const, id: 'draft-1' as DraftAttachmentId,
file: new File([Uint8Array.of(1)], 'sent.png', { type: 'image/png' }), previewUrl: 'blob:draft-1',
}
const addFiles = vi.fn(() => null)
const result = bench({
attachments: [sent],
addFiles,
imageLimits: {
maxImageBytes: 1024 * 1024,
maxImagesPerMessage: 1,
maxMessageImageBytes: 1024 * 1024,
maxImagePixels: 40_000_000,
maxImageDimension: 2000,
mediaTypes: ['image/png'] as const,
},
})
let refuse!: (outcome: SubmitOutcome) => void
result.sink.mockImplementationOnce(() => new Promise<SubmitOutcome>((resolve) => { refuse = resolve }))
fireEvent.keyDown(result.textarea, { key: 'Enter' })
await act(async () => { refuse({ kind: 'error' }) })
// The refused message's image stays in the draft, so the next message would carry it too.
expect(attachmentOwner(result.slotCalls).sendingAttachmentIds).toEqual([])
act(() => {
attachmentOwner(result.slotCalls).onAddFiles([new File([Uint8Array.of(2)], 'next.png', { type: 'image/png' })])
})
expect(addFiles).not.toHaveBeenCalled()
expect(result.view.getByRole('alert').textContent).toContain('一条消息最多添加 1 张图片')
})
it('announces the format problem before any limit when the batch holds a non-image', () => {
const addFiles = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片')
const result = bench({
@@ -94,9 +94,7 @@ function liveSession(initial: TestSnapshot) {
}
}
const INPUT_STATE: InputState = {
draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', sendingAttachmentIds: [], occurrences: [], queue: [],
}
const INPUT_STATE: InputState = { draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [] }
const t: QueueDockProps['t'] = makeTranslate(zh, commonZh)
const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
@@ -213,7 +213,7 @@ function standaloneProps(
): StandaloneBaseProps {
const trajectory = historySnapshot(nodes)
const input = createSnapshotStore<InputState>({
draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', sendingAttachmentIds: [], occurrences: [], queue: [],
draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [],
})
const inputActions: InputActions = {
captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }),
@@ -342,7 +342,7 @@ function mount(fixture: Awaited<ReturnType<typeof bench>>) {
createSnapshotStore<readonly ViewTab[]>(tabsOf(slots)),
)
const useInput = bindSnapshotSelector(createSnapshotStore<InputState>({
draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', sendingAttachmentIds: [], occurrences: [], queue: [],
draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [],
}))
const inputActions: InputActions = {
captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }),
@@ -104,7 +104,6 @@ const inputState: InputState = {
attachmentIds: [],
draftRev: 0,
phase: 'plain',
sendingAttachmentIds: [],
occurrences: [],
queue: [],
}
@@ -104,7 +104,6 @@ const inputState: InputState = {
attachmentIds: [],
draftRev: 0,
phase: 'plain',
sendingAttachmentIds: [],
occurrences: [],
queue: [],
}
+3
View File
@@ -443,6 +443,9 @@
"packages/client/ui-attachment/tests/attachment-rail.client.spec.tsx": {
"b42ab24a43e0f7542bf1deb8f7ac3a76698d29e098f8dc575af6c08774987c32": 1
},
"packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx": {
"ab22ebe23f59b5aa76d42502a0421f3953d1d170e8a4f900a0c442daf6f1397d": 1
},
"packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts": {
"a7d8cc8d42e37ad5cc4882241492ac572ed8163c7fb8663180e893c7178e906f": 1
},