* feat(linear): start sessions from Linear issues Authorize a Linear workspace on this OpenChamber server, map teams to projects, attach an issue from chat, start a session or worktree from an issue, and post started/completed/failed comments that open the session. Hidden in VS Code. * feat(linear): connect more than one Linear workspace Store each OAuth grant on this OpenChamber server and keep one current, so Settings can add and switch workspaces without dropping the others. Project mapping is per workspace. Remove the Linear button next to New Chat; start-from-issue stays on New Worktree. * feat(linear): add a right-hand issues panel Browse and filter issues in the rail, open a card to change status or start a session, and collapse search plus most filters to icons on a narrow panel. * feat(linear): open issues in the rail and filter by Linear status The rail icon only shows after Linear is connected. Clicking a Linear row on work status opens the panel. Status options match the card, including Done, Canceled, and Duplicate. The Integrations experimental warning sits under Third-party integrations. * fix(linear): use stable OAuth callback broker * fix(chat): preview Linear issue attachments The context switch missed linear-issue, so tsc treated the preview helpers as incomplete. * fix(ui): restore Linear i18n parity and the #2903 sync harness Turkish was missing the Linear dictionaries, and the subagent test still wrapped only SyncContext after reads moved to SyncRuntimeContext. * fix(linear): drop changelog hunks and close review races Keep changelogs out of this PR, restore CodeMirror ranges, ignore stale Linear list pages, and leave a persisted Linear tab open until auth has actually resolved. * fix(linear): tint active issue filters and clear them in one click * fix(markdown): read escaped brackets as text, not display math `\[...\]` is display math in LaTeX and an escaped bracket pair in CommonMark. The block tokenizer claimed every `\[`, so prose like `[title \[Bug\] more](url)` was handed to KaTeX: "Bug" rendered as a centered formula and the block token split the paragraph, tearing the link into three pieces. Linear, GitHub and any other source that escapes brackets the way CommonMark requires hit this. Display math now has to own its line — `\[` starts one and `\]` ends one. A formula on its own line still renders; `\[` mid-sentence stays an escape, which is what CommonMark says it is and what prose almost always means. Inline `\(...\)` keeps the same ambiguity, but inline math is legitimately mid-sentence, so there is no position to judge it by. Covered by regression tests, including the verbatim comment body that surfaced this. * feat(linear): make session status comments opt-in and public-only A status comment lands in a Linear workspace the whole team reads, and the link it carried pointed at whatever origin started the session — usually loopback or a LAN address. Everyone but its author got a dead link, and nobody had agreed to the comments in the first place. Comments are now off until the user turns them on in Settings -> Integrations -> Linear, and the check lives on the server: the event hub posts completed and failure without going through the interface, so a client-side gate would not hold. When the resolved origin is not publicly reachable the server posts nothing at all rather than a link only its author can open; `isPublicSessionOrigin` rejects loopback, private LAN, carrier-grade NAT, link-local and single-label hosts. The desktop deep-link origin is gone with it, since no one else can follow one either. The comment body also dropped the session title. It repeated the issue the comment already sits on, and issue titles routinely carry brackets ("[Bug] ...") that broke the markdown link. The body is now one short link, and `sessionTitle` is gone from the route, client and types. Also caps the dedupe file at the newest 500 sessions; it grew forever. * fix(linear): match the pull request panel and clear review findings Comments in the Linear panel now render as the same avatar timeline the pull request panel uses, with the shared time-format preference instead of a raw locale string. Comment authors carry `avatarUrl`, which the GraphQL selection was not requesting. Review findings from the same pass: - `status-runtime.js` hand-rolled `typeof` narrowing and failed the vendored anti-slop lint; it now parses through `parse.js` like every other file in the module. - `useLinearAuthStore` turned any failed request into `connected: false` with `hasChecked: true`. Since the rail icon, the composer entry and the worktree option all gate on `connected === true`, one network blip hid Linear for the rest of the session, and Settings only re-checked when it had never checked. It now keeps the last known status and leaves `hasChecked` false so the next caller retries. - `LinearIssuesView` (1096 lines) was a static import in `ContextPanel`, shipping in the main bundle although its rail icon stays hidden until a workspace is connected. It is lazy now, like `GitView`. - Dropped dead code: the unused port helpers left over from the loopback callback, two re-exported default values nothing read, and a redundant export in `linkedIssues`. - Integrations is no longer badged beta.
970 lines
36 KiB
TypeScript
970 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 { useDirectoryStore } from '@/stores/useDirectoryStore';
|
|
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';
|
|
|
|
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 ? `${relativePath}:${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 LINEAR_ISSUE_LINK_MIME = 'application/vnd.openchamber.linear-issue-link';
|
|
|
|
type IssueLinkKind = 'github-issue' | 'github-pr' | 'linear-issue';
|
|
|
|
const getIssueLinkKind = (file: FilePart): IssueLinkKind | null => {
|
|
if (file.mime === GITHUB_ISSUE_LINK_MIME) {
|
|
return 'github-issue';
|
|
}
|
|
if (file.mime === GITHUB_PR_LINK_MIME) {
|
|
return 'github-pr';
|
|
}
|
|
if (file.mime === LINEAR_ISSUE_LINK_MIME) {
|
|
return 'linear-issue';
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const issueLinkIcon = (kind: IssueLinkKind): 'github' | 'git-pull-request' | 'linear' => {
|
|
if (kind === 'github-pr') return 'git-pull-request';
|
|
if (kind === 'linear-issue') return 'linear';
|
|
return 'github';
|
|
};
|
|
|
|
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 = getIssueLinkKind(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 issueLinkKind = getIssueLinkKind(file);
|
|
return (
|
|
<Tooltip key={`file-${file.url || file.filename || index}`}>
|
|
<TooltipTrigger asChild>
|
|
{issueLinkKind && 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"
|
|
>
|
|
<Icon name={issueLinkIcon(issueLinkKind)} 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 issueLinkKind = getIssueLinkKind(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 (issueLinkKind && 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">
|
|
<Icon name={issueLinkIcon(issueLinkKind)} 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={() => {
|
|
const directory = useDirectoryStore.getState().currentDirectory;
|
|
if (directory) {
|
|
useUIStore.getState().openContextFile(directory, 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;
|
|
}
|
|
|
|
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';
|