* feat: add draw.io diagram editor integration Embed draw.io editor via react-drawio (MIT, zero deps) for inline editing of .drawio files. Changes auto-save to disk. Includes inline editor in FilesView with Visual/Source toggle, dark mode support, template picker for new files, and chat file attachment integration. * fix: debounce diagram autosave to prevent reload loop * fix: ignore watcher-triggered xml prop changes to prevent reload loop * fix: remove auto-save-to-disk, add manual save button for diagrams Autosave writes triggered file watcher cascade that reloaded the draw.io iframe and reset zoom. Replaced with explicit Save button in the toolbar (floppy disk icon). Editor XML is stable on mount and ignores watcher-triggered prop changes. * fix: remove auto-save write from DiagramView, add save button * fix: hide draw.io save/exit buttons in editor * fix: also hide save-and-exit button * fix: brighten save button styling, add saved confirmation * fix: remove autoSaveStatus toggle on diagram save to prevent toolbar collapse * fix: add local save confirmation state for diagram button * fix: remount drawio iframe on theme change, persisting XML across mounts * fix: clear persisted xml on mount to prevent leaking between files * fix: initialize dark mode synchronously, preserve edits across theme remount * fix: auto-focus drawio iframe on mount/theme-change for keyboard shortcuts * fix: add diagram i18n keys to Traditional Chinese locale * fix: restore upstream HMR host and LAN address support * fix: load sub-agent sessions on bootstrap for sidebar visibility Two-phase session load: first fetch root sessions (for accurate sessionTotal), then fetch all sessions and include child sessions (sub-agent delegations). This ensures sub-agent sessions appear in the sidebar immediately instead of relying on the async global session store. * remove opencode-drawio from PR branch * fix: atomic file writes to prevent concurrent read/write truncation Three-layer defense against the O_TRUNC race: 1. Write side (server): replace direct writeFile with write-to-temp- then-rename. fs.rename is atomic on POSIX. 2. Read side (server): retry up to 3 times with 50ms backoff when readFile returns empty but stat reported non-zero size. 3. FilesView client: refuse to save empty draftContent when the original fileContent was non-empty. * fix(dev): clean up orphaned OpenCode processes on Ctrl+C * fix: allow empty file saves, log warning instead of blocking Replaces the hard block on saving empty content with a console.warn. The atomic write + read retry on the server side handle the O_TRUNC race properly. The previous guard caused a UX regression by silently preventing users from clearing a file and saving. * fix: remove time window from sub-agent fallback for live tasks While a task tool is active, the fallback now matches any session with the correct parentID regardless of creation time. This allows late-appearing child sessions to be found when the OpenCode server is slow or the SSE event pipeline is delayed. The time window is still applied once the task tool has completed, as a final sanity check. * fix: three diagram editor bugs from Greptile review 1. stableXmlRef now resets when xml prop changes — switching between .drawio files renders the correct content. 2. Focus effect only runs on mount, not on isDark changes — theme toggle no longer steals keyboard focus 600ms later. 3. saveDiagram updates xml state after writing — dirty-check guard works correctly for subsequent saves. * fix: route session.created SSE events to correct directory Three-layer fix for sub-agent sessions not appearing in sidebar and inline chat: 1. protocol.js: parseSseEventEnvelope now extracts directory from properties.info.directory (where session.created/updated events carry it) in addition to properties.directory. WS frames relayed to the browser now carry the real directory instead of 'global', so child sessions routed to the correct directory store. 2. event-pipeline.ts: same fallback in resolveEventDirectory for defense-in-depth when SSE events bypass the WS relay. 3. resolveFallbackTaskSessionId.ts: time window lower bound now allows 2s grace before taskStartTime to accommodate server timing jitter (child session creation timestamps consistently precede the tool's recorded start by ~6-9ms), fixing the 'Open subtask' button not rendering in OpenChamber's inline chat. * fix: sub-agent sidebar visibility, file zeroing guard, inline badge fallback - Sync watchdog: periodic child session discovery poll (every 15s) detects sessions created by other OpenCode instances, triggers parent materialization - protocol.js: parseSseEventEnvelope extracts directory from properties.info.directory for session.created/updated events - event-pipeline.ts: same fallback in resolveEventDirectory for defense-in-depth - resolveFallbackTaskSessionId: don't require taskStartTime (cross-OpenCode); pick most recent child when multiple idle candidates exist - readTaskSessionIdFromOutput: parse <task id="ses_xxx"> format from output - FilesView: reinstate empty-draft guard (block save when draftContent='' but fileContent had content) to prevent file zeroing on tab switch * Fix diagram autosave reload loop * Highlight drawio files as XML * Use diff-compatible highlighting for drawio files * Restore drawio file icon mapping * Stabilize drawio source preview toggle --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
962 lines
36 KiB
TypeScript
962 lines
36 KiB
TypeScript
import React, { useRef, memo } from 'react';
|
|
import { useInputStore } from '@/sync/input-store';
|
|
import type { AttachedFile } from '@/sync/session-ui-store';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { toast } from '@/components/ui';
|
|
import { cn } from '@/lib/utils';
|
|
import { openExternalUrl } from '@/lib/url';
|
|
import { isDrawioFile } from '@/lib/toolHelpers';
|
|
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
|
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
|
import { Icon } from "@/components/icon/Icon";
|
|
import { useI18n } from '@/lib/i18n';
|
|
import { useDeviceInfo } from '@/lib/device';
|
|
|
|
import type { ToolPopupContent } from './message/types';
|
|
|
|
export const FileAttachmentButton = memo(() => {
|
|
const { t } = useI18n();
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const addAttachedFile = useInputStore((state) => state.addAttachedFile);
|
|
const isMobile = useUIStore((state) => state.isMobile);
|
|
const runtimeApis = useRuntimeAPIs();
|
|
const isVSCodeRuntime = runtimeApis.runtime.isVSCode;
|
|
const buttonSizeClass = isMobile ? 'h-9 w-9' : 'h-7 w-7';
|
|
const iconSizeClass = isMobile ? 'h-5 w-5' : 'h-[18px] w-[18px]';
|
|
|
|
const attachFiles = async (files: FileList | File[]) => {
|
|
for (let i = 0; i < files.length; i++) {
|
|
const file = files[i];
|
|
try {
|
|
await addAttachedFile(file);
|
|
} catch (error) {
|
|
console.error('File attach failed', error);
|
|
toast.error(error instanceof Error ? error.message : t('chat.fileAttachment.toast.attachFailed'));
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files;
|
|
if (!files) return;
|
|
await attachFiles(files);
|
|
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
};
|
|
|
|
const handleVSCodePick = async () => {
|
|
try {
|
|
const data = (await runtimeApis.vscode?.pickFiles?.()) as {
|
|
files?: Array<{ name: string; mimeType?: string; dataUrl?: string }>;
|
|
skipped?: Array<{ name?: string; reason?: string }>;
|
|
} | undefined;
|
|
const picked = Array.isArray(data?.files) ? data.files : [];
|
|
const skipped = Array.isArray(data?.skipped) ? data.skipped : [];
|
|
|
|
if (skipped.length > 0) {
|
|
const summary = skipped.map((s: { name?: string; reason?: string }) => `${s?.name || t('chat.fileAttachment.fileFallback')}: ${s?.reason || t('chat.fileAttachment.skippedFallback')}`).join('\n');
|
|
toast.error(t('chat.fileAttachment.toast.someFilesSkipped', { summary }));
|
|
}
|
|
|
|
const asFiles = picked
|
|
.map((file: { name: string; mimeType?: string; dataUrl?: string }) => {
|
|
if (!file?.dataUrl) return null;
|
|
try {
|
|
const [meta, base64] = file.dataUrl.split(',');
|
|
const mime = file.mimeType || (meta?.match(/data:(.*);base64/)?.[1] || 'application/octet-stream');
|
|
if (!base64) return null;
|
|
const binary = atob(base64);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
const blob = new Blob([bytes], { type: mime });
|
|
return new File([blob], file.name || t('chat.fileAttachment.fileFallback'), { type: mime });
|
|
} catch (err) {
|
|
console.error('Failed to decode VS Code picked file', err);
|
|
return null;
|
|
}
|
|
})
|
|
.filter(Boolean) as File[];
|
|
|
|
if (asFiles.length > 0) {
|
|
await attachFiles(asFiles);
|
|
}
|
|
} catch (error) {
|
|
console.error('VS Code file pick failed', error);
|
|
toast.error(error instanceof Error ? error.message : t('chat.fileAttachment.toast.vscodePickFailed'));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
className="hidden"
|
|
onChange={handleFileSelect}
|
|
/>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={isVSCodeRuntime ? handleVSCodePick : () => fileInputRef.current?.click()}
|
|
className={cn(
|
|
'flex items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
|
'hover:bg-muted text-muted-foreground',
|
|
buttonSizeClass
|
|
)}
|
|
aria-label={t('chat.fileAttachment.actions.attachAria')}
|
|
>
|
|
<Icon name="attachment-2" className={iconSizeClass} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">
|
|
<p>{t('chat.fileAttachment.actions.attach')}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</>
|
|
);
|
|
});
|
|
|
|
FileAttachmentButton.displayName = 'FileAttachmentButton';
|
|
|
|
interface ImagePreviewProps {
|
|
file: AttachedFile;
|
|
onRemove: () => void;
|
|
onShowPopup?: (content: ToolPopupContent) => void;
|
|
gallery?: NonNullable<ToolPopupContent['image']>['gallery'];
|
|
index?: number;
|
|
}
|
|
|
|
const ImagePreview = memo(({ file, onRemove, onShowPopup, gallery, index = 0 }: ImagePreviewProps) => {
|
|
const { t } = useI18n();
|
|
const { isMobile, isTablet } = useDeviceInfo();
|
|
const alwaysShowActions = isMobile || isTablet;
|
|
const isLocalImagePreview =
|
|
file.source !== 'server' &&
|
|
file.mimeType.startsWith('image/') &&
|
|
typeof file.dataUrl === 'string' &&
|
|
file.dataUrl.startsWith('data:image/');
|
|
|
|
const imageUrl = isLocalImagePreview ? file.dataUrl : (file.serverPath || '');
|
|
|
|
const extractFilename = (path: string): string => {
|
|
const normalized = path.replace(/\\/g, '/');
|
|
const parts = normalized.split('/');
|
|
return parts[parts.length - 1] || path;
|
|
};
|
|
|
|
const getFileExtension = (filename: string): string => {
|
|
const parts = filename.split('.');
|
|
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
|
|
};
|
|
|
|
const displayName = extractFilename(file.filename);
|
|
const extension = getFileExtension(file.filename);
|
|
const handleOpenPreview = React.useCallback(() => {
|
|
if (!onShowPopup || !imageUrl) return;
|
|
|
|
onShowPopup({
|
|
open: true,
|
|
title: displayName || 'Image',
|
|
content: '',
|
|
metadata: {
|
|
tool: 'image-preview',
|
|
filename: displayName,
|
|
mime: file.mimeType,
|
|
size: file.size,
|
|
},
|
|
image: {
|
|
url: imageUrl,
|
|
mimeType: file.mimeType,
|
|
filename: displayName,
|
|
size: file.size,
|
|
gallery,
|
|
index,
|
|
},
|
|
});
|
|
}, [displayName, file.mimeType, file.size, gallery, imageUrl, index, onShowPopup]);
|
|
|
|
if (!imageUrl) {
|
|
// Fallback to text-only for server images without preview
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="flex items-center gap-1.5 text-sm hover:opacity-80 transition-opacity text-left h-5"
|
|
>
|
|
<FileTypeIcon filePath={file.filename} extension={extension} className="h-4 w-4" />
|
|
<span className="text-foreground truncate max-w-[200px]">
|
|
{displayName}
|
|
</span>
|
|
<span
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onRemove();
|
|
}}
|
|
className="flex items-center justify-center h-5 w-5 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
|
|
aria-label={t('chat.fileAttachment.actions.removeNamed', { name: displayName })}
|
|
>
|
|
<Icon name="close" className="h-4 w-4 text-muted-foreground" />
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
role={onShowPopup ? 'button' : undefined}
|
|
tabIndex={onShowPopup ? 0 : undefined}
|
|
onClick={handleOpenPreview}
|
|
onKeyDown={(event) => {
|
|
if (!onShowPopup) return;
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault();
|
|
handleOpenPreview();
|
|
}
|
|
}}
|
|
className="relative h-10 w-10 rounded-lg border border-border/40 bg-muted/10 overflow-hidden flex-shrink-0 group cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
aria-label={displayName}
|
|
>
|
|
<img
|
|
src={imageUrl}
|
|
alt={displayName}
|
|
className="h-full w-full object-cover"
|
|
loading="lazy"
|
|
/>
|
|
<button
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onRemove();
|
|
}}
|
|
className={cn(
|
|
"absolute top-0.5 right-0.5 h-4 w-4 rounded-full bg-background/80 text-foreground hover:text-destructive flex items-center justify-center transition-opacity focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
|
alwaysShowActions ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
|
)}
|
|
title={t('chat.fileAttachment.actions.removeImage')}
|
|
aria-label={t('chat.fileAttachment.actions.removeNamed', { name: displayName })}
|
|
>
|
|
<Icon name="close" className="h-2.5 w-2.5" />
|
|
</button>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
ImagePreview.displayName = 'ImagePreview';
|
|
|
|
const useFileDetails = (file: AttachedFile) => {
|
|
const getFileExtension = (filename: string): string => {
|
|
const parts = filename.split('.');
|
|
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
|
|
};
|
|
|
|
const formatFileSize = (bytes: number) => {
|
|
if (!Number.isFinite(bytes) || bytes <= 0) return '';
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
};
|
|
|
|
const extractFilename = (path: string): string => {
|
|
const normalized = path.replace(/\\/g, '/');
|
|
const parts = normalized.split('/');
|
|
const filename = parts[parts.length - 1];
|
|
return filename || path;
|
|
};
|
|
|
|
return {
|
|
displayName: extractFilename(file.filename),
|
|
fileSize: formatFileSize(file.size),
|
|
extension: getFileExtension(file.filename),
|
|
};
|
|
};
|
|
|
|
interface FileChipProps {
|
|
file: AttachedFile;
|
|
onRemove: () => void;
|
|
}
|
|
|
|
const FileChip = memo(({ file, onRemove }: FileChipProps) => {
|
|
const { t } = useI18n();
|
|
const { displayName, fileSize, extension } = useFileDetails(file);
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
// Prevent click from bubbling if clicking the remove button
|
|
if ((e.target as HTMLElement).closest('[data-remove-button]')) {
|
|
return;
|
|
}
|
|
}}
|
|
className="flex items-center gap-1.5 text-sm hover:opacity-80 transition-opacity text-left h-5"
|
|
>
|
|
<FileTypeIcon filePath={file.filename} extension={extension} className="h-4 w-4" />
|
|
<span className="text-foreground truncate max-w-[200px]">
|
|
{displayName}
|
|
{fileSize && <span className="text-muted-foreground ml-1">({fileSize})</span>}
|
|
</span>
|
|
<span
|
|
data-remove-button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onRemove();
|
|
}}
|
|
className="flex items-center justify-center h-5 w-5 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
|
|
aria-label={t('chat.fileAttachment.actions.removeNamed', { name: displayName })}
|
|
>
|
|
<Icon name="close" className="h-4 w-4 text-muted-foreground" />
|
|
</span>
|
|
</button>
|
|
);
|
|
});
|
|
|
|
FileChip.displayName = 'FileChip';
|
|
|
|
const VSCodeFileChip = memo(({ file, onRemove }: FileChipProps) => {
|
|
const { t } = useI18n();
|
|
const { displayName, extension } = useFileDetails(file);
|
|
|
|
// Detect selection-style attachments: ends with ":N" or ":N-M"
|
|
const isSelectionAttachment = /:\d+(?:-\d+)?$/.test(displayName);
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
// Prevent click from bubbling if clicking the remove button
|
|
if ((e.target as HTMLElement).closest('[data-remove-button]')) {
|
|
return;
|
|
}
|
|
}}
|
|
className="inline-flex items-center gap-1 text-xs pr-1 rounded-sm border border-solid bg-transparent text-foreground not-italic hover:opacity-90 transition-colors text-left"
|
|
style={{ borderColor: 'var(--syntax-punctuation)' }}
|
|
title={file.vscodePath}
|
|
>
|
|
<span
|
|
data-remove-button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onRemove();
|
|
}}
|
|
className="flex items-center justify-center h-5 w-5 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
|
|
aria-label={t('chat.fileAttachment.activeEditor.remove')}
|
|
title={t('chat.fileAttachment.activeEditor.remove')}
|
|
>
|
|
<Icon name="close" className="h-4 w-4 text-muted-foreground" />
|
|
</span>
|
|
<FileTypeIcon filePath={file.filename} extension={extension} className="h-4 w-4" />
|
|
<span className={cn('text-foreground', isSelectionAttachment ? 'whitespace-nowrap' : 'truncate max-w-[200px]')}>
|
|
{displayName}
|
|
</span>
|
|
</button>
|
|
);
|
|
});
|
|
|
|
VSCodeFileChip.displayName = 'VSCodeFileChip';
|
|
|
|
interface AttachedFilesListProps {
|
|
onShowPopup?: (content: ToolPopupContent) => void;
|
|
}
|
|
|
|
export const AttachedVSCodeFileChips = memo(({ onShowPopup }: AttachedFilesListProps) => {
|
|
const attachedFiles = useInputStore((state) => state.attachedFiles);
|
|
const removeAttachedFile = useInputStore((state) => state.removeAttachedFile);
|
|
|
|
const vscodeFiles = attachedFiles.filter((file) => file.source === 'vscode');
|
|
|
|
if (vscodeFiles.length === 0) return null;
|
|
|
|
const images = vscodeFiles.filter((f) => f.mimeType.startsWith('image/'));
|
|
const otherFiles = vscodeFiles.filter((f) => !f.mimeType.startsWith('image/'));
|
|
const imageGallery = images.map((file) => ({
|
|
url: file.dataUrl || file.serverPath || '',
|
|
mimeType: file.mimeType,
|
|
filename: file.filename,
|
|
size: file.size,
|
|
})).filter((image) => image.url);
|
|
|
|
return (
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{images.map((file, index) => (
|
|
<ImagePreview key={file.id} file={file} onRemove={() => removeAttachedFile(file.id)} onShowPopup={onShowPopup} gallery={imageGallery} index={index} />
|
|
))}
|
|
{otherFiles.map((file) => (
|
|
<VSCodeFileChip key={file.id} file={file} onRemove={() => removeAttachedFile(file.id)} />
|
|
))}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
AttachedVSCodeFileChips.displayName = 'AttachedVSCodeFileChips';
|
|
|
|
export const AttachedFilesList = memo(({ onShowPopup }: AttachedFilesListProps) => {
|
|
const attachedFiles = useInputStore((state) => state.attachedFiles);
|
|
const removeAttachedFile = useInputStore((state) => state.removeAttachedFile);
|
|
|
|
const localFiles = attachedFiles.filter((file) => file.source !== 'server' && file.source !== 'vscode');
|
|
|
|
if (localFiles.length === 0) return null;
|
|
|
|
const images = localFiles.filter((f) => f.mimeType.startsWith('image/'));
|
|
const otherFiles = localFiles.filter((f) => !f.mimeType.startsWith('image/'));
|
|
const imageGallery = images.map((file) => ({
|
|
url: file.dataUrl || file.serverPath || '',
|
|
mimeType: file.mimeType,
|
|
filename: file.filename,
|
|
size: file.size,
|
|
})).filter((image) => image.url);
|
|
|
|
return (
|
|
<div className="pb-4 w-full px-1 space-y-3">
|
|
{/* Images row - inline with previews */}
|
|
{images.length > 0 && (
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
{images.map((file, index) => (
|
|
<ImagePreview
|
|
key={file.id}
|
|
file={file}
|
|
onRemove={() => removeAttachedFile(file.id)}
|
|
onShowPopup={onShowPopup}
|
|
gallery={imageGallery}
|
|
index={index}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Other files row - inline text-only */}
|
|
{otherFiles.length > 0 && (
|
|
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap">
|
|
{otherFiles.map((file) => (
|
|
<FileChip
|
|
key={file.id}
|
|
file={file}
|
|
onRemove={() => removeAttachedFile(file.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
AttachedFilesList.displayName = 'AttachedFilesList';
|
|
|
|
export const ActiveEditorFileSuggestion = memo(() => {
|
|
const { t } = useI18n();
|
|
const activeEditorFile = useInputStore((s) => s.activeEditorFile);
|
|
const attachedFiles = useInputStore((s) => s.attachedFiles)
|
|
const addVSCodeFileAttachment = useInputStore((s) => s.addVSCodeFileAttachment)
|
|
const addVSCodeSelectionAttachment = useInputStore((s) => s.addVSCodeSelectionAttachment)
|
|
const isVSCodeRuntime = useRuntimeAPIs().runtime.isVSCode;
|
|
|
|
if (!isVSCodeRuntime || !activeEditorFile) return null;
|
|
|
|
const { filePath, fileName, relativePath, selection, fileSize } = activeEditorFile;
|
|
|
|
// Normalize to forward slashes for comparison
|
|
const isFileAttached = attachedFiles.some(
|
|
(f) => f.source === 'vscode' && f.vscodeSource === 'file' && (f.vscodePath || '') === filePath
|
|
)
|
|
|
|
// Compute selection label using a compact range (single line shown as "N" not "N-N")
|
|
let selectionRange = ''
|
|
if (selection) {
|
|
selectionRange = selection.startLine === selection.endLine
|
|
? `${selection.startLine}`
|
|
: `${selection.startLine}-${selection.endLine}`
|
|
}
|
|
const selectionLabel = selection ? `${fileName}:${selectionRange}` : ''
|
|
const isSelectionAttached = !!selectionLabel && attachedFiles.some(
|
|
(f) => f.source === 'vscode' && f.vscodeSource === 'selection' && f.filename === selectionLabel && f.vscodePath === filePath
|
|
)
|
|
|
|
// Nothing to show — file is already attached and there's no (or already-attached) selection
|
|
if (isFileAttached && (!selection || isSelectionAttached)) return null;
|
|
|
|
const ext = fileName.split('.').pop() || '';
|
|
// Always show only the filename in the suggestion UI
|
|
const displayName = fileName;
|
|
|
|
const handleAddFile = () => {
|
|
addVSCodeFileAttachment(filePath, fileName, fileSize);
|
|
};
|
|
|
|
const handlePinSelection = async () => {
|
|
if (!selection) return;
|
|
const blob = new Blob([selection.text], { type: 'text/plain' });
|
|
const file = new File([blob], selectionLabel, { type: 'text/plain' });
|
|
await addVSCodeSelectionAttachment(filePath, file);
|
|
};
|
|
|
|
// If there is a selection, prefer showing the pin-selection UI only.
|
|
const showSelectionPin = !!selection && !isSelectionAttached;
|
|
const showFileAdd = !showSelectionPin && !isFileAttached;
|
|
|
|
if (!showSelectionPin && !showFileAdd) return null;
|
|
|
|
return (
|
|
<div className="inline-flex items-center">
|
|
{showSelectionPin && (
|
|
<div
|
|
className="inline-flex items-center gap-1 text-xs pr-1 rounded-sm italic text-muted-foreground border border-dashed bg-transparent"
|
|
style={{ borderColor: 'var(--syntax-punctuation)' }}
|
|
title={relativePath}
|
|
>
|
|
<button
|
|
type="button"
|
|
title={t('chat.fileAttachment.activeEditor.pinSelection')}
|
|
aria-label={t('chat.fileAttachment.activeEditor.pinSelection')}
|
|
onClick={() => { void handlePinSelection(); }}
|
|
className="flex items-center justify-center h-5 w-5 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
|
|
>
|
|
<Icon name="pushpin-2" className="h-4 w-4" />
|
|
</button>
|
|
<FileTypeIcon filePath={fileName} extension={ext} className="h-4 w-4 flex-shrink-0" />
|
|
<span className="text-xs whitespace-nowrap">{`${displayName}:${selectionRange}`}</span>
|
|
</div>
|
|
)}
|
|
{showFileAdd && (
|
|
<div
|
|
className="inline-flex items-center gap-1 text-xs pr-1 rounded-sm italic text-muted-foreground border border-dashed bg-transparent"
|
|
style={{ borderColor: 'var(--syntax-punctuation)' }}
|
|
title={relativePath}
|
|
>
|
|
<button
|
|
type="button"
|
|
title={t('chat.fileAttachment.activeEditor.addFile', { name: displayName })}
|
|
aria-label={t('chat.fileAttachment.activeEditor.addFile', { name: displayName })}
|
|
onClick={handleAddFile}
|
|
className="flex items-center justify-center h-5 w-5 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
|
|
>
|
|
<Icon name="add" className="h-4 w-4" />
|
|
</button>
|
|
<FileTypeIcon filePath={fileName} extension={ext} className="h-4 w-4 flex-shrink-0" />
|
|
<span className="text-xs truncate max-w-[220px]">{displayName}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
ActiveEditorFileSuggestion.displayName = 'ActiveEditorFileSuggestion';
|
|
|
|
interface FilePart {
|
|
type: string;
|
|
mime?: string;
|
|
url?: string;
|
|
filename?: string;
|
|
size?: number;
|
|
source?: Record<string, unknown>;
|
|
}
|
|
|
|
const GITHUB_ISSUE_LINK_MIME = 'application/vnd.github.issue-link';
|
|
const GITHUB_PR_LINK_MIME = 'application/vnd.github.pull-request-link';
|
|
|
|
const getGitHubLinkKind = (file: FilePart): 'issue' | 'pr' | null => {
|
|
if (file.mime === GITHUB_ISSUE_LINK_MIME) {
|
|
return 'issue';
|
|
}
|
|
if (file.mime === GITHUB_PR_LINK_MIME) {
|
|
return 'pr';
|
|
}
|
|
return null;
|
|
};
|
|
|
|
interface MessageFilesDisplayProps {
|
|
files: FilePart[];
|
|
onShowPopup?: (content: ToolPopupContent) => void;
|
|
compact?: boolean;
|
|
}
|
|
|
|
export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }: MessageFilesDisplayProps) => {
|
|
const { t } = useI18n();
|
|
|
|
const fileItems = files.filter(f => f.type === 'file' && (f.mime || f.url));
|
|
|
|
const extractFilename = (path?: string): string => {
|
|
if (!path) return 'Unnamed file';
|
|
|
|
const normalized = path.replace(/\\/g, '/');
|
|
const parts = normalized.split('/');
|
|
const filename = parts[parts.length - 1];
|
|
|
|
return filename || path;
|
|
};
|
|
|
|
const resolveDisplayName = React.useCallback((file: FilePart): string => {
|
|
const isGitHubLink = getGitHubLinkKind(file) !== null;
|
|
if (isGitHubLink && typeof file.filename === 'string' && file.filename.trim().length > 0) {
|
|
return file.filename.trim();
|
|
}
|
|
return extractFilename(file.filename || file.url);
|
|
}, []);
|
|
|
|
const formatFileSize = (bytes?: number) => {
|
|
if (!bytes || !Number.isFinite(bytes) || bytes <= 0) return '';
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
};
|
|
|
|
const imageFiles = fileItems.filter(f => f.mime?.startsWith('image/') && f.url);
|
|
const otherFiles = fileItems.filter(f => !f.mime?.startsWith('image/'));
|
|
|
|
const imageGallery = React.useMemo(
|
|
() =>
|
|
imageFiles.flatMap((file) => {
|
|
if (!file.url) return [];
|
|
const filename = resolveDisplayName(file) || 'Image';
|
|
return [{
|
|
url: file.url,
|
|
mimeType: file.mime,
|
|
filename,
|
|
size: file.size,
|
|
}];
|
|
}),
|
|
[imageFiles, resolveDisplayName]
|
|
);
|
|
|
|
const handleImageClick = React.useCallback((index: number) => {
|
|
if (!onShowPopup) {
|
|
return;
|
|
}
|
|
|
|
const file = imageGallery[index];
|
|
if (!file?.url) return;
|
|
|
|
const filename = file.filename || 'Image';
|
|
|
|
onShowPopup({
|
|
open: true,
|
|
title: filename,
|
|
content: '',
|
|
metadata: {
|
|
tool: 'image-preview',
|
|
filename,
|
|
mime: file.mimeType,
|
|
size: file.size,
|
|
},
|
|
image: {
|
|
url: file.url,
|
|
mimeType: file.mimeType,
|
|
filename,
|
|
size: file.size,
|
|
gallery: imageGallery,
|
|
index,
|
|
},
|
|
});
|
|
}, [imageGallery, onShowPopup]);
|
|
|
|
if (fileItems.length === 0) return null;
|
|
|
|
if (compact) {
|
|
return (
|
|
<div className="space-y-1.5 mt-1.5">
|
|
{otherFiles.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{otherFiles.map((file, index) => {
|
|
const fileName = resolveDisplayName(file);
|
|
const ext = fileName.split('.').pop() || '';
|
|
const sizeText = formatFileSize(file.size);
|
|
const githubLinkKind = getGitHubLinkKind(file);
|
|
return (
|
|
<Tooltip key={`file-${file.url || file.filename || index}`}>
|
|
<TooltipTrigger asChild>
|
|
{githubLinkKind && file.url ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
void openExternalUrl(file.url || '');
|
|
}}
|
|
className="inline-flex items-center bg-muted/30 border border-border/30 typography-meta gap-1 px-2 py-0.5 rounded-lg text-foreground hover:text-primary transition-colors"
|
|
>
|
|
{githubLinkKind === 'pr' ? (
|
|
<Icon name="git-pull-request" className="text-muted-foreground h-3.5 w-3.5" />
|
|
) : (
|
|
<Icon name="github" className="text-muted-foreground h-3.5 w-3.5" />
|
|
)}
|
|
<div className="overflow-hidden max-w-[220px]">
|
|
<span className="truncate block" title={fileName}>{fileName}</span>
|
|
</div>
|
|
</button>
|
|
) : (
|
|
<div className="inline-flex items-center bg-muted/30 border border-border/30 typography-meta gap-1 px-2 py-0.5 rounded-lg">
|
|
{file.mime?.includes('pdf') ? (
|
|
<Icon name="file-pdf" className="text-muted-foreground h-3.5 w-3.5" />
|
|
) : (
|
|
<FileTypeIcon filePath={fileName} extension={ext} className="text-muted-foreground h-3.5 w-3.5" />
|
|
)}
|
|
<div className="overflow-hidden max-w-[140px]">
|
|
<span className="truncate block" title={fileName}>{fileName}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{fileName}{sizeText ? ` (${sizeText})` : ''}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{imageFiles.length > 0 && (
|
|
<div className="overflow-x-auto -mx-1 px-1 py-0.5 scrollbar-thin">
|
|
<div className="flex snap-x snap-mandatory gap-2">
|
|
{imageFiles.map((file, index) => {
|
|
const filename = resolveDisplayName(file) || 'Image';
|
|
|
|
return (
|
|
<Tooltip key={`img-${file.url || file.filename || index}`}>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleImageClick(index)}
|
|
className="relative flex-none border border-border/40 bg-muted/10 overflow-hidden snap-start h-12 w-12 sm:h-14 sm:w-14 md:h-16 md:w-16 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-primary"
|
|
aria-label={filename}
|
|
>
|
|
{file.url ? (
|
|
<img
|
|
src={file.url}
|
|
alt={filename}
|
|
className="h-full w-full object-cover"
|
|
loading="lazy"
|
|
onError={(e) => {
|
|
const target = e.target as HTMLImageElement;
|
|
target.style.visibility = 'hidden';
|
|
}}
|
|
/>
|
|
) : (
|
|
<div className="h-full w-full flex items-center justify-center bg-muted/30 text-muted-foreground">
|
|
<Icon name="file-image" className="h-6 w-6" />
|
|
</div>
|
|
)}
|
|
<span className="sr-only">{filename}</span>
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" sideOffset={6} className="typography-meta px-2 py-1">
|
|
{filename}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={cn(
|
|
"grid gap-2",
|
|
compact ? "grid-cols-1" : "grid-cols-1 sm:grid-cols-2"
|
|
)}>
|
|
{fileItems.map((file, index) => {
|
|
const fileName = resolveDisplayName(file);
|
|
const isImage = file.mime?.startsWith('image/');
|
|
const sizeText = formatFileSize(file.size);
|
|
const githubLinkKind = getGitHubLinkKind(file);
|
|
|
|
if (isImage && file.url) {
|
|
return (
|
|
<div
|
|
key={file.url || `${fileName}-${index}`}
|
|
className="relative aspect-video rounded-lg border border-border/40 bg-muted/10 overflow-hidden group"
|
|
>
|
|
<img
|
|
src={file.url}
|
|
alt={fileName}
|
|
className="h-full w-full object-cover"
|
|
loading="lazy"
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
<div className="absolute bottom-0 left-0 right-0 p-2 text-white opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<p className="text-xs font-medium truncate">{fileName}</p>
|
|
{sizeText && <p className="text-xs opacity-80">{sizeText}</p>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (githubLinkKind && file.url) {
|
|
return (
|
|
<Tooltip key={file.url || `${fileName}-${index}`}>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
void openExternalUrl(file.url || '');
|
|
}}
|
|
className={cn(
|
|
"flex items-center gap-2 p-2 rounded-lg border border-border/40 bg-muted/10 hover:bg-muted/20 transition-colors text-left",
|
|
compact ? "text-xs" : "text-sm"
|
|
)}
|
|
>
|
|
<div className="flex-shrink-0">
|
|
{githubLinkKind === 'pr' ? (
|
|
<Icon name="git-pull-request" className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
|
|
) : (
|
|
<Icon name="github" className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium truncate">{fileName}</p>
|
|
{sizeText && <p className="text-xs text-muted-foreground">{sizeText}</p>}
|
|
</div>
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{fileName}{sizeText ? ` (${sizeText})` : ''}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
const source = file.source;
|
|
const sourceType = typeof source?.type === 'string' ? source.type : undefined;
|
|
const sourcePath = source && typeof (source as Record<string, unknown>).path === 'string' ? (source as Record<string, unknown>).path as string : undefined;
|
|
const filePath = sourceType === 'file' && sourcePath ? sourcePath : (file.url || '');
|
|
const isDrawio = filePath && isDrawioFile(filePath);
|
|
|
|
if (isDrawio) {
|
|
return (
|
|
<Tooltip key={file.url || `${fileName}-${index}`}>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
useUIStore.getState().navigateToDiagram(filePath);
|
|
}}
|
|
className={cn(
|
|
"flex items-center gap-2 p-2 rounded-lg border border-border/40 bg-muted/10 hover:bg-muted/20 transition-colors text-left cursor-pointer",
|
|
compact ? "text-xs" : "text-sm"
|
|
)}
|
|
>
|
|
<Icon name="file" className={cn("text-muted-foreground shrink-0", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium truncate">{fileName}</p>
|
|
<p className="text-xs text-status-info">{t('chat.fileAttachment.openInDiagram')}</p>
|
|
</div>
|
|
<Icon name="external-link" className={cn("text-muted-foreground shrink-0", compact ? "h-3 w-3" : "h-3.5 w-3.5")} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{t('chat.fileAttachment.openInDiagram')}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Tooltip key={file.url || `${fileName}-${index}`}>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (onShowPopup && file.url) {
|
|
onShowPopup({
|
|
open: true,
|
|
title: fileName,
|
|
content: '',
|
|
image: {
|
|
url: file.url,
|
|
mimeType: file.mime,
|
|
filename: fileName,
|
|
},
|
|
});
|
|
}
|
|
}}
|
|
className={cn(
|
|
"flex items-center gap-2 p-2 rounded-lg border border-border/40 bg-muted/10 hover:bg-muted/20 transition-colors text-left",
|
|
compact ? "text-xs" : "text-sm"
|
|
)}
|
|
>
|
|
<div className="flex-shrink-0">
|
|
{file.mime?.startsWith('image/') ? (
|
|
<Icon name="file-image" className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
|
|
) : file.mime?.includes('pdf') ? (
|
|
<Icon name="file-pdf" className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
|
|
) : (
|
|
<Icon name="file" className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium truncate">{fileName}</p>
|
|
{sizeText && <p className="text-xs text-muted-foreground">{sizeText}</p>}
|
|
</div>
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{fileName}{sizeText ? ` (${sizeText})` : ''}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
MessageFilesDisplay.displayName = 'MessageFilesDisplay';
|
|
|
|
interface ImageGalleryProps {
|
|
urls: string[];
|
|
caption?: string;
|
|
onShowPopup?: (content: ToolPopupContent) => void;
|
|
}
|
|
|
|
export const ImageGallery = memo(({ urls, caption, onShowPopup }: ImageGalleryProps) => {
|
|
if (urls.length === 0) return null;
|
|
|
|
const getGridCols = () => {
|
|
if (urls.length === 1) return 'grid-cols-1';
|
|
if (urls.length === 2) return 'grid-cols-2';
|
|
if (urls.length <= 4) return 'grid-cols-2';
|
|
return 'grid-cols-3';
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className={cn("grid gap-2", getGridCols())}>
|
|
{urls.map((url, index) => (
|
|
<button
|
|
key={url}
|
|
type="button"
|
|
onClick={() => onShowPopup?.({
|
|
open: true,
|
|
title: caption || `Image ${index + 1} of ${urls.length}`,
|
|
content: '',
|
|
image: {
|
|
url,
|
|
gallery: urls.map(u => ({ url: u })),
|
|
index,
|
|
},
|
|
})}
|
|
className="relative aspect-square rounded-lg border border-border/40 bg-muted/10 overflow-hidden group"
|
|
>
|
|
<img
|
|
src={url}
|
|
alt={caption || `Image ${index + 1}`}
|
|
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
|
loading="lazy"
|
|
/>
|
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
{caption && (
|
|
<p className="text-sm text-muted-foreground italic">{caption}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
ImageGallery.displayName = 'ImageGallery';
|