feat(vscode-ide-companion): add image paste support (#1978)

* feat(vscode-ide-companion): add image paste support

  - Add clipboard image paste functionality with drag-and-drop support
  - Implement image preview component with removal capability
  - Support multimodal content in ACP session manager for text and images
  - Save pasted images to temporary .gemini-clipboard directory
  - Add image attachment display in user messages
  - Update CSP to allow data: URIs for inline image display
  - Add comprehensive image utilities with size validation (max 10MB)
  - Include tests for image processing utilities

* refactor: simplify VS Code paste image implementation

- Remove dead code and redundant error handling
- Extract common isAuthError() helper function
- Simplify SessionMessageHandler methods (80% reduction)
- Change temp directory from .gemini-clipboard to clipboard (aligned with CLI)
- Keep multimodal image sending format (type: image + base64)

Stats:
- 6 files changed
- 367 insertions (+)
- 1176 deletions (-)

* refactor: align paste image handling

* chore: trim paste image diff

* refactor(vscode-ide-companion): remove unused attachments logic

- Remove unused ImageAttachment type imports
- Remove attachments field from TextMessage interface
- Remove attachments from message data sent to WebView
- Clean up debug console.log statements
- Simplify SessionMessageHandler handleSendMessage method

This removes dead code from the previous image paste implementation
that was no longer needed after switching to @path reference approach.

* refactor(vscode-ide-companion/webview): extract image handling into dedicated hooks and utils

- extract ImagePreview and ImageMessageRenderer components from App.tsx
- create useImageAttachments hook for managing image attachments
- create useImageResolution hook for image path resolution
- add imageAttachmentHandler for saving images to temp files
- add imageMessageUtils for message expansion and resolution
- add imagePathResolver for resolving image paths in webview
- integrate image resolution in useWebViewMessages
- extract shouldSendMessage utility from useMessageSubmit
- add getLocalResourceRoots in PanelManager for resource access

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix: harden vscode image handling and webview hosts

* fix: remove this alias in acp connection

* feat: add path escaping utility functions and tests

* feat: add support for image attachments and improve prompt handling

* refactor(webview): Optimize editing mode switching function

* refactor(vscode-ide-companion): move path escaping utilities to local module

- Move escapePath and unescapePath functions from qwen-code-core to local utils
- Add pathEscaping.ts with shell special characters handling
- Update imports in imageFormats.ts, imageAttachmentHandler.ts, and imageMessageUtils.ts
- Add unit tests for path escaping round-trip and browser bundle verification
- Fix browser bundling issue by avoiding node-only module dependencies in webview

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* refactor: consolidate image handling logic across vscode-ide-companion and webui

- Merge分散的 image hooks (useImageAttachments, useImageResolution, usePasteHandler) into unified useImage hook
- Replace image utils (imageMessageUtils, imagePathResolver, imageUtils) with imageHandler and imageSupport
- Remove clipboard image storage from core package
- Consolidate webui image components into ImageComponents.tsx
- Update imports and tests to reflect new structure

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* chore: drop unrelated core tool changes

* test: fix webview provider mocks and drop unrelated core diffs

* fix(cli): resolve original prompt through standard path in no_command case

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
易良 2026-03-20 13:47:09 +08:00 committed by GitHub
parent 85ed1a801d
commit 87f03cf2e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 2188 additions and 150 deletions

View file

@ -64,7 +64,7 @@ export interface InputFormProps {
/** Current input text */
inputText: string;
/** Ref for the input field */
inputFieldRef: React.RefObject<HTMLDivElement>;
inputFieldRef: React.RefObject<HTMLDivElement | null>;
/** Whether AI is currently generating */
isStreaming: boolean;
/** Whether waiting for response */
@ -117,8 +117,14 @@ export interface InputFormProps {
onCompletionFill?: (item: CompletionItem) => void;
/** Completion close callback */
onCompletionClose?: () => void;
/** Optional paste handler for the contentEditable input */
onPaste?: (e: React.ClipboardEvent) => void;
/** Optional content rendered between the input and actions */
extraContent?: ReactNode;
/** Placeholder text */
placeholder?: string;
/** Whether the current draft is eligible to submit */
canSubmit?: boolean;
}
/**
@ -174,9 +180,14 @@ export const InputForm: FC<InputFormProps> = ({
onCompletionSelect,
onCompletionFill,
onCompletionClose,
onPaste,
extraContent,
placeholder = 'Ask Qwen Code …',
canSubmit,
}) => {
const composerDisabled = isStreaming || isWaitingForResponse;
const hasDraftContent =
canSubmit ?? inputText.replace(/\u200B/g, '').trim().length > 0;
const completionItemsResolved = completionItems ?? [];
const completionActive =
completionIsOpen &&
@ -275,10 +286,15 @@ export const InputForm: FC<InputFormProps> = ({
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
onKeyDown={handleKeyDown}
onPaste={onPaste}
suppressContentEditableWarning
/>
</div>
{extraContent ? (
<div className="relative z-[1]">{extraContent}</div>
) : null}
<div className="composer-actions">
{/* Edit mode button */}
<button
@ -357,7 +373,7 @@ export const InputForm: FC<InputFormProps> = ({
<button
type="submit"
className="btn-send-compact [&>svg]:w-5 [&>svg]:h-5"
disabled={composerDisabled || !inputText.trim()}
disabled={composerDisabled || !hasDraftContent}
aria-label="Send message"
>
<ArrowUpIcon />

View file

@ -0,0 +1,119 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { FC } from 'react';
import { CloseSmallIcon } from '../icons/NavigationIcons.js';
// ======================== ImagePreview ========================
export interface ImagePreviewItem {
id: string;
name: string;
data: string;
}
export interface ImagePreviewProps {
images: ImagePreviewItem[];
onRemove: (id: string) => void;
}
export const ImagePreview: FC<ImagePreviewProps> = ({ images, onRemove }) => {
if (images.length === 0) {
return null;
}
return (
<div className="image-preview-container flex gap-2 px-2 pb-2">
{images.map((image) => (
<div key={image.id} className="image-preview-item relative group">
<div className="relative">
<img
src={image.data}
alt={image.name}
className="w-14 h-14 object-cover rounded-md border border-gray-500 dark:border-gray-600"
title={image.name}
/>
<button
type="button"
onClick={() => onRemove(image.id)}
className="absolute -top-2 -right-2 w-5 h-5 bg-gray-700 dark:bg-gray-600 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-gray-800 dark:hover:bg-gray-500"
aria-label={`Remove ${image.name}`}
>
<CloseSmallIcon />
</button>
</div>
</div>
))}
</div>
);
};
// ======================== ImageMessageRenderer ========================
export interface ImageMessageLike {
kind: 'image';
imagePath: string;
imageSrc?: string;
imageMissing?: boolean;
}
export interface ImageMessageRendererProps {
msg: ImageMessageLike;
imageIndex: number;
}
export const ImageMessageRenderer: FC<ImageMessageRendererProps> = ({
msg,
imageIndex,
}) => {
if (msg.kind !== 'image' || !msg.imagePath) {
return null;
}
const label = `[Image #${imageIndex}]`;
const showImage = Boolean(msg.imageSrc) && !msg.imageMissing;
return (
<div className="qwen-message user-message-container flex gap-0 my-1 items-start text-left flex-col relative">
<div
className="inline-block relative whitespace-pre-wrap rounded-md max-w-full overflow-x-auto overflow-y-hidden select-text leading-[1.5]"
style={{
border: '1px solid var(--app-input-border)',
borderRadius: 'var(--corner-radius-medium)',
backgroundColor: 'var(--app-input-background)',
padding: '6px 8px',
color: 'var(--app-primary-foreground)',
}}
>
<div
style={{
fontSize: '12px',
color: 'var(--app-secondary-foreground)',
marginBottom: '4px',
}}
>
{label}
</div>
{showImage ? (
<img
src={msg.imageSrc}
alt={msg.imagePath}
className="max-w-full rounded-md border border-gray-600"
/>
) : (
<div
style={{
fontSize: '12px',
color: 'var(--app-secondary-foreground)',
}}
>
@{msg.imagePath}
</div>
)}
</div>
</div>
);
};

View file

@ -92,6 +92,16 @@ export type {
Question,
QuestionOption,
} from './components/messages/AskUserQuestionDialog';
export {
ImagePreview,
ImageMessageRenderer,
} from './components/messages/ImageComponents';
export type {
ImagePreviewProps,
ImagePreviewItem,
ImageMessageRendererProps,
ImageMessageLike,
} from './components/messages/ImageComponents';
// ChatViewer - standalone chat display component
export {