diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index f358751f5fa..808b9b60a72 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -6120,6 +6120,54 @@ describe('Session', () => { ); }); + it('embeds text file resource blocks as File reference parts', async () => { + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue(createEmptyStream()); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [ + { + type: 'text', + text: 'check this log\n\n@attachment:///app.log', + }, + { + type: 'resource', + resource: { + uri: 'attachment:///app.log', + mimeType: 'text/plain', + text: 'line1\nline2', + }, + }, + ], + }); + + const sendMessageStream = mockChat.sendMessageStream as ReturnType< + typeof vi.fn + >; + const request = sendMessageStream.mock.calls[0]?.[1] as { + message: Array>; + }; + const parts = request.message; + expect( + parts.some( + (p) => + typeof p['text'] === 'string' && + (p['text'] as string).includes( + 'File: attachment:///app.log\nline1\nline2', + ), + ), + ).toBe(true); + expect( + parts.some( + (p) => + typeof p['text'] === 'string' && + (p['text'] as string).includes('@attachment:///app.log'), + ), + ).toBe(true); + }); + it('degrades an oversized inline image to a text placeholder before sending to the model', async () => { const ENV_KEY = 'QWEN_CODE_MAX_INLINE_MEDIA_BYTES'; const original = process.env[ENV_KEY]; diff --git a/packages/sdk-typescript/src/daemon/ui/store.ts b/packages/sdk-typescript/src/daemon/ui/store.ts index 255445cf880..61a4beed9dc 100644 --- a/packages/sdk-typescript/src/daemon/ui/store.ts +++ b/packages/sdk-typescript/src/daemon/ui/store.ts @@ -62,8 +62,13 @@ export function createDaemonTranscriptStore( text: string, images?: Array<{ data: string; mimeType: string }>, meta?: DaemonTextDeltaMeta, + files?: Array<{ name: string; mimeType: string }>, ) { - state = appendLocalUserTranscriptMessage(state, text, { images, meta }); + state = appendLocalUserTranscriptMessage(state, text, { + images, + meta, + files, + }); scheduleNotify(); }, reset(nextSeed: Partial = {}) { diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 6a615916d49..358960427e3 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -104,6 +104,7 @@ export function appendLocalUserTranscriptMessage( text: string, opts: DaemonTranscriptReducerOptions & { images?: Array<{ data: string; mimeType: string }>; + files?: Array<{ name: string; mimeType: string }>; meta?: DaemonTextDeltaMeta; } = {}, ): DaemonTranscriptState { @@ -120,6 +121,9 @@ export function appendLocalUserTranscriptMessage( if (opts.images && opts.images.length > 0) { (block as DaemonTextTranscriptBlock).images = [...opts.images]; } + if (opts.files && opts.files.length > 0) { + (block as DaemonTextTranscriptBlock).files = [...opts.files]; + } appendBlock(next, block); next.activeUserBlockId = block.id; return trimTranscriptState(next); diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index 5472701372a..9fa3704da3e 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -854,6 +854,13 @@ export interface DaemonTextTranscriptBlock extends DaemonTranscriptBlockBase { text: string; /** Images attached to this user message (base64 data URIs). */ images?: Array<{ data: string; mimeType: string }>; + /** + * Text file attachments on this user message (display metadata only — + * the content rides the prompt's resource blocks and is never stored + * on the block). Local optimistic messages only; daemon replays carry + * no attachment metadata. + */ + files?: Array<{ name: string; mimeType: string }>; streaming?: boolean; collapsed?: boolean; /** Used by the reducer for per-subAgent block routing; renderers may use it for nesting. */ @@ -1060,6 +1067,7 @@ export interface DaemonTranscriptStore { text: string, images?: Array<{ data: string; mimeType: string }>, meta?: DaemonTextDeltaMeta, + files?: Array<{ name: string; mimeType: string }>, ): void; reset(seed?: Partial): void; /** diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index ffa42b564fb..c832df2f845 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -215,6 +215,20 @@ describe('daemon UI normalizer and transcript reducer', () => { }); }); + it('stores text file attachment metadata on local user messages', () => { + const store = createDaemonTranscriptStore(); + + store.appendLocalUserMessage('check this', undefined, undefined, [ + { name: 'app.log', mimeType: 'text/plain' }, + ]); + + expect(store.getSnapshot().blocks[0]).toMatchObject({ + kind: 'user', + text: 'check this', + files: [{ name: 'app.log', mimeType: 'text/plain' }], + }); + }); + it('stores input annotations from replayed user message chunks', () => { const inputAnnotations = [ { diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 8b788531569..6133a86a035 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -54,6 +54,7 @@ type ChatEditorTestProps = { onSubmit: ( text: string, images?: { data: string; media_type: string }[], + files?: { name: string; media_type: string; text: string }[], commitAccepted?: () => void, metadata?: { inputAnnotations?: DaemonInputAnnotation[] }, ) => boolean | void; @@ -554,6 +555,13 @@ vi.mock('./components/ChatEditor', async () => { restoreImages: ( images: readonly { data: string; media_type: string }[], ) => void; + restoreFiles: ( + files: readonly { + name: string; + media_type: string; + text: string; + }[], + ) => void; restoreInputAnnotations: ( inputAnnotations: readonly DaemonInputAnnotation[], ) => void; @@ -592,11 +600,13 @@ vi.mock('./components/ChatEditor', async () => { testState.prompt = text; }, restoreImages: () => undefined, + restoreFiles: () => undefined, restoreInputAnnotations: editorRestoreInputAnnotations, submit: (input) => { const accepted = props.onSubmit( input?.text ?? testState.prompt, testState.promptImages, + undefined, editorCommit, testState.inputAnnotations ? { inputAnnotations: testState.inputAnnotations } @@ -624,12 +634,23 @@ vi.mock('./components/ChatEditor', async () => { 'data-preparing': props.isPreparing ? 'true' : 'false', onClick: () => { if (testState.inputAnnotations) { - props.onSubmit(testState.prompt, undefined, editorCommit, { - inputAnnotations: testState.inputAnnotations, - }); + props.onSubmit( + testState.prompt, + undefined, + undefined, + editorCommit, + { + inputAnnotations: testState.inputAnnotations, + }, + ); return; } - props.onSubmit(testState.prompt, undefined, editorCommit); + props.onSubmit( + testState.prompt, + undefined, + undefined, + editorCommit, + ); }, type: 'button', }, @@ -5055,6 +5076,7 @@ describe('App shell command queueing', () => { accepted = testState.latestChatEditorProps?.onSubmit( '!echo hi', undefined, + undefined, editorCommit, ); await vi.waitFor(() => { @@ -5098,6 +5120,7 @@ describe('App shell command queueing', () => { testState.latestChatEditorProps?.onSubmit( '!echo hi', undefined, + undefined, editorCommit, ); await Promise.resolve(); @@ -10328,6 +10351,7 @@ describe('App session callbacks', () => { images, undefined, undefined, + undefined, ); expect(onSessionChange).toHaveBeenCalledWith({ type: 'submit', @@ -11482,6 +11506,7 @@ describe('App session callbacks', () => { accepted = testState.latestChatEditorProps?.onSubmit( 'first prompt', undefined, + undefined, commitAccepted, ); }); @@ -11853,6 +11878,130 @@ describe('App session callbacks', () => { ); }); + it('carries file attachments through a cancelled turn-error retry restoration', async () => { + let approveRetry: (() => void) | undefined; + let admissionCount = 0; + const onSubmitBefore = vi.fn(() => { + admissionCount += 1; + if (admissionCount === 1) return Promise.resolve(); + return new Promise((resolve) => { + approveRetry = resolve; + }); + }); + const files = [ + { name: 'app.log', media_type: 'text/plain', text: 'SECRET=1' }, + ]; + const { container, rerender } = renderApp({ onSubmitBefore }); + await flush(); + + await act(async () => { + testState.latestChatEditorProps?.onSubmit( + 'first', + undefined, + files, + undefined, + ); + await Promise.resolve(); + }); + act(() => { + testState.blocks = [ + { + kind: 'error', + source: 'turn_error', + id: 'turn-error-1', + promptId: 'prompt-first', + }, + ]; + rerender({ onSubmitBefore }); + }); + expect(container.querySelector('[data-testid="retry"]')).not.toBeNull(); + + mockSessionActions.sendPrompt.mockClear(); + act(() => { + container + .querySelector('[data-testid="retry"]') + ?.click(); + }); + act(() => { + mockConnection.loadingTranscript = true; + rerender({ onSubmitBefore }); + }); + + const allowBPrompt = vi.fn().mockResolvedValue(undefined); + act(() => { + mockConnection.sessionId = 'session-2'; + mockConnection.workspaceCwd = '/tmp/project-2'; + testState.blocks = []; + testState.ownerVersion += 1; + mockConnection.loadingTranscript = false; + rerender({ onSubmitBefore: allowBPrompt }); + }); + const otherFiles = [ + { name: 'b.log', media_type: 'text/plain', text: 'OTHER=1' }, + ]; + await act(async () => { + testState.latestChatEditorProps?.onSubmit( + 'second', + undefined, + otherFiles, + undefined, + ); + await Promise.resolve(); + }); + expect(mockSessionActions.sendPrompt).toHaveBeenLastCalledWith( + 'second', + expect.objectContaining({ files: otherFiles }), + ); + await act(async () => { + approveRetry?.(); + await Promise.resolve(); + }); + // The gate resolved after the session switched away — the cancelled + // retry must NOT resubmit; only 'second' has been sent so far. + expect(mockSessionActions.sendPrompt).toHaveBeenCalledTimes(1); + + act(() => { + mockConnection.sessionId = 'session-1'; + mockConnection.workspaceCwd = '/tmp/project'; + testState.blocks = [ + { + kind: 'error', + source: 'turn_error', + id: 'turn-error-1', + promptId: 'prompt-first', + }, + ]; + testState.ownerVersion += 1; + rerender({ onSubmitBefore }); + }); + await flush(); + + expect(container.querySelector('[data-testid="retry"]')).not.toBeNull(); + mockSessionActions.sendPrompt.mockClear(); + const allowRetry = vi.fn().mockResolvedValue(undefined); + rerender({ onSubmitBefore: allowRetry }); + await act(async () => { + container + .querySelector('[data-testid="retry"]') + ?.click(); + await Promise.resolve(); + }); + expect(mockSessionActions.sendPrompt).toHaveBeenLastCalledWith( + 'first', + expect.objectContaining({ + files: [ + expect.objectContaining({ + name: 'app.log', + media_type: 'text/plain', + text: 'SECRET=1', + }), + ], + optimisticUserMessage: false, + retry: true, + }), + ); + }); + it('defers retry restoration until navigation commits', async () => { const retryApproval = deferred(); let admissionCount = 0; @@ -12946,9 +13095,15 @@ describe('App session callbacks', () => { await flush(); act(() => { - testState.latestChatEditorProps?.onSubmit('hello', images, editorCommit, { - inputAnnotations, - }); + testState.latestChatEditorProps?.onSubmit( + 'hello', + images, + undefined, + editorCommit, + { + inputAnnotations, + }, + ); }); await flush(); mockSessionActions.sendPrompt.mockClear(); @@ -13035,6 +13190,7 @@ describe('App session callbacks', () => { undefined, undefined, undefined, + undefined, ); expect(onSessionChange).toHaveBeenCalledWith({ type: 'submit', @@ -17453,6 +17609,7 @@ describe('App prompt send failure retry', () => { testState.latestChatEditorProps?.onSubmit( 'hello', undefined, + undefined, editorCommit, ); }); @@ -17482,6 +17639,7 @@ describe('App prompt send failure retry', () => { testState.latestChatEditorProps?.onSubmit( 'hello', undefined, + undefined, editorCommit, ); }); @@ -17579,6 +17737,7 @@ describe('App prompt send failure retry', () => { testState.latestChatEditorProps?.onSubmit( '@file.ts fix', undefined, + undefined, editorCommit, { inputAnnotations }, ); @@ -17626,9 +17785,15 @@ describe('App prompt send failure retry', () => { await flush(); act(() => { - testState.latestChatEditorProps?.onSubmit('hello', images, editorCommit, { - inputAnnotations, - }); + testState.latestChatEditorProps?.onSubmit( + 'hello', + images, + undefined, + editorCommit, + { + inputAnnotations, + }, + ); }); testState.messages = [{ id: 'u1', role: 'user', content: 'hello' }]; await act(async () => { @@ -17661,6 +17826,60 @@ describe('App prompt send failure retry', () => { ); }); + it('retries a rejected failed prompt with its file attachment intact', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const firstSend = deferred(); + mockSessionActions.sendPrompt.mockImplementationOnce(() => { + testState.blocks = [{ id: 'u1', kind: 'user' }]; + return firstSend.promise; + }); + const files = [ + { name: 'app.log', media_type: 'text/plain', text: 'SECRET=1' }, + ]; + renderApp(); + await flush(); + + act(() => { + testState.latestChatEditorProps?.onSubmit( + 'hello', + undefined, + files, + editorCommit, + ); + }); + testState.messages = [{ id: 'u1', role: 'user', content: 'hello' }]; + await act(async () => { + firstSend.reject(new DaemonHttpError(413, {}, 'Prompt too large')); + await Promise.resolve(); + }); + + expect( + document.querySelector('[data-testid="failed-prompt-retry"]') + ?.textContent, + ).toBe('u1'); + + await act(async () => { + document + .querySelector('[data-testid="failed-prompt-retry"]') + ?.click(); + await Promise.resolve(); + }); + + expect(mockSessionActions.sendPrompt).toHaveBeenLastCalledWith( + 'hello', + expect.objectContaining({ + files: [ + expect.objectContaining({ + name: 'app.log', + media_type: 'text/plain', + text: 'SECRET=1', + }), + ], + optimisticUserMessage: false, + }), + ); + }); + it('keeps a failed-prompt retry visible through background notifications', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}); const firstSend = deferred(); @@ -17954,6 +18173,7 @@ describe('App prompt send failure retry', () => { 'hello', undefined, undefined, + undefined, ); }); act(() => { @@ -18392,6 +18612,7 @@ describe('App prompt send failure retry', () => { 'hello', undefined, undefined, + undefined, ); expect( container.querySelector('[data-testid="failed-prompt-retry"]') @@ -18514,6 +18735,7 @@ describe('App prompt send failure retry', () => { 'hello', undefined, undefined, + undefined, ); act(() => { rerender({ onSubmitBefore }); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index f36aa91c833..ccc1c756c87 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -83,7 +83,7 @@ import type { ComposerSubmitCommit, EditorHandle, } from './hooks/useComposerCore'; -import type { PromptImage } from './adapters/promptTypes'; +import type { PromptFile, PromptImage } from './adapters/promptTypes'; import { StatusBar, type StatusBarHandle } from './components/StatusBar'; import { StreamingStatus } from './components/StreamingStatus'; import { @@ -489,6 +489,7 @@ interface ActiveGoalStatus { interface SendPromptOptionsWithRetry { optimisticUserMessage?: boolean; images?: PromptImage[]; + files?: PromptFile[]; inputAnnotations?: DaemonInputAnnotation[]; retry?: boolean; onAdmissionStarted?: () => void; @@ -512,6 +513,7 @@ interface FailedPrompt { previousIdentity?: TranscriptUserMessageIdentity; text: string; images?: PromptImage[]; + files?: PromptFile[]; inputAnnotations?: DaemonInputAnnotation[]; owner: CancelledRetryOwner; } @@ -549,6 +551,7 @@ type CancelledRetryState = identity: TranscriptTurnErrorIdentity; text: string; images?: PromptImage[]; + files?: PromptFile[]; inputAnnotations?: DaemonInputAnnotation[]; previousRetriedTurnErrorId: string | null; previousShowRetryHint: boolean; @@ -611,6 +614,7 @@ interface UnknownPromptAdmission { messageId?: string; text?: string; images?: PromptImage[]; + files?: PromptFile[]; inputAnnotations?: DaemonInputAnnotation[]; payloadAvailable: boolean; } @@ -4111,6 +4115,7 @@ export function App({ }, [displayMessages, streamingState]); const lastSubmittedPromptRef = useRef(''); const lastSubmittedImagesRef = useRef(undefined); + const lastSubmittedFilesRef = useRef(undefined); const lastSubmittedInputAnnotationsRef = useRef< DaemonInputAnnotation[] | undefined >(undefined); @@ -4127,6 +4132,7 @@ export function App({ errorId: string; text: string; images?: PromptImage[]; + files?: PromptFile[]; inputAnnotations?: DaemonInputAnnotation[]; owner: CancelledRetryOwner; } | null>(null); @@ -4166,6 +4172,7 @@ export function App({ } lastSubmittedPromptRef.current = failedRetry.text; lastSubmittedImagesRef.current = failedRetry.images; + lastSubmittedFilesRef.current = failedRetry.files; lastSubmittedInputAnnotationsRef.current = failedRetry.inputAnnotations; lastSubmittedSourceVersionRef.current = composerSourceVersionRef.current; retryableTurnErrorIdRef.current = retryableTurnError.id; @@ -4241,6 +4248,7 @@ export function App({ }; lastSubmittedPromptRef.current = ''; lastSubmittedImagesRef.current = undefined; + lastSubmittedFilesRef.current = undefined; lastSubmittedInputAnnotationsRef.current = undefined; lastSubmittedSourceVersionRef.current = -1; retryableTurnErrorIdRef.current = null; @@ -4278,6 +4286,10 @@ export function App({ failed.inputAnnotations?.length ? { inputAnnotations: failed.inputAnnotations } : undefined, + failed.files?.map((file) => ({ + name: file.name, + mimeType: file.media_type, + })), ); const rehydratedMessage = getLatestUserBlock( store.getSnapshot().blocks, @@ -4330,6 +4342,7 @@ export function App({ } lastSubmittedPromptRef.current = state.text; lastSubmittedImagesRef.current = state.images; + lastSubmittedFilesRef.current = state.files; lastSubmittedInputAnnotationsRef.current = state.inputAnnotations; lastSubmittedSourceVersionRef.current = composerSourceVersionRef.current; retryableTurnErrorIdRef.current = renderedTurnError.id; @@ -5513,6 +5526,7 @@ export function App({ async ( text: string, images?: PromptImage[], + files?: PromptFile[], opts?: { optimisticUserMessage?: boolean; retry?: boolean; @@ -5636,6 +5650,7 @@ export function App({ ) { lastSubmittedPromptRef.current = text; lastSubmittedImagesRef.current = images; + lastSubmittedFilesRef.current = files; lastSubmittedInputAnnotationsRef.current = opts?.inputAnnotations; lastSubmittedSourceVersionRef.current = composerSourceVersionRef.current; @@ -5667,6 +5682,7 @@ export function App({ let admitted = false; const promptOptions: SendPromptOptionsWithRetry = { images, + files, inputAnnotations: opts?.inputAnnotations, optimisticUserMessage: opts?.optimisticUserMessage, retry: opts?.retry, @@ -5687,7 +5703,10 @@ export function App({ opts?.onAdmitted?.(); }, }; - if (sessionIdAfterEnsure && (text.trim() || (images?.length ?? 0) > 0)) { + if ( + sessionIdAfterEnsure && + (text.trim() || (images?.length ?? 0) > 0 || (files?.length ?? 0) > 0) + ) { dispatchSessionChangeRef.current?.({ type: 'submit', sessionId: sessionIdAfterEnsure, @@ -5993,7 +6012,7 @@ export function App({ }); let admitted = false; let admissionStarted = false; - sendPrompt(failed.text, failed.images, { + sendPrompt(failed.text, failed.images, failed.files, { optimisticUserMessage: false, inputAnnotations: failed.inputAnnotations, onAdmissionStarted: () => { @@ -6028,6 +6047,7 @@ export function App({ messageId: failed.messageId, text: failed.text, images: failed.images ? [...failed.images] : undefined, + files: failed.files ? [...failed.files] : undefined, inputAnnotations: failed.inputAnnotations, payloadAvailable: true, }); @@ -6108,6 +6128,7 @@ export function App({ ( text: string, images?: PromptImage[], + files?: PromptFile[], onComplete?: () => void, commitComposerAccepted?: ComposerSubmitCommit, inputAnnotations?: DaemonInputAnnotation[], @@ -6138,6 +6159,7 @@ export function App({ const result = rawEnqueuePrompt( text, images, + files, onComplete, inputAnnotations, ); @@ -6148,7 +6170,12 @@ export function App({ editorRef.current?.clear(); } } - if (sourceSessionId && (text.trim() || (images?.length ?? 0) > 0)) { + if ( + sourceSessionId && + (text.trim() || + (images?.length ?? 0) > 0 || + (files?.length ?? 0) > 0) + ) { dispatchSessionChangeRef.current?.({ type: 'submit', sessionId: sourceSessionId, @@ -6173,11 +6200,15 @@ export function App({ const result = rawEnqueuePrompt( text, images, + files, onComplete, inputAnnotations, ); const sessionId = connectionRef.current.sessionId; - if (sessionId && (text.trim() || (images?.length ?? 0) > 0)) { + if ( + sessionId && + (text.trim() || (images?.length ?? 0) > 0 || (files?.length ?? 0) > 0) + ) { dispatchSessionChangeRef.current?.({ type: 'submit', sessionId, @@ -6872,7 +6903,7 @@ export function App({ blockLocalCommandDuringTurn(); return; } - sendPrompt(command, undefined, { ownerRef: owner }) + sendPrompt(command, undefined, undefined, { ownerRef: owner }) .then(refreshSettings) .catch((error: unknown) => { if (!owner.current.isCurrent()) return; @@ -7134,10 +7165,12 @@ export function App({ lastSubmittedSourceVersionRef.current === composerSourceVersionRef.current && (lastSubmittedPromptRef.current.length > 0 || - (lastSubmittedImagesRef.current?.length ?? 0) > 0); + (lastSubmittedImagesRef.current?.length ?? 0) > 0 || + (lastSubmittedFilesRef.current?.length ?? 0) > 0); if (retryableTurnError && previousIdentity && !identityMatches) { lastSubmittedPromptRef.current = ''; lastSubmittedImagesRef.current = undefined; + lastSubmittedFilesRef.current = undefined; lastSubmittedInputAnnotationsRef.current = undefined; lastSubmittedSourceVersionRef.current = -1; retryableTurnErrorIdentityRef.current = undefined; @@ -8210,7 +8243,7 @@ export function App({ admitted = true; resolve(); }; - sendPrompt(prompt, undefined, { onAdmitted: admit }).then( + sendPrompt(prompt, undefined, undefined, { onAdmitted: admit }).then( () => { if (!admitted) { reject(new Error('Run was cancelled before it started')); @@ -8447,6 +8480,7 @@ export function App({ ( text: string, images?: PromptImage[], + files?: PromptFile[], opts?: { sendToDaemon?: boolean; commitComposerAccepted?: ComposerSubmitCommit; @@ -8461,7 +8495,7 @@ export function App({ createSessionPromiseRef.current !== null; const clearComposerOnPromptStart = !connectionRef.current.sessionId || deferComposerCommit; - sendPrompt(text, images, { + sendPrompt(text, images, files, { ownerRef: owner, clearComposerOnPromptStart, commitComposerAccepted: clearComposerOnPromptStart @@ -8522,6 +8556,7 @@ export function App({ ( text: string, images?: PromptImage[], + files?: PromptFile[], commitComposerAccepted?: ComposerSubmitCommit, metadata?: { inputAnnotations?: DaemonInputAnnotation[] }, ) => { @@ -8556,6 +8591,7 @@ export function App({ const submitPromptFromEditor = ( promptText: string, promptImages: PromptImage[] | undefined, + promptFiles: PromptFile[] | undefined, errorMessage: string, opts?: { optimisticUserMessage?: boolean; @@ -8588,7 +8624,7 @@ export function App({ let admitted = false; let admissionStarted = false; let admissionSessionId: string | undefined; - sendPrompt(promptText, promptImages, { + sendPrompt(promptText, promptImages, promptFiles, { ownerRef: admissionAttachment, ...sendOptions, clearComposerOnPromptStart, @@ -8625,6 +8661,7 @@ export function App({ messageId: failedMessage?.messageId, text: promptText, images: promptImages ? [...promptImages] : undefined, + files: promptFiles ? [...promptFiles] : undefined, inputAnnotations: sendOptions.inputAnnotations, payloadAvailable: true, }); @@ -8657,6 +8694,7 @@ export function App({ ...failedMessage, text: promptText, images: promptImages, + files: promptFiles, inputAnnotations: sendOptions.inputAnnotations, }); } @@ -8673,6 +8711,7 @@ export function App({ return enqueuePrompt( text, images, + files, undefined, commitComposerAccepted, metadata?.inputAnnotations, @@ -8681,6 +8720,7 @@ export function App({ return submitPromptFromEditor( text, images, + files, 'Failed to send hidden slash command', { inputAnnotations: metadata?.inputAnnotations }, ); @@ -8742,7 +8782,7 @@ export function App({ } return blockLocalCommandDuringTurn(); } - return handleGoalSlashCommand(text, images, { + return handleGoalSlashCommand(text, images, files, { commitComposerAccepted, }); } @@ -8812,13 +8852,18 @@ export function App({ createSessionPromiseRef.current !== null; const clearComposerOnPromptStart = !connectionRef.current.sessionId || deferComposerCommit; - sendPrompt(`/language ui ${nextLanguage}`, undefined, { - ownerRef: owner, - clearComposerOnPromptStart, - commitComposerAccepted: clearComposerOnPromptStart - ? commitComposerAccepted - : undefined, - }) + sendPrompt( + `/language ui ${nextLanguage}`, + undefined, + undefined, + { + ownerRef: owner, + clearComposerOnPromptStart, + commitComposerAccepted: clearComposerOnPromptStart + ? commitComposerAccepted + : undefined, + }, + ) .then(() => { if (!owner.current.isCurrent()) return; return sessionActions.refreshCommands(); @@ -8913,6 +8958,7 @@ export function App({ return enqueuePrompt( text, images, + files, undefined, commitComposerAccepted, metadata?.inputAnnotations, @@ -8921,6 +8967,7 @@ export function App({ return submitPromptFromEditor( text, images, + files, 'Failed to send /model --fast', { inputAnnotations: metadata?.inputAnnotations }, ); @@ -8982,6 +9029,7 @@ export function App({ return submitPromptFromEditor( prompt, images, + files, 'Failed to send plan prompt', { inputAnnotations: metadata?.inputAnnotations }, ); @@ -9007,7 +9055,7 @@ export function App({ sessionWriteBlockGenerationRef.current === writeBlockGeneration ) { - return sendPrompt(prompt, images, { + return sendPrompt(prompt, images, files, { clearComposerOnPromptStart: true, inputAnnotations: metadata?.inputAnnotations, }).catch((error: unknown) => @@ -9068,6 +9116,7 @@ export function App({ return enqueuePrompt( skillPrompt, images, + files, undefined, commitComposerAccepted, metadata?.inputAnnotations, @@ -9076,6 +9125,7 @@ export function App({ return submitPromptFromEditor( skillPrompt, images, + files, 'Failed to send /skills command', { inputAnnotations: metadata?.inputAnnotations }, ); @@ -9296,6 +9346,7 @@ export function App({ return enqueuePrompt( text, images, + files, undefined, commitComposerAccepted, metadata?.inputAnnotations, @@ -9304,6 +9355,7 @@ export function App({ return submitPromptFromEditor( text, images, + files, 'Failed to send /rename command', { inputAnnotations: metadata?.inputAnnotations }, ); @@ -9526,14 +9578,21 @@ export function App({ return enqueuePrompt( text, images, + files, undefined, commitComposerAccepted, metadata?.inputAnnotations, ); } - return submitPromptFromEditor(text, images, 'Failed to send command', { - inputAnnotations: metadata?.inputAnnotations, - }); + return submitPromptFromEditor( + text, + images, + files, + 'Failed to send command', + { + inputAnnotations: metadata?.inputAnnotations, + }, + ); } else if (text.startsWith('!')) { const cmd = text.slice(1).trim(); if (!cmd) return false; @@ -9600,15 +9659,22 @@ export function App({ return enqueuePrompt( text, images, + files, undefined, commitComposerAccepted, metadata?.inputAnnotations, ); } - return submitPromptFromEditor(text, images, 'Failed to send message', { - inputAnnotations: metadata?.inputAnnotations, - trackSendFailure: true, - }); + return submitPromptFromEditor( + text, + images, + files, + 'Failed to send message', + { + inputAnnotations: metadata?.inputAnnotations, + trackSendFailure: true, + }, + ); } }, [ @@ -9673,12 +9739,14 @@ export function App({ ( text: string, images?: PromptImage[], + files?: PromptFile[], commitComposerAccepted?: ComposerSubmitCommit, metadata?: { inputAnnotations?: DaemonInputAnnotation[] }, ) => { const accepted = handleSubmitRef.current( text, images, + files, commitComposerAccepted, metadata, ); @@ -9769,6 +9837,7 @@ export function App({ : draft; if (restoredText !== draft) editor.setText(restoredText); if (current.images?.length) editor.restoreImages(current.images); + if (current.files?.length) editor.restoreFiles(current.files); if (current.inputAnnotations?.length) { editor.restoreInputAnnotations?.(current.inputAnnotations); } @@ -9798,7 +9867,8 @@ export function App({ retryableTurnErrorIdentityRef.current && connectionRef.current.sessionId && (lastSubmittedPromptRef.current || - (lastSubmittedImagesRef.current?.length ?? 0) > 0) + (lastSubmittedImagesRef.current?.length ?? 0) > 0 || + (lastSubmittedFilesRef.current?.length ?? 0) > 0) ) { const savedRetryErrorIdentity = retryableTurnErrorIdentityRef.current; const currentRetryError = getRetryableTurnError( @@ -9811,6 +9881,7 @@ export function App({ ) { lastSubmittedPromptRef.current = ''; lastSubmittedImagesRef.current = undefined; + lastSubmittedFilesRef.current = undefined; lastSubmittedInputAnnotationsRef.current = undefined; lastSubmittedSourceVersionRef.current = -1; retryableTurnErrorIdRef.current = null; @@ -9824,6 +9895,7 @@ export function App({ const retrySessionId = connectionRef.current.sessionId; const retryText = lastSubmittedPromptRef.current; const retryImages = lastSubmittedImagesRef.current; + const retryFiles = lastSubmittedFilesRef.current; const retryInputAnnotations = lastSubmittedInputAnnotationsRef.current; const previousRetriedTurnErrorId = retriedTurnErrorIdRef.current; const previousShowRetryHint = showRetryHintRef.current; @@ -9855,7 +9927,7 @@ export function App({ }); let admissionStarted = false; let admitted = false; - sendPrompt(retryText, retryImages, { + sendPrompt(retryText, retryImages, retryFiles, { optimisticUserMessage: false, retry: true, inputAnnotations: retryInputAnnotations, @@ -9881,6 +9953,7 @@ export function App({ identity: retryErrorIdentity, text: retryText, images: retryImages, + files: retryFiles, inputAnnotations: retryInputAnnotations, previousRetriedTurnErrorId, previousShowRetryHint, @@ -9897,6 +9970,7 @@ export function App({ messageId: retryErrorId, text: retryText, images: retryImages ? [...retryImages] : undefined, + files: retryFiles ? [...retryFiles] : undefined, inputAnnotations: retryInputAnnotations, payloadAvailable: true, }); @@ -9915,6 +9989,7 @@ export function App({ identity: retryErrorIdentity, text: retryText, images: retryImages, + files: retryFiles, inputAnnotations: retryInputAnnotations, previousRetriedTurnErrorId, previousShowRetryHint, @@ -9930,6 +10005,7 @@ export function App({ errorId: retryErrorId, text: retryText, images: retryImages, + files: retryFiles, inputAnnotations: retryInputAnnotations, owner: retryOwner, }; @@ -10389,7 +10465,7 @@ export function App({ const scopeFlag = modelSettingScope === 'user' ? ' --global' : ' --project'; const owner = { current: sessionOwnerGuard.capture() }; - sendPrompt(`/model --fast ${modelId}${scopeFlag}`, undefined, { + sendPrompt(`/model --fast ${modelId}${scopeFlag}`, undefined, undefined, { ownerRef: owner, }) .then(() => { @@ -11805,10 +11881,15 @@ export function App({ current: sessionOwnerGuard.capture(), }; try { - await sendPrompt(`/goal ${condition}`, undefined, { - clearComposerOnPromptStart: true, - ownerRef: owner, - }); + await sendPrompt( + `/goal ${condition}`, + undefined, + undefined, + { + clearComposerOnPromptStart: true, + ownerRef: owner, + }, + ); if (!owner.current.isCurrent()) return false; } catch (error) { // `sendPrompt` creates the session lazily, so by now diff --git a/packages/web-shell/client/adapters/messageTypes.ts b/packages/web-shell/client/adapters/messageTypes.ts index 051b827e2df..35cab9fc9dd 100644 --- a/packages/web-shell/client/adapters/messageTypes.ts +++ b/packages/web-shell/client/adapters/messageTypes.ts @@ -82,6 +82,7 @@ export interface DaemonUserMessage extends DaemonMessageMeta { role: 'user'; content: string; images?: Array<{ data: string; mimeType: string }>; + files?: Array<{ name: string; mimeType: string }>; inputAnnotations?: DaemonInputAnnotation[]; source?: string; } diff --git a/packages/web-shell/client/adapters/promptTypes.ts b/packages/web-shell/client/adapters/promptTypes.ts index ecc09787551..bd620b226a1 100644 --- a/packages/web-shell/client/adapters/promptTypes.ts +++ b/packages/web-shell/client/adapters/promptTypes.ts @@ -2,3 +2,10 @@ export interface PromptImage { data: string; media_type: string; } + +export interface PromptFile { + name: string; + media_type: string; + text: string; + size?: number; +} diff --git a/packages/web-shell/client/adapters/transcriptToMessages.test.ts b/packages/web-shell/client/adapters/transcriptToMessages.test.ts index fd60c467a84..1bc344a2bfd 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.test.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.test.ts @@ -140,6 +140,21 @@ describe('transcriptBlocksToDaemonMessages', () => { }); }); + it('preserves user file attachment metadata', () => { + const messages = transcriptBlocksToDaemonMessages([ + textBlock('user-1', 'user', 'check this', 1, false, { + files: [{ name: 'app.log', mimeType: 'text/plain' }], + }), + ]); + + expect(messages[0]).toMatchObject({ + id: 'user-1', + role: 'user', + content: 'check this', + files: [{ name: 'app.log', mimeType: 'text/plain' }], + }); + }); + it('preserves user input annotations metadata', () => { const inputAnnotations = [ { diff --git a/packages/web-shell/client/adapters/transcriptToMessages.ts b/packages/web-shell/client/adapters/transcriptToMessages.ts index 9b522092ef1..8bd248e93da 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.ts @@ -396,6 +396,12 @@ export function transcriptBlocksToDaemonMessages( mimeType: img.mimeType || 'image/*', })); } + if (textBlock.files && textBlock.files.length > 0) { + msg.files = textBlock.files.map((file) => ({ + name: file.name, + mimeType: file.mimeType || 'text/plain', + })); + } messages.push(msg); break; } diff --git a/packages/web-shell/client/components/ChatEditor.module.css b/packages/web-shell/client/components/ChatEditor.module.css index 7fafd75b2fa..dbe766f7f9e 100644 --- a/packages/web-shell/client/components/ChatEditor.module.css +++ b/packages/web-shell/client/components/ChatEditor.module.css @@ -1939,6 +1939,69 @@ opacity: 0; } +.files { + display: flex; + flex: 0 0 auto; + gap: 6px; + padding: 4px 0 0; + flex-wrap: wrap; +} + +.fileChip { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 240px; + padding: 4px 8px; + border: 1px solid var(--chat-editor-border-color); + border-radius: 6px; + font-size: 12px; + line-height: 16px; + color: var(--chat-editor-text-primary); +} + +.fileChipIcon { + flex: 0 0 auto; + color: var(--chat-editor-text-secondary, currentColor); +} + +.fileChipName { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fileChipSize { + flex: 0 0 auto; + color: var(--chat-editor-text-secondary, currentColor); + opacity: 0.75; +} + +.fileChipRemove { + flex: 0 0 auto; + width: 16px; + height: 16px; + font-size: 12px; + line-height: 16px; + text-align: center; + background: transparent; + color: var(--error-color); + border: none; + cursor: pointer; + border-radius: 4px; + padding: 0; +} + +.fileChipRemove:not(:disabled):hover { + background: var(--error-color); + color: var(--chat-editor-text-primary); +} + +.fileChipRemove:disabled { + cursor: default; + opacity: 0.55; +} + .searchPanel { display: flex; flex-direction: column; diff --git a/packages/web-shell/client/components/ChatEditor.test.tsx b/packages/web-shell/client/components/ChatEditor.test.tsx index ec2d63eb9d9..f3ccfc63354 100644 --- a/packages/web-shell/client/components/ChatEditor.test.tsx +++ b/packages/web-shell/client/components/ChatEditor.test.tsx @@ -26,6 +26,12 @@ Element.prototype.scrollIntoView = vi.fn(); const mockComposerCoreState = vi.hoisted(() => ({ composerTags: [] as WebShellComposerTag[], pastedImages: [] as Array<{ data: string; media_type: string }>, + pastedFiles: [] as Array<{ + name: string; + media_type: string; + text: string; + size?: number; + }>, removeTopTag: vi.fn(), })); @@ -177,6 +183,7 @@ vi.mock('../hooks/useComposerCore', async (importOriginal) => { hasInput: vi.fn(() => false), hasAttachments: mockComposerCoreState.pastedImages.length > 0 || + mockComposerCoreState.pastedFiles.length > 0 || mockComposerCoreState.composerTags.length > 0, hasContent: false, canSubmit: false, @@ -197,10 +204,13 @@ vi.mock('../hooks/useComposerCore', async (importOriginal) => { submit: vi.fn(), hasAttachments: () => mockComposerCoreState.pastedImages.length > 0 || + mockComposerCoreState.pastedFiles.length > 0 || mockComposerCoreState.composerTags.length > 0, }, pastedImages: mockComposerCoreState.pastedImages, removeImage: vi.fn(), + pastedFiles: mockComposerCoreState.pastedFiles, + removeFile: vi.fn(), composerTags: mockComposerCoreState.composerTags, removeTopTag: mockComposerCoreState.removeTopTag, addTags: composerCoreState.addTags, @@ -301,6 +311,7 @@ afterEach(() => { } mockComposerCoreState.composerTags = []; mockComposerCoreState.pastedImages = []; + mockComposerCoreState.pastedFiles = []; mockComposerCoreState.removeTopTag.mockReset(); vi.useRealTimers(); }); @@ -308,6 +319,12 @@ afterEach(() => { interface ChatEditorRenderProps { composerTags?: WebShellComposerTag[]; pastedImages?: Array<{ data: string; media_type: string }>; + pastedFiles?: Array<{ + name: string; + media_type: string; + text: string; + size?: number; + }>; gitBranch?: string; workspaceName?: string; workspaceTitle?: string; @@ -342,6 +359,7 @@ function renderChatEditorInto( const { composerTags, pastedImages, + pastedFiles, customization, renderComposerTagTooltip, onComposerTagClick, @@ -353,6 +371,9 @@ function renderChatEditorInto( if (pastedImages) { mockComposerCoreState.pastedImages = pastedImages; } + if (pastedFiles) { + mockComposerCoreState.pastedFiles = pastedFiles; + } act(() => { root.render( diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index 0e8c5b933c8..f69362b9473 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -82,6 +82,7 @@ import { WorkspaceIndicator } from './WorkspaceIndicator'; import { ChevronDownIcon, ChevronRightIcon, + FileTextIcon, FolderClosedIcon, LoaderCircleIcon, UploadIcon, @@ -156,6 +157,7 @@ interface ChatEditorProps { onSubmit: ( text: string, images?: import('../adapters/promptTypes').PromptImage[], + files?: import('../adapters/promptTypes').PromptFile[], commitAccepted?: import('../hooks/useComposerCore').ComposerSubmitCommit, metadata?: ComposerSubmitMetadata, ) => boolean | void; @@ -317,6 +319,12 @@ function isTouchLikeDevice(): boolean { ); } +function formatAttachmentSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + function TopComposerTag({ tag, content, @@ -2583,7 +2591,9 @@ export const ChatEditor = memo( )}
- {(core.composerTags.length > 0 || core.pastedImages.length > 0) && ( + {(core.composerTags.length > 0 || + core.pastedImages.length > 0 || + core.pastedFiles.length > 0) && (
)} + {core.pastedFiles.length > 0 && ( +
+ {core.pastedFiles.map((file, i) => ( +
+
+ ))} +
+ )}
)} {uploadDragActive && ( diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx index c1de7b08f9c..e2d45632a6d 100644 --- a/packages/web-shell/client/components/ChatPane.test.tsx +++ b/packages/web-shell/client/components/ChatPane.test.tsx @@ -883,7 +883,7 @@ describe('ChatPane', () => { const commit = vi.fn(); let returned: boolean | undefined; act(() => { - returned = latestOnSubmit!('hi', undefined, commit); + returned = latestOnSubmit!('hi', undefined, undefined, commit); }); expect(returned).toBe(false); expect(commit).not.toHaveBeenCalled(); @@ -980,7 +980,7 @@ describe('ChatPane', () => { ]; render(); act(() => { - latestOnSubmit!('check @.husky/', undefined, undefined, { + latestOnSubmit!('check @.husky/', undefined, undefined, undefined, { inputAnnotations, }); }); @@ -997,7 +997,7 @@ describe('ChatPane', () => { const commit = vi.fn(); let returned: boolean | undefined; act(() => { - returned = latestOnSubmit!('queued next', undefined, commit); + returned = latestOnSubmit!('queued next', undefined, undefined, commit); }); expect(returned).toBe(true); expect(enqueuePrompt).toHaveBeenCalledWith( @@ -1005,6 +1005,7 @@ describe('ChatPane', () => { undefined, undefined, undefined, + undefined, expect.any(Function), ); expect(catalogController.invalidateWorkspace).toHaveBeenCalledWith('/w'); @@ -1020,7 +1021,7 @@ describe('ChatPane', () => { latestOnSubmit!('name this queued task'); }); - const onAdmitted = enqueuePrompt.mock.calls[0]?.[4] as + const onAdmitted = enqueuePrompt.mock.calls[0]?.[5] as | (() => void) | undefined; expect(onAdmitted).toEqual(expect.any(Function)); @@ -1103,7 +1104,7 @@ describe('ChatPane', () => { ]; render(); act(() => { - latestOnSubmit!('queue @.husky/', undefined, undefined, { + latestOnSubmit!('queue @.husky/', undefined, undefined, undefined, { inputAnnotations, }); }); @@ -1111,6 +1112,7 @@ describe('ChatPane', () => { 'queue @.husky/', undefined, undefined, + undefined, inputAnnotations, expect.any(Function), ); @@ -1129,6 +1131,7 @@ describe('ChatPane', () => { images, undefined, undefined, + undefined, expect.any(Function), ); }); @@ -1142,7 +1145,7 @@ describe('ChatPane', () => { latestOnSubmit!('', images); }); - expect(enqueuePrompt).toHaveBeenCalledWith('', images); + expect(enqueuePrompt).toHaveBeenCalledWith('', images, undefined); expect(sendPrompt).not.toHaveBeenCalled(); }); @@ -1196,7 +1199,7 @@ describe('ChatPane', () => { render({ onError, onImageIngestionNotice }); const commit = vi.fn(); await act(async () => { - latestOnSubmit!('hi', undefined, commit); + latestOnSubmit!('hi', undefined, undefined, commit); await Promise.resolve(); }); expect(commit).not.toHaveBeenCalled(); @@ -1270,7 +1273,7 @@ describe('ChatPane', () => { const commit = vi.fn(); act(() => { - latestOnSubmit!('hi', undefined, commit); + latestOnSubmit!('hi', undefined, undefined, commit); sendPromptAdmit?.(); }); await act(async () => { @@ -1296,7 +1299,7 @@ describe('ChatPane', () => { render(); const commit = vi.fn(); await act(async () => { - latestOnSubmit!('hi', undefined, commit); + latestOnSubmit!('hi', undefined, undefined, commit); await Promise.resolve(); }); @@ -1560,7 +1563,7 @@ describe('ChatPane', () => { render(); let returned: boolean | undefined; act(() => { - returned = latestOnSubmit!(' ', undefined, vi.fn()); + returned = latestOnSubmit!(' ', undefined, undefined, vi.fn()); }); expect(returned).toBe(false); expect(sendPrompt).not.toHaveBeenCalled(); diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx index b9a6b2a2acb..dba968eac29 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -41,7 +41,7 @@ import { useAnimationFrameTranscriptBlocks } from '../hooks/useAnimationFrameTra import { useMessagesFromBlocks } from '../hooks/useMessages'; import { useSessionArtifacts } from '../hooks/useSessionArtifacts'; import { extractPendingPermission } from '../adapters/transcriptAdapter'; -import type { PromptImage } from '../adapters/promptTypes'; +import type { PromptFile, PromptImage } from '../adapters/promptTypes'; import type { ComposerSubmitCommit, ComposerSubmitMetadata, @@ -566,11 +566,13 @@ export function ChatPane({ ( text: string, images?: PromptImage[], + files?: PromptFile[], commitAccepted?: ComposerSubmitCommit, metadata?: ComposerSubmitMetadata, ): boolean => { const trimmed = text.trim(); - if (!trimmed && (images?.length ?? 0) === 0) return false; + if (!trimmed && (images?.length ?? 0) === 0 && (files?.length ?? 0) === 0) + return false; if (admissionPayloadLocked) return false; if ( trimmed && @@ -605,6 +607,7 @@ export function ChatPane({ actions .sendPrompt(trimmed, { ...(images && images.length ? { images } : {}), + ...(files && files.length ? { files } : {}), ...(inputAnnotations ? { inputAnnotations } : {}), onAdmissionStarted: () => { admissionStarted = true; @@ -651,10 +654,11 @@ export function ChatPane({ } const queued = !trimmed && !inputAnnotations - ? enqueuePrompt(trimmed, images) + ? enqueuePrompt(trimmed, images, files) : enqueuePrompt( trimmed, images, + files, undefined, inputAnnotations, notifyFirstPromptAdmitted, diff --git a/packages/web-shell/client/components/MessageItem.tsx b/packages/web-shell/client/components/MessageItem.tsx index e0f914dd326..eb9e4bd6740 100644 --- a/packages/web-shell/client/components/MessageItem.tsx +++ b/packages/web-shell/client/components/MessageItem.tsx @@ -72,6 +72,7 @@ export const MessageItem = memo(function MessageItem({ void; onAdmitted?: () => void; @@ -192,6 +193,7 @@ export function QueuedPromptDisplay({ getQueuedPromptParts(prompt, parseUserMessageContent), ); const imageCount = prompt.images?.length ?? 0; + const fileCount = prompt.files?.length ?? 0; const isSubmitting = prompt.serverState === 'submitting'; const isQueued = prompt.serverState === 'queued'; const isRunning = prompt.serverState === 'running'; @@ -257,6 +259,9 @@ export function QueuedPromptDisplay({ {imageCount > 0 ? ` ${t('queue.imageCount', { count: imageCount })}` : ''} + {fileCount > 0 + ? ` ${t('queue.fileCount', { count: fileCount })}` + : ''} {isAdmissionUnknown && !hasUnknownPayload ? ` ${t('queue.localCopyDiscarded')}` : ''} diff --git a/packages/web-shell/client/components/messages/UserMessage.module.css b/packages/web-shell/client/components/messages/UserMessage.module.css index 76d16e63d10..925c047fc63 100644 --- a/packages/web-shell/client/components/messages/UserMessage.module.css +++ b/packages/web-shell/client/components/messages/UserMessage.module.css @@ -264,3 +264,29 @@ .chatImageThumbInteractive:hover { opacity: 0.85; } + +.chatFiles { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; +} + +.chatFileChip { + display: inline-flex; + align-items: center; + gap: 5px; + max-width: 240px; + padding: 3px 8px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 12px; + line-height: 16px; + overflow: hidden; +} + +.chatFileName { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/packages/web-shell/client/components/messages/UserMessage.test.tsx b/packages/web-shell/client/components/messages/UserMessage.test.tsx index a914097677e..9f4ae880d6f 100644 --- a/packages/web-shell/client/components/messages/UserMessage.test.tsx +++ b/packages/web-shell/client/components/messages/UserMessage.test.tsx @@ -368,6 +368,17 @@ describe('UserMessage', () => { ); }); + it('renders file attachment chips when provided', () => { + const container = render( + , + ); + expect(container.textContent).toContain('check this'); + expect(container.textContent).toContain('app.log'); + }); + it('uses a custom content renderer when provided', () => { const container = render( )} + {files && files.length > 0 && ( +
+ {files.map((file, index) => ( + + + ))} +
+ )} {renderedContent}
{heightOverflowing && ( diff --git a/packages/web-shell/client/customization.tsx b/packages/web-shell/client/customization.tsx index 16b90b05be7..e6d2411abf7 100644 --- a/packages/web-shell/client/customization.tsx +++ b/packages/web-shell/client/customization.tsx @@ -168,6 +168,7 @@ export type ChatHeaderRenderer = (info: ChatHeaderRenderInfo) => ReactNode; export interface UserMessageContentRenderInfo { content: string; images?: readonly { data: string; mimeType: string }[]; + files?: readonly { name: string; mimeType: string }[]; inputAnnotations?: readonly DaemonInputAnnotation[]; } diff --git a/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx b/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx index 57a2d1c47dd..25220e804d1 100644 --- a/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx +++ b/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx @@ -537,7 +537,7 @@ describe('useComposerCore history and drafts', () => { it('commits a new-task prompt after its session is allocated', async () => { let commitPrompt: ComposerSubmitCommit | undefined; const onSubmit = vi.fn( - (_text, _images, commitAccepted) => { + (_text, _images, _files, commitAccepted) => { commitPrompt = commitAccepted; return false; }, @@ -567,7 +567,7 @@ describe('useComposerCore history and drafts', () => { it('commits a delayed queued prompt to history exactly once', async () => { let commitQueuedPrompt: ComposerSubmitCommit | undefined; const onSubmit = vi.fn( - (_text, _images, commitAccepted) => { + (_text, _images, _files, commitAccepted) => { commitQueuedPrompt = commitAccepted; return false; }, @@ -613,7 +613,7 @@ describe('useComposerCore history and drafts', () => { vi.useFakeTimers(); let commitQueuedPrompt: ComposerSubmitCommit | undefined; const onSubmit = vi.fn( - (_text, _images, commitAccepted) => { + (_text, _images, _files, commitAccepted) => { commitQueuedPrompt = commitAccepted; return false; }, @@ -658,7 +658,7 @@ describe('useComposerCore history and drafts', () => { it('does not clear retyped identical input after delayed acceptance', async () => { let commitQueuedPrompt: ComposerSubmitCommit | undefined; const onSubmit = vi.fn( - (_text, _images, commitAccepted) => { + (_text, _images, _files, commitAccepted) => { commitQueuedPrompt = commitAccepted; return false; }, @@ -683,7 +683,7 @@ describe('useComposerCore history and drafts', () => { it('does not clear the next session when a delayed prompt is accepted', async () => { let commitQueuedPrompt: ComposerSubmitCommit | undefined; const onSubmit = vi.fn( - (_text, _images, commitAccepted) => { + (_text, _images, _files, commitAccepted) => { commitQueuedPrompt = commitAccepted; return false; }, @@ -723,7 +723,7 @@ describe('useComposerCore history and drafts', () => { vi.useFakeTimers(); let commitQueuedPrompt: ComposerSubmitCommit | undefined; const onSubmit = vi.fn( - (_text, _images, commitAccepted) => { + (_text, _images, _files, commitAccepted) => { commitQueuedPrompt = commitAccepted; return false; }, @@ -853,11 +853,96 @@ describe('useComposerCore paste', () => { expect(onSubmit).toHaveBeenCalledWith( '', [expect.objectContaining({ media_type: 'image/png' })], + undefined, expect.any(Function), undefined, ); }); + it('ingests dropped text files, sanitizes names, and submits them', async () => { + const onSubmit = vi.fn(); + await mount({ onSubmit }); + const drop = (files: File[]) => { + const event = new Event('drop', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'dataTransfer', { + value: { files, items: [], types: ['Files'], dropEffect: 'none' }, + }); + container! + .querySelector('[data-web-shell-composer-surface]')! + .dispatchEvent(event); + }; + + act(() => { + drop([ + new File(['line1\nline2'], 'my app.log', { type: 'text/plain' }), + new File(['second'], 'my app.log', { type: '' }), + ]); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + + expect(latest!.pastedFiles).toMatchObject([ + { name: 'my_app.log', text: 'line1\nline2' }, + { name: 'my_app.log-2', text: 'second' }, + ]); + expect(latest!.hasAttachments).toBe(true); + + act(() => latest!.submitText()); + expect(onSubmit).toHaveBeenCalledWith( + '', + undefined, + [ + expect.objectContaining({ name: 'my_app.log' }), + expect.objectContaining({ name: 'my_app.log-2' }), + ], + expect.any(Function), + undefined, + ); + }); + + it('removes a pasted file via its chip index', async () => { + await mount(); + const drop = (files: File[]) => { + const event = new Event('drop', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'dataTransfer', { + value: { files, items: [], types: ['Files'], dropEffect: 'none' }, + }); + container! + .querySelector('[data-web-shell-composer-surface]')! + .dispatchEvent(event); + }; + + act(() => { + drop([ + new File(['one'], 'one.log', { type: 'text/plain' }), + new File(['two'], 'two.log', { type: 'text/plain' }), + ]); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + expect(latest!.pastedFiles).toHaveLength(2); + + act(() => latest!.removeFile(0)); + expect(latest!.pastedFiles).toMatchObject([{ name: 'two.log' }]); + }); + + it('restores files with sanitized deduped names', async () => { + await mount(); + act(() => { + latest!.handle.restoreFiles([ + { name: 'app.log', media_type: 'text/plain', text: 'a' }, + { name: 'app.log', media_type: 'text/plain', text: 'b' }, + ]); + }); + + expect(latest!.pastedFiles).toMatchObject([ + { name: 'app.log', text: 'a' }, + { name: 'app.log-2', text: 'b' }, + ]); + }); + it('keeps drag feedback across composer children and clears it globally', async () => { await mount(); const surface = container!.querySelector( @@ -906,8 +991,8 @@ describe('useComposerCore paste', () => { const onImageIngestionNotice = vi.fn(); await mount({ onImageIngestionNotice }); const first = new File(['first'], 'first.bmp', { type: 'image/x-bmp' }); - const unsupported = new File(['text'], 'notes.txt', { - type: 'text/plain', + const unsupported = new File(['zip'], 'archive.zip', { + type: 'application/zip', }); const second = new File(['second'], 'second.png', { type: 'image/png' }); const drop = (files: File[]) => { @@ -954,7 +1039,7 @@ describe('useComposerCore paste', () => { }; act(() => { - drop([new File(['text'], 'notes.txt', { type: 'text/plain' })]); + drop([new File(['zip'], 'archive.zip', { type: 'application/zip' })]); drop([new File(['png'], 'photo.png', { type: 'image/png' })]); }); await waitForImageIngestion(); @@ -1031,6 +1116,7 @@ describe('useComposerCore tags', () => { expect(onSubmit).toHaveBeenCalledWith( '@file.ts\n\nfix it', undefined, + undefined, expect.any(Function), { inputAnnotations }, ); @@ -1066,6 +1152,7 @@ describe('useComposerCore tags', () => { expect(onSubmit).toHaveBeenCalledWith( '@b new\n@a old', undefined, + undefined, expect.any(Function), { inputAnnotations: [ @@ -1099,6 +1186,7 @@ describe('useComposerCore tags', () => { expect(onSubmit).toHaveBeenCalledWith( 'please @file.ts\n\nfix it', undefined, + undefined, expect.any(Function), { inputAnnotations: [ @@ -1135,6 +1223,7 @@ describe('useComposerCore tags', () => { expect(onSubmit).toHaveBeenCalledWith( '@Xile.ts\n\nfix it', undefined, + undefined, expect.any(Function), undefined, ); @@ -1457,6 +1546,7 @@ describe('useComposerCore tags', () => { expect(onSubmit).toHaveBeenCalledWith( ' explain', undefined, + undefined, expect.any(Function), { inputAnnotations: [ @@ -1517,6 +1607,7 @@ describe('useComposerCore tags', () => { expect(onSubmit).toHaveBeenLastCalledWith( prompt, undefined, + undefined, expect.any(Function), { inputAnnotations: [ @@ -1696,6 +1787,7 @@ describe('useComposerCore tags', () => { expect(onSubmit).toHaveBeenLastCalledWith( prompt, undefined, + undefined, expect.any(Function), { inputAnnotations: [ @@ -1756,6 +1848,7 @@ describe('useComposerCore tags', () => { expect(onSubmit).toHaveBeenLastCalledWith( prompt, undefined, + undefined, expect.any(Function), { inputAnnotations: [ @@ -1793,6 +1886,7 @@ describe('useComposerCore tags', () => { expect(onSubmit).toHaveBeenLastCalledWith( historyText, undefined, + undefined, expect.any(Function), undefined, ); @@ -1837,6 +1931,7 @@ describe('useComposerCore tags', () => { expect(onSubmit).toHaveBeenLastCalledWith( prompt, undefined, + undefined, expect.any(Function), undefined, ); @@ -1872,6 +1967,7 @@ describe('useComposerCore tags', () => { expect(onSubmit).toHaveBeenLastCalledWith( prompt, undefined, + undefined, expect.any(Function), { inputAnnotations: [ diff --git a/packages/web-shell/client/hooks/useComposerCore.ts b/packages/web-shell/client/hooks/useComposerCore.ts index d846c1b991e..53945eff7dc 100644 --- a/packages/web-shell/client/hooks/useComposerCore.ts +++ b/packages/web-shell/client/hooks/useComposerCore.ts @@ -43,7 +43,7 @@ import { useIsTouchComposer, } from './useIsTouchComposer'; import type { CommandInfo } from '../adapters/types'; -import type { PromptImage } from '../adapters/promptTypes'; +import type { PromptFile, PromptImage } from '../adapters/promptTypes'; import { useOptionalWorkspace, type UseDaemonFollowupSuggestionReturn, @@ -100,10 +100,14 @@ import type { } from '../customization'; import { useWebShellPortalRoot } from '../portalRoot'; import { - extractImageTransfer, + dedupeAttachmentName, + extractFileTransfer, hasFileTransferPayload, MAX_IMAGE_ATTACHMENT_DATA_BYTES, + MAX_TEXT_ATTACHMENT_DATA_BYTES, readImageTransfer, + readTextTransfer, + sanitizeAttachmentName, } from '../utils/imageIngestion'; const TOOLTIP_STYLE_ID = 'web-shell-tooltip-styles'; @@ -928,6 +932,7 @@ export interface EditorHandle extends WebShellComposerApi { hasInput(): boolean; retryLast(): void; restoreImages(images: readonly PromptImage[]): void; + restoreFiles(files: readonly PromptFile[]): void; restoreInputAnnotations?( inputAnnotations: readonly DaemonInputAnnotation[], ): void; @@ -1081,6 +1086,7 @@ export interface UseComposerCoreOptions { onSubmit: ( text: string, images?: PromptImage[], + files?: PromptFile[], commitAccepted?: ComposerSubmitCommit, metadata?: ComposerSubmitMetadata, ) => boolean | void; @@ -1331,6 +1337,8 @@ export interface UseComposerCoreReturn { handle: EditorHandle; pastedImages: PromptImage[]; removeImage: (index: number) => void; + pastedFiles: PromptFile[]; + removeFile: (index: number) => void; composerTags: WebShellComposerTag[]; removeTopTag: (id: string) => void; addTags: ( @@ -1678,6 +1686,8 @@ export function useComposerCore( const searchDraftRef = useRef(''); const [pastedImages, setPastedImages] = useState([]); const pastedImagesRef = useRef([]); + const [pastedFiles, setPastedFiles] = useState([]); + const pastedFilesRef = useRef([]); const [pendingImageBatchCount, setPendingImageBatchCount] = useState(0); const [imageDragActive, setImageDragActive] = useState(false); const imageDragDepthRef = useRef(0); @@ -1694,6 +1704,7 @@ export function useComposerCore( previousLane.generation + 1, ); pastedImagesRef.current = []; + pastedFilesRef.current = []; for (const reader of previousLane.activeReaders) { reader.abort(); } @@ -1701,6 +1712,7 @@ export function useComposerCore( imageDragDepthRef.current = 0; if (updateState) { setPastedImages([]); + setPastedFiles([]); setPendingImageBatchCount(0); setImageDragActive(false); } @@ -1724,7 +1736,7 @@ export function useComposerCore( ); const enqueueImageTransfer = useCallback( (dataTransfer: DataTransfer, source: 'paste' | 'drop') => { - const transfer = extractImageTransfer(dataTransfer, source); + const transfer = extractFileTransfer(dataTransfer, source); if (!transfer.claimed) return false; if (disabledRef.current) return true; @@ -1734,31 +1746,72 @@ export function useComposerCore( lane.tail = lane.tail .then(async () => { if (imageIngestionLaneRef.current !== lane) return; - const result = await readImageTransfer(transfer, { - onReaderCreated: (reader) => lane.activeReaders.add(reader), - onReaderSettled: (reader) => lane.activeReaders.delete(reader), + const readerLifecycle = { + onReaderCreated: (reader: FileReader) => + lane.activeReaders.add(reader), + onReaderSettled: (reader: FileReader) => + lane.activeReaders.delete(reader), + }; + const imageResult = await readImageTransfer( + transfer.imageCandidates, + { + ...readerLifecycle, + maxEncodedBytes: Math.max( + 0, + MAX_IMAGE_ATTACHMENT_DATA_BYTES - + pastedImagesRef.current.reduce( + (total, image) => total + image.data.length, + 0, + ), + ), + }, + ); + if (imageIngestionLaneRef.current !== lane) return; + const textResult = await readTextTransfer(transfer.textCandidates, { + ...readerLifecycle, maxEncodedBytes: Math.max( 0, - MAX_IMAGE_ATTACHMENT_DATA_BYTES - - pastedImagesRef.current.reduce( - (total, image) => total + image.data.length, + MAX_TEXT_ATTACHMENT_DATA_BYTES - + pastedFilesRef.current.reduce( + (total, file) => total + (file.size ?? file.text.length), 0, ), ), }); if (imageIngestionLaneRef.current !== lane) return; - if (result.accepted.length > 0) { - const next = [...pastedImagesRef.current, ...result.accepted]; + if (imageResult.accepted.length > 0) { + const next = [...pastedImagesRef.current, ...imageResult.accepted]; pastedImagesRef.current = next; setPastedImages(next); } - const skipped = result.rejected.filter( + if (textResult.accepted.length > 0) { + const taken = new Set( + pastedFilesRef.current.map((file) => file.name), + ); + const named = textResult.accepted.map((file) => { + const name = dedupeAttachmentName( + sanitizeAttachmentName(file.name), + taken, + ); + taken.add(name); + return { ...file, name }; + }); + const next = [...pastedFilesRef.current, ...named]; + pastedFilesRef.current = next; + setPastedFiles(next); + } + const rejected = [ + ...transfer.rejected, + ...imageResult.rejected, + ...textResult.rejected, + ]; + const skipped = rejected.filter( ({ reason }) => reason !== 'read-failed' && reason !== 'too-large', ).length; - const tooLarge = result.rejected.filter( + const tooLarge = rejected.filter( ({ reason }) => reason === 'too-large', ).length; - const failed = result.rejected.filter( + const failed = rejected.filter( ({ reason }) => reason === 'read-failed', ).length; if (skipped > 0) { @@ -2220,11 +2273,13 @@ export function useComposerCore( text.trim().length > 0 || !!followupCompletion || composerTags.length > 0 || - pastedImages.length > 0, + pastedImages.length > 0 || + pastedFiles.length > 0, ); }, [ composerTags, pastedImages, + pastedFiles, mobileText, followupState?.isVisible, followupState?.suggestion, @@ -2449,11 +2504,13 @@ export function useComposerCore( : []; const tags = tagsOverride ?? composerTagsRef.current; const images = pastedImagesRef.current; + const files = pastedFilesRef.current; if ( !rawText && tags.length === 0 && inlineTags.length === 0 && - images.length === 0 + images.length === 0 && + files.length === 0 ) { return true; } @@ -2496,6 +2553,7 @@ export function useComposerCore( const mobileTextVersionAtSubmit = mobileTextVersionRef.current; const composerTagsAtSubmit = composerTagsRef.current; const pastedImagesAtSubmit = pastedImagesRef.current; + const pastedFilesAtSubmit = pastedFilesRef.current; const restoredInputAnnotationsAtSubmit = restoredInputAnnotationsRef.current; const shellModeAtSubmit = shellModeRef.current; @@ -2544,6 +2602,7 @@ export function useComposerCore( : mobileTextVersionRef.current === mobileTextVersionAtSubmit) && composerTagsRef.current === composerTagsAtSubmit && pastedImagesRef.current === pastedImagesAtSubmit && + pastedFilesRef.current === pastedFilesAtSubmit && restoredInputAnnotationsRef.current === restoredInputAnnotationsAtSubmit && shellModeRef.current === shellModeAtSubmit; @@ -2559,8 +2618,10 @@ export function useComposerCore( clearPromptHistoryDraftTags(); setComposerTags([]); pastedImagesRef.current = []; + pastedFilesRef.current = []; restoredInputAnnotationsRef.current = []; setPastedImages([]); + setPastedFiles([]); if (view) { view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: '' }, @@ -2573,6 +2634,7 @@ export function useComposerCore( const accepted = onSubmitRef.current( promptText, images.length > 0 ? [...images] : undefined, + files.length > 0 ? [...files] : undefined, commitAccepted, inputAnnotations.length > 0 ? { inputAnnotations } : undefined, ); @@ -3159,7 +3221,8 @@ export function useComposerCore( text.trim().length > 0 || !!followupCompletion || composerTagsRef.current.length > 0 || - pastedImagesRef.current.length > 0, + pastedImagesRef.current.length > 0 || + pastedFilesRef.current.length > 0, ); } }), @@ -3234,7 +3297,8 @@ export function useComposerCore( updateHasContent( initialText.length > 0 || composerTagsRef.current.length > 0 || - pastedImagesRef.current.length > 0, + pastedImagesRef.current.length > 0 || + pastedFilesRef.current.length > 0, ); return () => { @@ -3849,7 +3913,8 @@ export function useComposerCore( return ( text.trim().length > 0 || composerTagsRef.current.length > 0 || - pastedImagesRef.current.length > 0 + pastedImagesRef.current.length > 0 || + pastedFilesRef.current.length > 0 ); }, [isTouchComposer]); @@ -3860,7 +3925,8 @@ export function useComposerCore( return ( inlineTags.length > 0 || composerTagsRef.current.length > 0 || - pastedImagesRef.current.length > 0 + pastedImagesRef.current.length > 0 || + pastedFilesRef.current.length > 0 ); }, []); @@ -3905,8 +3971,10 @@ export function useComposerCore( const accepted = onSubmitRef.current(last); if (accepted === false) return; pastedImagesRef.current = []; + pastedFilesRef.current = []; restoredInputAnnotationsRef.current = []; setPastedImages([]); + setPastedFiles([]); }, []); const replaceEditorText = useCallback( @@ -4083,9 +4151,11 @@ export function useComposerCore( const text = match.trim(); if (!text) return; const images = pastedImagesRef.current; + const files = pastedFilesRef.current; const accepted = onSubmitRef.current( `!${text}`, images.length > 0 ? [...images] : undefined, + files.length > 0 ? [...files] : undefined, ); if (accepted === false) { restoreSelectedHistoryMatch(match); @@ -4095,8 +4165,10 @@ export function useComposerCore( shellHistoryActionsRef.current.push(text); shellHistoryActionsRef.current.reset(); pastedImagesRef.current = []; + pastedFilesRef.current = []; restoredInputAnnotationsRef.current = []; setPastedImages([]); + setPastedFiles([]); replaceEditorText(''); }, [ @@ -4167,6 +4239,12 @@ export function useComposerCore( setPastedImages(next); }, []); + const removeFile = useCallback((index: number) => { + const next = pastedFilesRef.current.filter((_, idx) => idx !== index); + pastedFilesRef.current = next; + setPastedFiles(next); + }, []); + // ---- Computed ---- const canSubmit = @@ -4188,6 +4266,20 @@ export function useComposerCore( pastedImagesRef.current = next; setPastedImages(next); }, []); + const restoreFiles = useCallback((files: readonly PromptFile[]) => { + const taken = new Set(pastedFilesRef.current.map((file) => file.name)); + const named = files.map((file) => { + const name = dedupeAttachmentName( + sanitizeAttachmentName(file.name), + taken, + ); + taken.add(name); + return { ...file, name }; + }); + const next = [...pastedFilesRef.current, ...named]; + pastedFilesRef.current = next; + setPastedFiles(next); + }, []); const restoreInputAnnotations = useCallback( (inputAnnotations: readonly DaemonInputAnnotation[]) => { const restored = new Map( @@ -4222,6 +4314,7 @@ export function useComposerCore( insertText, retryLast, restoreImages, + restoreFiles, restoreInputAnnotations, submit, }; @@ -4236,6 +4329,7 @@ export function useComposerCore( insertText, removeTopTag, restoreImages, + restoreFiles, restoreInputAnnotations, retryLast, setText, @@ -4264,7 +4358,10 @@ export function useComposerCore( getText, hasInput, hasAttachments: - hasInlineTags || composerTags.length > 0 || pastedImages.length > 0, + hasInlineTags || + composerTags.length > 0 || + pastedImages.length > 0 || + pastedFiles.length > 0, hasContent, canSubmit, pendingImageBatchCount, @@ -4274,6 +4371,8 @@ export function useComposerCore( handle, pastedImages, removeImage, + pastedFiles, + removeFile, composerTags, removeTopTag, addTags, diff --git a/packages/web-shell/client/hooks/useQueuedPrompts.dom.test.tsx b/packages/web-shell/client/hooks/useQueuedPrompts.dom.test.tsx index ac27515d7c1..15a8b743fcb 100644 --- a/packages/web-shell/client/hooks/useQueuedPrompts.dom.test.tsx +++ b/packages/web-shell/client/hooks/useQueuedPrompts.dom.test.tsx @@ -290,6 +290,7 @@ describe('useQueuedPrompts default mid-turn insertion', () => { '', [{ data: 'Ym1w', mimeType: 'image/bmp' }], undefined, + undefined, ); expect(latest.queuedPrompts).toMatchObject([ { @@ -339,6 +340,7 @@ describe('useQueuedPrompts default mid-turn insertion', () => { '', [{ data: 'c2Vjb25k', mimeType: 'image/png' }], undefined, + undefined, ); }); @@ -378,6 +380,7 @@ describe('useQueuedPrompts default mid-turn insertion', () => { '', [{ data: 'dGVybWluYWw=', mimeType: 'image/png' }], undefined, + undefined, ); }); @@ -655,7 +658,13 @@ describe('useQueuedPrompts default mid-turn insertion', () => { ]; act(() => - latest.enqueuePrompt('describe', images, undefined, inputAnnotations), + latest.enqueuePrompt( + 'describe', + images, + undefined, + undefined, + inputAnnotations, + ), ); await act(async () => { pendingSubmit.reject(new DaemonHttpError(413, undefined, 'Too large')); @@ -731,6 +740,7 @@ describe('useQueuedPrompts default mid-turn insertion', () => { '@file.ts\n\nfix it', undefined, undefined, + undefined, inputAnnotations, ), ); @@ -1231,7 +1241,7 @@ describe('useQueuedPrompts default mid-turn insertion', () => { act(() => latest.enqueuePrompt('图片', [{ data: 'x', media_type: 'x' }])); act(() => latest.enqueuePrompt('/help')); act(() => - latest.enqueuePrompt('@file.ts fix', undefined, undefined, [ + latest.enqueuePrompt('@file.ts fix', undefined, undefined, undefined, [ { type: 'reference', start: 0, diff --git a/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx b/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx index 16b18c0b620..ed76c94a4cb 100644 --- a/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx +++ b/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx @@ -337,7 +337,9 @@ describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_que try { await harness.render({ streamingState: 'responding' }); await act(async () => { - harness.result().enqueuePrompt('note', undefined, onComplete); + harness + .result() + .enqueuePrompt('note', undefined, undefined, onComplete); }); await harness.render({ streamingState: 'idle' }); expect(harness.result().queuedPrompts).toEqual([]); @@ -364,7 +366,9 @@ describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_que try { await harness.render({ streamingState: 'responding' }); await act(async () => { - harness.result().enqueuePrompt('promote me', undefined, onComplete); + harness + .result() + .enqueuePrompt('promote me', undefined, undefined, onComplete); }); await harness.render({ streamingState: 'idle' }); @@ -605,7 +609,9 @@ describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_que try { await harness.render({ streamingState: 'responding' }); await act(async () => { - harness.result().enqueuePrompt('recover me', undefined, onComplete); + harness + .result() + .enqueuePrompt('recover me', undefined, undefined, onComplete); }); const row = harness.result().queuedPrompts[0]!; const messageId = row.midTurnMessageId!; @@ -678,7 +684,7 @@ describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_que await act(async () => { harness .result() - .enqueuePrompt('deleted by peer', undefined, onComplete); + .enqueuePrompt('deleted by peer', undefined, undefined, onComplete); }); await act(async () => { await Promise.resolve(); @@ -1271,7 +1277,9 @@ describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_que try { await harness.render({ streamingState: 'responding' }); await act(async () => { - harness.result().enqueuePrompt('note', undefined, onComplete); + harness + .result() + .enqueuePrompt('note', undefined, undefined, onComplete); }); for (let i = 0; i < 3; i++) { await act(async () => { @@ -1308,7 +1316,9 @@ describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_que try { await harness.render({ streamingState: 'responding' }); await act(async () => { - harness.result().enqueuePrompt('rejected', undefined, onComplete); + harness + .result() + .enqueuePrompt('rejected', undefined, undefined, onComplete); }); for (let i = 0; i < 3; i++) { await act(async () => { @@ -1359,7 +1369,7 @@ describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_que await act(async () => { harness .result() - .enqueuePrompt('lost in transit', undefined, onComplete); + .enqueuePrompt('lost in transit', undefined, undefined, onComplete); }); for (let i = 0; i < 3; i++) { await act(async () => { @@ -1441,7 +1451,9 @@ describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_que try { await harness.render({ streamingState: 'responding' }); await act(async () => { - harness.result().enqueuePrompt('echoed', undefined, onComplete); + harness + .result() + .enqueuePrompt('echoed', undefined, undefined, onComplete); }); await act(async () => { await Promise.resolve(); @@ -1545,7 +1557,9 @@ describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_que try { await harness.render({ streamingState: 'responding' }); await act(async () => { - harness.result().enqueuePrompt('promote me', undefined, onComplete); + harness + .result() + .enqueuePrompt('promote me', undefined, undefined, onComplete); }); for (let i = 0; i < 3; i++) { await act(async () => { diff --git a/packages/web-shell/client/hooks/useQueuedPrompts.ts b/packages/web-shell/client/hooks/useQueuedPrompts.ts index cc3b3f789f8..490e506fb0c 100644 --- a/packages/web-shell/client/hooks/useQueuedPrompts.ts +++ b/packages/web-shell/client/hooks/useQueuedPrompts.ts @@ -29,7 +29,7 @@ import type { DaemonPendingPromptSummary, DaemonTranscriptStore, } from '@qwen-code/sdk/daemon'; -import type { PromptImage } from '../adapters/promptTypes'; +import type { PromptFile, PromptImage } from '../adapters/promptTypes'; import type { EditorHandle } from './useComposerCore'; import { removeInjectedFromQueue } from '../midTurnDedup'; import { isCommandPrompt } from '../utils/localCommandQueue'; @@ -115,6 +115,7 @@ function areQueuedPromptsEqual( prompt.admissionOutcome === other.admissionOutcome && prompt.payloadAvailable === other.payloadAvailable && (prompt.images?.length ?? 0) === (other.images?.length ?? 0) && + (prompt.files?.length ?? 0) === (other.files?.length ?? 0) && (prompt.inputAnnotations?.length ?? 0) === (other.inputAnnotations?.length ?? 0) ); @@ -131,12 +132,23 @@ function toStoreImages( })); } +function toStoreFiles( + files: readonly PromptFile[] | undefined, +): Array<{ name: string; mimeType: string }> | undefined { + if (!files || files.length === 0) return undefined; + return files.map((file) => ({ + name: file.name, + mimeType: file.media_type || 'text/plain', + })); +} + export interface UseQueuedPromptsResult { queuedPrompts: QueuedPrompt[]; queuedTexts: string[]; enqueuePrompt: ( text: string, images?: PromptImage[], + files?: PromptFile[], onComplete?: () => void, inputAnnotations?: DaemonInputAnnotation[], onAdmitted?: () => void, @@ -299,6 +311,7 @@ export function useQueuedPrompts({ p.serverState === 'submitting' && p.admissionOutcome !== 'unknown' && (p.images?.length ?? 0) === 0 && + (p.files?.length ?? 0) === 0 && p.text === serverPrompt.text, ); if (submittingMatches.length === 1) { @@ -317,14 +330,15 @@ export function useQueuedPrompts({ if (serverPrompt.state === 'running' || hasDisplayedPrompt) { continue; } - const hasUnboundImageSubmission = next.some( + const hasUnboundAttachmentSubmission = next.some( (prompt) => !prompt.serverPromptId && prompt.serverState === 'submitting' && prompt.admissionOutcome !== 'unknown' && - (prompt.images?.length ?? 0) > 0, + ((prompt.images?.length ?? 0) > 0 || + (prompt.files?.length ?? 0) > 0), ); - if (hasUnboundImageSubmission) continue; + if (hasUnboundAttachmentSubmission) continue; next.push({ id: nextQueuedPromptIdRef.current++, sessionId: targetSessionId, @@ -593,6 +607,8 @@ export function useQueuedPrompts({ ); const images = attachmentPrompts.flatMap((prompt) => prompt.images ?? []); if (images.length > 0) editor.restoreImages(images); + const files = attachmentPrompts.flatMap((prompt) => prompt.files ?? []); + if (files.length > 0) editor.restoreFiles(files); let annotationOffset = 0; const inputAnnotations: DaemonInputAnnotation[] = []; for (const prompt of attachmentPrompts) { @@ -671,7 +687,9 @@ export function useQueuedPrompts({ if ( displayedServerPromptIdsRef.current.has(promptId) || prompt.payloadCompleteness === 'summary-only' || - (!prompt.text && (prompt.images?.length ?? 0) === 0) + (!prompt.text && + (prompt.images?.length ?? 0) === 0 && + (prompt.files?.length ?? 0) === 0) ) { return; } @@ -682,6 +700,7 @@ export function useQueuedPrompts({ prompt.inputAnnotations?.length ? { inputAnnotations: prompt.inputAnnotations } : undefined, + toStoreFiles(prompt.files), ); }, [store], @@ -766,6 +785,7 @@ export function useQueuedPrompts({ item.serverState === 'submitting' && item.admissionOutcome !== 'unknown' && (item.images?.length ?? 0) === 0 && + (item.files?.length ?? 0) === 0 && item.text === eventText, ); if (prompt) { @@ -859,6 +879,7 @@ export function useQueuedPrompts({ sessionActions .submitPrompt(prompt.text, { images: prompt.images, + files: prompt.files, inputAnnotations: prompt.inputAnnotations, optimisticUserMessage: false, sessionId: targetSessionId, @@ -942,7 +963,10 @@ export function useQueuedPrompts({ if (prompt.onComplete) { settleCompletionCallback(result.promptId, prompt.onComplete); } - if ((prompt.images?.length ?? 0) > 0) { + if ( + (prompt.images?.length ?? 0) > 0 || + (prompt.files?.length ?? 0) > 0 + ) { void refreshPendingPrompts(targetSessionId); } return; @@ -982,7 +1006,10 @@ export function useQueuedPrompts({ if (prompt.onComplete) { settleCompletionCallback(result.promptId, prompt.onComplete); } - if ((prompt.images?.length ?? 0) > 0) { + if ( + (prompt.images?.length ?? 0) > 0 || + (prompt.files?.length ?? 0) > 0 + ) { void refreshPendingPrompts(targetSessionId); } }) @@ -1063,18 +1090,21 @@ export function useQueuedPrompts({ ( text: string, images?: PromptImage[], + files?: PromptFile[], onComplete?: () => void, inputAnnotations?: DaemonInputAnnotation[], onAdmitted?: () => void, ) => { const trimmed = text.trim(); - if (!trimmed && (images?.length ?? 0) === 0) return true; + if (!trimmed && (images?.length ?? 0) === 0 && (files?.length ?? 0) === 0) + return true; const targetSessionId = latestSessionIdRef.current; const targetWorkspaceCwd = latestWorkspaceCwdRef.current; const ownerToken = ownerTokenRef.current; const shouldInsertMidTurn = latestStreamingStateRef.current !== 'idle' && (images?.length ?? 0) === 0 && + (files?.length ?? 0) === 0 && (inputAnnotations?.length ?? 0) === 0 && !isCommandPrompt(trimmed); const midTurnMessageId = @@ -1213,6 +1243,7 @@ export function useQueuedPrompts({ sessionId: targetSessionId, text: trimmed, images: images ? [...images] : undefined, + files: files ? [...files] : undefined, inputAnnotations: inputAnnotations ? [...inputAnnotations] : undefined, onComplete, onAdmitted, @@ -1670,6 +1701,7 @@ export function useQueuedPrompts({ ...prompt, text: '', images: undefined, + files: undefined, inputAnnotations: undefined, onComplete: undefined, payloadAvailable: false, diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 69d7b91c621..4e15065dc57 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -1014,11 +1014,11 @@ const EN: Messages = { 'editor.shellPlaceholder': 'Enter terminal command', 'editor.send': 'Send message', 'editor.imagesSkipped': (v) => - `${v?.count ?? 0} unsupported image file(s) were skipped.`, + `${v?.count ?? 0} unsupported file(s) were skipped.`, 'editor.imagesReadFailed': (v) => - `${v?.count ?? 0} image file(s) could not be read.`, + `${v?.count ?? 0} file(s) could not be read.`, 'editor.imagesTooLarge': (v) => - `${v?.count ?? 0} image file(s) exceeded the attachment size limit.`, + `${v?.count ?? 0} file(s) exceeded the attachment size limit.`, 'editor.connectionDisconnected': 'Connection interrupted. Please try again after it reconnects.', 'editor.sessionLoading': 'Session is still loading. Try again in a moment.', @@ -1424,6 +1424,7 @@ const EN: Messages = { 'queue.footer': 'Press ↑ to edit the latest queued message · Esc to clear queue', 'queue.imageCount': (v) => `(+${v?.count ?? 0} images)`, + 'queue.fileCount': (v) => `(+${v?.count ?? 0} files)`, 'queue.more': (v) => `... (+${v?.count ?? 0} more)`, 'midTurn.inserted': (v) => `Inserted message: ${v?.message ?? ''}`, 'help.builtIn': 'Built-in commands', @@ -3962,10 +3963,9 @@ const ZH: Messages = { 'history.retry': '重试', 'editor.shellPlaceholder': '请输入终端命令', 'editor.send': '发送消息', - 'editor.imagesSkipped': (v) => `已跳过 ${v?.count ?? 0} 个不支持的图片文件。`, - 'editor.imagesReadFailed': (v) => `${v?.count ?? 0} 个图片文件读取失败。`, - 'editor.imagesTooLarge': (v) => - `${v?.count ?? 0} 个图片文件超过附件大小限制。`, + 'editor.imagesSkipped': (v) => `已跳过 ${v?.count ?? 0} 个不支持的文件。`, + 'editor.imagesReadFailed': (v) => `${v?.count ?? 0} 个文件读取失败。`, + 'editor.imagesTooLarge': (v) => `${v?.count ?? 0} 个文件超过附件大小限制。`, 'editor.connectionDisconnected': '连接已中断,请在恢复后重试。', 'editor.sessionLoading': '会话正在加载,请稍后再发送。', 'editor.processing': '处理中。新消息会进入队列。', @@ -4345,6 +4345,7 @@ const ZH: Messages = { 'queue.editFailed': '编辑排队消息失败', 'queue.footer': '按 ↑ 编辑最后一条排队消息 · Esc 清空队列', 'queue.imageCount': (v) => `(+${v?.count ?? 0} 张图片)`, + 'queue.fileCount': (v) => `(+${v?.count ?? 0} 个文件)`, 'queue.more': (v) => `...(还有 ${v?.count ?? 0} 条)`, 'midTurn.inserted': (v) => `已插入消息:${v?.message ?? ''}`, 'help.builtIn': '内置命令', diff --git a/packages/web-shell/client/midTurnDedup.test.ts b/packages/web-shell/client/midTurnDedup.test.ts index 50ff78b9ceb..5572ab631d7 100644 --- a/packages/web-shell/client/midTurnDedup.test.ts +++ b/packages/web-shell/client/midTurnDedup.test.ts @@ -14,6 +14,7 @@ interface Item { id: number; text: string; images?: unknown[]; + files?: unknown[]; midTurnState?: 'submitting' | 'queued'; midTurnMessageId?: string; } @@ -101,6 +102,20 @@ describe('removeInjectedFromQueue', () => { expect(next?.[0].images).toEqual([{ data: 'x' }]); }); + it('never matches a file-bearing entry (files are not pushed mid-turn)', () => { + const withFile = { ...q('with file'), files: [{ name: 'app.log' }] }; + const prompts = [withFile, q('with file')]; + const next = removeInjectedFromQueue( + prompts, + [batch('s', 'with file')], + 's', + ); + // The text-only one is removed; the file-bearing one stays. + expect(next).not.toBeNull(); + expect(next).toHaveLength(1); + expect(next?.[0]).toBe(withFile); + }); + it('does not remove an ordinary queued prompt with the same text', () => { const ordinary = { ...q('same'), midTurnState: undefined }; const inserted = q('same'); diff --git a/packages/web-shell/client/midTurnDedup.ts b/packages/web-shell/client/midTurnDedup.ts index da7bc7929d9..253e1ab611c 100644 --- a/packages/web-shell/client/midTurnDedup.ts +++ b/packages/web-shell/client/midTurnDedup.ts @@ -7,6 +7,7 @@ export interface MidTurnQueueItem { text: string; images?: unknown[]; + files?: unknown[]; midTurnState?: 'submitting' | 'queued'; midTurnMessageId?: string; } @@ -34,10 +35,10 @@ export interface MidTurnInjectedBatch { * no ids (older daemon), or a still-`submitting` row that hasn't received its id * yet. Matching stays count-based — one removal per injected message — so a * queue that holds the same text twice loses one entry per matching injection. - * Entries carrying images are never matched: image messages aren't pushed - * mid-turn (the drain channel carries plain strings), so they stay queued for - * the next turn. An entry that already fell back to the ordinary path - * (`midTurnState === undefined`) is never matched. + * Entries carrying images or file attachments are never matched: attachment + * messages aren't pushed mid-turn (the drain channel carries plain strings), + * so they stay queued for the next turn. An entry that already fell back to + * the ordinary path (`midTurnState === undefined`) is never matched. * * Text fallback skips batches from another originator so coincidentally equal * messages are not removed. In strict-id mode, an exact id still wins because @@ -58,7 +59,8 @@ export function removeInjectedFromQueue( ): T[] | null { const remaining = [...prompts]; const isTextOnly = (prompt: T) => - !prompt.images || prompt.images.length === 0; + (!prompt.images || prompt.images.length === 0) && + (!prompt.files || prompt.files.length === 0); let changed = false; for (const batch of batches) { if (batch.sessionId !== sessionId) continue; diff --git a/packages/web-shell/client/utils/imageIngestion.test.ts b/packages/web-shell/client/utils/imageIngestion.test.ts index 36a7cd846a5..336ff3c37ee 100644 --- a/packages/web-shell/client/utils/imageIngestion.test.ts +++ b/packages/web-shell/client/utils/imageIngestion.test.ts @@ -2,9 +2,13 @@ import { describe, expect, it, vi } from 'vitest'; import { - extractImageTransfer, + dedupeAttachmentName, + extractFileTransfer, normalizeImageMediaType, + normalizeTextMediaType, readImageTransfer, + readTextTransfer, + sanitizeAttachmentName, } from './imageIngestion'; function transfer({ @@ -40,7 +44,7 @@ describe('image ingestion', () => { it('uses files as the authoritative source without duplicating item files', () => { const file = new File(['png'], 'photo.png', { type: 'image/png' }); const getAsFile = vi.fn(() => file); - const result = extractImageTransfer( + const result = extractFileTransfer( transfer({ files: [file], items: [{ kind: 'file', type: 'image/png', getAsFile }], @@ -50,24 +54,25 @@ describe('image ingestion', () => { ); expect(result.claimed).toBe(true); - expect(result.candidates).toHaveLength(1); + expect(result.imageCandidates).toHaveLength(1); expect(getAsFile).not.toHaveBeenCalled(); }); it('claims unsupported file drops but leaves unsupported file pastes native', () => { - const file = new File(['text'], 'notes.txt', { type: 'text/plain' }); + const file = new File(['zip'], 'archive.zip', { type: 'application/zip' }); const dataTransfer = transfer({ files: [file], types: ['Files'] }); - expect(extractImageTransfer(dataTransfer, 'drop')).toMatchObject({ + expect(extractFileTransfer(dataTransfer, 'drop')).toMatchObject({ claimed: true, - candidates: [], - rejected: [{ name: 'notes.txt', reason: 'unsupported' }], + imageCandidates: [], + textCandidates: [], + rejected: [{ name: 'archive.zip', reason: 'unsupported' }], }); - expect(extractImageTransfer(dataTransfer, 'paste').claimed).toBe(false); + expect(extractFileTransfer(dataTransfer, 'paste').claimed).toBe(false); }); it('claims a supported clipboard item that cannot expose its file', () => { - const result = extractImageTransfer( + const result = extractFileTransfer( transfer({ items: [{ kind: 'file', type: 'image/png', getAsFile: () => null }], types: ['Files'], @@ -77,7 +82,7 @@ describe('image ingestion', () => { expect(result).toMatchObject({ claimed: true, - candidates: [], + imageCandidates: [], rejected: [{ reason: 'unavailable' }], }); }); @@ -85,14 +90,14 @@ describe('image ingestion', () => { it('reads supported files in selection order and reports lifecycle settlement', async () => { const first = new File(['first'], 'first.png', { type: 'image/png' }); const second = new File(['second'], 'second.bmp', { type: 'image/x-bmp' }); - const extracted = extractImageTransfer( + const extracted = extractFileTransfer( transfer({ files: [first, second], types: ['Files'] }), 'drop', ); const created: FileReader[] = []; const settled: FileReader[] = []; - const result = await readImageTransfer(extracted, { + const result = await readImageTransfer(extracted.imageCandidates, { onReaderCreated: (reader) => created.push(reader), onReaderSettled: (reader) => settled.push(reader), }); @@ -116,14 +121,14 @@ describe('image ingestion', () => { (_, index) => new File([`image-${index}`], `${index}.png`, { type: 'image/png' }), ); - const extracted = extractImageTransfer( + const extracted = extractFileTransfer( transfer({ files, types: ['Files'] }), 'drop', ); let activeReaders = 0; let maxActiveReaders = 0; - const result = await readImageTransfer(extracted, { + const result = await readImageTransfer(extracted.imageCandidates, { onReaderCreated: () => { activeReaders += 1; maxActiveReaders = Math.max(maxActiveReaders, activeReaders); @@ -142,14 +147,192 @@ describe('image ingestion', () => { new File(['one'], 'one.png', { type: 'image/png' }), new File(['two'], 'two.png', { type: 'image/png' }), ]; - const extracted = extractImageTransfer( + const extracted = extractFileTransfer( transfer({ files, types: ['Files'] }), 'drop', ); - const result = await readImageTransfer(extracted, { maxEncodedBytes: 4 }); + const result = await readImageTransfer(extracted.imageCandidates, { + maxEncodedBytes: 4, + }); expect(result.accepted).toHaveLength(1); expect(result.rejected).toEqual([{ name: 'two.png', reason: 'too-large' }]); }); }); + +describe('text file ingestion', () => { + it.each([ + ['text/plain', 'app.bin', 'text/plain'], + ['text/markdown', 'README.md', 'text/markdown'], + ['application/json', 'data.bin', 'application/json'], + ['application/yaml', 'cfg.bin', 'application/yaml'], + ['', 'app.LOG', 'text/plain'], + ['application/octet-stream', 'notes.md', 'text/plain'], + ['application/octet-stream', 'script.sh', 'text/plain'], + ['application/pdf', 'doc.pdf', undefined], + ['', 'archive.zip', undefined], + ['image/png', 'photo.png', undefined], + ])('normalizes %s and %s to %s', (type, name, expected) => { + expect(normalizeTextMediaType(type, name)).toBe(expected); + }); + + it.each([ + ['video/mp2t', 'foo.ts'], + ['video/mp2t', 'clip.mts'], + ['application/vnd.ms-excel', 'data.csv'], + ['application/vnd.ms-excel', 'data.tsv'], + ['application/octet-stream', 'notes.json'], + ])( + 'extension allowlist wins over conflicting MIME %s for %s', + (type, name) => { + expect(normalizeTextMediaType(type, name)).toBe('text/plain'); + }, + ); + + it.each(['Dockerfile', 'Makefile', 'LICENSE', 'Gemfile', 'Procfile'])( + 'accepts extensionless plain-text name %s', + (name) => { + expect(normalizeTextMediaType('', name)).toBe('text/plain'); + expect(normalizeTextMediaType('application/octet-stream', name)).toBe( + 'text/plain', + ); + }, + ); + + it('still rejects conflicting MIME for unlisted extensions', () => { + expect( + normalizeTextMediaType('application/pdf', 'doc.pdf'), + ).toBeUndefined(); + expect(normalizeTextMediaType('image/gif', 'anim.gif')).toBeUndefined(); + }); + + it('classifies mixed drops into image and text candidates', () => { + const image = new File(['png'], 'photo.png', { type: 'image/png' }); + const log = new File(['log'], 'app.log', { type: '' }); + const zip = new File(['zip'], 'archive.zip', { type: 'application/zip' }); + const result = extractFileTransfer( + transfer({ files: [image, log, zip], types: ['Files'] }), + 'drop', + ); + + expect(result.claimed).toBe(true); + expect(result.imageCandidates.map((c) => c.file.name)).toEqual([ + 'photo.png', + ]); + expect(result.textCandidates.map((c) => c.file.name)).toEqual(['app.log']); + expect(result.rejected).toEqual([ + { name: 'archive.zip', reason: 'unsupported' }, + ]); + }); + + it('claims a text file paste', () => { + const file = new File(['hello'], 'notes.txt', { type: 'text/plain' }); + const result = extractFileTransfer( + transfer({ files: [file], types: ['Files'] }), + 'paste', + ); + + expect(result.claimed).toBe(true); + expect(result.textCandidates).toHaveLength(1); + }); + + it('reads text files with their content and size', async () => { + const file = new File(['line1\nline2'], 'app.log', { type: 'text/plain' }); + const extracted = extractFileTransfer( + transfer({ files: [file], types: ['Files'] }), + 'drop', + ); + + const result = await readTextTransfer(extracted.textCandidates); + + expect(result.rejected).toEqual([]); + expect(result.accepted).toEqual([ + { + name: 'app.log', + media_type: 'text/plain', + text: 'line1\nline2', + size: file.size, + }, + ]); + }); + + it('rejects files whose decoded content contains NUL bytes', async () => { + const binary = new File([new Uint8Array([0x89, 0x00, 0x50])], 'fake.log', { + type: 'text/plain', + }); + const extracted = extractFileTransfer( + transfer({ files: [binary], types: ['Files'] }), + 'drop', + ); + + const result = await readTextTransfer(extracted.textCandidates); + + expect(result.accepted).toEqual([]); + expect(result.rejected).toEqual([ + { name: 'fake.log', reason: 'unsupported' }, + ]); + }); + + it('rejects text files that exceed the remaining size budget', async () => { + const files = [ + new File(['one'], 'one.log', { type: 'text/plain' }), + new File(['two'], 'two.log', { type: 'text/plain' }), + ]; + const extracted = extractFileTransfer( + transfer({ files, types: ['Files'] }), + 'drop', + ); + + const result = await readTextTransfer(extracted.textCandidates, { + maxEncodedBytes: 3, + }); + + expect(result.accepted).toHaveLength(1); + expect(result.rejected).toEqual([{ name: 'two.log', reason: 'too-large' }]); + }); + + it('keeps image and text budgets independent', async () => { + const image = new File(['png'], 'photo.png', { type: 'image/png' }); + const log = new File(['log'], 'app.log', { type: 'text/plain' }); + const extracted = extractFileTransfer( + transfer({ files: [image, log], types: ['Files'] }), + 'drop', + ); + + const imageResult = await readImageTransfer(extracted.imageCandidates, { + maxEncodedBytes: 4, + }); + const textResult = await readTextTransfer(extracted.textCandidates, { + maxEncodedBytes: 3, + }); + + expect(imageResult.accepted).toHaveLength(1); + expect(textResult.accepted).toHaveLength(1); + }); +}); + +describe('attachment naming', () => { + it('replaces token-unsafe characters and strips control characters', () => { + expect(sanitizeAttachmentName('my log(1).log')).toBe('my_log_1_.log'); + expect(sanitizeAttachmentName('a,b;c.txt')).toBe('a_b_c.txt'); + expect(sanitizeAttachmentName('weird\nname.log')).toBe('weird_name.log'); + }); + + it('falls back for names that sanitize to nothing', () => { + expect(sanitizeAttachmentName('')).toBe('attachment'); + expect(sanitizeAttachmentName(' ')).toBe('attachment'); + }); + + it('strips invisible bidi and zero-width format characters', () => { + expect(sanitizeAttachmentName('app\u202e.log')).toBe('app.log'); + expect(sanitizeAttachmentName('sec\u200bret.log')).toBe('secret.log'); + expect(sanitizeAttachmentName('a\u2066b\u2069.log')).toBe('ab.log'); + }); + + it('dedupes against taken names with a numeric suffix', () => { + const taken = new Set(['app.log', 'app.log-2']); + expect(dedupeAttachmentName('app.log', taken)).toBe('app.log-3'); + expect(dedupeAttachmentName('other.log', taken)).toBe('other.log'); + }); +}); diff --git a/packages/web-shell/client/utils/imageIngestion.ts b/packages/web-shell/client/utils/imageIngestion.ts index 79a8b03f996..a35ffb3dc49 100644 --- a/packages/web-shell/client/utils/imageIngestion.ts +++ b/packages/web-shell/client/utils/imageIngestion.ts @@ -1,4 +1,4 @@ -import type { PromptImage } from '../adapters/promptTypes'; +import type { PromptFile, PromptImage } from '../adapters/promptTypes'; export type ImageIngestionRejectionReason = | 'unsupported' @@ -11,14 +11,20 @@ export interface ImageIngestionRejection { reason: ImageIngestionRejectionReason; } -interface ImageFileCandidate { +export interface ImageFileCandidate { file: File; mediaType: string; } -export interface ExtractedImageTransfer { +export interface TextFileCandidate { + file: File; + mediaType: string; +} + +export interface ExtractedFileTransfer { claimed: boolean; - candidates: ImageFileCandidate[]; + imageCandidates: ImageFileCandidate[]; + textCandidates: TextFileCandidate[]; rejected: ImageIngestionRejection[]; } @@ -27,6 +33,11 @@ export interface ImageIngestionBatchResult { rejected: ImageIngestionRejection[]; } +export interface TextIngestionBatchResult { + accepted: PromptFile[]; + rejected: ImageIngestionRejection[]; +} + interface ReaderLifecycle { onReaderCreated?: (reader: FileReader) => void; onReaderSettled?: (reader: FileReader) => void; @@ -34,6 +45,7 @@ interface ReaderLifecycle { } export const MAX_IMAGE_ATTACHMENT_DATA_BYTES = 8 * 1024 * 1024; +export const MAX_TEXT_ATTACHMENT_DATA_BYTES = 512 * 1024; const MAX_CONCURRENT_IMAGE_READERS = 4; const IMAGE_MIME_BY_EXTENSION: Readonly> = { @@ -65,6 +77,133 @@ export function normalizeImageMediaType( return extension ? IMAGE_MIME_BY_EXTENSION[extension] : undefined; } +const TEXT_MIME_TYPES: ReadonlySet = new Set([ + 'application/json', + 'application/xml', + 'application/javascript', + 'application/x-javascript', + 'application/typescript', + 'application/yaml', + 'application/x-yaml', + 'application/toml', + 'application/x-sh', + 'application/sql', +]); + +const TEXT_FILE_EXTENSIONS: ReadonlySet = new Set([ + 'log', + 'txt', + 'text', + 'md', + 'markdown', + 'json', + 'jsonl', + 'ndjson', + 'csv', + 'tsv', + 'xml', + 'yaml', + 'yml', + 'toml', + 'ini', + 'cfg', + 'conf', + 'config', + 'env', + 'properties', + 'sh', + 'bash', + 'zsh', + 'py', + 'js', + 'mjs', + 'cjs', + 'jsx', + 'ts', + 'mts', + 'cts', + 'tsx', + 'java', + 'go', + 'rs', + 'c', + 'h', + 'cpp', + 'cc', + 'cxx', + 'hpp', + 'cs', + 'rb', + 'php', + 'swift', + 'kt', + 'scala', + 'sql', + 'html', + 'htm', + 'css', + 'scss', + 'less', + 'vue', + 'svelte', + 'diff', + 'patch', +]); + +const TEXT_FILENAMES: ReadonlySet = new Set([ + 'dockerfile', + 'makefile', + 'license', + 'readme', + 'gemfile', + 'procfile', + 'vagrantfile', +]); + +export function normalizeTextMediaType( + mediaType: string, + fileName = '', +): string | undefined { + const normalized = mediaType.trim().toLowerCase(); + if (normalized.startsWith('text/')) return normalized; + if (TEXT_MIME_TYPES.has(normalized)) return normalized; + // Extension and well-known-name fallbacks run even when the OS reports a + // conflicting MIME (`.ts`/`video/mp2t`, `.csv`/`vnd.ms-excel`); actually + // binary content is still rejected downstream by the NUL sniff in readText. + const extension = fileName.split('.').pop()?.toLowerCase(); + if (extension && TEXT_FILE_EXTENSIONS.has(extension)) return 'text/plain'; + if (TEXT_FILENAMES.has(fileName.trim().toLowerCase())) return 'text/plain'; + return undefined; +} + +// The daemon derives a `@` token and a `File: ` label from the +// resource URI, and `@`-token scanning stops at these characters — keep the +// name identical across chip, URI, and token by normalizing once here. +const ATTACHMENT_NAME_UNSAFE_RE = /[\s,;!?()[\]{}]+/g; +/* eslint-disable no-control-regex -- intentionally strips C0/DEL controls and invisible bidi/zero-width format chars from dropped file names */ +const CONTROL_CHAR_RE = + /[\u0000-\u001f\u007f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g; +/* eslint-enable no-control-regex */ + +export function sanitizeAttachmentName(name: string): string { + const cleaned = name + .trim() + .replace(ATTACHMENT_NAME_UNSAFE_RE, '_') + .replace(CONTROL_CHAR_RE, ''); + return cleaned || 'attachment'; +} + +export function dedupeAttachmentName( + name: string, + taken: ReadonlySet, +): string { + if (!taken.has(name)) return name; + for (let i = 2; ; i += 1) { + const candidate = `${name}-${i}`; + if (!taken.has(candidate)) return candidate; + } +} + export function hasFileTransferPayload(dataTransfer: DataTransfer): boolean { if (dataTransfer.files.length > 0) return true; if (Array.from(dataTransfer.types).some((type) => type === 'Files')) { @@ -73,29 +212,42 @@ export function hasFileTransferPayload(dataTransfer: DataTransfer): boolean { return Array.from(dataTransfer.items).some((item) => item.kind === 'file'); } -export function extractImageTransfer( +export function extractFileTransfer( dataTransfer: DataTransfer, source: 'paste' | 'drop', -): ExtractedImageTransfer { - const candidates: ImageFileCandidate[] = []; +): ExtractedFileTransfer { + const imageCandidates: ImageFileCandidate[] = []; + const textCandidates: TextFileCandidate[] = []; const rejected: ImageIngestionRejection[] = []; let hasSupportedUnavailableItem = false; + const classify = (file: File, typeHint: string) => { + const imageMediaType = normalizeImageMediaType(typeHint, file.name); + if (imageMediaType) { + imageCandidates.push({ file, mediaType: imageMediaType }); + return; + } + const textMediaType = normalizeTextMediaType(typeHint, file.name); + if (textMediaType) { + textCandidates.push({ file, mediaType: textMediaType }); + return; + } + rejected.push({ name: file.name, reason: 'unsupported' }); + }; + if (dataTransfer.files.length > 0) { for (const file of Array.from(dataTransfer.files)) { - const mediaType = normalizeImageMediaType(file.type, file.name); - if (mediaType) { - candidates.push({ file, mediaType }); - } else { - rejected.push({ name: file.name, reason: 'unsupported' }); - } + classify(file, file.type); } } else { for (const item of Array.from(dataTransfer.items)) { if (item.kind !== 'file') continue; const file = item.getAsFile(); if (!file) { - if (normalizeImageMediaType(item.type)) { + if ( + normalizeImageMediaType(item.type) || + normalizeTextMediaType(item.type) + ) { hasSupportedUnavailableItem = true; rejected.push({ reason: 'unavailable' }); } else if (source === 'drop') { @@ -103,15 +255,7 @@ export function extractImageTransfer( } continue; } - const mediaType = normalizeImageMediaType( - file.type || item.type, - file.name, - ); - if (mediaType) { - candidates.push({ file, mediaType }); - } else { - rejected.push({ name: file.name, reason: 'unsupported' }); - } + classify(file, file.type || item.type); } } @@ -119,8 +263,11 @@ export function extractImageTransfer( claimed: source === 'drop' ? hasFileTransferPayload(dataTransfer) - : candidates.length > 0 || hasSupportedUnavailableItem, - candidates, + : imageCandidates.length > 0 || + textCandidates.length > 0 || + hasSupportedUnavailableItem, + imageCandidates, + textCandidates, rejected, }; } @@ -173,15 +320,15 @@ function readImage( } export async function readImageTransfer( - transfer: ExtractedImageTransfer, + imageCandidates: readonly ImageFileCandidate[], lifecycle: ReaderLifecycle = {}, ): Promise { const candidates: ImageFileCandidate[] = []; - const rejected = [...transfer.rejected]; + const rejected: ImageIngestionRejection[] = []; let estimatedEncodedBytes = 0; const maxEncodedBytes = lifecycle.maxEncodedBytes ?? MAX_IMAGE_ATTACHMENT_DATA_BYTES; - for (const candidate of transfer.candidates) { + for (const candidate of imageCandidates) { const candidateBytes = Math.ceil(candidate.file.size / 3) * 4; if (estimatedEncodedBytes + candidateBytes > maxEncodedBytes) { rejected.push({ name: candidate.file.name, reason: 'too-large' }); @@ -227,3 +374,114 @@ export async function readImageTransfer( }); return { accepted, rejected }; } + +class BinaryContentError extends Error { + constructor() { + super('File decodes to binary content'); + this.name = 'BinaryContentError'; + } +} + +function readText( + candidate: TextFileCandidate, + lifecycle: ReaderLifecycle, +): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + let settled = false; + lifecycle.onReaderCreated?.(reader); + + const settle = (result?: PromptFile, error?: Error) => { + if (settled) return; + settled = true; + lifecycle.onReaderSettled?.(reader); + reader.onload = null; + reader.onerror = null; + reader.onabort = null; + if (result) { + resolve(result); + } else { + reject(error ?? new Error('Failed to read text file')); + } + }; + + reader.onload = () => { + const text = typeof reader.result === 'string' ? reader.result : ''; + if (text.includes(String.fromCharCode(0))) { + settle(undefined, new BinaryContentError()); + return; + } + settle({ + name: candidate.file.name, + media_type: candidate.mediaType, + text, + size: candidate.file.size, + }); + }; + reader.onerror = () => settle(); + reader.onabort = () => settle(); + + try { + reader.readAsText(candidate.file); + } catch { + settle(); + } + }); +} + +export async function readTextTransfer( + textCandidates: readonly TextFileCandidate[], + lifecycle: ReaderLifecycle = {}, +): Promise { + const candidates: TextFileCandidate[] = []; + const rejected: ImageIngestionRejection[] = []; + let estimatedBytes = 0; + const maxBytes = lifecycle.maxEncodedBytes ?? MAX_TEXT_ATTACHMENT_DATA_BYTES; + for (const candidate of textCandidates) { + if (estimatedBytes + candidate.file.size > maxBytes) { + rejected.push({ name: candidate.file.name, reason: 'too-large' }); + continue; + } + estimatedBytes += candidate.file.size; + candidates.push(candidate); + } + + const settled: Array> = new Array( + candidates.length, + ); + let nextIndex = 0; + const readNext = async () => { + while (nextIndex < candidates.length) { + const index = nextIndex++; + try { + settled[index] = { + status: 'fulfilled', + value: await readText(candidates[index]!, lifecycle), + }; + } catch (reason) { + settled[index] = { status: 'rejected', reason }; + } + } + }; + await Promise.all( + Array.from( + { length: Math.min(MAX_CONCURRENT_IMAGE_READERS, candidates.length) }, + readNext, + ), + ); + const accepted: PromptFile[] = []; + settled.forEach((result, index) => { + if (result.status === 'fulfilled') { + accepted.push(result.value); + } else { + rejected.push({ + name: candidates[index]?.file.name, + reason: + result.reason instanceof BinaryContentError + ? 'unsupported' + : 'read-failed', + }); + } + }); + return { accepted, rejected }; +} diff --git a/packages/webui/src/daemon-react-sdk.ts b/packages/webui/src/daemon-react-sdk.ts index 963732cc76a..b87f45d4b0d 100644 --- a/packages/webui/src/daemon-react-sdk.ts +++ b/packages/webui/src/daemon-react-sdk.ts @@ -216,6 +216,8 @@ export type { UseDaemonFollowupSuggestionReturn, /** Image attachment (base64 data + MIME type) for prompt submission. */ DaemonPromptImage, + /** Text file attachment (name + text content) for prompt submission. */ + DaemonPromptFile, /** Permission approval level: `'plan' | 'default' | 'auto-edit' | 'auto' | 'yolo'`. */ DaemonApprovalMode, DaemonAuthProviderBaseUrlOption, diff --git a/packages/webui/src/daemon/index.ts b/packages/webui/src/daemon/index.ts index cf1597ca70d..5c7939fffc4 100644 --- a/packages/webui/src/daemon/index.ts +++ b/packages/webui/src/daemon/index.ts @@ -43,6 +43,7 @@ export type { DaemonNoticeCategory, DaemonNoticeOperation, DaemonNoticeSeverity, + DaemonPromptFile, DaemonPromptImage, DaemonPromptStatus, DaemonReasoningControls, diff --git a/packages/webui/src/daemon/session/actions.test.ts b/packages/webui/src/daemon/session/actions.test.ts index 4ee29181f5f..440590fdbed 100644 --- a/packages/webui/src/daemon/session/actions.test.ts +++ b/packages/webui/src/daemon/session/actions.test.ts @@ -1183,6 +1183,42 @@ describe('createDaemonSessionActions', () => { currentMode: 'target-mode', }); }); + + it('forwards text file attachments as resource blocks and chip metadata', async () => { + const session = createMockSession('session-a'); + const { actions, store } = createActionsHarness({ session }); + + const prompt = actions.sendPrompt('check this', { + files: [{ name: 'app.log', text: 'line1', media_type: 'text/plain' }], + }); + + await vi.waitFor(() => { + expect(session.submitPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: [ + { type: 'text', text: 'check this\n\n@attachment:///app.log' }, + { + type: 'resource', + resource: { + uri: 'attachment:///app.log', + mimeType: 'text/plain', + text: 'line1', + }, + }, + ], + }), + expect.any(AbortSignal), + ); + }); + expect(store.appendLocalUserMessage).toHaveBeenCalledWith( + 'check this', + [], + undefined, + [{ name: 'app.log', text: 'line1', mimeType: 'text/plain' }], + ); + await actions.cancel(); + await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' }); + }); }); function createActionsHarness( diff --git a/packages/webui/src/daemon/session/actions.ts b/packages/webui/src/daemon/session/actions.ts index 3b914161ef9..fa12553d5c8 100644 --- a/packages/webui/src/daemon/session/actions.ts +++ b/packages/webui/src/daemon/session/actions.ts @@ -48,6 +48,7 @@ import type { AddDaemonSessionNotice, DaemonConnectionState, DaemonNoticeOperation, + DaemonPromptFile, DaemonPromptStatus, DaemonSessionActions, SettledPrompt, @@ -58,6 +59,17 @@ interface RefBox { current: T; } +function normalizePromptFiles( + files: readonly DaemonPromptFile[] | undefined, +): Array<{ name: string; text: string; mimeType: string }> { + return (files ?? []).map((file) => ({ + name: file.name, + text: file.text, + mimeType: + file.mimeType || file.mediaType || file.media_type || 'text/plain', + })); +} + const DEFAULT_RESTORE_SERVER_TIMEOUT_MS = 60_000; const RESTORE_REQUEST_HEADROOM_MS = 10_000; const RESTORE_WATCHDOG_HEADROOM_MS = 15_000; @@ -483,6 +495,7 @@ export function createDaemonSessionActions({ mimeType: img.mimeType || img.mediaType || img.media_type || 'image/*', })); + const normalizedFiles = normalizePromptFiles(options?.files); const inputAnnotations = options?.inputAnnotations && options.inputAnnotations.length > 0 ? options.inputAnnotations @@ -492,10 +505,15 @@ export function createDaemonSessionActions({ text, normalizedImages, inputAnnotations ? { inputAnnotations } : undefined, + normalizedFiles, ); } const promptRequest: Record = { - prompt: toDaemonPromptContent(text, normalizedImages), + prompt: toDaemonPromptContent( + text, + normalizedImages, + normalizedFiles, + ), }; if (inputAnnotations) { promptRequest['_meta'] = { inputAnnotations }; @@ -570,6 +588,7 @@ export function createDaemonSessionActions({ data: img.data, mimeType: img.mimeType || img.mediaType || img.media_type || 'image/*', })); + const normalizedFiles = normalizePromptFiles(options?.files); const inputAnnotations = options?.inputAnnotations && options.inputAnnotations.length > 0 ? options.inputAnnotations @@ -579,10 +598,11 @@ export function createDaemonSessionActions({ text, normalizedImages, inputAnnotations ? { inputAnnotations } : undefined, + normalizedFiles, ); } const promptRequest: Record = { - prompt: toDaemonPromptContent(text, normalizedImages), + prompt: toDaemonPromptContent(text, normalizedImages, normalizedFiles), }; if (inputAnnotations) { promptRequest['_meta'] = { inputAnnotations }; diff --git a/packages/webui/src/daemon/session/index.ts b/packages/webui/src/daemon/session/index.ts index 69855c23d90..55194c37f5a 100644 --- a/packages/webui/src/daemon/session/index.ts +++ b/packages/webui/src/daemon/session/index.ts @@ -31,6 +31,7 @@ export type { DaemonNoticeCategory, DaemonNoticeOperation, DaemonNoticeSeverity, + DaemonPromptFile, DaemonPromptImage, DaemonPromptStatus, DaemonReasoningControls, diff --git a/packages/webui/src/daemon/session/promptContent.test.ts b/packages/webui/src/daemon/session/promptContent.test.ts index 063b8c9ca05..6f50aced072 100644 --- a/packages/webui/src/daemon/session/promptContent.test.ts +++ b/packages/webui/src/daemon/session/promptContent.test.ts @@ -35,4 +35,89 @@ describe('toDaemonPromptContent', () => { { type: 'image', data: 'Qk0=', mimeType: 'image/bmp' }, ]); }); + + it('embeds text files as resource blocks with transcript tokens', () => { + expect( + toDaemonPromptContent( + 'check this', + [], + [{ name: 'app.log', text: 'line1\nline2', media_type: 'text/plain' }], + ), + ).toEqual([ + { + type: 'text', + text: 'check this\n\n@attachment:///app.log', + }, + { + type: 'resource', + resource: { + uri: 'attachment:///app.log', + mimeType: 'text/plain', + text: 'line1\nline2', + }, + }, + ]); + }); + + it('keeps the token identical to the resource uri and joins multiple files', () => { + const content = toDaemonPromptContent( + 'look', + [], + [ + { name: 'a.log', text: 'aaa' }, + { name: 'b.md', text: 'bbb', mimeType: 'text/markdown' }, + ], + ); + expect(content[0]).toEqual({ + type: 'text', + text: 'look\n\n@attachment:///a.log\n@attachment:///b.md', + }); + expect(content[1]).toEqual({ + type: 'resource', + resource: { uri: 'attachment:///a.log', text: 'aaa' }, + }); + expect(content[2]).toEqual({ + type: 'resource', + resource: { + uri: 'attachment:///b.md', + mimeType: 'text/markdown', + text: 'bbb', + }, + }); + }); + + it('omits token lines for slash commands while still sending resources', () => { + expect( + toDaemonPromptContent('/review', [], [{ name: 'app.log', text: 'x' }]), + ).toEqual([ + { type: 'text', text: '/review' }, + { + type: 'resource', + resource: { uri: 'attachment:///app.log', text: 'x' }, + }, + ]); + }); + + it('uses bare tokens as the text block when the prompt is file-only', () => { + expect( + toDaemonPromptContent('', [], [{ name: 'app.log', text: 'x' }])[0], + ).toEqual({ type: 'text', text: '@attachment:///app.log' }); + }); + + it('orders text, images, then files', () => { + expect( + toDaemonPromptContent( + 'both', + [{ data: 'a', mimeType: 'image/png' }], + [{ name: 'app.log', text: 'x' }], + ), + ).toEqual([ + { type: 'text', text: 'both\n\n@attachment:///app.log' }, + { type: 'image', data: 'a', mimeType: 'image/png' }, + { + type: 'resource', + resource: { uri: 'attachment:///app.log', text: 'x' }, + }, + ]); + }); }); diff --git a/packages/webui/src/daemon/session/promptContent.ts b/packages/webui/src/daemon/session/promptContent.ts index d9c8318219a..36d6f5dec82 100644 --- a/packages/webui/src/daemon/session/promptContent.ts +++ b/packages/webui/src/daemon/session/promptContent.ts @@ -5,13 +5,30 @@ */ import type { PromptContentBlock } from '@qwen-code/sdk/daemon'; -import type { DaemonPromptImage } from './types.js'; +import type { DaemonPromptFile, DaemonPromptImage } from './types.js'; + +export function attachmentUriForName(name: string): string { + return `attachment:///${name}`; +} export function toDaemonPromptContent( text: string, images: readonly DaemonPromptImage[] = [], + files: readonly DaemonPromptFile[] = [], ): PromptContentBlock[] { - const prompt: PromptContentBlock[] = [{ type: 'text', text }]; + // Token lines keep a visible trace in the daemon-recorded transcript + // (which stores text blocks only). Skipped for slash commands: the + // daemon's slash path drops attachment blocks, so a token would dangle. + const tokenText = files + .map((file) => `@${attachmentUriForName(file.name)}`) + .join('\n'); + const withTokens = + files.length > 0 && !text.trimStart().startsWith('/') + ? text.trim().length > 0 + ? `${text.trimEnd()}\n\n${tokenText}` + : tokenText + : text; + const prompt: PromptContentBlock[] = [{ type: 'text', text: withTokens }]; for (const image of images) { const mimeType = image.mimeType ?? image.mediaType ?? image.media_type; @@ -31,5 +48,17 @@ export function toDaemonPromptContent( } } + for (const file of files) { + const mimeType = file.mimeType ?? file.mediaType ?? file.media_type; + prompt.push({ + type: 'resource', + resource: { + uri: attachmentUriForName(file.name), + ...(mimeType ? { mimeType } : {}), + text: file.text, + }, + }); + } + return prompt; } diff --git a/packages/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index a3bcca23d2d..7236f289517 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -270,6 +270,7 @@ export interface DaemonCommandInfo { export interface SendPromptOptions { optimisticUserMessage?: boolean; images?: DaemonPromptImage[]; + files?: DaemonPromptFile[]; inputAnnotations?: DaemonInputAnnotation[]; /** * When true, the daemon strips orphaned user entries from the chat @@ -309,6 +310,14 @@ export interface DaemonPromptImage { media_type?: string; } +export interface DaemonPromptFile { + name: string; + text: string; + mimeType?: string; + mediaType?: string; + media_type?: string; +} + export type DaemonTodoStatus = 'pending' | 'in_progress' | 'completed'; export type DaemonTodoPriority = 'low' | 'medium' | 'high';