feat: streamline worktree session flow (#577)
* feat: add unified worktree creation dialog with GitHub integration Unified dialog for creating worktrees with branch selection and GitHub integration New GitHub picker dialog for selecting issues and PRs with validation Persistent state when switching between new branch and existing branch modes * refactor: minimalistic compact design for worktree dialogs Redesigned dialogs with minimalistic compact layout Moved mode tabs inline with dialog header Inline validation errors with action buttons * fix: maintain consistent dialog height in all states Fixed dialog height fluctuations when loading or switching tabs Consistent 300px height for loading, empty, and populated states Nested container structure prevents layout shifts * refactor: minimalistic compact UI redesign for worktree dialogs Minimalistic compact layouts with reduced spacing and inline tabs Standardized SortableTabsStrip with pills variant for mode selection Two-row linked item display and inline validation with action buttons * feat: mobile overlays for worktree dialogs with branch pickers Mobile overlay panels replace dropdowns for branch selection Fixed footer button overlapping with proper flex layout Responsive design with conditional mobile/desktop rendering * refactor: relocate selected item badge to footer with proper alignment Selected item badge moved to footer left side on desktop Proper alignment with Cancel/Select buttons using h-8 height Cleaner header layout removing the selected item display * perf: use cached branches from git store for instant branch selection Branch list now loads instantly from git store cache No more loading delay when opening new worktree dialog Removes redundant API calls by using existing cached data * refactor: improve branch selection UI with grouped headers and compact layout Group branches into Local and Remote sections with clear headers Compact dropdowns with better sizing and text wrapping Bold labels and smaller issue/PR titles for cleaner layout * feat: add prefilled slugs and improve branch selection UX in new worktree dialog Prefilled unique branch name when opening new worktree dialog Group branches into Local and Remote sections with bold headers Instant branch list from cache with compact, wrapped dropdowns * feat: update GitHub button text to be more descriptive * feat: add GitHub issue linking to draft sessions Link GitHub issues to draft sessions with full context including body, labels and comments View issue author avatar, number and title in session input area Open linked issues in browser or remove link with inline action buttons * style: redesign queued messages with text-only row layout * style: redesign attached files with text-only row layout and file type icons Redesigned attached files with clean text-only rows matching linked issue style Split display into two rows: image previews and file list with proper type icons Removed internal Server/Local indicators to reduce visual clutter * refactor: remove New from PR and Manage branches from sessions UI Remove New from PR and Manage branches buttons from sessions sidebar Delete GitHubPullRequestPickerDialog component and related code Simplify GitView to sidebar-only mode with no mode prop * fix: simplify new session shortcuts and remove worktree toggle * fix: restore issue and PR context when creating worktree sessions Worktrees created from GitHub issues and PRs now seed the new session with visible and synthetic context messages Sidebar now opens the created linked session directly instead of creating an extra draft Visible prompts are cleaner and include issue or PR numbers for clarity
This commit is contained in:
committed by
GitHub
parent
d4269a1cc8
commit
bdad912ea5
@@ -4,9 +4,12 @@ import {
|
||||
RiAddCircleLine,
|
||||
RiAiAgentLine,
|
||||
RiAttachment2,
|
||||
RiCloseLine,
|
||||
RiCommandLine,
|
||||
RiExternalLinkLine,
|
||||
RiFileUploadLine,
|
||||
RiFullscreenLine,
|
||||
RiGithubLine,
|
||||
RiSendPlane2Line,
|
||||
} from '@remixicon/react';
|
||||
import { BrowserVoiceButton } from '@/components/voice';
|
||||
@@ -48,6 +51,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { GitHubIssuePickerDialog } from '@/components/session/GitHubIssuePickerDialog';
|
||||
|
||||
const MAX_VISIBLE_TEXTAREA_LINES = 8;
|
||||
const EMPTY_QUEUE: QueuedMessage[] = [];
|
||||
@@ -157,6 +161,16 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const prevWasAbortedRef = React.useRef(false);
|
||||
|
||||
// Issue linking state (for draft sessions)
|
||||
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
|
||||
const [linkedIssue, setLinkedIssue] = React.useState<{
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
contextText: string;
|
||||
author?: { login: string; avatarUrl?: string };
|
||||
} | null>(null);
|
||||
|
||||
// Message queue
|
||||
const queueModeEnabled = useMessageQueueStore((state) => state.queueModeEnabled);
|
||||
const queuedMessages = useMessageQueueStore(
|
||||
@@ -554,6 +568,15 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
}
|
||||
}
|
||||
|
||||
// Add linked issue as synthetic part (only the parts with synthetic: true)
|
||||
// The text part (synthetic: false) is completely dropped per requirements
|
||||
if (linkedIssue && newSessionDraftOpen) {
|
||||
additionalParts.push({
|
||||
text: linkedIssue.contextText,
|
||||
synthetic: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!primaryText && additionalParts.length === 0) return;
|
||||
|
||||
// Clear queue and input
|
||||
@@ -625,7 +648,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
additionalParts.length > 0 ? additionalParts : undefined,
|
||||
currentVariant,
|
||||
inputMode
|
||||
).catch((error: unknown) => {
|
||||
).then(() => {
|
||||
// Clear linked issue after successful message send in draft mode
|
||||
if (linkedIssue && newSessionDraftOpen) {
|
||||
setLinkedIssue(null);
|
||||
}
|
||||
}).catch((error: unknown) => {
|
||||
const rawMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
@@ -674,6 +702,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
}
|
||||
};
|
||||
|
||||
// Update ref with latest handleSubmit on every render
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
|
||||
// Primary action for send button - respects queue mode setting
|
||||
@@ -2135,6 +2164,70 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Linked Issue Button - only in draft mode */}
|
||||
{newSessionDraftOpen && (
|
||||
<div className="pb-2 w-full px-1">
|
||||
{linkedIssue ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIssuePickerOpen(true)}
|
||||
className="flex w-full items-center gap-1.5 text-sm hover:opacity-80 transition-opacity text-left h-5 px-1"
|
||||
>
|
||||
{linkedIssue.author?.avatarUrl && (
|
||||
<img
|
||||
src={linkedIssue.author.avatarUrl}
|
||||
alt={linkedIssue.author.login}
|
||||
className="h-5 w-5 rounded-full flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="text-muted-foreground flex-shrink-0">
|
||||
#{linkedIssue.number}
|
||||
{linkedIssue.author && (
|
||||
<span className="ml-1">by {linkedIssue.author.login}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-foreground truncate">
|
||||
{linkedIssue.title}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<a
|
||||
href={linkedIssue.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors"
|
||||
aria-label="Open issue in browser"
|
||||
>
|
||||
<RiExternalLinkLine className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setLinkedIssue(null);
|
||||
}}
|
||||
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
|
||||
aria-label="Remove linked issue"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIssuePickerOpen(true)}
|
||||
className="flex w-full items-center gap-1.5 text-sm hover:opacity-80 transition-opacity text-left h-5 px-1"
|
||||
>
|
||||
<RiGithubLine
|
||||
className="h-4 w-4 flex-shrink-0"
|
||||
style={{ color: currentTheme?.colors?.status?.success }}
|
||||
/>
|
||||
<span className="text-muted-foreground">Link GitHub Issue</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col relative overflow-visible",
|
||||
@@ -2378,6 +2471,14 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Issue Picker Dialog */}
|
||||
<GitHubIssuePickerDialog
|
||||
open={issuePickerOpen}
|
||||
onOpenChange={setIssuePickerOpen}
|
||||
mode="select"
|
||||
onSelect={(issue) => setLinkedIssue(issue)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useRef, memo } from 'react';
|
||||
import { RiAttachment2, RiCloseLine, RiComputerLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiHardDrive3Line } from '@remixicon/react';
|
||||
import { RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine, RiFilePdfLine } from '@remixicon/react';
|
||||
import { useSessionStore, type AttachedFile } from '@/stores/useSessionStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { toast } from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
|
||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
|
||||
import type { ToolPopupContent } from './message/types';
|
||||
|
||||
@@ -98,142 +99,210 @@ export const FileAttachmentButton = memo(() => {
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
accept="*/*"
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
if (isVSCodeRuntime) {
|
||||
void handleVSCodePick();
|
||||
} else {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
buttonSizeClass,
|
||||
'flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0'
|
||||
)}
|
||||
title='Attach files'
|
||||
>
|
||||
<RiAttachment2 className={cn(iconSizeClass, 'text-current')} />
|
||||
</button>
|
||||
<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="Attach files"
|
||||
>
|
||||
<RiAttachment2 className={iconSizeClass} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Attach files</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
FileAttachmentButton.displayName = 'FileAttachmentButton';
|
||||
|
||||
interface ImagePreviewProps {
|
||||
file: AttachedFile;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => {
|
||||
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);
|
||||
|
||||
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={`Remove ${displayName}`}
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-10 w-10 rounded-lg border border-border/40 bg-muted/10 overflow-hidden flex-shrink-0 group">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={displayName}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="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 opacity-0 group-hover:opacity-100 transition-opacity focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
title="Remove image"
|
||||
aria-label={`Remove ${displayName}`}
|
||||
>
|
||||
<RiCloseLine className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ImagePreview.displayName = 'ImagePreview';
|
||||
|
||||
interface FileChipProps {
|
||||
file: AttachedFile;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
const FileChip = memo(({ file, onRemove }: FileChipProps) => {
|
||||
const getFileIcon = () => {
|
||||
if (file.mimeType.startsWith('image/')) {
|
||||
return <RiFileImageLine className="h-3.5 w-3.5" />;
|
||||
}
|
||||
if (file.mimeType.includes('text') || file.mimeType.includes('code')) {
|
||||
return <RiFileLine className="h-3.5 w-3.5" />;
|
||||
}
|
||||
if (file.mimeType.includes('json') || file.mimeType.includes('xml')) {
|
||||
return <RiFilePdfLine className="h-3.5 w-3.5" />;
|
||||
}
|
||||
return <RiFileLine className="h-3.5 w-3.5" />;
|
||||
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 (!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;
|
||||
};
|
||||
|
||||
const displayName = extractFilename(file.filename);
|
||||
const isLocalImagePreview =
|
||||
file.source !== 'server' &&
|
||||
file.mimeType.startsWith('image/') &&
|
||||
typeof file.dataUrl === 'string' &&
|
||||
file.dataUrl.startsWith('data:image/');
|
||||
|
||||
if (isLocalImagePreview) {
|
||||
return (
|
||||
<div className="relative h-12 w-12 sm:h-14 sm:w-14 overflow-hidden rounded-lg border border-border/40 bg-muted/10 flex-shrink-0">
|
||||
<img
|
||||
src={file.dataUrl}
|
||||
alt={displayName}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="absolute top-1 right-1 h-5 w-5 rounded-full bg-background/80 text-foreground hover:text-destructive flex items-center justify-center focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
title="Remove image"
|
||||
aria-label={`Remove ${displayName}`}
|
||||
>
|
||||
<RiCloseLine className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const fileSize = formatFileSize(file.size);
|
||||
const extension = getFileExtension(file.filename);
|
||||
|
||||
return (
|
||||
<div className="flex w-full sm:inline-flex sm:w-auto items-center gap-1.5 px-3 sm:px-2.5 py-1 bg-muted/30 border border-border/30 rounded-xl typography-meta max-w-full min-w-0">
|
||||
<div title={file.source === 'server' ? "Server file" : "Local file"}>
|
||||
{file.source === 'server' ? (
|
||||
<RiHardDrive3Line className="h-3 w-3 text-primary flex-shrink-0" />
|
||||
) : (
|
||||
<RiComputerLine className="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
{getFileIcon()}
|
||||
<div className="overflow-hidden max-w-[120px] sm:max-w-[180px] flex-1 min-w-0">
|
||||
<span className="truncate block" title={file.serverPath || displayName}>
|
||||
{displayName}
|
||||
</span>
|
||||
</div>
|
||||
<span className="ml-auto text-muted-foreground flex-shrink-0 text-xs">
|
||||
{formatFileSize(file.size)}
|
||||
<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>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="hover:text-destructive min-h-6 min-w-6 sm:min-h-0 sm:min-w-0 sm:p-0.5 flex items-center justify-center flex-shrink-0 rounded focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
title="Remove file"
|
||||
<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={`Remove ${displayName}`}
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 sm:h-3 sm:w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
FileChip.displayName = 'FileChip';
|
||||
|
||||
export const AttachedFilesList = memo(() => {
|
||||
const { attachedFiles, removeAttachedFile } = useSessionStore();
|
||||
|
||||
if (attachedFiles.length === 0) return null;
|
||||
|
||||
const images = attachedFiles.filter(f => f.mimeType.startsWith('image/'));
|
||||
const otherFiles = attachedFiles.filter(f => !f.mimeType.startsWith('image/'));
|
||||
|
||||
return (
|
||||
<div className="pb-2 overflow-hidden">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:flex-wrap gap-2 px-3 py-2 bg-muted/30 rounded-xl border border-border/30">
|
||||
{attachedFiles.map((file) => (
|
||||
<FileChip
|
||||
key={file.id}
|
||||
file={file}
|
||||
onRemove={() => removeAttachedFile(file.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<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) => (
|
||||
<ImagePreview
|
||||
key={file.id}
|
||||
file={file}
|
||||
onRemove={() => removeAttachedFile(file.id)}
|
||||
/>
|
||||
))}
|
||||
</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';
|
||||
|
||||
interface FilePart {
|
||||
type: string;
|
||||
mime?: string;
|
||||
@@ -257,258 +326,152 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
|
||||
const normalized = path.replace(/\\/g, '/');
|
||||
const parts = normalized.split('/');
|
||||
return parts[parts.length - 1] || path;
|
||||
const filename = parts[parts.length - 1];
|
||||
|
||||
return filename || path;
|
||||
};
|
||||
|
||||
const getFileIcon = (mimeType?: string) => {
|
||||
if (!mimeType) return <RiFileLine className="h-3.5 w-3.5" />;
|
||||
|
||||
if (mimeType.startsWith('image/')) {
|
||||
return <RiFileImageLine className="h-3.5 w-3.5" />;
|
||||
}
|
||||
if (mimeType.includes('text') || mimeType.includes('code')) {
|
||||
return <RiFileLine className="h-3.5 w-3.5" />;
|
||||
}
|
||||
if (mimeType.includes('json') || mimeType.includes('xml')) {
|
||||
return <RiFilePdfLine className="h-3.5 w-3.5" />;
|
||||
}
|
||||
return <RiFileLine className="h-3.5 w-3.5" />;
|
||||
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 extractExtension = (value?: string): string => {
|
||||
if (!value) return '';
|
||||
const normalized = value.replace(/\\/g, '/').trim();
|
||||
const filename = normalized.split('/').pop() || normalized;
|
||||
const dotIndex = filename.lastIndexOf('.');
|
||||
if (dotIndex < 0) return '';
|
||||
return filename.slice(dotIndex).toLowerCase();
|
||||
};
|
||||
|
||||
const isMermaidMimeType = (mimeType?: string): boolean => {
|
||||
if (!mimeType) return false;
|
||||
const normalized = mimeType.toLowerCase();
|
||||
return normalized === 'text/vnd.mermaid'
|
||||
|| normalized === 'application/vnd.mermaid'
|
||||
|| normalized === 'text/x-mermaid'
|
||||
|| normalized === 'application/x-mermaid';
|
||||
};
|
||||
|
||||
const isGenericTextOrUnknownMime = (mimeType?: string): boolean => {
|
||||
if (!mimeType) return true;
|
||||
const normalized = mimeType.toLowerCase();
|
||||
return normalized === 'text/plain' || normalized === 'application/octet-stream';
|
||||
};
|
||||
|
||||
const isExplicitBinaryMime = (mimeType?: string): boolean => {
|
||||
if (!mimeType) return false;
|
||||
const normalized = mimeType.toLowerCase();
|
||||
return normalized.startsWith('image/')
|
||||
|| normalized.startsWith('video/')
|
||||
|| normalized.startsWith('audio/')
|
||||
|| normalized === 'application/pdf';
|
||||
};
|
||||
|
||||
const isMermaidFile = (file: FilePart): boolean => {
|
||||
const normalizedMime = file.mime?.toLowerCase();
|
||||
if (isMermaidMimeType(normalizedMime)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isExplicitBinaryMime(normalizedMime)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const extension = extractExtension(file.filename || file.url);
|
||||
const isMermaidExtension = extension === '.mmd' || extension === '.mermaid';
|
||||
return isMermaidExtension && isGenericTextOrUnknownMime(normalizedMime);
|
||||
};
|
||||
|
||||
const imageFiles = fileItems.filter(f => f.mime?.startsWith('image/') && f.url);
|
||||
const mermaidFiles = fileItems.filter((file) => isMermaidFile(file) && file.url);
|
||||
const otherFiles = fileItems.filter((file) => {
|
||||
if (file.mime?.startsWith('image/')) {
|
||||
return false;
|
||||
}
|
||||
return !isMermaidFile(file);
|
||||
});
|
||||
|
||||
const imageGallery = React.useMemo(
|
||||
() =>
|
||||
imageFiles.flatMap((file) => {
|
||||
if (!file.url) return [];
|
||||
const filename = extractFilename(file.filename) || 'Image';
|
||||
return [{
|
||||
url: file.url,
|
||||
mimeType: file.mime,
|
||||
filename,
|
||||
size: file.size,
|
||||
}];
|
||||
}),
|
||||
[imageFiles]
|
||||
);
|
||||
|
||||
const handleImageClick = React.useCallback((index: number) => {
|
||||
if (!onShowPopup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const file = imageGallery[index];
|
||||
if (!file?.url) return;
|
||||
|
||||
const filename = file.filename || 'Image';
|
||||
|
||||
const popupPayload: ToolPopupContent = {
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
onShowPopup(popupPayload);
|
||||
}, [imageGallery, onShowPopup]);
|
||||
|
||||
const handleMermaidClick = React.useCallback((file: FilePart) => {
|
||||
if (!onShowPopup || !file.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = extractFilename(file.filename) || 'Diagram';
|
||||
onShowPopup({
|
||||
open: true,
|
||||
title: filename,
|
||||
content: '',
|
||||
metadata: {
|
||||
tool: 'mermaid-preview',
|
||||
filename,
|
||||
mime: file.mime,
|
||||
size: file.size,
|
||||
},
|
||||
mermaid: {
|
||||
url: file.url,
|
||||
mimeType: file.mime,
|
||||
filename,
|
||||
},
|
||||
});
|
||||
}, [onShowPopup]);
|
||||
|
||||
if (fileItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={cn(compact ? 'space-y-1.5 mt-1.5' : 'space-y-2 mt-2')}>
|
||||
{}
|
||||
{otherFiles.length > 0 && (
|
||||
<div className={cn('flex flex-wrap', compact ? 'gap-1.5' : 'gap-2')}>
|
||||
{otherFiles.map((file, index) => (
|
||||
<div className={cn(
|
||||
"grid gap-2",
|
||||
compact ? "grid-cols-1" : "grid-cols-1 sm:grid-cols-2"
|
||||
)}>
|
||||
{fileItems.map((file, index) => {
|
||||
const fileName = extractFilename(file.filename || file.url);
|
||||
const isImage = file.mime?.startsWith('image/');
|
||||
const sizeText = formatFileSize(file.size);
|
||||
|
||||
if (isImage && file.url) {
|
||||
return (
|
||||
<div
|
||||
key={`file-${file.url || file.filename || index}`}
|
||||
className={cn(
|
||||
'inline-flex items-center bg-muted/30 border border-border/30 typography-meta',
|
||||
compact ? 'gap-1 px-2 py-0.5 rounded-lg' : 'gap-1.5 px-2.5 py-1 rounded-xl'
|
||||
)}
|
||||
key={index}
|
||||
className="relative aspect-video rounded-lg border border-border/40 bg-muted/10 overflow-hidden group"
|
||||
>
|
||||
{getFileIcon(file.mime)}
|
||||
<div className={cn('overflow-hidden', compact ? 'max-w-[140px]' : 'max-w-[200px]')}>
|
||||
<span className="truncate block" title={extractFilename(file.filename)}>
|
||||
{extractFilename(file.filename)}
|
||||
</span>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
}
|
||||
|
||||
{mermaidFiles.length > 0 && (
|
||||
<div className={cn('flex flex-wrap', compact ? 'gap-1.5' : 'gap-2')}>
|
||||
{mermaidFiles.map((file, index) => {
|
||||
const filename = extractFilename(file.filename) || 'Diagram';
|
||||
|
||||
return (
|
||||
return (
|
||||
<Tooltip key={index}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
key={`mermaid-${file.url || file.filename || index}`}
|
||||
type="button"
|
||||
onClick={() => handleMermaidClick(file)}
|
||||
onClick={() => {
|
||||
if (onShowPopup && file.url) {
|
||||
onShowPopup({
|
||||
open: true,
|
||||
title: fileName,
|
||||
content: '',
|
||||
image: {
|
||||
url: file.url,
|
||||
mimeType: file.mime,
|
||||
filename: fileName,
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex items-center bg-muted/30 border border-border/30 typography-meta transition-colors',
|
||||
'hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
|
||||
compact ? 'gap-1 px-2 py-0.5 rounded-lg' : 'gap-1.5 px-2.5 py-1 rounded-xl'
|
||||
"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"
|
||||
)}
|
||||
title={`Open ${filename}`}
|
||||
aria-label={`Open diagram ${filename}`}
|
||||
>
|
||||
{getFileIcon(file.mime)}
|
||||
<div className={cn('overflow-hidden', compact ? 'max-w-[140px]' : 'max-w-[200px]')}>
|
||||
<span className="truncate block" title={filename}>
|
||||
{filename}
|
||||
</span>
|
||||
<div className="flex-shrink-0">
|
||||
{file.mime?.startsWith('image/') ? (
|
||||
<RiFileImageLine className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
|
||||
) : file.mime?.includes('pdf') ? (
|
||||
<RiFilePdfLine className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
|
||||
) : (
|
||||
<RiFileLine 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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{fileName}{sizeText ? ` (${sizeText})` : ''}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
{}
|
||||
{imageFiles.length > 0 && (
|
||||
<div className={cn('overflow-x-auto -mx-1 px-1 scrollbar-thin', compact ? 'py-0.5' : 'py-1')}>
|
||||
<div className={cn('flex snap-x snap-mandatory', compact ? 'gap-2' : 'gap-3')}>
|
||||
{imageFiles.map((file, index) => {
|
||||
const filename = extractFilename(file.filename) || 'Image';
|
||||
MessageFilesDisplay.displayName = 'MessageFilesDisplay';
|
||||
|
||||
return (
|
||||
<Tooltip key={`img-${file.url || file.filename || index}`} delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleImageClick(index)}
|
||||
className={cn(
|
||||
'relative flex-none border border-border/40 bg-muted/10 overflow-hidden snap-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-primary',
|
||||
compact
|
||||
? 'h-12 w-12 sm:h-14 sm:w-14 md:h-16 md:w-16 rounded-lg'
|
||||
: 'aspect-square w-16 sm:w-20 md:w-24 rounded-xl'
|
||||
)}
|
||||
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">
|
||||
<RiFileImageLine 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>
|
||||
);
|
||||
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={index}
|
||||
type="button"
|
||||
onClick={() => onShowPopup?.({
|
||||
open: true,
|
||||
title: caption || `Image ${index + 1} of ${urls.length}`,
|
||||
content: '',
|
||||
image: {
|
||||
url,
|
||||
gallery: urls.map(u => ({ url: u })),
|
||||
index,
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
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';
|
||||
|
||||
@@ -17,7 +17,7 @@ const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChi
|
||||
const firstLine = React.useMemo(() => {
|
||||
const lines = message.content.split('\n');
|
||||
const first = lines[0] || '';
|
||||
const maxLength = 50;
|
||||
const maxLength = 100;
|
||||
if (first.length > maxLength) {
|
||||
return first.substring(0, maxLength) + '...';
|
||||
}
|
||||
@@ -27,32 +27,34 @@ const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChi
|
||||
const attachmentCount = message.attachments?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-muted/30 border border-border/30 rounded-xl typography-meta group">
|
||||
<RiMessage2Line className="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(message)}
|
||||
className="flex items-center gap-1.5 hover:text-foreground transition-colors text-left"
|
||||
title="Click to edit"
|
||||
>
|
||||
<span className="truncate max-w-[200px]">
|
||||
{firstLine || '(empty)'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(message)}
|
||||
className="flex w-full items-center gap-1.5 text-sm hover:opacity-80 transition-opacity text-left h-5 px-1"
|
||||
>
|
||||
<RiMessage2Line
|
||||
className="h-4 w-4 flex-shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span className="text-muted-foreground flex-shrink-0">
|
||||
Queued
|
||||
{attachmentCount > 0 && (
|
||||
<span className="text-muted-foreground flex-shrink-0">
|
||||
+{attachmentCount} file{attachmentCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
<span className="ml-1">+{attachmentCount} file{attachmentCount > 1 ? 's' : ''}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFromQueue(sessionId, message.id)}
|
||||
className="ml-1 hover:text-destructive p-0.5 opacity-60 group-hover:opacity-100 transition-opacity"
|
||||
title="Remove from queue"
|
||||
</span>
|
||||
<span className="text-foreground truncate">
|
||||
{firstLine || '(empty)'}
|
||||
</span>
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFromQueue(sessionId, message.id);
|
||||
}}
|
||||
className="flex items-center justify-center h-6 w-6 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
|
||||
aria-label="Remove from queue"
|
||||
>
|
||||
<RiCloseLine className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -98,17 +100,15 @@ export const QueuedMessageChips = memo(({ onEditMessage }: QueuedMessageChipsPro
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-2">
|
||||
<div className="flex items-center flex-wrap gap-2 px-3 py-2 bg-muted/30 rounded-xl border border-border/30">
|
||||
{queuedMessages.map((message) => (
|
||||
<QueuedMessageChip
|
||||
key={message.id}
|
||||
message={message}
|
||||
sessionId={currentSessionId}
|
||||
onEdit={handleEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="pb-2 w-full px-1 space-y-1">
|
||||
{queuedMessages.map((message) => (
|
||||
<QueuedMessageChip
|
||||
key={message.id}
|
||||
message={message}
|
||||
sessionId={currentSessionId}
|
||||
onEdit={handleEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user