Files
openchamber/packages/ui/src/components/chat/FileAttachment.tsx
T
nerdosaurusandBohdan Triapitsyn d9b9b56599 Diagram editor pr (#1432)
* 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>
2026-06-08 18:50:56 +03:00

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';