mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-07-09 17:28:35 +00:00
ui polish and ux bug fix for new added feature
This commit is contained in:
parent
0f99676c48
commit
d9fc758e0a
19 changed files with 1595 additions and 1311 deletions
|
|
@ -51,6 +51,7 @@ export default [
|
|||
// Build outputs
|
||||
'dist/**',
|
||||
'dist-electron/**',
|
||||
'**/dist/**',
|
||||
'build/**',
|
||||
'release/**',
|
||||
// Cache
|
||||
|
|
|
|||
|
|
@ -394,9 +394,9 @@ export function ChatInputModelDropdown({
|
|||
}
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex min-h-[1.25rem] min-w-0 items-center gap-1.5 overflow-hidden">
|
||||
<span className="min-w-0 gap-1.5 inline-flex min-h-[1.25rem] items-center overflow-hidden">
|
||||
<Sparkles className="size-3.5 shrink-0" strokeWidth={2} aria-hidden />
|
||||
<span className="min-w-0 truncate !text-label-xs font-semibold">
|
||||
<span className="min-w-0 !text-label-xs font-semibold truncate">
|
||||
{triggerModelName}
|
||||
</span>
|
||||
</span>
|
||||
|
|
@ -420,19 +420,19 @@ export function ChatInputModelDropdown({
|
|||
className={cn(
|
||||
modelTriggerShellClass,
|
||||
'min-w-0 cursor-pointer border-0 text-left',
|
||||
'justify-between font-semibold transition-colors',
|
||||
'font-semibold justify-between transition-colors',
|
||||
'hover:bg-ds-bg-neutral-subtle-hover active:bg-ds-bg-neutral-subtle-default',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-border-neutral-strong-default focus-visible:ring-offset-2 focus-visible:ring-offset-ds-bg-neutral-default-default',
|
||||
'focus-visible:ring-ds-border-neutral-strong-default focus-visible:ring-offset-ds-bg-neutral-default-default focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
|
||||
'disabled:pointer-events-none disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
|
||||
<span className="min-w-0 gap-1.5 flex flex-1 items-center overflow-hidden">
|
||||
<Sparkles
|
||||
className="size-3.5 shrink-0"
|
||||
strokeWidth={2}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-left !text-label-xs text-ds-text-neutral-default-default">
|
||||
<span className="min-w-0 !text-label-xs text-ds-text-neutral-default-default flex-1 truncate text-left">
|
||||
{triggerModelName}
|
||||
</span>
|
||||
</span>
|
||||
|
|
@ -454,7 +454,7 @@ export function ChatInputModelDropdown({
|
|||
{import.meta.env.VITE_USE_LOCAL_PROXY !== 'true' && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
className="flex w-full min-w-0 items-center justify-start gap-2 [&>svg:first-child]:!h-4 [&>svg:first-child]:!min-h-4 [&>svg:first-child]:!w-4 [&>svg:first-child]:!min-w-4"
|
||||
className="min-w-0 gap-2 [&>svg:first-child]:!h-4 [&>svg:first-child]:!min-h-4 [&>svg:first-child]:!w-4 [&>svg:first-child]:!min-w-4 flex w-full items-center justify-start"
|
||||
onPointerEnter={(e) => {
|
||||
activeSubTriggerRef.current = e.currentTarget;
|
||||
}}
|
||||
|
|
@ -465,7 +465,7 @@ export function ChatInputModelDropdown({
|
|||
className="mt-0.5 h-4 w-4 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1 text-left text-body-sm">
|
||||
<span className="min-w-0 text-body-sm flex-1 text-left">
|
||||
{t('setting.eigent-cloud')}
|
||||
</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
|
|
@ -493,16 +493,16 @@ export function ChatInputModelDropdown({
|
|||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
className="flex w-full min-w-0 items-center justify-start gap-2 [&>svg:first-child]:!h-5 [&>svg:first-child]:!min-h-4 [&>svg:first-child]:!w-4 [&>svg:first-child]:!min-w-4"
|
||||
className="min-w-0 gap-2 [&>svg:first-child]:!h-5 [&>svg:first-child]:!min-h-4 [&>svg:first-child]:!w-4 [&>svg:first-child]:!min-w-4 flex w-full items-center justify-start"
|
||||
onPointerEnter={(e) => {
|
||||
activeSubTriggerRef.current = e.currentTarget;
|
||||
}}
|
||||
>
|
||||
<Layers
|
||||
className="shrink-0 text-ds-icon-neutral-default-default"
|
||||
className="text-ds-icon-neutral-default-default shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1 text-left text-body-sm">
|
||||
<span className="min-w-0 text-body-sm flex-1 text-left">
|
||||
{t('setting.custom-model')}
|
||||
</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
|
|
@ -510,82 +510,91 @@ export function ChatInputModelDropdown({
|
|||
ref={subContentCallbackRef}
|
||||
className="max-h-[440px] w-[220px] overflow-y-auto"
|
||||
>
|
||||
{items.map((item, idx) => {
|
||||
const isSubscriptionAuth = item.authMode === 'oauth_subscription';
|
||||
const isConfigured = isSubscriptionAuth
|
||||
? codexStatus.connected
|
||||
: !!form[idx]?.provider_id;
|
||||
const isPreferred = isSubscriptionAuth
|
||||
? modelType === 'codex_subscription'
|
||||
: form[idx]?.prefer;
|
||||
const modelImage = getModelImage(item.id);
|
||||
{items
|
||||
.map((item, idx) => ({ item, idx }))
|
||||
.sort((a, b) => {
|
||||
// Subscription (OAuth) providers first, original order otherwise.
|
||||
const aSub = a.item.authMode === 'oauth_subscription' ? 0 : 1;
|
||||
const bSub = b.item.authMode === 'oauth_subscription' ? 0 : 1;
|
||||
return aSub - bSub;
|
||||
})
|
||||
.map(({ item, idx }) => {
|
||||
const isSubscriptionAuth =
|
||||
item.authMode === 'oauth_subscription';
|
||||
const isConfigured = isSubscriptionAuth
|
||||
? codexStatus.connected
|
||||
: !!form[idx]?.provider_id;
|
||||
const isPreferred = isSubscriptionAuth
|
||||
? modelType === 'codex_subscription'
|
||||
: form[idx]?.prefer;
|
||||
const modelImage = getModelImage(item.id);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onSelect={() => {
|
||||
if (isSubscriptionAuth) {
|
||||
if (isConfigured) {
|
||||
handleCodexSetDefault();
|
||||
} else {
|
||||
navigate(DEFAULT_MODEL_CONFIGURE_PATH);
|
||||
}
|
||||
return;
|
||||
}
|
||||
void handleDefaultModelSelect('custom', item.id);
|
||||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{modelImage ? (
|
||||
<img
|
||||
src={modelImage}
|
||||
alt={item.name}
|
||||
className="h-4 w-4"
|
||||
style={
|
||||
needsInvert(item.id)
|
||||
? { filter: 'invert(1)' }
|
||||
: undefined
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onSelect={() => {
|
||||
if (isSubscriptionAuth) {
|
||||
if (isConfigured) {
|
||||
handleCodexSetDefault();
|
||||
} else {
|
||||
navigate(DEFAULT_MODEL_CONFIGURE_PATH);
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Key className="h-3 w-3 text-ds-icon-neutral-muted-default" />
|
||||
)}
|
||||
<span
|
||||
className={`text-body-sm ${isConfigured ? 'text-ds-text-neutral-default-default' : 'text-ds-text-neutral-subtle-default'}`}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{!isConfigured && (
|
||||
<div className="h-2 w-2 rounded-full bg-ds-text-neutral-subtle-default opacity-10" />
|
||||
)}
|
||||
{isPreferred && (
|
||||
<Check className="h-4 w-4 text-ds-text-success-default-default" />
|
||||
)}
|
||||
{isConfigured && !isPreferred && (
|
||||
<div className="h-2 w-2 rounded-full bg-ds-text-success-default-default" />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
return;
|
||||
}
|
||||
void handleDefaultModelSelect('custom', item.id);
|
||||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="gap-2 flex items-center">
|
||||
{modelImage ? (
|
||||
<img
|
||||
src={modelImage}
|
||||
alt={item.name}
|
||||
className="h-4 w-4"
|
||||
style={
|
||||
needsInvert(item.id)
|
||||
? { filter: 'invert(1)' }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Key className="h-3 w-3 text-ds-icon-neutral-muted-default" />
|
||||
)}
|
||||
<span
|
||||
className={`text-body-sm ${isConfigured ? 'text-ds-text-neutral-default-default' : 'text-ds-text-neutral-subtle-default'}`}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="gap-1 flex items-center">
|
||||
{!isConfigured && (
|
||||
<div className="h-2 w-2 bg-ds-text-neutral-subtle-default rounded-full opacity-10" />
|
||||
)}
|
||||
{isPreferred && (
|
||||
<Check className="h-4 w-4 text-ds-text-success-default-default" />
|
||||
)}
|
||||
{isConfigured && !isPreferred && (
|
||||
<div className="h-2 w-2 bg-ds-text-success-default-default rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
className="flex w-full min-w-0 items-center justify-start gap-2 [&>svg:first-child]:!h-4 [&>svg:first-child]:!min-h-4 [&>svg:first-child]:!w-4 [&>svg:first-child]:!min-w-4"
|
||||
className="min-w-0 gap-2 [&>svg:first-child]:!h-4 [&>svg:first-child]:!min-h-4 [&>svg:first-child]:!w-4 [&>svg:first-child]:!min-w-4 flex w-full items-center justify-start"
|
||||
onPointerEnter={(e) => {
|
||||
activeSubTriggerRef.current = e.currentTarget;
|
||||
}}
|
||||
>
|
||||
<HardDrive
|
||||
className="shrink-0 text-ds-icon-neutral-default-default"
|
||||
className="text-ds-icon-neutral-default-default shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1 text-left text-body-sm">
|
||||
<span className="min-w-0 text-body-sm flex-1 text-left">
|
||||
{t('setting.local-model')}
|
||||
</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
|
|
@ -606,7 +615,7 @@ export function ChatInputModelDropdown({
|
|||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="gap-2 flex items-center">
|
||||
{modelImage ? (
|
||||
<img
|
||||
src={modelImage}
|
||||
|
|
@ -627,15 +636,15 @@ export function ChatInputModelDropdown({
|
|||
{model.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="gap-1 flex items-center">
|
||||
{!isConfigured && (
|
||||
<div className="h-2 w-2 rounded-full bg-ds-text-neutral-subtle-default opacity-10" />
|
||||
<div className="h-2 w-2 bg-ds-text-neutral-subtle-default rounded-full opacity-10" />
|
||||
)}
|
||||
{isPreferred && (
|
||||
<Check className="h-4 w-4 text-ds-text-success-default-default" />
|
||||
)}
|
||||
{isConfigured && !isPreferred && (
|
||||
<div className="h-2 w-2 rounded-full bg-ds-text-success-default-default" />
|
||||
<div className="h-2 w-2 bg-ds-text-success-default-default rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@
|
|||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { useHost } from '@/host';
|
||||
import { fileInfoFromPath } from '@/lib/fileInfo';
|
||||
import { usePageTabStore } from '@/store/pageTabStore';
|
||||
import { Check, Copy, FileText, ThumbsDown, ThumbsUp } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
|
@ -49,8 +50,7 @@ export function AgentMessageCard({
|
|||
attaches,
|
||||
deferredFooter,
|
||||
}: AgentMessageCardProps) {
|
||||
const host = useHost();
|
||||
const ipcRenderer = host?.ipcRenderer;
|
||||
const openFilePreview = usePageTabStore((s) => s.openFilePreview);
|
||||
const [markdownAndTypingComplete, setMarkdownAndTypingComplete] = useState(
|
||||
() => completedTypewriterByMessageId.has(id)
|
||||
);
|
||||
|
|
@ -129,7 +129,9 @@ export function AgentMessageCard({
|
|||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
ipcRenderer?.invoke('reveal-in-folder', file.filePath);
|
||||
openFilePreview(
|
||||
fileInfoFromPath(file.filePath, file.fileName)
|
||||
);
|
||||
}}
|
||||
key={'attache-' + file.fileName}
|
||||
className="gap-2 rounded-2xl border-ds-border-neutral-subtle-default bg-ds-bg-neutral-default-default py-1 pl-2 flex w-full cursor-pointer items-center border border-solid"
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@
|
|||
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import { useHost } from '@/host';
|
||||
import { fileInfoFromPath } from '@/lib/fileInfo';
|
||||
import { isHtmlDocument } from '@/lib/htmlFontStyles';
|
||||
import { escapeHtml } from '@/lib/richText';
|
||||
import { usePageTabStore } from '@/store/pageTabStore';
|
||||
import '@/style/markdown-styles.css';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { marked } from 'marked';
|
||||
|
|
@ -77,6 +79,7 @@ export const MarkDown = memo(
|
|||
}) => {
|
||||
const host = useHost();
|
||||
const electronAPI = host?.electronAPI;
|
||||
const openFilePreview = usePageTabStore((s) => s.openFilePreview);
|
||||
const [displayedContent, setDisplayedContent] = useState('');
|
||||
const [html, setHtml] = useState('');
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
|
|
@ -220,6 +223,44 @@ export const MarkDown = memo(
|
|||
}
|
||||
}
|
||||
|
||||
// Annotate links that point to local project files so clicking them
|
||||
// opens the inline file preview instead of navigating the renderer.
|
||||
// External links (http/mailto/anchors/etc.) are left untouched.
|
||||
const anchorRegex = /<a([^>]*?)href=["']([^"']+)["']([^>]*?)>/gi;
|
||||
for (const match of Array.from(rawHtml.matchAll(anchorRegex))) {
|
||||
const fullTag = match[0];
|
||||
const href = match[2];
|
||||
if (!href) continue;
|
||||
const lower = href.toLowerCase();
|
||||
const isExternalOrSpecial =
|
||||
lower.startsWith('http://') ||
|
||||
lower.startsWith('https://') ||
|
||||
lower.startsWith('mailto:') ||
|
||||
lower.startsWith('tel:') ||
|
||||
lower.startsWith('data:') ||
|
||||
lower.startsWith('javascript:') ||
|
||||
href.startsWith('#') ||
|
||||
href.includes('${');
|
||||
if (isExternalOrSpecial) continue;
|
||||
|
||||
let resolved = href;
|
||||
if (href.startsWith('file://')) {
|
||||
resolved = decodeURIComponent(href.replace(/^file:\/\//, ''));
|
||||
} else {
|
||||
const isRelative =
|
||||
!href.startsWith('/') && !/^[a-zA-Z]:[\\/]/.test(href);
|
||||
if (isRelative && contentBasePath) {
|
||||
resolved = resolveRelativePath(contentBasePath, href);
|
||||
}
|
||||
}
|
||||
|
||||
const newTag = fullTag.replace(
|
||||
/^<a/,
|
||||
`<a data-file-path="${resolved.replace(/"/g, '"')}"`
|
||||
);
|
||||
rawHtml = rawHtml.replace(fullTag, newTag);
|
||||
}
|
||||
|
||||
// Sanitize HTML — explicitly allow class so syntax-highlighted code
|
||||
// blocks keep their language-* className after sanitization.
|
||||
const sanitized = DOMPurify.sanitize(rawHtml, {
|
||||
|
|
@ -238,7 +279,7 @@ export const MarkDown = memo(
|
|||
useEffect(() => {
|
||||
if (!contentRef.current) return;
|
||||
|
||||
const handleImageClick = (e: MouseEvent) => {
|
||||
const handleContentClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === 'IMG' &&
|
||||
|
|
@ -246,16 +287,26 @@ export const MarkDown = memo(
|
|||
) {
|
||||
const src = (target as HTMLImageElement).src;
|
||||
setPreviewImage(src);
|
||||
return;
|
||||
}
|
||||
// Local file links open the inline preview instead of navigating.
|
||||
const anchor = target.closest('a[data-file-path]');
|
||||
if (anchor) {
|
||||
e.preventDefault();
|
||||
const filePath = anchor.getAttribute('data-file-path');
|
||||
if (filePath) {
|
||||
openFilePreview(fileInfoFromPath(filePath));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const div = contentRef.current;
|
||||
div.addEventListener('click', handleImageClick);
|
||||
div.addEventListener('click', handleContentClick);
|
||||
|
||||
return () => {
|
||||
div.removeEventListener('click', handleImageClick);
|
||||
div.removeEventListener('click', handleContentClick);
|
||||
};
|
||||
}, [html]);
|
||||
}, [html, openFilePreview]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@
|
|||
import { inferSessionModeFromTask } from '@/lib/sessionMode';
|
||||
import { VanillaChatStore } from '@/store/chatStore';
|
||||
import { usePageTabStore } from '@/store/pageTabStore';
|
||||
import { useProjectRuntimeStore } from '@/store/projectRuntimeStore';
|
||||
import { AgentStep, ChatTaskStatus, SessionMode } from '@/types/constants';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ChevronDown, FileText } from 'lucide-react';
|
||||
|
|
@ -47,26 +46,26 @@ const AgentResultCard: React.FC<{
|
|||
const label = agentName || 'Agent';
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden px-2">
|
||||
<div className="px-2 overflow-hidden">
|
||||
{/* Header (always visible) */}
|
||||
<button
|
||||
type="button"
|
||||
className="focus-visible:ring-ds-border-brand-default-focus/40 flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm font-semibold text-ds-text-neutral-default-default transition-colors hover:bg-ds-bg-neutral-default-hover focus-visible:outline-none focus-visible:ring-2 active:bg-ds-bg-neutral-default-active"
|
||||
className="focus-visible:ring-ds-border-brand-default-focus/40 gap-2 rounded-xl px-3 py-2 text-sm font-semibold text-ds-text-neutral-default-default hover:bg-ds-bg-neutral-default-hover active:bg-ds-bg-neutral-default-active flex w-full items-center text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
onClick={() => setIsOpen((v) => !v)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
aria-hidden
|
||||
className={`shrink-0 text-ds-icon-neutral-default-default transition-transform duration-200 ${isOpen ? 'rotate-180' : 'rotate-0'}`}
|
||||
className={`text-ds-icon-neutral-default-default shrink-0 transition-transform duration-200 ${isOpen ? 'rotate-180' : 'rotate-0'}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Collapsible body */}
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-200 ease-in-out ${isOpen ? 'max-h-[2000px] opacity-100' : 'max-h-0 opacity-0'}`}
|
||||
className={`ease-in-out overflow-hidden transition-all duration-200 ${isOpen ? 'max-h-[2000px] opacity-100' : 'max-h-0 opacity-0'}`}
|
||||
>
|
||||
<div className="border-t border-ds-border-neutral-default-default px-1 py-1">
|
||||
<div className="border-ds-border-neutral-default-default px-1 py-1 border-t">
|
||||
<AgentMessageCard
|
||||
id={id}
|
||||
content={content}
|
||||
|
|
@ -137,26 +136,14 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
|
|||
const chatState = chatStore.getState();
|
||||
|
||||
const activeTaskId = scopedTaskId ?? chatState.activeTaskId;
|
||||
const activeProjectId = useProjectRuntimeStore(
|
||||
(state) => state.activeProjectId
|
||||
);
|
||||
const setActiveWorkspaceTab = usePageTabStore(
|
||||
(state) => state.setActiveWorkspaceTab
|
||||
const openFilePreviewInPanel = usePageTabStore(
|
||||
(state) => state.openFilePreview
|
||||
);
|
||||
const openFilePreview = useCallback(
|
||||
(file: FileInfo) => {
|
||||
const state = chatStore.getState();
|
||||
const taskId = state.activeTaskId;
|
||||
if (!taskId) return;
|
||||
|
||||
state.setSelectedFile(taskId, file);
|
||||
state.setNuwFileNum(taskId, 0);
|
||||
state.setActiveWorkspace(taskId, 'documentWorkSpace');
|
||||
setActiveWorkspaceTab('inbox', {
|
||||
clearInboxForProjectId: activeProjectId,
|
||||
});
|
||||
openFilePreviewInPanel(file);
|
||||
},
|
||||
[activeProjectId, chatStore, setActiveWorkspaceTab]
|
||||
[openFilePreviewInPanel]
|
||||
);
|
||||
|
||||
// Subscribe to streaming decompose text separately for efficient updates
|
||||
|
|
@ -433,7 +420,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
|
|||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="flex flex-col gap-4"
|
||||
className="gap-4 flex flex-col"
|
||||
>
|
||||
<AgentMessageCard
|
||||
typewriter={shouldUseLiveAgentTypewriter(task, message.id)}
|
||||
|
|
@ -442,7 +429,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
|
|||
onTyping={() => {}}
|
||||
deferredFooter={
|
||||
message.fileList?.length ? (
|
||||
<div className="my-2 flex flex-wrap gap-2">
|
||||
<div className="my-2 gap-2 flex flex-wrap">
|
||||
{message.fileList.map(
|
||||
(file: any, fileIndex: number) => (
|
||||
<motion.div
|
||||
|
|
@ -453,14 +440,14 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
|
|||
onClick={() => {
|
||||
openFilePreview(file);
|
||||
}}
|
||||
className="flex w-[140px] cursor-pointer items-center gap-2 rounded-lg bg-ds-bg-neutral-default-default px-3 py-2 transition-colors hover:bg-ds-bg-neutral-default-hover"
|
||||
className="gap-2 rounded-lg bg-ds-bg-neutral-default-default px-3 py-2 hover:bg-ds-bg-neutral-default-hover flex w-[140px] cursor-pointer items-center transition-colors"
|
||||
>
|
||||
<FileText
|
||||
size={16}
|
||||
className="flex-shrink-0 text-ds-icon-neutral-default-default"
|
||||
className="text-ds-icon-neutral-default-default flex-shrink-0"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<div className="max-w-[100px] overflow-hidden text-ellipsis whitespace-nowrap text-body-sm font-bold text-ds-text-neutral-default-default">
|
||||
<div className="text-body-sm font-bold text-ds-text-neutral-default-default max-w-[100px] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{file.name.split('.')[0]}
|
||||
</div>
|
||||
<div className="text-label-xs font-medium text-ds-text-neutral-muted-default">
|
||||
|
|
@ -483,7 +470,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
|
|||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="flex flex-col gap-4"
|
||||
className="gap-4 flex flex-col"
|
||||
>
|
||||
<AgentMessageCard
|
||||
key={message.id}
|
||||
|
|
@ -518,7 +505,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
|
|||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="flex flex-col gap-4"
|
||||
className="gap-4 flex flex-col"
|
||||
>
|
||||
<AgentMessageCard
|
||||
key={message.id}
|
||||
|
|
@ -538,10 +525,10 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
|
|||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="flex flex-col gap-4"
|
||||
className="gap-4 flex flex-col"
|
||||
>
|
||||
{message.fileList && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="gap-2 flex flex-wrap">
|
||||
{message.fileList.map((file: any, fileIndex: number) => (
|
||||
<motion.div
|
||||
key={`file-${message.id}-${file.name}-${fileIndex}`}
|
||||
|
|
@ -551,14 +538,14 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
|
|||
onClick={() => {
|
||||
openFilePreview(file);
|
||||
}}
|
||||
className="flex w-[120px] cursor-pointer items-center gap-2 rounded-2xl bg-ds-bg-neutral-default-default px-2 py-1 transition-colors hover:bg-ds-bg-neutral-default-hover"
|
||||
className="gap-2 rounded-2xl bg-ds-bg-neutral-default-default px-2 py-1 hover:bg-ds-bg-neutral-default-hover flex w-[120px] cursor-pointer items-center transition-colors"
|
||||
>
|
||||
<FileText
|
||||
size={16}
|
||||
className="flex-shrink-0 text-ds-icon-neutral-default-default"
|
||||
className="text-ds-icon-neutral-default-default flex-shrink-0"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-body max-w-48 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-bold text-ds-text-neutral-default-default">
|
||||
<div className="text-body max-w-48 text-sm font-bold text-ds-text-neutral-default-default overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{file.name.split('.')[0]}
|
||||
</div>
|
||||
<div className="text-xs font-medium leading-29 text-ds-text-neutral-default-default">
|
||||
|
|
|
|||
293
src/components/Folder/FilePreview.tsx
Normal file
293
src/components/Folder/FilePreview.tsx
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useHost } from '@/host';
|
||||
import { FileText, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
downloadFromUrl,
|
||||
downloadOpenedFile,
|
||||
fetchRemoteFileAsDataUrl,
|
||||
FileViewerPanel,
|
||||
isAudioFile,
|
||||
isImageFile,
|
||||
isVideoFile,
|
||||
} from './index';
|
||||
|
||||
export interface FilePreviewProps {
|
||||
/** File to preview, or null to show the empty "select a file" placeholder. */
|
||||
file: FileInfo | null;
|
||||
/** Outer surface background class (project page uses default-default). */
|
||||
surfaceClassName?: string;
|
||||
/** Sibling project files, used by the HTML renderer to resolve local assets. */
|
||||
projectFiles?: FileInfo[];
|
||||
/** Close the preview column. */
|
||||
onClose?: () => void;
|
||||
/**
|
||||
* Navigate to the Context (Inbox) tab for the given file (or null to just open
|
||||
* the file list). Wired from the breadcrumb "Context" root and the empty state.
|
||||
*/
|
||||
onJumpToContext?: (file: FileInfo | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline file preview shown beside the chat content on the project page.
|
||||
* Owns its own content-loading state and reuses {@link FileViewerPanel} so it
|
||||
* renders markdown/PDF/docs/HTML/media identically to the Inbox/Folder tab.
|
||||
*/
|
||||
export function FilePreview({
|
||||
file,
|
||||
surfaceClassName = 'bg-ds-bg-neutral-default-default',
|
||||
projectFiles = [],
|
||||
onClose,
|
||||
onJumpToContext,
|
||||
}: FilePreviewProps) {
|
||||
const { t } = useTranslation();
|
||||
const host = useHost();
|
||||
const ipcRenderer = host?.ipcRenderer;
|
||||
|
||||
const [selectedFile, setSelectedFile] = useState<FileInfo | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isShowSourceCode, setIsShowSourceCode] = useState(false);
|
||||
|
||||
// Mirror of the Inbox/Folder loader (selectedFileChange): read content via the
|
||||
// electron host (or remote fetch) and stash it on the file for the viewer.
|
||||
const loadFileContent = useCallback(
|
||||
(target: FileInfo, showSource?: boolean) => {
|
||||
const isWebMode = !ipcRenderer?.invoke;
|
||||
|
||||
// Folders / archives are not previewable inline.
|
||||
if (target.isFolder || target.type === 'zip') {
|
||||
setSelectedFile(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFile(target);
|
||||
setLoading(true);
|
||||
|
||||
if (target.isRemote && target.path?.startsWith('http')) {
|
||||
if (isImageFile(target)) {
|
||||
void fetchRemoteFileAsDataUrl(target.path)
|
||||
.then((content) => setSelectedFile({ ...target, content }))
|
||||
.catch((error) => {
|
||||
console.error('Failed to load remote image:', error);
|
||||
setSelectedFile({ ...target });
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAudioFile(target) || isVideoFile(target)) {
|
||||
setSelectedFile({ ...target });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isWebMode && ipcRenderer) {
|
||||
ipcRenderer
|
||||
.invoke('open-file', target.type, target.path, showSource)
|
||||
.then((res: string) => {
|
||||
setSelectedFile({ ...target, content: res });
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error('open-file error:', error);
|
||||
setLoading(false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const resp = await fetch(target.path);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const contentType = resp.headers.get('content-type') || '';
|
||||
let content: string;
|
||||
if (
|
||||
target.type === 'pdf' ||
|
||||
contentType.includes('application/pdf')
|
||||
) {
|
||||
const blob = await resp.blob();
|
||||
content = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
} else {
|
||||
content = await resp.text();
|
||||
}
|
||||
setSelectedFile({ ...target, content });
|
||||
} catch (error) {
|
||||
console.error('Failed to load remote file:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
// PDF: use a data URL so the iframe can render it.
|
||||
if (target.type === 'pdf') {
|
||||
if (ipcRenderer) {
|
||||
ipcRenderer
|
||||
.invoke('read-file-dataurl', target.path)
|
||||
.then((dataUrl: string) => {
|
||||
setSelectedFile({ ...target, content: dataUrl });
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error('read-file-dataurl error:', error);
|
||||
setLoading(false);
|
||||
});
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Audio/video: loaders read the file:// source themselves.
|
||||
if (isAudioFile(target) || isVideoFile(target)) {
|
||||
setSelectedFile({ ...target });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ipcRenderer) {
|
||||
ipcRenderer
|
||||
.invoke('open-file', target.type, target.path, showSource)
|
||||
.then((res: string) => {
|
||||
setSelectedFile({ ...target, content: res });
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error('open-file error:', error);
|
||||
setLoading(false);
|
||||
});
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[ipcRenderer]
|
||||
);
|
||||
|
||||
// Reload whenever the previewed file changes. Reset the source-code toggle so
|
||||
// each new file opens in its rich view.
|
||||
useEffect(() => {
|
||||
setIsShowSourceCode(false);
|
||||
if (!file) {
|
||||
setSelectedFile(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
loadFileContent(file, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [file?.path, file?.isRemote, loadFileContent]);
|
||||
|
||||
// Breadcrumb is intentionally shallow: "Context > filename". The "Context"
|
||||
// root navigates to the Inbox/Context tab for this file.
|
||||
const contextLabel = t('layout.context-breadcrumb-root', {
|
||||
defaultValue: 'Context',
|
||||
});
|
||||
const breadcrumbSegments = useMemo(
|
||||
() => (selectedFile ? [contextLabel, selectedFile.name] : []),
|
||||
[selectedFile, contextLabel]
|
||||
);
|
||||
|
||||
const handleBreadcrumbSegmentClick = useCallback(
|
||||
(index: number) => {
|
||||
if (index === 0) {
|
||||
onJumpToContext?.(selectedFile);
|
||||
}
|
||||
},
|
||||
[onJumpToContext, selectedFile]
|
||||
);
|
||||
|
||||
const handleToggleSourceCode = useCallback(() => {
|
||||
if (!selectedFile) return;
|
||||
loadFileContent(selectedFile, !isShowSourceCode);
|
||||
setIsShowSourceCode((prev) => !prev);
|
||||
}, [selectedFile, isShowSourceCode, loadFileContent]);
|
||||
|
||||
const handleRevealFile = useCallback(() => {
|
||||
if (!selectedFile) return;
|
||||
if (selectedFile.isRemote) {
|
||||
void downloadFromUrl(selectedFile.path, selectedFile.name);
|
||||
return;
|
||||
}
|
||||
ipcRenderer?.invoke('reveal-in-folder', selectedFile.path);
|
||||
}, [selectedFile, ipcRenderer]);
|
||||
|
||||
const handleDownloadFile = useCallback(() => {
|
||||
if (!selectedFile || selectedFile.isFolder) return;
|
||||
void downloadOpenedFile(selectedFile);
|
||||
}, [selectedFile]);
|
||||
|
||||
return (
|
||||
<FileViewerPanel
|
||||
selectedFile={selectedFile}
|
||||
loading={loading}
|
||||
isShowSourceCode={isShowSourceCode}
|
||||
breadcrumbSegments={breadcrumbSegments}
|
||||
onBreadcrumbSegmentClick={
|
||||
onJumpToContext ? handleBreadcrumbSegmentClick : undefined
|
||||
}
|
||||
projectFiles={projectFiles}
|
||||
surfaceClassName={surfaceClassName}
|
||||
onRevealFile={handleRevealFile}
|
||||
onDownloadFile={handleDownloadFile}
|
||||
onToggleSourceCode={handleToggleSourceCode}
|
||||
emptyState={
|
||||
<div className="gap-3 px-6 text-ds-text-neutral-muted-default flex h-full w-full flex-1 flex-col items-center justify-center text-center">
|
||||
<FileText className="h-12 w-12 text-ds-icon-neutral-muted-default" />
|
||||
<p className="text-sm">
|
||||
{t('chat.no-file-selected', {
|
||||
defaultValue: 'No file selected.',
|
||||
})}
|
||||
</p>
|
||||
{onJumpToContext ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onJumpToContext(null)}
|
||||
>
|
||||
{t('layout.jump-to-context-files', {
|
||||
defaultValue: 'See all files in your workspace',
|
||||
})}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
headerActionsExtra={
|
||||
onClose ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t('common.close', { defaultValue: 'Close' })}
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-4 w-4 text-ds-icon-neutral-muted-default" />
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilePreview;
|
||||
|
|
@ -262,13 +262,13 @@ function treeSegmentLabel(value?: string | null, fallback = 'Project') {
|
|||
return (trimmed || fallback).replace(/[\\/]/g, '-');
|
||||
}
|
||||
|
||||
function isImageFile(file: FileTypeTarget) {
|
||||
export function isImageFile(file: FileTypeTarget) {
|
||||
return IMAGE_EXTENSIONS.includes(getFileType(file));
|
||||
}
|
||||
function isAudioFile(file: FileTypeTarget) {
|
||||
export function isAudioFile(file: FileTypeTarget) {
|
||||
return AUDIO_EXTENSIONS.includes(getFileType(file));
|
||||
}
|
||||
function isVideoFile(file: FileTypeTarget) {
|
||||
export function isVideoFile(file: FileTypeTarget) {
|
||||
return VIDEO_EXTENSIONS.includes(getFileType(file));
|
||||
}
|
||||
|
||||
|
|
@ -399,7 +399,7 @@ function getComparableRelativePath(file?: FileInfo | null): string {
|
|||
return getNormalizedTreeRelativePath(file).toLowerCase();
|
||||
}
|
||||
|
||||
function isSameFileIdentity(
|
||||
export function isSameFileIdentity(
|
||||
left?: FileInfo | null,
|
||||
right?: FileInfo | null
|
||||
): boolean {
|
||||
|
|
@ -410,7 +410,104 @@ function isSameFileIdentity(
|
|||
return left.path === right.path;
|
||||
}
|
||||
|
||||
function findMatchingFile(
|
||||
/** Build a nested {@link FileTreeNode} tree from a flat file list. */
|
||||
export function buildFileTree(files: FileInfo[]): FileTreeNode {
|
||||
const root: FileTreeNode = {
|
||||
name: 'root',
|
||||
path: '',
|
||||
children: [],
|
||||
isFolder: true,
|
||||
};
|
||||
|
||||
const folderMap = new Map<string, FileTreeNode>();
|
||||
folderMap.set('', root);
|
||||
|
||||
const ensureFolderNode = (segments: string[]): FileTreeNode => {
|
||||
let parentNode = root;
|
||||
let currentFolderPath = '';
|
||||
|
||||
for (const segment of segments) {
|
||||
currentFolderPath = currentFolderPath
|
||||
? `${currentFolderPath}/${segment}`
|
||||
: segment;
|
||||
|
||||
let folderNode = folderMap.get(currentFolderPath);
|
||||
if (!folderNode) {
|
||||
folderNode = {
|
||||
name: segment,
|
||||
path: currentFolderPath,
|
||||
isFolder: true,
|
||||
children: [],
|
||||
relativePath: currentFolderPath,
|
||||
};
|
||||
parentNode.children!.push(folderNode);
|
||||
folderMap.set(currentFolderPath, folderNode);
|
||||
}
|
||||
|
||||
parentNode = folderNode;
|
||||
}
|
||||
|
||||
return parentNode;
|
||||
};
|
||||
|
||||
const sortedFiles = [...files].sort((left, right) => {
|
||||
const leftRelativePath = getNormalizedTreeRelativePath(left);
|
||||
const rightRelativePath = getNormalizedTreeRelativePath(right);
|
||||
const leftDepth = leftRelativePath.split('/').filter(Boolean).length;
|
||||
const rightDepth = rightRelativePath.split('/').filter(Boolean).length;
|
||||
|
||||
if (leftDepth !== rightDepth) {
|
||||
return leftDepth - rightDepth;
|
||||
}
|
||||
|
||||
return leftRelativePath.localeCompare(rightRelativePath);
|
||||
});
|
||||
|
||||
for (const file of sortedFiles) {
|
||||
const normalizedRelativePath = getNormalizedTreeRelativePath(file);
|
||||
const pathSegments = normalizedRelativePath.split('/').filter(Boolean);
|
||||
if (!pathSegments.length) continue;
|
||||
|
||||
if (file.isFolder) {
|
||||
ensureFolderNode(pathSegments);
|
||||
continue;
|
||||
}
|
||||
|
||||
const folderSegments = pathSegments.slice(0, -1);
|
||||
const fileName = pathSegments[pathSegments.length - 1] || file.name;
|
||||
const parentNode = ensureFolderNode(folderSegments);
|
||||
|
||||
parentNode.children!.push({
|
||||
name: fileName || file.name,
|
||||
path: file.path,
|
||||
type: file.type,
|
||||
projectId: file.projectId,
|
||||
isFolder: file.isFolder,
|
||||
icon: file.icon,
|
||||
children: file.isFolder ? [] : undefined,
|
||||
isRemote: file.isRemote,
|
||||
relativePath: file.relativePath,
|
||||
});
|
||||
}
|
||||
|
||||
const sortTree = (node: FileTreeNode) => {
|
||||
if (!node.children?.length) return;
|
||||
|
||||
node.children.sort((left, right) => {
|
||||
if (!!left.isFolder !== !!right.isFolder) {
|
||||
return left.isFolder ? -1 : 1;
|
||||
}
|
||||
return left.name.localeCompare(right.name);
|
||||
});
|
||||
|
||||
node.children.forEach(sortTree);
|
||||
};
|
||||
|
||||
sortTree(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
export function findMatchingFile(
|
||||
files: FileInfo[],
|
||||
target?: FileInfo | null
|
||||
): FileInfo | undefined {
|
||||
|
|
@ -452,7 +549,7 @@ function getAncestorFolderPathsForFile(file?: FileInfo | null): string[] {
|
|||
}
|
||||
|
||||
/** Breadcrumb: project root label → parent folders (from `relativePath`) → file name. */
|
||||
function getFileBreadcrumbSegments(
|
||||
export function getFileBreadcrumbSegments(
|
||||
file: FileInfo,
|
||||
options: {
|
||||
projectRootLabel: string;
|
||||
|
|
@ -530,14 +627,14 @@ export const FileTree: React.FC<FileTreeProps> = ({
|
|||
onSelectFile(fileInfo);
|
||||
}
|
||||
}}
|
||||
className={`mb-1 flex w-full min-w-0 flex-row items-center justify-start gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-ds-bg-neutral-subtle-hover ${
|
||||
className={`mb-1 min-w-0 gap-2 rounded-lg px-2 py-1.5 hover:bg-ds-bg-neutral-subtle-hover flex w-full flex-row items-center justify-start text-left transition-colors ${
|
||||
isRowSelected
|
||||
? 'bg-ds-bg-neutral-default-default text-ds-text-neutral-default-default'
|
||||
: 'bg-transparent text-ds-text-neutral-muted-default'
|
||||
: 'text-ds-text-neutral-muted-default bg-transparent'
|
||||
}`}
|
||||
>
|
||||
{child.isFolder ? (
|
||||
<span className="inline-flex w-4 shrink-0 items-center justify-start">
|
||||
<span className="w-4 inline-flex shrink-0 items-center justify-start">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className={rowIconClass} />
|
||||
) : (
|
||||
|
|
@ -556,13 +653,13 @@ export const FileTree: React.FC<FileTreeProps> = ({
|
|||
)
|
||||
)}
|
||||
|
||||
<span className="min-w-0 flex-1 truncate text-left text-body-sm font-medium leading-normal">
|
||||
<span className="min-w-0 text-body-sm font-medium leading-normal flex-1 truncate text-left">
|
||||
{child.name}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{hasNested ? (
|
||||
<div className="ml-4 border-y-0 border-l border-r-0 border-solid border-ds-border-neutral-subtle-default pl-1">
|
||||
<div className="ml-4 border-ds-border-neutral-subtle-default pl-1 border-y-0 border-r-0 border-l border-solid">
|
||||
<FileTree
|
||||
node={child}
|
||||
level={level + 1}
|
||||
|
|
@ -674,7 +771,7 @@ async function blobFromDataUrl(dataUrl: string): Promise<Blob> {
|
|||
}
|
||||
|
||||
/** Web-only: fetch URL or path (same-origin relative) and save with the given name. */
|
||||
async function downloadFromUrl(
|
||||
export async function downloadFromUrl(
|
||||
url: string | undefined,
|
||||
suggestedFilename: string
|
||||
): Promise<void> {
|
||||
|
|
@ -732,7 +829,7 @@ async function downloadFromUrl(
|
|||
}
|
||||
|
||||
/** Web-first download for the file viewer: prefers in-memory content, then fetchable URL. */
|
||||
async function downloadOpenedFile(file: FileInfo): Promise<void> {
|
||||
export async function downloadOpenedFile(file: FileInfo): Promise<void> {
|
||||
if (file.isFolder || (!file.path && file.content === undefined)) return;
|
||||
|
||||
const filename = file.name || 'download';
|
||||
|
|
@ -1008,102 +1105,6 @@ export default function Folder({ data: _data }: { data?: Agent }) {
|
|||
setIsShowSourceCode(!isShowSourceCode);
|
||||
};
|
||||
|
||||
const buildFileTree = (files: FileInfo[]): FileTreeNode => {
|
||||
const root: FileTreeNode = {
|
||||
name: 'root',
|
||||
path: '',
|
||||
children: [],
|
||||
isFolder: true,
|
||||
};
|
||||
|
||||
const folderMap = new Map<string, FileTreeNode>();
|
||||
folderMap.set('', root);
|
||||
|
||||
const ensureFolderNode = (segments: string[]): FileTreeNode => {
|
||||
let parentNode = root;
|
||||
let currentFolderPath = '';
|
||||
|
||||
for (const segment of segments) {
|
||||
currentFolderPath = currentFolderPath
|
||||
? `${currentFolderPath}/${segment}`
|
||||
: segment;
|
||||
|
||||
let folderNode = folderMap.get(currentFolderPath);
|
||||
if (!folderNode) {
|
||||
folderNode = {
|
||||
name: segment,
|
||||
path: currentFolderPath,
|
||||
isFolder: true,
|
||||
children: [],
|
||||
relativePath: currentFolderPath,
|
||||
};
|
||||
parentNode.children!.push(folderNode);
|
||||
folderMap.set(currentFolderPath, folderNode);
|
||||
}
|
||||
|
||||
parentNode = folderNode;
|
||||
}
|
||||
|
||||
return parentNode;
|
||||
};
|
||||
|
||||
const sortedFiles = [...files].sort((left, right) => {
|
||||
const leftRelativePath = getNormalizedTreeRelativePath(left);
|
||||
const rightRelativePath = getNormalizedTreeRelativePath(right);
|
||||
const leftDepth = leftRelativePath.split('/').filter(Boolean).length;
|
||||
const rightDepth = rightRelativePath.split('/').filter(Boolean).length;
|
||||
|
||||
if (leftDepth !== rightDepth) {
|
||||
return leftDepth - rightDepth;
|
||||
}
|
||||
|
||||
return leftRelativePath.localeCompare(rightRelativePath);
|
||||
});
|
||||
|
||||
for (const file of sortedFiles) {
|
||||
const normalizedRelativePath = getNormalizedTreeRelativePath(file);
|
||||
const pathSegments = normalizedRelativePath.split('/').filter(Boolean);
|
||||
if (!pathSegments.length) continue;
|
||||
|
||||
if (file.isFolder) {
|
||||
ensureFolderNode(pathSegments);
|
||||
continue;
|
||||
}
|
||||
|
||||
const folderSegments = pathSegments.slice(0, -1);
|
||||
const fileName = pathSegments[pathSegments.length - 1] || file.name;
|
||||
const parentNode = ensureFolderNode(folderSegments);
|
||||
|
||||
parentNode.children!.push({
|
||||
name: fileName || file.name,
|
||||
path: file.path,
|
||||
type: file.type,
|
||||
projectId: file.projectId,
|
||||
isFolder: file.isFolder,
|
||||
icon: file.icon,
|
||||
children: file.isFolder ? [] : undefined,
|
||||
isRemote: file.isRemote,
|
||||
relativePath: file.relativePath,
|
||||
});
|
||||
}
|
||||
|
||||
const sortTree = (node: FileTreeNode) => {
|
||||
if (!node.children?.length) return;
|
||||
|
||||
node.children.sort((left, right) => {
|
||||
if (!!left.isFolder !== !!right.isFolder) {
|
||||
return left.isFolder ? -1 : 1;
|
||||
}
|
||||
return left.name.localeCompare(right.name);
|
||||
});
|
||||
|
||||
node.children.forEach(sortTree);
|
||||
};
|
||||
|
||||
sortTree(root);
|
||||
return root;
|
||||
};
|
||||
|
||||
const toggleFolder = (folderPath: string) => {
|
||||
setExpandedFolders((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
|
|
@ -1598,15 +1599,15 @@ export default function Folder({ data: _data }: { data?: Agent }) {
|
|||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
{/* header */}
|
||||
<div className="border-b-1 flex w-full shrink-0 items-center gap-2 border-x-0 border-t-0 border-solid border-ds-border-neutral-subtle-default p-2">
|
||||
<div className="flex min-w-0 max-w-[min(20rem,45%)] items-center">
|
||||
<div className="gap-2 border-ds-border-neutral-subtle-default p-2 flex w-full shrink-0 items-center border-x-0 border-t-0 border-b-1 border-solid">
|
||||
<div className="min-w-0 flex max-w-[min(20rem,45%)] items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
buttonContent="icon-only"
|
||||
aria-pressed={isFileSidebarOpen}
|
||||
className="shrink-0 text-ds-icon-neutral-default-default"
|
||||
className="text-ds-icon-neutral-default-default shrink-0"
|
||||
aria-label={
|
||||
isFileSidebarOpen
|
||||
? t('chat.hide-file-sidebar', {
|
||||
|
|
@ -1634,21 +1635,21 @@ export default function Folder({ data: _data }: { data?: Agent }) {
|
|||
)}
|
||||
</Button>
|
||||
<span
|
||||
className="min-w-0 truncate text-body-sm font-semibold leading-none text-ds-text-neutral-default-default"
|
||||
className="min-w-0 text-body-sm font-semibold text-ds-text-neutral-default-default truncate leading-none"
|
||||
title={folderHeaderTitle}
|
||||
>
|
||||
{folderHeaderTitle}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-auto flex min-w-0 items-center gap-2">
|
||||
<div className="relative h-7 w-32 min-w-[10rem] max-w-xs shrink-0 rounded-lg">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ds-text-brand-default-default" />
|
||||
<div className="min-w-0 gap-2 ml-auto flex items-center">
|
||||
<div className="h-7 w-32 max-w-xs rounded-lg relative min-w-[10rem] shrink-0">
|
||||
<Search className="left-2 h-3.5 w-3.5 text-ds-text-brand-default-default pointer-events-none absolute top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={fileSearchQuery}
|
||||
onChange={(e) => setFileSearchQuery(e.target.value)}
|
||||
placeholder={t('chat.search')}
|
||||
className="h-7 w-full rounded-lg border border-solid border-ds-border-neutral-subtle-default py-0 pl-7 pr-2 text-sm leading-none focus:outline-none focus:ring-2 focus:ring-ds-ring-brand-default-focus focus:ring-offset-0"
|
||||
className="h-7 rounded-lg border-ds-border-neutral-subtle-default py-0 pl-7 pr-2 text-sm focus:ring-ds-ring-brand-default-focus w-full border border-solid leading-none focus:ring-2 focus:ring-offset-0 focus:outline-none"
|
||||
aria-label={t('chat.search')}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -1669,18 +1670,18 @@ export default function Folder({ data: _data }: { data?: Agent }) {
|
|||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="z-50 border-ds-border-neutral-default-default bg-ds-bg-neutral-strong-default"
|
||||
className="border-ds-border-neutral-default-default bg-ds-bg-neutral-strong-default z-50"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleOpenInIDE('system')}
|
||||
className="cursor-pointer bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover"
|
||||
className="bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover cursor-pointer"
|
||||
>
|
||||
<FolderIcon className="size-4 shrink-0" aria-hidden />
|
||||
{t('chat.open-in-file-manager')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleOpenInIDE('cursor')}
|
||||
className="cursor-pointer bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover"
|
||||
className="bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover cursor-pointer"
|
||||
>
|
||||
<img
|
||||
src={cursorIcon}
|
||||
|
|
@ -1692,7 +1693,7 @@ export default function Folder({ data: _data }: { data?: Agent }) {
|
|||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleOpenInIDE('vscode')}
|
||||
className="cursor-pointer bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover"
|
||||
className="bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover cursor-pointer"
|
||||
>
|
||||
<img
|
||||
src={vsCodeIcon}
|
||||
|
|
@ -1708,11 +1709,11 @@ export default function Folder({ data: _data }: { data?: Agent }) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<div className="min-h-0 flex flex-1 overflow-hidden">
|
||||
{/* sidebar */}
|
||||
{isFileSidebarOpen ? (
|
||||
<div className="flex h-full w-64 flex-shrink-0 flex-col border-y-0 border-l-0 border-r border-solid border-ds-border-neutral-subtle-default">
|
||||
<div className="flex h-8 items-center px-1">
|
||||
<div className="w-64 border-ds-border-neutral-subtle-default flex h-full flex-shrink-0 flex-col border-y-0 border-r border-l-0 border-solid">
|
||||
<div className="h-8 px-1 flex items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
|
|
@ -1721,7 +1722,7 @@ export default function Folder({ data: _data }: { data?: Agent }) {
|
|||
size="sm"
|
||||
buttonContent="text"
|
||||
>
|
||||
<span className="min-w-0 truncate text-left font-bold">
|
||||
<span className="min-w-0 font-bold truncate text-left">
|
||||
{t('chat.files')}
|
||||
</span>
|
||||
<ChevronDown className="size-3.5 shrink-0 opacity-70" />
|
||||
|
|
@ -1730,7 +1731,7 @@ export default function Folder({ data: _data }: { data?: Agent }) {
|
|||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="z-50 min-w-[10rem] border-ds-border-neutral-default-default bg-ds-bg-neutral-strong-default"
|
||||
className="border-ds-border-neutral-default-default bg-ds-bg-neutral-strong-default z-50 min-w-[10rem]"
|
||||
>
|
||||
<DropdownMenuRadioGroup
|
||||
value={fileTreeScope}
|
||||
|
|
@ -1740,7 +1741,7 @@ export default function Folder({ data: _data }: { data?: Agent }) {
|
|||
>
|
||||
<DropdownMenuRadioItem
|
||||
value="all"
|
||||
className="cursor-pointer bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover"
|
||||
className="bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover cursor-pointer"
|
||||
>
|
||||
{t('folder.files-scope-all', {
|
||||
defaultValue: 'All files',
|
||||
|
|
@ -1748,7 +1749,7 @@ export default function Folder({ data: _data }: { data?: Agent }) {
|
|||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem
|
||||
value="new"
|
||||
className="cursor-pointer bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover"
|
||||
className="bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover cursor-pointer"
|
||||
>
|
||||
{t('folder.files-scope-new', {
|
||||
defaultValue: 'New files',
|
||||
|
|
@ -1759,7 +1760,7 @@ export default function Folder({ data: _data }: { data?: Agent }) {
|
|||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="scrollbar-always-visible min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="h-full pl-1.5">
|
||||
<div className="pl-1.5 h-full">
|
||||
<FileTree
|
||||
node={sidebarFileTree}
|
||||
selectedFile={selectedFile}
|
||||
|
|
@ -1776,175 +1777,28 @@ export default function Folder({ data: _data }: { data?: Agent }) {
|
|||
) : null}
|
||||
|
||||
{/* content */}
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden bg-ds-bg-neutral-subtle-default">
|
||||
{/* head */}
|
||||
{selectedFile && (
|
||||
<div className="flex h-8 flex-shrink-0 items-center justify-between gap-2 pl-3 pr-2">
|
||||
<div
|
||||
onClick={() => {
|
||||
// if file is remote, don't call reveal-in-folder
|
||||
if (selectedFile.isRemote) {
|
||||
void downloadFromUrl(selectedFile.path, selectedFile.name);
|
||||
return;
|
||||
}
|
||||
ipcRenderer?.invoke('reveal-in-folder', selectedFile.path);
|
||||
}}
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center overflow-hidden"
|
||||
>
|
||||
<nav
|
||||
className="scrollbar-always-visible flex min-w-0 max-w-full items-center gap-1 overflow-x-auto text-body-sm text-ds-text-neutral-muted-default"
|
||||
aria-label={t('folder.file-path-breadcrumb', {
|
||||
defaultValue: 'File path',
|
||||
})}
|
||||
>
|
||||
{fileBreadcrumbSegments.map((segment, index) => {
|
||||
const isLast = index === fileBreadcrumbSegments.length - 1;
|
||||
return (
|
||||
<Fragment key={`${index}-${segment}`}>
|
||||
{index > 0 ? (
|
||||
<ChevronRight
|
||||
className="h-3.5 w-3.5 shrink-0 text-ds-icon-neutral-muted-default"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
className={
|
||||
isLast
|
||||
? 'shrink-0 font-bold text-ds-text-neutral-default-default'
|
||||
: 'shrink-0 font-normal'
|
||||
}
|
||||
>
|
||||
{segment}
|
||||
</span>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-0.5">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
aria-label={t('folder.download-file', {
|
||||
defaultValue: 'Download file',
|
||||
})}
|
||||
onClick={() => {
|
||||
if (!selectedFile || selectedFile.isFolder) return;
|
||||
void downloadOpenedFile(selectedFile);
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4 text-ds-icon-neutral-muted-default" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => isShowSourceCodeChange()}
|
||||
>
|
||||
<CodeXml className="h-4 w-4 text-ds-icon-neutral-muted-default" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* content */}
|
||||
<div
|
||||
className={`flex min-h-0 flex-1 flex-col ${
|
||||
selectedFile?.type === 'html' && !isShowSourceCode
|
||||
? 'overflow-hidden'
|
||||
: 'scrollbar-always-visible overflow-y-auto'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex flex-col ${
|
||||
selectedFile?.type === 'html' && !isShowSourceCode
|
||||
? 'h-full min-h-0'
|
||||
: 'min-h-full py-2 pl-4 pr-2'
|
||||
} file-viewer-content`}
|
||||
>
|
||||
{selectedFile ? (
|
||||
!loading ? (
|
||||
selectedFile.type === 'md' && !isShowSourceCode ? (
|
||||
<div className="prose prose-sm max-w-none">
|
||||
<MarkDown
|
||||
content={selectedFile.content || ''}
|
||||
enableTypewriter={false}
|
||||
contentBasePath={
|
||||
selectedFile.isRemote
|
||||
? null
|
||||
: getDirPath(selectedFile.path)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : selectedFile.type === 'pdf' ? (
|
||||
<iframe
|
||||
src={selectedFile.content as string}
|
||||
className="h-full w-full border-0"
|
||||
title={selectedFile.name}
|
||||
/>
|
||||
) : ['csv', 'doc', 'docx', 'pptx', 'xlsx'].includes(
|
||||
selectedFile.type
|
||||
) ? (
|
||||
<FolderComponent selectedFile={selectedFile} />
|
||||
) : selectedFile.type === 'html' ? (
|
||||
isShowSourceCode ? (
|
||||
<>{selectedFile.content}</>
|
||||
) : (
|
||||
<HtmlRenderer
|
||||
selectedFile={selectedFile}
|
||||
projectFiles={fileGroups[0]?.files || []}
|
||||
/>
|
||||
)
|
||||
) : selectedFile.type === 'zip' ? (
|
||||
<div className="flex h-full w-full items-center justify-center text-ds-text-neutral-muted-default">
|
||||
<div className="text-center">
|
||||
<FileText className="mx-auto mb-4 h-12 w-12 text-ds-text-neutral-muted-default" />
|
||||
<p className="text-sm">
|
||||
{t('folder.zip-file-is-not-supported-yet')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : isAudioFile(selectedFile) ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<AudioLoader selectedFile={selectedFile} />
|
||||
</div>
|
||||
) : isVideoFile(selectedFile) ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<VideoLoader selectedFile={selectedFile} />
|
||||
</div>
|
||||
) : isImageFile(selectedFile) ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<ImageLoader selectedFile={selectedFile} />
|
||||
</div>
|
||||
) : (
|
||||
<pre className="overflow-auto whitespace-pre-wrap break-words font-mono text-sm text-ds-text-neutral-default-default">
|
||||
{selectedFile.content}
|
||||
</pre>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full"></div>
|
||||
<p className="text-body-sm text-ds-text-neutral-muted-default">
|
||||
{t('chat.loading')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-1 items-center justify-center text-ds-text-neutral-muted-default">
|
||||
<div className="text-center">
|
||||
<FileText className="mx-auto mb-4 h-12 w-12 text-ds-text-neutral-muted-default" />
|
||||
<p className="text-sm">
|
||||
{t('chat.select-a-file-to-view-its-contents')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FileViewerPanel
|
||||
selectedFile={selectedFile}
|
||||
loading={loading}
|
||||
isShowSourceCode={isShowSourceCode}
|
||||
breadcrumbSegments={fileBreadcrumbSegments}
|
||||
projectFiles={fileGroups[0]?.files || []}
|
||||
surfaceClassName="bg-ds-bg-neutral-subtle-default"
|
||||
onRevealFile={() => {
|
||||
if (!selectedFile) return;
|
||||
// if file is remote, don't call reveal-in-folder
|
||||
if (selectedFile.isRemote) {
|
||||
void downloadFromUrl(selectedFile.path, selectedFile.name);
|
||||
return;
|
||||
}
|
||||
ipcRenderer?.invoke('reveal-in-folder', selectedFile.path);
|
||||
}}
|
||||
onDownloadFile={() => {
|
||||
if (!selectedFile || selectedFile.isFolder) return;
|
||||
void downloadOpenedFile(selectedFile);
|
||||
}}
|
||||
onToggleSourceCode={() => isShowSourceCodeChange()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -2029,7 +1883,7 @@ function ImageLoader({ selectedFile }: { selectedFile: FileInfo }) {
|
|||
if (!src) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto h-8 w-8 animate-spin rounded-full" />
|
||||
<div className="h-8 w-8 animate-spin mx-auto rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2058,7 +1912,7 @@ function AudioLoader({ selectedFile }: { selectedFile: FileInfo }) {
|
|||
}, [selectedFile]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center gap-4 px-8">
|
||||
<div className="gap-4 px-8 flex w-full flex-col items-center">
|
||||
<p className="text-sm font-medium text-ds-text-neutral-default-default">
|
||||
{selectedFile.name}
|
||||
</p>
|
||||
|
|
@ -2393,7 +2247,7 @@ function readBlobAsDataUrl(blob: Blob): Promise<string> {
|
|||
});
|
||||
}
|
||||
|
||||
async function fetchRemoteFileAsDataUrl(url: string): Promise<string> {
|
||||
export async function fetchRemoteFileAsDataUrl(url: string): Promise<string> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
|
|
@ -2948,7 +2802,7 @@ function HtmlRenderer({
|
|||
if (selectedFile.content && !processedHtml) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full" />
|
||||
<div className="mb-4 h-8 w-8 animate-spin mx-auto rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2965,7 +2819,7 @@ function HtmlRenderer({
|
|||
|
||||
{/* Content area with zoom */}
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-hidden bg-code-surface"
|
||||
className="min-h-0 bg-code-surface flex-1 overflow-hidden"
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<div
|
||||
|
|
@ -2991,3 +2845,237 @@ function HtmlRenderer({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface FileViewerPanelProps {
|
||||
/** File whose content is shown, or null for the empty placeholder. */
|
||||
selectedFile: FileInfo | null;
|
||||
/** Whether content is currently being fetched. */
|
||||
loading: boolean;
|
||||
/** Render raw source instead of the rich view (md/html). */
|
||||
isShowSourceCode: boolean;
|
||||
/** Breadcrumb labels for the file path header. */
|
||||
breadcrumbSegments: string[];
|
||||
/** Sibling project files, used by the HTML renderer to resolve local assets. */
|
||||
projectFiles: FileInfo[];
|
||||
/** Outer surface background class. */
|
||||
surfaceClassName?: string;
|
||||
/** Clicking the breadcrumb (reveal in folder / download remote). Ignored when
|
||||
* {@link onBreadcrumbSegmentClick} is provided (segments become individually
|
||||
* clickable instead). */
|
||||
onRevealFile: () => void;
|
||||
/** When set, breadcrumb segments are individually clickable (e.g. a "Context"
|
||||
* root that navigates elsewhere). Receives the clicked segment index. */
|
||||
onBreadcrumbSegmentClick?: (index: number) => void;
|
||||
/** Download button. */
|
||||
onDownloadFile: () => void;
|
||||
/** Toggle the source-code view. */
|
||||
onToggleSourceCode: () => void;
|
||||
/** Extra controls rendered at the end of the header row (e.g. a close button). */
|
||||
headerActionsExtra?: React.ReactNode;
|
||||
/** Replaces the default placeholder shown when no file is selected. */
|
||||
emptyState?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Presentational file viewer: breadcrumb header + type-aware content body.
|
||||
* Shared by the Inbox/Folder tab and the inline project-page preview so both
|
||||
* render markdown/PDF/docs/HTML/media identically. All data and callbacks are
|
||||
* supplied by the parent — this component owns no loading state.
|
||||
*/
|
||||
export function FileViewerPanel({
|
||||
selectedFile,
|
||||
loading,
|
||||
isShowSourceCode,
|
||||
breadcrumbSegments,
|
||||
projectFiles,
|
||||
surfaceClassName = 'bg-ds-bg-neutral-subtle-default',
|
||||
onRevealFile,
|
||||
onBreadcrumbSegmentClick,
|
||||
onDownloadFile,
|
||||
onToggleSourceCode,
|
||||
headerActionsExtra,
|
||||
emptyState,
|
||||
}: FileViewerPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const segmentsClickable = Boolean(onBreadcrumbSegmentClick);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-0 mb-sm rounded-xl flex flex-1 flex-col overflow-hidden ${surfaceClassName}`}
|
||||
>
|
||||
{/* head */}
|
||||
{selectedFile && (
|
||||
<div className="py-2 gap-2 pl-3 pr-2 flex flex-shrink-0 items-center justify-between">
|
||||
<div
|
||||
onClick={segmentsClickable ? undefined : onRevealFile}
|
||||
className={`min-w-0 flex flex-1 items-center overflow-hidden ${
|
||||
segmentsClickable ? '' : 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<nav
|
||||
className="scrollbar-always-visible min-w-0 gap-1 text-body-sm text-ds-text-neutral-muted-default flex max-w-full items-center overflow-x-auto"
|
||||
aria-label={t('folder.file-path-breadcrumb', {
|
||||
defaultValue: 'File path',
|
||||
})}
|
||||
>
|
||||
{breadcrumbSegments.map((segment, index) => {
|
||||
const isLast = index === breadcrumbSegments.length - 1;
|
||||
const isClickable = segmentsClickable && !isLast;
|
||||
return (
|
||||
<Fragment key={`${index}-${segment}`}>
|
||||
{index > 0 ? (
|
||||
<ChevronRight
|
||||
className="h-3.5 w-3.5 text-ds-icon-neutral-muted-default shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
{isClickable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onBreadcrumbSegmentClick?.(index)}
|
||||
className="font-normal text-ds-text-neutral-muted-default hover:text-ds-text-neutral-default-default shrink-0 cursor-pointer hover:underline"
|
||||
>
|
||||
{segment}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className={
|
||||
isLast
|
||||
? 'font-bold text-ds-text-neutral-default-default shrink-0'
|
||||
: 'font-normal shrink-0'
|
||||
}
|
||||
>
|
||||
{segment}
|
||||
</span>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="gap-0.5 flex flex-shrink-0 items-center">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
aria-label={t('folder.download-file', {
|
||||
defaultValue: 'Download file',
|
||||
})}
|
||||
onClick={onDownloadFile}
|
||||
>
|
||||
<Download className="h-4 w-4 text-ds-icon-neutral-muted-default" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggleSourceCode}
|
||||
>
|
||||
<CodeXml className="h-4 w-4 text-ds-icon-neutral-muted-default" />
|
||||
</Button>
|
||||
{headerActionsExtra}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* content */}
|
||||
<div
|
||||
className={`min-h-0 flex flex-1 flex-col ${
|
||||
selectedFile?.type === 'html' && !isShowSourceCode
|
||||
? 'overflow-hidden'
|
||||
: 'scrollbar-always-visible overflow-y-auto'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex flex-col ${
|
||||
selectedFile?.type === 'html' && !isShowSourceCode
|
||||
? 'min-h-0 h-full'
|
||||
: 'py-2 pl-4 pr-2 min-h-full'
|
||||
} file-viewer-content`}
|
||||
>
|
||||
{selectedFile ? (
|
||||
!loading ? (
|
||||
selectedFile.type === 'md' && !isShowSourceCode ? (
|
||||
<div className="prose prose-sm max-w-none">
|
||||
<MarkDown
|
||||
content={selectedFile.content || ''}
|
||||
enableTypewriter={false}
|
||||
contentBasePath={
|
||||
selectedFile.isRemote
|
||||
? null
|
||||
: getDirPath(selectedFile.path)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : selectedFile.type === 'pdf' ? (
|
||||
<iframe
|
||||
src={selectedFile.content as string}
|
||||
className="h-full w-full border-0"
|
||||
title={selectedFile.name}
|
||||
/>
|
||||
) : ['csv', 'doc', 'docx', 'pptx', 'xlsx'].includes(
|
||||
selectedFile.type
|
||||
) ? (
|
||||
<FolderComponent selectedFile={selectedFile} />
|
||||
) : selectedFile.type === 'html' ? (
|
||||
isShowSourceCode ? (
|
||||
<>{selectedFile.content}</>
|
||||
) : (
|
||||
<HtmlRenderer
|
||||
selectedFile={selectedFile}
|
||||
projectFiles={projectFiles}
|
||||
/>
|
||||
)
|
||||
) : selectedFile.type === 'zip' ? (
|
||||
<div className="text-ds-text-neutral-muted-default flex h-full w-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<FileText className="mb-4 h-12 w-12 text-ds-text-neutral-muted-default mx-auto" />
|
||||
<p className="text-sm">
|
||||
{t('folder.zip-file-is-not-supported-yet')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : isAudioFile(selectedFile) ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<AudioLoader selectedFile={selectedFile} />
|
||||
</div>
|
||||
) : isVideoFile(selectedFile) ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<VideoLoader selectedFile={selectedFile} />
|
||||
</div>
|
||||
) : isImageFile(selectedFile) ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<ImageLoader selectedFile={selectedFile} />
|
||||
</div>
|
||||
) : (
|
||||
<pre className="font-mono text-sm text-ds-text-neutral-default-default overflow-auto break-words whitespace-pre-wrap">
|
||||
{selectedFile.content}
|
||||
</pre>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 h-8 w-8 animate-spin mx-auto rounded-full"></div>
|
||||
<p className="text-body-sm text-ds-text-neutral-muted-default">
|
||||
{t('chat.loading')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
(emptyState ?? (
|
||||
<div className="text-ds-text-neutral-muted-default flex h-full w-full flex-1 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<FileText className="mb-4 h-12 w-12 text-ds-text-neutral-muted-default mx-auto" />
|
||||
<p className="text-sm">
|
||||
{t('chat.select-a-file-to-view-its-contents')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,13 +21,8 @@ import {
|
|||
import { GlobalSearchDialog } from '@/components/GlobalSearch';
|
||||
import AlertDialog from '@/components/ui/alertDialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { TooltipSimple } from '@/components/ui/tooltip';
|
||||
import { useHost } from '@/host';
|
||||
import {
|
||||
createSpaceFromFolderPicker,
|
||||
getFolderSpaceErrorMessage,
|
||||
} from '@/lib/createSpaceFromFolder';
|
||||
import {
|
||||
isProjectAchieved,
|
||||
setProjectAchievedState,
|
||||
|
|
@ -42,12 +37,9 @@ import {
|
|||
resolveProjectNavLeadPresentation,
|
||||
} from '@/lib/sessionNavLead';
|
||||
import {
|
||||
getActiveSpaceTriggerLabel,
|
||||
getContextTabBindingLabel,
|
||||
getDefaultNewSpaceName,
|
||||
isUnboundUntitledSpace,
|
||||
} from '@/lib/spaceLabel';
|
||||
import { resolveServerBackedSpaceId } from '@/lib/spaceProject';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import type { ChatStore } from '@/store/chatStore';
|
||||
|
|
@ -55,33 +47,20 @@ import { usePageTabStore } from '@/store/pageTabStore';
|
|||
import { useProjectRuntimeStore } from '@/store/projectRuntimeStore';
|
||||
import {
|
||||
getVisibleProjectMetasForSpace,
|
||||
isDisposableBlankSpace,
|
||||
useSpaceStore,
|
||||
} from '@/store/spaceStore';
|
||||
import { useTriggerStore } from '@/store/triggerStore';
|
||||
import { ChatTaskStatus } from '@/types/constants';
|
||||
import {
|
||||
Cast,
|
||||
ChevronsUpDown,
|
||||
FolderIcon,
|
||||
Inbox,
|
||||
LayoutGrid,
|
||||
Plus,
|
||||
Zap,
|
||||
ZapOff,
|
||||
} from 'lucide-react';
|
||||
import { Cast, Inbox, LayoutGrid, Plus, Zap, ZapOff } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
NavTab,
|
||||
NavTabReconnectSuffix,
|
||||
WORKSPACE_TAB_LABEL_CLASS,
|
||||
triggerListenerLeadIconClass,
|
||||
workspaceTabButtonClass,
|
||||
} from './NavTab';
|
||||
import { ProjectNavList } from './ProjectNavList';
|
||||
import { SpaceSwitchDropdown } from './SpaceSwitchDropdown';
|
||||
|
||||
export interface ProjectPageSidebarProps {
|
||||
chatStore: ChatStore | null;
|
||||
|
|
@ -119,9 +98,6 @@ export default function ProjectPageSidebar({
|
|||
const activeSpaceId = useSpaceStore((s) => s.activeSpaceId);
|
||||
const spacesById = useSpaceStore((s) => s.spaces);
|
||||
const projectsBySpaceId = useSpaceStore((s) => s.projectsBySpaceId);
|
||||
const setActiveSpace = useSpaceStore((s) => s.setActiveSpace);
|
||||
const createSpaceOnServer = useSpaceStore((s) => s.createSpaceOnServer);
|
||||
const renameSpaceOnServer = useSpaceStore((s) => s.renameSpaceOnServer);
|
||||
const projectMetasForActiveSpace = useMemo(() => {
|
||||
if (!activeSpaceId) return [];
|
||||
return getVisibleProjectMetasForSpace(projectsBySpaceId, activeSpaceId);
|
||||
|
|
@ -130,10 +106,6 @@ export default function ProjectPageSidebar({
|
|||
!!activeProjectId && inboxUnviewedForProjects.has(activeProjectId);
|
||||
const { t } = useTranslation();
|
||||
const [globalSearchOpen, setGlobalSearchOpen] = useState(false);
|
||||
const [switchingSpaceId, setSwitchingSpaceId] = useState<string | null>(null);
|
||||
const [renameSpaceDialogOpen, setRenameSpaceDialogOpen] = useState(false);
|
||||
const [renameSpaceValue, setRenameSpaceValue] = useState('');
|
||||
const [renamingSpace, setRenamingSpace] = useState(false);
|
||||
const [deleteProjectId, setDeleteProjectId] = useState<string | null>(null);
|
||||
const [deleteProjectLoading, setDeleteProjectLoading] = useState(false);
|
||||
const [achieveProjectId, setAchieveProjectId] = useState<string | null>(null);
|
||||
|
|
@ -179,37 +151,7 @@ export default function ProjectPageSidebar({
|
|||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, []);
|
||||
|
||||
const activeSpaces = useMemo(
|
||||
() =>
|
||||
Object.values(spacesById)
|
||||
.filter(
|
||||
(space) =>
|
||||
space.status !== 'archived' &&
|
||||
!(
|
||||
space.id === 'legacy_local' &&
|
||||
activeSpaceId !== 'legacy_local' &&
|
||||
getVisibleProjectMetasForSpace(projectsBySpaceId, space.id)
|
||||
.length === 0
|
||||
) &&
|
||||
(space.id === activeSpaceId ||
|
||||
!isDisposableBlankSpace(space, projectsBySpaceId))
|
||||
)
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt),
|
||||
[activeSpaceId, projectsBySpaceId, spacesById]
|
||||
);
|
||||
|
||||
const activeSpace = activeSpaceId ? spacesById[activeSpaceId] : null;
|
||||
const activeSpaceLabel = getActiveSpaceTriggerLabel(activeSpace?.name, t, {
|
||||
emptyLabelKey: activeSpaceId
|
||||
? 'layout.spaces-untitled'
|
||||
: 'layout.spaces-select-space',
|
||||
});
|
||||
const canRenameActiveSpace = Boolean(
|
||||
activeSpace &&
|
||||
activeSpace.status === 'active' &&
|
||||
activeSpace.sourceType !== 'legacy' &&
|
||||
activeSpace.metadata?.legacy !== true
|
||||
);
|
||||
const isActiveSpaceUnbound = isUnboundUntitledSpace(activeSpace, t);
|
||||
const contextTabBinding = useMemo(
|
||||
() => getContextTabBindingLabel(activeSpace, t),
|
||||
|
|
@ -661,179 +603,12 @@ export default function ProjectPageSidebar({
|
|||
t,
|
||||
]);
|
||||
|
||||
const handleSpaceSelect = useCallback(
|
||||
async (spaceId: string) => {
|
||||
setSwitchingSpaceId(spaceId);
|
||||
try {
|
||||
const resolvedSpaceId = await resolveServerBackedSpaceId(
|
||||
projectStore,
|
||||
spaceId
|
||||
);
|
||||
const spaceStore = useSpaceStore.getState();
|
||||
if (
|
||||
resolvedSpaceId.startsWith('legacy_') ||
|
||||
spaceStore.shouldSyncProjects(resolvedSpaceId)
|
||||
) {
|
||||
await spaceStore.syncProjectsFromServer(resolvedSpaceId);
|
||||
}
|
||||
const projectsInSpace = useSpaceStore
|
||||
.getState()
|
||||
.getProjectsForSpace(resolvedSpaceId);
|
||||
setActiveSpace(resolvedSpaceId);
|
||||
if (projectsInSpace.length > 0) {
|
||||
const lastVisitedProjectId =
|
||||
spaceStore.lastVisitedProjectBySpace[resolvedSpaceId];
|
||||
const targetProject =
|
||||
projectsInSpace.find(
|
||||
(project) => project.id === lastVisitedProjectId
|
||||
) ?? projectsInSpace[0];
|
||||
projectStore.setActiveProject(targetProject.id);
|
||||
await ensureProjectLoaded(targetProject.id);
|
||||
} else {
|
||||
projectStore.setActiveProject(null);
|
||||
}
|
||||
setActiveWorkspaceTab('workforce');
|
||||
requestWorkspaceChatFocus();
|
||||
} catch (error) {
|
||||
console.error('Failed to create Project for Space:', error);
|
||||
toast.error(t('layout.spaces-create-failed'), {
|
||||
closeButton: true,
|
||||
});
|
||||
} finally {
|
||||
setSwitchingSpaceId(null);
|
||||
}
|
||||
},
|
||||
[
|
||||
ensureProjectLoaded,
|
||||
projectStore,
|
||||
requestWorkspaceChatFocus,
|
||||
setActiveSpace,
|
||||
setActiveWorkspaceTab,
|
||||
t,
|
||||
]
|
||||
);
|
||||
|
||||
const handleNewSpace = useCallback(async () => {
|
||||
try {
|
||||
const spaceId = await createSpaceOnServer({
|
||||
name: getDefaultNewSpaceName(t),
|
||||
sourceType: 'blank',
|
||||
setActive: false,
|
||||
metadata: {
|
||||
createdFrom: 'project_sidebar_space_selector',
|
||||
autoCreatedPlaceholder: true,
|
||||
},
|
||||
});
|
||||
await ensureScratchSpaceWorkspaceBinding({
|
||||
email,
|
||||
userId,
|
||||
space: useSpaceStore.getState().getSpaceById(spaceId),
|
||||
});
|
||||
setActiveSpace(spaceId);
|
||||
projectStore.setActiveProject(null);
|
||||
setActiveWorkspaceTab('workforce');
|
||||
requestWorkspaceChatFocus();
|
||||
} catch (error) {
|
||||
console.error('Failed to create Space:', error);
|
||||
toast.error(t('layout.spaces-create-failed'), {
|
||||
closeButton: true,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
createSpaceOnServer,
|
||||
email,
|
||||
projectStore,
|
||||
requestWorkspaceChatFocus,
|
||||
setActiveSpace,
|
||||
setActiveWorkspaceTab,
|
||||
t,
|
||||
userId,
|
||||
]);
|
||||
|
||||
const handleCreateSpaceFromFolder = useCallback(async () => {
|
||||
try {
|
||||
const spaceId = await createSpaceFromFolderPicker({
|
||||
host,
|
||||
email,
|
||||
userId,
|
||||
activeSpaceId,
|
||||
projectStore,
|
||||
createdFrom: 'project_sidebar_space_selector',
|
||||
});
|
||||
if (!spaceId) return;
|
||||
setActiveWorkspaceTab('workforce');
|
||||
requestWorkspaceChatFocus();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[ProjectPageSidebar] Failed to create folder Space:',
|
||||
error
|
||||
);
|
||||
toast.error(getFolderSpaceErrorMessage(error, t), {
|
||||
closeButton: true,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
activeSpaceId,
|
||||
email,
|
||||
host,
|
||||
projectStore,
|
||||
requestWorkspaceChatFocus,
|
||||
setActiveWorkspaceTab,
|
||||
t,
|
||||
userId,
|
||||
]);
|
||||
|
||||
const openRenameSpaceDialog = useCallback(() => {
|
||||
if (!canRenameActiveSpace || !activeSpace) return;
|
||||
setRenameSpaceValue(activeSpace.name?.trim() || '');
|
||||
setRenameSpaceDialogOpen(true);
|
||||
}, [activeSpace, canRenameActiveSpace]);
|
||||
|
||||
const handleRenameSpace = useCallback(async () => {
|
||||
const nextName = renameSpaceValue.trim();
|
||||
if (!activeSpaceId || !nextName || renamingSpace) return;
|
||||
setRenamingSpace(true);
|
||||
try {
|
||||
await renameSpaceOnServer(activeSpaceId, nextName);
|
||||
toast.success(t('layout.spaces-rename-success'));
|
||||
setRenameSpaceDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.warn('[ProjectPageSidebar] Failed to rename Space:', error);
|
||||
toast.error(t('layout.spaces-rename-failed'));
|
||||
} finally {
|
||||
setRenamingSpace(false);
|
||||
}
|
||||
}, [activeSpaceId, renameSpaceOnServer, renameSpaceValue, renamingSpace, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlobalSearchDialog
|
||||
open={globalSearchOpen}
|
||||
onOpenChange={setGlobalSearchOpen}
|
||||
/>
|
||||
<AlertDialog
|
||||
isOpen={renameSpaceDialogOpen}
|
||||
onClose={() => setRenameSpaceDialogOpen(false)}
|
||||
onConfirm={() => void handleRenameSpace()}
|
||||
title={t('layout.spaces-rename-title')}
|
||||
confirmText={t('layout.save')}
|
||||
cancelText={t('layout.cancel')}
|
||||
confirmVariant="primary"
|
||||
confirmDisabled={!renameSpaceValue.trim() || renamingSpace}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
value={renameSpaceValue}
|
||||
placeholder={t('layout.spaces-rename-placeholder')}
|
||||
onChange={(event) => setRenameSpaceValue(event.target.value)}
|
||||
onEnter={() => {
|
||||
if (renameSpaceValue.trim() && !renamingSpace) {
|
||||
void handleRenameSpace();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog
|
||||
isOpen={deleteProjectId != null}
|
||||
onClose={() => {
|
||||
|
|
@ -873,48 +648,6 @@ export default function ProjectPageSidebar({
|
|||
<div className="min-h-0 min-w-0 flex h-full w-full max-w-full flex-col overflow-x-hidden">
|
||||
<div className="min-h-0 min-w-0 flex flex-1 flex-col overflow-hidden">
|
||||
<div className="gap-1 flex w-full shrink-0 flex-col">
|
||||
<SpaceSwitchDropdown
|
||||
triggerTooltip="Spaces"
|
||||
triggerTooltipEnabled={projectSidebarFolded}
|
||||
trigger={
|
||||
<button
|
||||
type="button"
|
||||
className={cn(workspaceTabButtonClass(false))}
|
||||
aria-label={t('layout.spaces-switch-space')}
|
||||
>
|
||||
<FolderIcon
|
||||
className="h-4 w-4 text-ds-icon-neutral-muted-default shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
WORKSPACE_TAB_LABEL_CLASS,
|
||||
projectSidebarFolded && 'hidden'
|
||||
)}
|
||||
>
|
||||
{activeSpaceLabel}
|
||||
</span>
|
||||
<ChevronsUpDown
|
||||
className={cn(
|
||||
'h-4 w-4 text-ds-icon-neutral-subtle-default ml-auto shrink-0',
|
||||
projectSidebarFolded && 'hidden'
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
spaces={activeSpaces}
|
||||
activeSpaceId={activeSpaceId}
|
||||
switchingSpaceId={switchingSpaceId}
|
||||
canRenameActiveSpace={canRenameActiveSpace}
|
||||
createSpaceMenu={{
|
||||
onStartFromScratch: handleNewSpace,
|
||||
onSelectFolder: handleCreateSpaceFromFolder,
|
||||
}}
|
||||
onRenameSpace={openRenameSpaceDialog}
|
||||
onSpaceSelect={handleSpaceSelect}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 gap-1 flex w-full flex-col">
|
||||
<NavTab
|
||||
active={activeWorkspaceTab === 'workforce'}
|
||||
|
|
|
|||
|
|
@ -17,9 +17,10 @@ import tokenLightIcon from '@/assets/custom/token-light.svg';
|
|||
import { AnimatedTokenNumber } from '@/components/ChatBox/MessageItem/TokenUtils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TooltipSimple } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePageTabStore } from '@/store/pageTabStore';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { ArrowLeft, FileText } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface HeaderBoxProps {
|
||||
|
|
@ -39,10 +40,15 @@ export function HeaderBox({
|
|||
const { t } = useTranslation();
|
||||
const { appearance } = useAuthStore();
|
||||
const setActiveWorkspaceTab = usePageTabStore((s) => s.setActiveWorkspaceTab);
|
||||
const filePreviewOpen = usePageTabStore((s) => s.filePreviewOpen);
|
||||
const toggleFilePreview = usePageTabStore((s) => s.toggleFilePreview);
|
||||
const tokenIcon = appearance === 'dark' ? tokenDarkIcon : tokenLightIcon;
|
||||
const backToWorkspaceTooltip = t('layout.back-to-workspace-tooltip', {
|
||||
defaultValue: 'Back to workspace',
|
||||
});
|
||||
const filePreviewTooltip = t('layout.toggle-file-preview-tooltip', {
|
||||
defaultValue: 'Toggle file preview',
|
||||
});
|
||||
|
||||
if (empty) {
|
||||
return (
|
||||
|
|
@ -55,7 +61,7 @@ export function HeaderBox({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`px-3 flex h-[44px] w-full flex-row items-center justify-between ${className || ''}`}
|
||||
className={`pl-3 pr-1.5 flex h-[44px] w-full flex-row items-center justify-between ${className || ''}`}
|
||||
>
|
||||
{/* Left: return to project workspace */}
|
||||
<div className="gap-2 flex items-center">
|
||||
|
|
@ -74,7 +80,7 @@ export function HeaderBox({
|
|||
</TooltipSimple>
|
||||
</div>
|
||||
|
||||
{/* Right: project total token count */}
|
||||
{/* Right: project total token count + file preview toggle */}
|
||||
<div className="gap-2 text-ds-text-neutral-muted-default flex items-center">
|
||||
<div className="gap-1 flex items-center">
|
||||
<img src={tokenIcon} alt="" className="h-3.5 w-3.5" />
|
||||
|
|
@ -83,6 +89,24 @@ export function HeaderBox({
|
|||
<AnimatedTokenNumber value={totalTokens} />
|
||||
</span>
|
||||
</div>
|
||||
<TooltipSimple content={filePreviewTooltip}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
buttonContent="icon-only"
|
||||
onClick={toggleFilePreview}
|
||||
className={cn(
|
||||
'no-drag text-ds-text-neutral-muted-default hover:bg-ds-bg-neutral-strong-default shrink-0',
|
||||
filePreviewOpen &&
|
||||
'bg-ds-bg-neutral-strong-default text-ds-text-neutral-default-default'
|
||||
)}
|
||||
aria-label={filePreviewTooltip}
|
||||
aria-pressed={filePreviewOpen}
|
||||
>
|
||||
<FileText className="h-4 w-4" aria-hidden />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import { useTranslation } from 'react-i18next';
|
|||
export function SingleAgentSidePanel() {
|
||||
const { t } = useTranslation();
|
||||
const { projectStore } = useChatStoreAdapter();
|
||||
const setActiveWorkspaceTab = usePageTabStore((s) => s.setActiveWorkspaceTab);
|
||||
const openFilePreview = usePageTabStore((s) => s.openFilePreview);
|
||||
|
||||
const selectedTurn = useSelectedProjectTurn(projectStore.activeProjectId);
|
||||
const selectedTask = selectedTurn.task;
|
||||
|
|
@ -96,18 +96,9 @@ export function SingleAgentSidePanel() {
|
|||
|
||||
const handleOpenAgentFile = useCallback(
|
||||
(file: FileInfo) => {
|
||||
if (!selectedTaskId || !selectedTurn.chatStore) return;
|
||||
selectedTurn.chatStore.getState().setSelectedFile(selectedTaskId, file);
|
||||
setActiveWorkspaceTab('inbox', {
|
||||
clearInboxForProjectId: projectStore.activeProjectId ?? null,
|
||||
});
|
||||
openFilePreview(file);
|
||||
},
|
||||
[
|
||||
projectStore.activeProjectId,
|
||||
selectedTaskId,
|
||||
selectedTurn.chatStore,
|
||||
setActiveWorkspaceTab,
|
||||
]
|
||||
[openFilePreview]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export function WorkforceSidePanel({
|
|||
}: WorkforceSidePanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { projectStore } = useChatStoreAdapter();
|
||||
const setActiveWorkspaceTab = usePageTabStore((s) => s.setActiveWorkspaceTab);
|
||||
const openFilePreview = usePageTabStore((s) => s.openFilePreview);
|
||||
|
||||
const selectedTurn = useSelectedProjectTurn(projectStore.activeProjectId);
|
||||
const selectedTask = selectedTurn.task;
|
||||
|
|
@ -124,18 +124,9 @@ export function WorkforceSidePanel({
|
|||
|
||||
const handleOpenAgentFile = useCallback(
|
||||
(file: FileInfo) => {
|
||||
if (!selectedTaskId || !selectedTurn.chatStore) return;
|
||||
selectedTurn.chatStore.getState().setSelectedFile(selectedTaskId, file);
|
||||
setActiveWorkspaceTab('inbox', {
|
||||
clearInboxForProjectId: projectStore.activeProjectId ?? null,
|
||||
});
|
||||
openFilePreview(file);
|
||||
},
|
||||
[
|
||||
projectStore.activeProjectId,
|
||||
selectedTaskId,
|
||||
selectedTurn.chatStore,
|
||||
setActiveWorkspaceTab,
|
||||
]
|
||||
[openFilePreview]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -13,9 +13,11 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import ChatBox from '@/components/ChatBox';
|
||||
import { FilePreview } from '@/components/Folder/FilePreview';
|
||||
import { HeaderBox } from '@/components/Session/HeaderBox';
|
||||
import Workspace from '@/components/Workspace';
|
||||
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
|
||||
import { useSelectedProjectTurn } from '@/hooks/useSelectedProjectTurn';
|
||||
import { inferSessionModeFromTask } from '@/lib/sessionMode';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePageTabStore } from '@/store/pageTabStore';
|
||||
|
|
@ -26,13 +28,24 @@ import {
|
|||
SessionMode,
|
||||
type SessionModeType,
|
||||
} from '@/types/constants';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { SessionSidePanel } from './SessionSidePanel';
|
||||
import {
|
||||
SESSION_SIDE_PANEL_EXPANDED_OUTER_CLASS,
|
||||
SESSION_SIDE_PANEL_FOLDED_OUTER_CLASS,
|
||||
} from './sessionSidePanelLayout';
|
||||
|
||||
/**
|
||||
* When the inline preview is open, the chat column is pinned to its max width
|
||||
* (so the chat flow keeps its comfortable reading width) and the preview fills
|
||||
* the rest. The chat content itself is capped at 600px; 680 leaves side gutters.
|
||||
*/
|
||||
const CHAT_PRIORITY_WIDTH = 680;
|
||||
/** Smallest the chat column may be dragged to. */
|
||||
const CHAT_MIN_WIDTH = 360;
|
||||
/** Keep at least this much room for the preview when the chat is widened. */
|
||||
const PREVIEW_MIN_WIDTH = 320;
|
||||
|
||||
/**
|
||||
* Active Project: header + chat (left) and a mode-dependent side panel (right).
|
||||
* The side panel is selected from Project.mode. Task/session mode fields are
|
||||
|
|
@ -47,6 +60,9 @@ export default function Session({ isNewProject = false }: SessionProps) {
|
|||
const { chatStore, projectStore } = useChatStoreAdapter();
|
||||
const activeWorkspaceTab = usePageTabStore((s) => s.activeWorkspaceTab);
|
||||
const setActiveWorkspaceTab = usePageTabStore((s) => s.setActiveWorkspaceTab);
|
||||
const filePreviewOpen = usePageTabStore((s) => s.filePreviewOpen);
|
||||
const filePreviewFile = usePageTabStore((s) => s.filePreviewFile);
|
||||
const closeFilePreview = usePageTabStore((s) => s.closeFilePreview);
|
||||
const activeProjectId = projectStore.activeProjectId;
|
||||
const isHistoryLoadingActiveProject = useProjectRuntimeStore((s) =>
|
||||
activeProjectId
|
||||
|
|
@ -213,6 +229,85 @@ export default function Session({ isNewProject = false }: SessionProps) {
|
|||
setIsSidePanelVisible((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Inline preview column sizing. The chat column is width-controlled so it can
|
||||
// animate between full-width (closed) and its max width (open); the preview is
|
||||
// flex-1 and fills the remaining space ("full width"), absorbing the extra
|
||||
// room freed by the side-panel fold. Dragging the divider resizes the chat.
|
||||
const chatRowRef = useRef<HTMLDivElement>(null);
|
||||
const [chatWidth, setChatWidth] = useState(CHAT_PRIORITY_WIDTH);
|
||||
const [isResizingPreview, setIsResizingPreview] = useState(false);
|
||||
|
||||
// Opening the inline file preview auto-folds the session side panel so the
|
||||
// preview has room, and resets the chat back to its priority max width.
|
||||
useEffect(() => {
|
||||
if (filePreviewOpen) {
|
||||
setIsSidePanelVisible(false);
|
||||
const rowWidth = chatRowRef.current?.getBoundingClientRect().width ?? 0;
|
||||
const maxChat = rowWidth
|
||||
? Math.max(CHAT_MIN_WIDTH, rowWidth - PREVIEW_MIN_WIDTH)
|
||||
: CHAT_PRIORITY_WIDTH;
|
||||
setChatWidth(Math.min(CHAT_PRIORITY_WIDTH, maxChat));
|
||||
}
|
||||
}, [filePreviewOpen]);
|
||||
|
||||
// The preview is a project-page concern; close it when leaving that tab or
|
||||
// switching projects so it never lingers over an unrelated view.
|
||||
useEffect(() => {
|
||||
if (activeWorkspaceTab !== 'project') {
|
||||
closeFilePreview();
|
||||
}
|
||||
}, [activeWorkspaceTab, activeProjectId, closeFilePreview]);
|
||||
|
||||
const handlePreviewResizeStart = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
const rowWidth =
|
||||
chatRowRef.current?.getBoundingClientRect().width ?? window.innerWidth;
|
||||
// Chat never exceeds its priority max width, and always leaves room for
|
||||
// the preview's minimum width.
|
||||
const maxChat = Math.max(
|
||||
CHAT_MIN_WIDTH,
|
||||
Math.min(CHAT_PRIORITY_WIDTH, rowWidth - PREVIEW_MIN_WIDTH)
|
||||
);
|
||||
const startX = e.clientX;
|
||||
const startWidth = chatWidth;
|
||||
setIsResizingPreview(true);
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
// Dragging right (larger clientX) widens the chat, shrinking the preview.
|
||||
const next = Math.min(
|
||||
maxChat,
|
||||
Math.max(CHAT_MIN_WIDTH, startWidth + (ev.clientX - startX))
|
||||
);
|
||||
setChatWidth(next);
|
||||
};
|
||||
const onUp = () => {
|
||||
setIsResizingPreview(false);
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
};
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
},
|
||||
[chatWidth]
|
||||
);
|
||||
|
||||
// "Context" breadcrumb / empty-state action: open the Inbox tab for this file.
|
||||
const selectedTurn = useSelectedProjectTurn(activeProjectId);
|
||||
const handleJumpToContext = useCallback(
|
||||
(file: FileInfo | null) => {
|
||||
if (file && selectedTurn.taskId && selectedTurn.chatStore) {
|
||||
selectedTurn.chatStore
|
||||
.getState()
|
||||
.setSelectedFile(selectedTurn.taskId, file);
|
||||
}
|
||||
setActiveWorkspaceTab('inbox', {
|
||||
clearInboxForProjectId: activeProjectId ?? null,
|
||||
});
|
||||
closeFilePreview();
|
||||
},
|
||||
[selectedTurn, setActiveWorkspaceTab, activeProjectId, closeFilePreview]
|
||||
);
|
||||
|
||||
const toggleExpandedOverlay = useCallback(() => {
|
||||
setIsExpandedOverlayOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
|
@ -241,10 +336,10 @@ export default function Session({ isNewProject = false }: SessionProps) {
|
|||
|
||||
if (isNewProject) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full min-w-0 flex-1 flex-row overflow-hidden">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="min-h-0 min-w-0 flex h-full w-full flex-1 flex-row overflow-hidden">
|
||||
<div className="min-h-0 min-w-0 flex flex-1 flex-col overflow-hidden">
|
||||
<HeaderBox empty />
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="min-h-0 min-w-0 flex flex-1 flex-col overflow-hidden">
|
||||
<Workspace
|
||||
variant="new-project"
|
||||
embedded
|
||||
|
|
@ -257,7 +352,7 @@ export default function Session({ isNewProject = false }: SessionProps) {
|
|||
<div
|
||||
id="session-side-panel"
|
||||
className={cn(
|
||||
'flex min-h-0 shrink-0 flex-col overflow-hidden transition-[width] duration-200 ease-out',
|
||||
'min-h-0 ease-out flex shrink-0 flex-col overflow-hidden transition-[width] duration-200',
|
||||
isSidePanelVisible
|
||||
? SESSION_SIDE_PANEL_EXPANDED_OUTER_CLASS
|
||||
: cn(SESSION_SIDE_PANEL_FOLDED_OUTER_CLASS, 'rounded-l-xl')
|
||||
|
|
@ -270,22 +365,59 @@ export default function Session({ isNewProject = false }: SessionProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full min-w-0 flex-1 flex-row overflow-hidden">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="min-h-0 min-w-0 flex h-full w-full flex-1 flex-row overflow-hidden">
|
||||
<div className="min-h-0 min-w-0 flex flex-1 flex-col overflow-hidden">
|
||||
{chatStore.activeTaskId && hasAnyMessages && (
|
||||
<HeaderBox
|
||||
totalTokens={chatStore.tasks[chatStore.activeTaskId]?.tokens || 0}
|
||||
/>
|
||||
)}
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<ChatBox />
|
||||
<div
|
||||
ref={chatRowRef}
|
||||
className="min-h-0 min-w-0 flex flex-1 flex-row overflow-hidden"
|
||||
>
|
||||
<div
|
||||
style={{ width: filePreviewOpen ? chatWidth : '100%' }}
|
||||
className={cn(
|
||||
'min-h-0 flex shrink-0 flex-col overflow-hidden',
|
||||
!isResizingPreview && 'ease-out transition-[width] duration-200'
|
||||
)}
|
||||
>
|
||||
<ChatBox />
|
||||
</div>
|
||||
{filePreviewOpen && (
|
||||
<div
|
||||
onPointerDown={handlePreviewResizeStart}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
data-resize-handle-state={isResizingPreview ? 'drag' : 'inactive'}
|
||||
className={cn(
|
||||
// Mirrors the project sidebar ResizableHandle: transparent 2px
|
||||
// rail with a centered ::after line, brand color on hover/drag.
|
||||
'hover:bg-ds-bg-brand-subtle-default relative z-10 flex w-[2px] shrink-0 cursor-col-resize items-center justify-center bg-transparent transition-all',
|
||||
// Widen the pointer hit area without changing the visible width.
|
||||
"before:inset-y-0 before:-left-1 before:-right-1 before:absolute before:content-['']",
|
||||
'after:inset-y-0 after:w-1 after:bg-ds-bg-neutral-default-default after:absolute after:left-1/2 after:-translate-x-1/2 after:transition-all',
|
||||
isResizingPreview &&
|
||||
'bg-ds-bg-brand-subtle-default after:bg-ds-bg-brand-default-focus'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="min-h-0 min-w-0 flex flex-1 flex-col overflow-hidden">
|
||||
<FilePreview
|
||||
file={filePreviewFile}
|
||||
surfaceClassName="bg-ds-bg-neutral-default-default"
|
||||
onClose={closeFilePreview}
|
||||
onJumpToContext={handleJumpToContext}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="session-side-panel"
|
||||
className={cn(
|
||||
'flex min-h-0 shrink-0 flex-col overflow-hidden transition-[width] duration-200 ease-out',
|
||||
'min-h-0 ease-out flex shrink-0 flex-col overflow-hidden transition-[width] duration-200',
|
||||
isSidePanelVisible
|
||||
? SESSION_SIDE_PANEL_EXPANDED_OUTER_CLASS
|
||||
: cn(SESSION_SIDE_PANEL_FOLDED_OUTER_CLASS, 'rounded-l-xl')
|
||||
|
|
|
|||
|
|
@ -22,20 +22,7 @@ import InviteCodeDialog from '@/components/Dialog/InviteCodeDialog';
|
|||
import ReportBugDialog from '@/components/Dialog/ReportBugDialog';
|
||||
import { SpaceSwitchDropdown } from '@/components/ProjectPageSidebar/SpaceSwitchDropdown';
|
||||
import AlertDialog from '@/components/ui/alertDialog';
|
||||
import { Blocks } from '@/components/ui/animate-ui/icons/blocks';
|
||||
import { Bot } from '@/components/ui/animate-ui/icons/bot';
|
||||
import { Compass } from '@/components/ui/animate-ui/icons/compass';
|
||||
import { Hammer } from '@/components/ui/animate-ui/icons/hammer';
|
||||
import { AnimateIcon } from '@/components/ui/animate-ui/icons/icon';
|
||||
import { Radio } from '@/components/ui/animate-ui/icons/radio';
|
||||
import { Settings as AnimateSettings } from '@/components/ui/animate-ui/icons/settings';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { TooltipSimple } from '@/components/ui/tooltip';
|
||||
import { useHost } from '@/host';
|
||||
|
|
@ -68,6 +55,7 @@ import {
|
|||
ArrowLeft,
|
||||
ChevronsUpDown,
|
||||
CircleHelp,
|
||||
Folder,
|
||||
Minus,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
|
|
@ -75,14 +63,7 @@ import {
|
|||
Square,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
NavigationType,
|
||||
|
|
@ -153,19 +134,6 @@ const topBarCrossfade = {
|
|||
ease: [0.4, 0, 0.2, 1] as const,
|
||||
};
|
||||
|
||||
const HOME_NAV_HISTORY_MENU: {
|
||||
id: HistoryTabId;
|
||||
labelKey: string;
|
||||
icon: ReactNode;
|
||||
}[] = [
|
||||
{ id: 'home', labelKey: 'layout.spaces', icon: <Blocks /> },
|
||||
{ id: 'agents', labelKey: 'layout.agents', icon: <Bot /> },
|
||||
{ id: 'channels', labelKey: 'layout.channels', icon: <Radio /> },
|
||||
{ id: 'connectors', labelKey: 'layout.connectors', icon: <Hammer /> },
|
||||
{ id: 'browser', labelKey: 'layout.browser', icon: <Compass /> },
|
||||
{ id: 'settings', labelKey: 'layout.settings', icon: <AnimateSettings /> },
|
||||
];
|
||||
|
||||
function HeaderWin() {
|
||||
const { t } = useTranslation();
|
||||
const host = useHost();
|
||||
|
|
@ -199,7 +167,6 @@ function HeaderWin() {
|
|||
const appearance = useAuthStore((state) => state.appearance);
|
||||
const email = useAuthStore((s) => s.email);
|
||||
const userId = useAuthStore((s) => s.user_id);
|
||||
const [homeNavMenuOpen, setHomeNavMenuOpen] = useState(false);
|
||||
const [packageUpdateAvailable, setPackageUpdateAvailable] = useState(false);
|
||||
const ipcRenderer = host?.ipcRenderer;
|
||||
const { isInstalling, installationState } = useInstallationUI();
|
||||
|
|
@ -304,7 +271,6 @@ function HeaderWin() {
|
|||
|
||||
const navigateToHistoryTab = useCallback(
|
||||
(tab: HistoryTabId) => {
|
||||
setHomeNavMenuOpen(false);
|
||||
if (tab === 'home') {
|
||||
// The Home/Spaces hub is a project-independent surface and may
|
||||
// re-select the same project the user just left. Clearing
|
||||
|
|
@ -545,47 +511,29 @@ function HeaderWin() {
|
|||
}}
|
||||
/>
|
||||
</AlertDialog>
|
||||
{/* Leading: home ↔ dashboard / new Space */}
|
||||
<div className="no-drag flex shrink-0 items-center justify-center">
|
||||
{/* Leading: workspace controls, or a single back button on history */}
|
||||
<div className="no-drag gap-0.5 flex shrink-0 items-center justify-center">
|
||||
{isHistoryRoute ? (
|
||||
<TooltipSimple
|
||||
content={t('layout.back', { defaultValue: 'Back' })}
|
||||
side="bottom"
|
||||
align="center"
|
||||
// History page: one "back to workspace" button (arrow + text)
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="no-drag gap-1.5 font-bold shrink-0 rounded-full"
|
||||
onClick={handleExitHistoryOrSettings}
|
||||
aria-label={t('layout.back-to-workspace', {
|
||||
defaultValue: 'Back to workspace',
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
buttonContent="icon-only"
|
||||
className="no-drag shrink-0 rounded-full"
|
||||
onClick={handleExitHistoryOrSettings}
|
||||
aria-label={t('layout.back', { defaultValue: 'Back' })}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden />
|
||||
{t('layout.back-to-workspace', {
|
||||
defaultValue: 'Back to workspace',
|
||||
})}
|
||||
</Button>
|
||||
) : (
|
||||
<TooltipSimple
|
||||
content={
|
||||
projectSidebarFolded
|
||||
? t('layout.expand-project-sidebar', {
|
||||
defaultValue: 'Expand sidebar',
|
||||
})
|
||||
: t('layout.fold-project-sidebar', {
|
||||
defaultValue: 'Fold sidebar',
|
||||
})
|
||||
}
|
||||
side="bottom"
|
||||
align="center"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
buttonContent="icon-only"
|
||||
className="no-drag shrink-0 rounded-full"
|
||||
onClick={() => toggleProjectSidebarFolded()}
|
||||
aria-pressed={!projectSidebarFolded}
|
||||
aria-label={
|
||||
<>
|
||||
{/* Left panel: fold / expand the project sidebar */}
|
||||
<TooltipSimple
|
||||
content={
|
||||
projectSidebarFolded
|
||||
? t('layout.expand-project-sidebar', {
|
||||
defaultValue: 'Expand sidebar',
|
||||
|
|
@ -594,139 +542,95 @@ function HeaderWin() {
|
|||
defaultValue: 'Fold sidebar',
|
||||
})
|
||||
}
|
||||
side="bottom"
|
||||
align="center"
|
||||
>
|
||||
{projectSidebarFolded ? (
|
||||
<PanelLeft className="h-4 w-4" aria-hidden />
|
||||
) : (
|
||||
<PanelLeftClose className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
{isHistoryRoute ? (
|
||||
<div
|
||||
className="no-drag px-2 flex min-h-[28px] items-center"
|
||||
aria-hidden
|
||||
>
|
||||
<img
|
||||
src={
|
||||
appearance === 'dark' ? eigentAppIconWhite : eigentAppIconBlack
|
||||
}
|
||||
alt=""
|
||||
className="h-6 w-6 mt-[2px] select-none"
|
||||
width={16}
|
||||
height={16}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={homeNavMenuOpen}
|
||||
onOpenChange={setHomeNavMenuOpen}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="no-drag focus-visible:ring-ds-ring-brand-default-focus/50 w-22 gap-1.5 px-2 text-label-sm font-bold !text-ds-text-neutral-default-default hover:bg-ds-bg-neutral-default-hover active:bg-ds-bg-neutral-default-active flex min-h-[28px] items-center rounded-full outline-none focus-visible:ring-[3px]"
|
||||
aria-label={t('layout.home')}
|
||||
aria-haspopup="menu"
|
||||
onDoubleClick={(e) => {
|
||||
e.preventDefault();
|
||||
setHomeNavMenuOpen(false);
|
||||
navigateToHistoryTab('home');
|
||||
}}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
buttonContent="icon-only"
|
||||
className="no-drag shrink-0 rounded-full"
|
||||
onClick={() => toggleProjectSidebarFolded()}
|
||||
aria-pressed={!projectSidebarFolded}
|
||||
aria-label={
|
||||
projectSidebarFolded
|
||||
? t('layout.expand-project-sidebar', {
|
||||
defaultValue: 'Expand sidebar',
|
||||
})
|
||||
: t('layout.fold-project-sidebar', {
|
||||
defaultValue: 'Fold sidebar',
|
||||
})
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={
|
||||
appearance === 'dark'
|
||||
? eigentAppIconWhite
|
||||
: eigentAppIconBlack
|
||||
}
|
||||
alt=""
|
||||
className="h-6 w-6 mt-[2px] select-none"
|
||||
width={16}
|
||||
height={16}
|
||||
draggable={false}
|
||||
/>
|
||||
{t('layout.home')}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="min-w-32 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 duration-100"
|
||||
{projectSidebarFolded ? (
|
||||
<PanelLeft className="h-4 w-4" aria-hidden />
|
||||
) : (
|
||||
<PanelLeftClose className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
|
||||
{/* Home button: go straight to the Home/Spaces hub */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigateToHistoryTab('home')}
|
||||
aria-label={t('layout.home')}
|
||||
className="no-drag focus-visible:ring-ds-ring-brand-default-focus/50 gap-1.5 px-2 text-label-sm font-bold text-ds-text-neutral-default-default hover:bg-ds-bg-neutral-default-hover flex min-h-[28px] items-center rounded-full transition-colors outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
{HOME_NAV_HISTORY_MENU.map(({ id, labelKey, icon }) => (
|
||||
<AnimateIcon key={id} animateOnHover="default" asChild>
|
||||
<DropdownMenuItem
|
||||
className="gap-2 cursor-pointer"
|
||||
onClick={() => navigateToHistoryTab(id)}
|
||||
>
|
||||
<span className="size-4 [&_svg]:size-4 inline-flex shrink-0 items-center justify-center">
|
||||
{icon}
|
||||
</span>
|
||||
<span>{t(labelKey)}</span>
|
||||
</DropdownMenuItem>
|
||||
</AnimateIcon>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<img
|
||||
src={
|
||||
appearance === 'dark'
|
||||
? eigentAppIconWhite
|
||||
: eigentAppIconBlack
|
||||
}
|
||||
alt=""
|
||||
className="h-5 w-5 select-none"
|
||||
width={16}
|
||||
height={16}
|
||||
draggable={false}
|
||||
/>
|
||||
{t('layout.home')}
|
||||
</button>
|
||||
|
||||
{/* Workspace dropdown: the whole button opens the space switcher */}
|
||||
<SpaceSwitchDropdown
|
||||
contentSideOffset={6}
|
||||
trigger={
|
||||
<button
|
||||
id="active-space-title-btn"
|
||||
type="button"
|
||||
className="no-drag focus-visible:ring-ds-ring-brand-default-focus/50 min-w-0 gap-1.5 px-2 text-label-sm font-bold text-ds-text-neutral-default-default hover:bg-ds-bg-neutral-default-hover flex min-h-[28px] items-center rounded-full transition-colors outline-none focus-visible:ring-[3px]"
|
||||
aria-haspopup="menu"
|
||||
aria-label={activeSpaceTitle}
|
||||
>
|
||||
<Folder className="h-4 w-4 shrink-0" aria-hidden />
|
||||
<span className="min-w-0 max-w-[220px] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{activeSpaceTitle}
|
||||
</span>
|
||||
<ChevronsUpDown
|
||||
className="h-3.5 w-3.5 text-ds-icon-neutral-subtle-default shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
spaces={activeSpaces}
|
||||
activeSpaceId={activeSpaceId}
|
||||
switchingSpaceId={switchingSpaceId}
|
||||
canRenameActiveSpace={canRenameActiveSpace}
|
||||
createSpaceMenu={{
|
||||
onStartFromScratch: handleCreateBlankSpace,
|
||||
onSelectFolder: handleCreateSpaceFromFolder,
|
||||
}}
|
||||
onRenameSpace={openRenameSpaceDialog}
|
||||
onSpaceSelect={handleTopBarSpaceSelect}
|
||||
contentAlign="start"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Middle: full width on project home only (/) — nav + title */}
|
||||
<div className="no-drag h-7 min-h-0 min-w-0 relative z-50 flex w-full">
|
||||
<AnimatePresence initial={false}>
|
||||
{isHomeRoute && projectSidebarFolded && (
|
||||
<motion.div
|
||||
key="home-middle"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={topBarCrossfade}
|
||||
className="drag inset-0 min-w-0 absolute z-10 flex items-center"
|
||||
>
|
||||
<div className="ml-1 min-h-0 min-w-0 border-ds-border-neutral-subtle-default pl-1 relative z-50 flex h-full items-center border-y-0 border-r-0 border-l border-solid">
|
||||
<SpaceSwitchDropdown
|
||||
contentSideOffset={6}
|
||||
trigger={
|
||||
<button
|
||||
id="active-space-title-btn"
|
||||
type="button"
|
||||
className="no-drag focus-visible:ring-ds-ring-brand-default-focus/50 min-w-0 gap-1.5 px-2 text-label-sm font-bold !text-ds-text-neutral-default-default hover:bg-ds-bg-neutral-default-hover active:bg-ds-bg-neutral-default-active flex min-h-[28px] w-full max-w-[300px] flex-1 items-center rounded-full text-left outline-none focus-visible:ring-[3px]"
|
||||
aria-haspopup="menu"
|
||||
aria-label={activeSpaceTitle}
|
||||
>
|
||||
<span className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{activeSpaceTitle}
|
||||
</span>
|
||||
<ChevronsUpDown
|
||||
className="h-3.5 w-3.5 text-ds-icon-neutral-subtle-default shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
spaces={activeSpaces}
|
||||
activeSpaceId={activeSpaceId}
|
||||
switchingSpaceId={switchingSpaceId}
|
||||
canRenameActiveSpace={canRenameActiveSpace}
|
||||
createSpaceMenu={{
|
||||
onStartFromScratch: handleCreateBlankSpace,
|
||||
onSelectFolder: handleCreateSpaceFromFolder,
|
||||
}}
|
||||
onRenameSpace={openRenameSpaceDialog}
|
||||
onSpaceSelect={handleTopBarSpaceSelect}
|
||||
contentAlign="start"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{(!isHomeRoute || !projectSidebarFolded) && (
|
||||
<div className="drag min-h-0 min-w-0 flex-1" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
{/* Middle: draggable spacer that pushes trailing controls to the right */}
|
||||
<div className="drag h-7 min-h-0 min-w-0 flex-1" aria-hidden />
|
||||
|
||||
{/* Trailing: project actions (home only) + utilities + settings/back + update */}
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -117,8 +117,11 @@ const SelectTrigger = React.forwardRef<
|
|||
'whitespace-nowrap [&>span]:line-clamp-1',
|
||||
// Default surface (when no error/success)
|
||||
!state && variantTriggerBase[variant],
|
||||
// Interactive states (only when no error/success state)
|
||||
state !== 'error' &&
|
||||
// Interactive states (only when enabled and no error/success state).
|
||||
// Disabled triggers still match :hover, so the hover/focus classes
|
||||
// must be withheld or the disabled select keeps reacting to hover.
|
||||
!disabled &&
|
||||
state !== 'error' &&
|
||||
state !== 'success' &&
|
||||
variantTriggerInteractive[variant],
|
||||
// Validation states (override defaults)
|
||||
|
|
|
|||
38
src/lib/fileInfo.ts
Normal file
38
src/lib/fileInfo.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
/** Lowercased file extension from a name or path, or '' when there is none. */
|
||||
export function getFileExtension(value?: string): string {
|
||||
if (!value) return '';
|
||||
const normalized = value.split(/[?#]/)[0];
|
||||
const lastSegment = normalized.split(/[\\/]/).pop() || normalized;
|
||||
if (!lastSegment.includes('.')) return '';
|
||||
return lastSegment.split('.').pop()?.toLowerCase() || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal {@link FileInfo} from a file path (and optional display name),
|
||||
* inferring `type` from the extension and `isRemote` from an http(s) prefix.
|
||||
* Used to open the inline preview from chat-message file references where only
|
||||
* a path/name is available.
|
||||
*/
|
||||
export function fileInfoFromPath(path: string, name?: string): FileInfo {
|
||||
const cleanName = name || path.split(/[\\/]/).pop() || path;
|
||||
return {
|
||||
name: cleanName,
|
||||
path,
|
||||
type: getFileExtension(cleanName) || getFileExtension(path),
|
||||
isRemote: /^https?:\/\//i.test(path),
|
||||
};
|
||||
}
|
||||
|
|
@ -209,8 +209,11 @@ export default function SettingModels() {
|
|||
// Sidebar selected tab - default to cloud
|
||||
const [selectedTab, setSelectedTab] = useState<SidebarTab>('cloud');
|
||||
|
||||
// BYOK accordion state
|
||||
const [byokCollapsed, setByokCollapsed] = useState(false);
|
||||
// Subscription sub-accordion state (nested inside Custom Model)
|
||||
const [subscriptionCollapsed, setSubscriptionCollapsed] = useState(false);
|
||||
|
||||
// BYOK (API key) sub-accordion state (nested inside Custom Model)
|
||||
const [byokGroupCollapsed, setByokGroupCollapsed] = useState(false);
|
||||
|
||||
// Local Model accordion state
|
||||
const [localCollapsed, setLocalCollapsed] = useState(false);
|
||||
|
|
@ -580,8 +583,13 @@ export default function SettingModels() {
|
|||
setSelectedTab('cloud');
|
||||
} else if (category === 'custom') {
|
||||
setSelectedTab(`byok-${modelId}` as SidebarTab);
|
||||
// Expand BYOK section if collapsed
|
||||
if (byokCollapsed) setByokCollapsed(false);
|
||||
// Expand the relevant Custom Model sub-accordion if collapsed
|
||||
const target = items.find((item) => item.id === modelId);
|
||||
if (target?.authMode === 'oauth_subscription') {
|
||||
setSubscriptionCollapsed(false);
|
||||
} else {
|
||||
setByokGroupCollapsed(false);
|
||||
}
|
||||
} else if (category === 'local') {
|
||||
setSelectedTab(`local-${modelId}` as SidebarTab);
|
||||
// Expand Local section if collapsed
|
||||
|
|
@ -1166,14 +1174,18 @@ export default function SettingModels() {
|
|||
};
|
||||
const [credits, setCredits] = useState<any>(0);
|
||||
const [loadingCredits, setLoadingCredits] = useState(false);
|
||||
// True when the credits request failed (treated as "server not connected").
|
||||
const [creditsError, setCreditsError] = useState(false);
|
||||
const updateCredits = async () => {
|
||||
try {
|
||||
setLoadingCredits(true);
|
||||
const res = await proxyFetchGet(`/api/v1/user/current_credits`);
|
||||
console.log(res?.credits);
|
||||
setCredits(res?.credits);
|
||||
setCreditsError(false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setCreditsError(true);
|
||||
} finally {
|
||||
setLoadingCredits(false);
|
||||
}
|
||||
|
|
@ -1255,7 +1267,10 @@ export default function SettingModels() {
|
|||
modelId: string | null,
|
||||
isActive: boolean,
|
||||
isSubItem: boolean = false,
|
||||
isConfigured: boolean = false
|
||||
isConfigured: boolean = false,
|
||||
// When provided, renders a connection dot with this tone (overrides the
|
||||
// default binary "configured" green dot). `null` renders no dot.
|
||||
dotTone?: 'success' | 'error' | 'muted' | null
|
||||
) => {
|
||||
const modelImage = getModelImage(modelId);
|
||||
const fallbackIcon =
|
||||
|
|
@ -1271,13 +1286,13 @@ export default function SettingModels() {
|
|||
<button
|
||||
key={tabId}
|
||||
onClick={() => setSelectedTab(tabId)}
|
||||
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 transition-all duration-200 ${isSubItem ? 'pl-3' : ''} ${
|
||||
className={`rounded-xl px-3 py-2 flex w-full items-center justify-between transition-all duration-200 ${isSubItem ? 'pl-3' : ''} ${
|
||||
isActive
|
||||
? 'bg-ds-bg-neutral-subtle-default hover:bg-ds-bg-neutral-subtle-default'
|
||||
: 'bg-fill-fill-transparent hover:bg-fill-fill-transparent-hover'
|
||||
} `}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div className="gap-3 flex items-center justify-center">
|
||||
{modelImage ? (
|
||||
<img
|
||||
src={modelImage}
|
||||
|
|
@ -1302,9 +1317,21 @@ export default function SettingModels() {
|
|||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{isConfigured && (
|
||||
<div className="m-1 h-2 w-2 shrink-0 rounded-full bg-ds-text-success-default-default" />
|
||||
)}
|
||||
{dotTone !== undefined
|
||||
? dotTone && (
|
||||
<div
|
||||
className={`m-1 h-2 w-2 shrink-0 rounded-full ${
|
||||
dotTone === 'success'
|
||||
? 'bg-ds-text-success-default-default'
|
||||
: dotTone === 'error'
|
||||
? 'bg-ds-text-error-default-default'
|
||||
: 'bg-ds-text-neutral-default-default opacity-10'
|
||||
}`}
|
||||
/>
|
||||
)
|
||||
: isConfigured && (
|
||||
<div className="m-1 h-2 w-2 bg-ds-text-success-default-default shrink-0 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
|
@ -1450,7 +1477,7 @@ export default function SettingModels() {
|
|||
if (selectedTab === 'cloud') {
|
||||
if (import.meta.env.VITE_USE_LOCAL_PROXY === 'true') {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center text-ds-text-neutral-muted-default">
|
||||
<div className="h-64 text-ds-text-neutral-muted-default flex items-center justify-center">
|
||||
{t('setting.cloud-not-available-in-local-proxy')}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1462,44 +1489,57 @@ export default function SettingModels() {
|
|||
const trialTotalLimit =
|
||||
Number(subscription?.trial_total_credits_limit) || 1000;
|
||||
return (
|
||||
<div className="flex w-full flex-col rounded-2xl bg-ds-bg-neutral-subtle-default">
|
||||
<div className="mx-6 mb-4 flex flex-col justify-start self-stretch border-x-0 border-b-[0.5px] border-t-0 border-solid border-ds-border-neutral-default-default pb-4 pt-2">
|
||||
<div className="inline-flex items-center justify-start gap-2 self-stretch">
|
||||
<div className="text-body-base my-2 flex-1 justify-center font-bold text-ds-text-neutral-default-default">
|
||||
<div className="rounded-2xl bg-ds-bg-neutral-subtle-default flex w-full flex-col">
|
||||
<div className="mx-6 mb-4 border-ds-border-neutral-default-default pb-4 pt-2 flex flex-col justify-start self-stretch border-x-0 border-t-0 border-b-[0.5px] border-solid">
|
||||
<div className="gap-2 inline-flex items-center justify-start self-stretch">
|
||||
<div className="text-body-base my-2 font-bold text-ds-text-neutral-default-default flex-1 justify-center">
|
||||
{t('setting.eigent-cloud')}
|
||||
</div>
|
||||
{cloudPrefer ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="success"
|
||||
size="xs"
|
||||
buttonContent="text"
|
||||
textWeight="bold"
|
||||
buttonRadius="full"
|
||||
disabled
|
||||
>
|
||||
{t('setting.default')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
tone="neutral"
|
||||
size="xs"
|
||||
buttonContent="text"
|
||||
textWeight="bold"
|
||||
buttonRadius="full"
|
||||
className="!text-ds-text-neutral-muted-default"
|
||||
onClick={() => {
|
||||
setLocalPrefer(false);
|
||||
setActiveModelIdx(null);
|
||||
setForm((f) => f.map((fi) => ({ ...fi, prefer: false })));
|
||||
setCloudPrefer(true);
|
||||
setModelType('cloud');
|
||||
}}
|
||||
>
|
||||
{t('setting.set-as-default')}
|
||||
</Button>
|
||||
)}
|
||||
<div className="gap-2 flex items-center">
|
||||
{cloudPrefer ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="success"
|
||||
size="xs"
|
||||
buttonContent="text"
|
||||
textWeight="bold"
|
||||
buttonRadius="full"
|
||||
disabled
|
||||
>
|
||||
{t('setting.default')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
tone="neutral"
|
||||
size="xs"
|
||||
buttonContent="text"
|
||||
textWeight="bold"
|
||||
buttonRadius="full"
|
||||
className="!text-ds-text-neutral-muted-default"
|
||||
onClick={() => {
|
||||
setLocalPrefer(false);
|
||||
setActiveModelIdx(null);
|
||||
setForm((f) => f.map((fi) => ({ ...fi, prefer: false })));
|
||||
setCloudPrefer(true);
|
||||
setModelType('cloud');
|
||||
}}
|
||||
>
|
||||
{t('setting.set-as-default')}
|
||||
</Button>
|
||||
)}
|
||||
{/* Connection dot: green = connected with credits,
|
||||
grey = server not connected, error = out of credits. */}
|
||||
<div
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${
|
||||
creditsError
|
||||
? 'bg-ds-text-neutral-default-default opacity-10'
|
||||
: Number(credits) > 0
|
||||
? 'bg-ds-text-success-default-default'
|
||||
: 'bg-ds-text-error-default-default'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="justify-center self-stretch">
|
||||
<span className="text-body-sm text-ds-text-neutral-muted-default">
|
||||
|
|
@ -1510,7 +1550,7 @@ export default function SettingModels() {
|
|||
onClick={() => {
|
||||
window.location.href = `${SITE_URL}/pricing`;
|
||||
}}
|
||||
className="cursor-pointer text-body-sm text-ds-text-neutral-muted-default underline"
|
||||
className="text-body-sm text-ds-text-neutral-muted-default cursor-pointer underline"
|
||||
>
|
||||
{t('setting.pricing-options')}
|
||||
</span>
|
||||
|
|
@ -1520,9 +1560,9 @@ export default function SettingModels() {
|
|||
</div>
|
||||
</div>
|
||||
{/*Content Area*/}
|
||||
<div className="flex w-full flex-row items-start justify-between gap-4 px-6 pb-4">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex items-center gap-1 text-body-sm text-text-body">
|
||||
<div className="gap-4 px-6 pb-4 flex w-full flex-row items-start justify-between">
|
||||
<div className="min-w-0 gap-1 flex flex-1 flex-col">
|
||||
<div className="gap-1 text-body-sm text-text-body flex items-center">
|
||||
<span>{t('setting.credits')}:</span>
|
||||
{loadingCredits ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
|
|
@ -1531,7 +1571,7 @@ export default function SettingModels() {
|
|||
)}
|
||||
</div>
|
||||
{isTrialing && (
|
||||
<p className="m-0 max-w-[560px] text-label-sm leading-5 text-text-label">
|
||||
<p className="m-0 text-label-sm leading-5 text-text-label max-w-[560px]">
|
||||
{t('setting.trial-plan-notice-before-upgrade', {
|
||||
defaultValue:
|
||||
"You're on a trial. Your {{planName}} plan includes {{planCredits}} credits; the trial unlocks {{daily}} credits/day (up to {{total}}) before you upgrade.",
|
||||
|
|
@ -1543,7 +1583,7 @@ export default function SettingModels() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setTrialUpgradeDialogOpen(true)}
|
||||
className="cursor-pointer border-0 bg-transparent p-0 text-label-sm font-medium text-text-body underline"
|
||||
className="p-0 text-label-sm font-medium text-text-body cursor-pointer border-0 bg-transparent underline"
|
||||
>
|
||||
{t('setting.upgrade', { defaultValue: 'Upgrade' })}
|
||||
</button>{' '}
|
||||
|
|
@ -1613,9 +1653,9 @@ export default function SettingModels() {
|
|||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div className="flex w-full flex-1 items-center justify-between px-6 pb-4">
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-body-sm">
|
||||
<div className="px-6 pb-4 flex w-full flex-1 items-center justify-between">
|
||||
<div className="min-w-0 flex flex-1 items-center">
|
||||
<span className="text-body-sm overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{t('setting.select-model-type')}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -1654,31 +1694,44 @@ export default function SettingModels() {
|
|||
if (isSubscriptionAuth) {
|
||||
const isConnected = codexStatus.connected;
|
||||
const isDefault = modelType === 'codex_subscription';
|
||||
const statusLabel = isConnected
|
||||
? isDefault
|
||||
? t('setting.default')
|
||||
: t('setting.connected', { defaultValue: 'Connected' })
|
||||
: t('setting.not-configured');
|
||||
|
||||
return (
|
||||
<ConfigModelCard status={configCardRing}>
|
||||
<div className="mx-6 mb-4 flex flex-col items-start justify-between border-x-0 border-b-[0.5px] border-t-0 border-solid border-ds-border-neutral-default-default pb-4 pt-2">
|
||||
<div className="inline-flex items-center justify-between gap-2 self-stretch">
|
||||
<div className="mx-6 mb-4 border-ds-border-neutral-default-default pb-4 pt-2 flex flex-col items-start justify-between border-x-0 border-t-0 border-b-[0.5px] border-solid">
|
||||
<div className="gap-2 inline-flex items-center justify-between self-stretch">
|
||||
<div className="text-body-base my-2 font-bold text-ds-text-neutral-default-default">
|
||||
{item.name}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
tone={isDefault ? 'success' : 'neutral'}
|
||||
size="xs"
|
||||
buttonContent="text"
|
||||
textWeight="bold"
|
||||
disabled
|
||||
buttonRadius="full"
|
||||
>
|
||||
{statusLabel}
|
||||
</Button>
|
||||
<div className="gap-2 flex items-center">
|
||||
{isConnected ? (
|
||||
isDefault ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="success"
|
||||
size="xs"
|
||||
buttonContent="text"
|
||||
textWeight="bold"
|
||||
buttonRadius="full"
|
||||
disabled
|
||||
>
|
||||
{t('setting.default')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
tone="neutral"
|
||||
size="xs"
|
||||
buttonContent="text"
|
||||
textWeight="bold"
|
||||
buttonRadius="full"
|
||||
className="!text-ds-text-neutral-muted-default"
|
||||
disabled={codexBusy}
|
||||
onClick={handleCodexSetDefault}
|
||||
>
|
||||
{t('setting.set-as-default')}
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
<div
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${
|
||||
isConnected
|
||||
|
|
@ -1692,56 +1745,32 @@ export default function SettingModels() {
|
|||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-4 px-6 pb-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-body-sm font-medium text-ds-text-neutral-default-default">
|
||||
{t('setting.model-type')}
|
||||
</label>
|
||||
<Input
|
||||
value={codex_model_type}
|
||||
onChange={(e) => setCodexModelType(e.target.value)}
|
||||
placeholder="gpt-5.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-solid border-ds-border-neutral-default-default px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-body-sm font-medium text-ds-text-neutral-default-default">
|
||||
<div className="gap-4 px-6 pb-4 flex w-full flex-col">
|
||||
{/* Login row: left status text, right action */}
|
||||
<div className="gap-3 flex w-full items-center justify-between">
|
||||
<div className="min-w-0 flex flex-1 flex-col">
|
||||
<span className="text-body-sm text-ds-text-neutral-default-default">
|
||||
{isConnected
|
||||
? codexStatus.account_label ||
|
||||
t('setting.connected', { defaultValue: 'Connected' })
|
||||
: t('setting.not-configured')}
|
||||
</div>
|
||||
</span>
|
||||
{codexStatus.expires_at ? (
|
||||
<div className="text-body-xs text-ds-text-neutral-muted-default">
|
||||
<span className="text-body-xs text-ds-text-neutral-muted-default">
|
||||
{codexStatus.expires_at}
|
||||
</div>
|
||||
</span>
|
||||
) : null}
|
||||
{!isConnected && codexStatus.last_error_code ? (
|
||||
<div className="text-body-xs text-ds-text-neutral-muted-default">
|
||||
<span className="text-body-xs text-ds-text-neutral-muted-default">
|
||||
{codexStatus.last_error_code}
|
||||
</div>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{isConnected && !isDefault ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
tone="neutral"
|
||||
size="sm"
|
||||
buttonContent="text"
|
||||
textWeight="bold"
|
||||
buttonRadius="lg"
|
||||
disabled={codexBusy}
|
||||
onClick={handleCodexSetDefault}
|
||||
>
|
||||
{t('setting.set-as-default')}
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="ml-4 gap-2 flex shrink-0 items-center">
|
||||
{isConnected ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
tone="neutral"
|
||||
tone="error"
|
||||
size="sm"
|
||||
buttonContent="text"
|
||||
textWeight="bold"
|
||||
|
|
@ -1771,6 +1800,23 @@ export default function SettingModels() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model type row: left label, right input */}
|
||||
<div className="gap-3 flex w-full items-center justify-between">
|
||||
<div className="min-w-0 flex flex-1 items-center">
|
||||
<span className="text-body-sm overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{t('setting.model-type')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-4 flex-shrink-0">
|
||||
<Input
|
||||
value={codex_model_type}
|
||||
onChange={(e) => setCodexModelType(e.target.value)}
|
||||
placeholder="gpt-5.5"
|
||||
className="w-[220px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ConfigModelCard>
|
||||
);
|
||||
|
|
@ -1778,12 +1824,12 @@ export default function SettingModels() {
|
|||
|
||||
return (
|
||||
<ConfigModelCard status={configCardRing}>
|
||||
<div className="mx-6 mb-4 flex flex-col items-start justify-between border-x-0 border-b-[0.5px] border-t-0 border-solid border-ds-border-neutral-default-default pb-4 pt-2">
|
||||
<div className="inline-flex items-center justify-between gap-2 self-stretch">
|
||||
<div className="mx-6 mb-4 border-ds-border-neutral-default-default pb-4 pt-2 flex flex-col items-start justify-between border-x-0 border-t-0 border-b-[0.5px] border-solid">
|
||||
<div className="gap-2 inline-flex items-center justify-between self-stretch">
|
||||
<div className="text-body-base my-2 font-bold text-ds-text-neutral-default-default">
|
||||
{item.name}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="gap-2 flex items-center">
|
||||
{form[idx].prefer ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
|
|
@ -1823,9 +1869,9 @@ export default function SettingModels() {
|
|||
</Button>
|
||||
)}
|
||||
{form[idx].provider_id ? (
|
||||
<div className="h-2 w-2 shrink-0 rounded-full bg-ds-text-success-default-default" />
|
||||
<div className="h-2 w-2 bg-ds-text-success-default-default shrink-0 rounded-full" />
|
||||
) : (
|
||||
<div className="h-2 w-2 shrink-0 rounded-full bg-ds-text-neutral-default-default opacity-10" />
|
||||
<div className="h-2 w-2 bg-ds-text-neutral-default-default shrink-0 rounded-full opacity-10" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1846,7 +1892,7 @@ export default function SettingModels() {
|
|||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center gap-4 px-6">
|
||||
<div className="gap-4 px-6 flex w-full flex-col items-center">
|
||||
{/* API Key Setting */}
|
||||
<Input
|
||||
id={`apiKey-${item.id}`}
|
||||
|
|
@ -1928,9 +1974,9 @@ export default function SettingModels() {
|
|||
disabled={!form[idx].apiKey}
|
||||
disabledReason="Enter API Key first."
|
||||
onRefresh={() => void fetchCloudProviderModels(idx)}
|
||||
triggerPlaceholder={`${t('setting.enter-your-model-type')} ${
|
||||
item.name
|
||||
} ${t('setting.model-type')}`}
|
||||
triggerPlaceholder={t('setting.select-model-type', {
|
||||
defaultValue: 'Select model type',
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
|
|
@ -1962,7 +2008,7 @@ export default function SettingModels() {
|
|||
{item.externalConfig &&
|
||||
form[idx].externalConfig &&
|
||||
form[idx].externalConfig.map((ec, ecIdx) => (
|
||||
<div key={ec.key} className="flex h-full w-full flex-col gap-4">
|
||||
<div key={ec.key} className="gap-4 flex h-full w-full flex-col">
|
||||
{ec.options && ec.options.length > 0 ? (
|
||||
<Select
|
||||
value={ec.value}
|
||||
|
|
@ -2053,7 +2099,7 @@ export default function SettingModels() {
|
|||
))}
|
||||
</div>
|
||||
{/* Action Button */}
|
||||
<div className="flex justify-end gap-2 px-6 py-4">
|
||||
<div className="gap-2 px-6 py-4 flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
tone="neutral"
|
||||
|
|
@ -2099,9 +2145,9 @@ export default function SettingModels() {
|
|||
|
||||
return (
|
||||
<ConfigModelCard status={configCardRing}>
|
||||
<div className="mx-6 mb-4 flex flex-col items-start justify-between border-x-0 border-b-[0.5px] border-t-0 border-solid border-ds-border-neutral-default-default pb-4 pt-2">
|
||||
<div className="inline-flex items-center justify-between gap-2 self-stretch">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mx-6 mb-4 border-ds-border-neutral-default-default pb-4 pt-2 flex flex-col items-start justify-between border-x-0 border-t-0 border-b-[0.5px] border-solid">
|
||||
<div className="gap-2 inline-flex items-center justify-between self-stretch">
|
||||
<div className="gap-2 flex items-center">
|
||||
<div className="text-body-base my-2 font-bold text-ds-text-neutral-default-default">
|
||||
{getLocalPlatformName(platform)}
|
||||
</div>
|
||||
|
|
@ -2131,7 +2177,7 @@ export default function SettingModels() {
|
|||
onClick={() => handleLocalSwitch(true)}
|
||||
className={
|
||||
isConfigured
|
||||
? 'bg-ds-bg-neutral-default-hover !text-ds-text-neutral-muted-default shadow-none hover:bg-ds-bg-neutral-default-active'
|
||||
? 'bg-ds-bg-neutral-default-hover !text-ds-text-neutral-muted-default hover:bg-ds-bg-neutral-default-active shadow-none'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
|
|
@ -2142,14 +2188,14 @@ export default function SettingModels() {
|
|||
)}
|
||||
</div>
|
||||
{isConfigured ? (
|
||||
<div className="h-2 w-2 rounded-full bg-text-success" />
|
||||
<div className="h-2 w-2 bg-text-success rounded-full" />
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-text-label opacity-10" />
|
||||
<div className="h-2 w-2 bg-text-label rounded-full opacity-10" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Model Endpoint URL Setting */}
|
||||
<div className="flex w-full flex-col items-center gap-4 px-6">
|
||||
<div className="gap-4 px-6 flex w-full flex-col items-center">
|
||||
<Input
|
||||
size="default"
|
||||
title={t('setting.model-endpoint-url')}
|
||||
|
|
@ -2191,8 +2237,8 @@ export default function SettingModels() {
|
|||
note={localError ?? undefined}
|
||||
/>
|
||||
{isModelListPlatform ? (
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="flex w-full items-end gap-2">
|
||||
<div className="gap-1 flex w-full flex-col">
|
||||
<div className="gap-2 flex w-full items-end">
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={currentType}
|
||||
|
|
@ -2290,7 +2336,7 @@ export default function SettingModels() {
|
|||
)}
|
||||
</div>
|
||||
{/* Action Button */}
|
||||
<div className="flex justify-end gap-2 px-6 py-4">
|
||||
<div className="gap-2 px-6 py-4 flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
tone="neutral"
|
||||
|
|
@ -2325,8 +2371,8 @@ export default function SettingModels() {
|
|||
return (
|
||||
<div className="m-auto flex h-auto w-full flex-1 flex-col">
|
||||
{/* Header Section */}
|
||||
<div className="z-10 flex w-full items-center justify-between px-6 pb-6 pt-8">
|
||||
<div className="flex w-full flex-col items-start justify-between gap-4">
|
||||
<div className="px-6 pb-6 pt-8 z-10 flex w-full items-center justify-between">
|
||||
<div className="gap-4 flex w-full flex-col items-start justify-between">
|
||||
<div className="flex flex-col">
|
||||
<div className="text-heading-sm font-bold text-ds-text-neutral-default-default">
|
||||
{t('setting.models')}
|
||||
|
|
@ -2335,10 +2381,10 @@ export default function SettingModels() {
|
|||
</div>
|
||||
</div>
|
||||
{/* Content Section */}
|
||||
<div className="mb-8 flex flex-col gap-6">
|
||||
<div className="mb-8 gap-6 flex flex-col">
|
||||
{/* Default Model Cascading Dropdown */}
|
||||
<div className="flex w-full flex-col items-end justify-between gap-4 rounded-2xl bg-ds-bg-neutral-default-default px-6 py-4">
|
||||
<div className="flex w-full flex-col items-start justify-center gap-1">
|
||||
<div className="gap-4 rounded-2xl bg-ds-bg-neutral-default-default px-6 py-4 flex w-full flex-col items-end justify-between">
|
||||
<div className="gap-1 flex w-full flex-col items-start justify-center">
|
||||
<div className="text-body-base font-bold text-ds-text-neutral-default-default">
|
||||
{t('setting.models-default-setting-title')}
|
||||
</div>
|
||||
|
|
@ -2352,11 +2398,11 @@ export default function SettingModels() {
|
|||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex w-fit items-center gap-2 rounded-lg border-[0.5px] border-solid border-ds-bg-brand-default-default bg-ds-bg-brand-default-default px-3 py-1 font-semibold text-ds-text-brand-inverse-default outline-none transition-colors hover:border-ds-bg-brand-default-hover hover:bg-ds-bg-brand-default-hover focus:outline-none focus-visible:outline-none active:border-ds-bg-brand-default-active active:bg-ds-bg-brand-default-active">
|
||||
<span className="whitespace-nowrap text-body-sm leading-none">
|
||||
<button className="gap-2 rounded-lg border-ds-bg-brand-default-default bg-ds-bg-brand-default-default px-3 py-1 font-semibold text-ds-text-brand-inverse-default hover:border-ds-bg-brand-default-hover hover:bg-ds-bg-brand-default-hover active:border-ds-bg-brand-default-active active:bg-ds-bg-brand-default-active flex w-fit items-center border-[0.5px] border-solid transition-colors outline-none focus:outline-none focus-visible:outline-none">
|
||||
<span className="text-body-sm leading-none whitespace-nowrap">
|
||||
{getDefaultModelDisplayText()}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 flex-shrink-0 !text-ds-text-brand-inverse-default" />
|
||||
<ChevronDown className="h-4 w-4 !text-ds-text-brand-inverse-default flex-shrink-0" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[180px]">
|
||||
|
|
@ -2424,7 +2470,7 @@ export default function SettingModels() {
|
|||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="gap-2 flex items-center">
|
||||
{modelImage ? (
|
||||
<img
|
||||
src={modelImage}
|
||||
|
|
@ -2445,15 +2491,15 @@ export default function SettingModels() {
|
|||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="gap-1 flex items-center">
|
||||
{!isConfigured && (
|
||||
<div className="h-2 w-2 rounded-full bg-text-label opacity-10" />
|
||||
<div className="h-2 w-2 bg-text-label rounded-full opacity-10" />
|
||||
)}
|
||||
{isPreferred && (
|
||||
<Check className="h-4 w-4 text-ds-text-status-completed-strong-default" />
|
||||
)}
|
||||
{isConfigured && !isPreferred && (
|
||||
<div className="h-2 w-2 rounded-full bg-text-success" />
|
||||
<div className="h-2 w-2 bg-text-success rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -2485,7 +2531,7 @@ export default function SettingModels() {
|
|||
}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="gap-2 flex items-center">
|
||||
{modelImage ? (
|
||||
<img
|
||||
src={modelImage}
|
||||
|
|
@ -2506,15 +2552,15 @@ export default function SettingModels() {
|
|||
{model.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="gap-1 flex items-center">
|
||||
{!isConfigured && (
|
||||
<div className="h-2 w-2 rounded-full bg-text-label opacity-10" />
|
||||
<div className="h-2 w-2 bg-text-label rounded-full opacity-10" />
|
||||
)}
|
||||
{isPreferred && (
|
||||
<Check className="h-4 w-4 text-ds-text-status-completed-strong-default" />
|
||||
)}
|
||||
{isConfigured && !isPreferred && (
|
||||
<div className="h-2 w-2 rounded-full bg-text-success" />
|
||||
<div className="h-2 w-2 bg-text-success rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -2527,17 +2573,17 @@ export default function SettingModels() {
|
|||
</div>
|
||||
|
||||
{/* Content Section with Sidebar */}
|
||||
<div className="flex w-full flex-col items-start justify-between rounded-2xl bg-ds-bg-neutral-default-default px-3 py-2">
|
||||
<div className="text-body-base sticky top-[48px] z-10 mb-4 w-full border-x-0 border-b-[0.5px] border-t-0 border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default px-3 py-2 pb-2 font-bold text-ds-text-neutral-default-default">
|
||||
<div className="rounded-2xl bg-ds-bg-neutral-default-default px-3 py-2 flex w-full flex-col items-start justify-between">
|
||||
<div className="text-body-base mb-4 border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default px-3 py-2 pb-2 font-bold text-ds-text-neutral-default-default sticky top-[48px] z-10 w-full border-x-0 border-t-0 border-b-[0.5px] border-solid">
|
||||
{t('setting.models-configuration')}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-row items-start justify-between px-3">
|
||||
<div className="px-3 flex w-full flex-row items-start justify-between">
|
||||
{/* Sidebar */}
|
||||
<div className="-ml-2 mr-4 h-full w-[240px] rounded-2xl bg-ds-bg-neutral-default-default">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="-ml-2 mr-4 rounded-2xl bg-ds-bg-neutral-default-default h-full w-[240px]">
|
||||
<div className="gap-4 flex flex-col">
|
||||
{/* Eigent Cloud Section */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="gap-1 flex flex-col">
|
||||
<div className="px-3 py-2 text-body-sm font-bold text-ds-text-neutral-default-default">
|
||||
{t('setting.eigent-cloud')}
|
||||
</div>
|
||||
|
|
@ -2548,51 +2594,114 @@ export default function SettingModels() {
|
|||
'cloud',
|
||||
selectedTab === 'cloud',
|
||||
false,
|
||||
cloudPrefer
|
||||
cloudPrefer,
|
||||
creditsError
|
||||
? 'muted'
|
||||
: Number(credits) > 0
|
||||
? 'success'
|
||||
: 'error'
|
||||
)}
|
||||
</div>
|
||||
{/* Bring Your Own Key Section */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => setByokCollapsed(!byokCollapsed)}
|
||||
className="flex items-center justify-between rounded-lg bg-transparent px-3 py-2 transition-colors hover:bg-ds-bg-neutral-default-default"
|
||||
>
|
||||
<div className="text-body-sm font-bold text-ds-text-neutral-default-default">
|
||||
{t('setting.custom-model')}
|
||||
</div>
|
||||
{byokCollapsed ? (
|
||||
<ChevronDown className="h-4 w-4 text-ds-text-neutral-muted-default" />
|
||||
) : (
|
||||
<ChevronUp className="h-4 w-4 text-ds-text-neutral-muted-default" />
|
||||
{/* Custom Model Section */}
|
||||
<div className="gap-1 flex flex-col">
|
||||
<div className="px-3 py-2 text-body-sm font-bold text-ds-text-neutral-default-default">
|
||||
{t('setting.custom-model')}
|
||||
</div>
|
||||
<div className="gap-2 flex flex-col">
|
||||
{/* Subscription sub-accordion (OAuth login providers) */}
|
||||
{items.some(
|
||||
(item) => item.authMode === 'oauth_subscription'
|
||||
) && (
|
||||
<div className="gap-1 flex flex-col">
|
||||
<button
|
||||
onClick={() =>
|
||||
setSubscriptionCollapsed(!subscriptionCollapsed)
|
||||
}
|
||||
className="rounded-lg px-3 py-2 hover:bg-ds-bg-neutral-default-default flex items-center justify-between bg-transparent transition-colors"
|
||||
>
|
||||
<div className="text-body-sm font-medium text-ds-text-neutral-muted-default">
|
||||
{t('setting.subscription', {
|
||||
defaultValue: 'Subscription',
|
||||
})}
|
||||
</div>
|
||||
{subscriptionCollapsed ? (
|
||||
<ChevronDown className="h-4 w-4 text-ds-text-neutral-muted-default" />
|
||||
) : (
|
||||
<ChevronUp className="h-4 w-4 text-ds-text-neutral-muted-default" />
|
||||
)}
|
||||
</button>
|
||||
<div
|
||||
className={`ease-in-out overflow-hidden transition-all duration-300 ${
|
||||
subscriptionCollapsed
|
||||
? 'max-h-0 opacity-0'
|
||||
: 'max-h-[2000px] opacity-100'
|
||||
}`}
|
||||
>
|
||||
{items.map((item) =>
|
||||
item.authMode === 'oauth_subscription'
|
||||
? renderSidebarItem(
|
||||
`byok-${item.id}` as SidebarTab,
|
||||
item.name,
|
||||
item.id,
|
||||
selectedTab === `byok-${item.id}`,
|
||||
true,
|
||||
codexStatus.connected
|
||||
)
|
||||
: null
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-300 ease-in-out ${
|
||||
byokCollapsed
|
||||
? 'max-h-0 opacity-0'
|
||||
: 'max-h-[2000px] opacity-100'
|
||||
}`}
|
||||
>
|
||||
{items.map((item, idx) =>
|
||||
renderSidebarItem(
|
||||
`byok-${item.id}` as SidebarTab,
|
||||
item.name,
|
||||
item.id,
|
||||
selectedTab === `byok-${item.id}`,
|
||||
true,
|
||||
item.authMode === 'oauth_subscription'
|
||||
? codexStatus.connected
|
||||
: !!form[idx].provider_id
|
||||
)
|
||||
{/* BYOK (API key) sub-accordion */}
|
||||
{items.some(
|
||||
(item) => item.authMode !== 'oauth_subscription'
|
||||
) && (
|
||||
<div className="gap-1 flex flex-col">
|
||||
<button
|
||||
onClick={() =>
|
||||
setByokGroupCollapsed(!byokGroupCollapsed)
|
||||
}
|
||||
className="rounded-lg px-3 py-2 hover:bg-ds-bg-neutral-default-default flex items-center justify-between bg-transparent transition-colors"
|
||||
>
|
||||
<div className="text-body-sm font-medium text-ds-text-neutral-muted-default">
|
||||
{t('setting.byok', { defaultValue: 'BYOK' })}
|
||||
</div>
|
||||
{byokGroupCollapsed ? (
|
||||
<ChevronDown className="h-4 w-4 text-ds-text-neutral-muted-default" />
|
||||
) : (
|
||||
<ChevronUp className="h-4 w-4 text-ds-text-neutral-muted-default" />
|
||||
)}
|
||||
</button>
|
||||
<div
|
||||
className={`ease-in-out overflow-hidden transition-all duration-300 ${
|
||||
byokGroupCollapsed
|
||||
? 'max-h-0 opacity-0'
|
||||
: 'max-h-[2000px] opacity-100'
|
||||
}`}
|
||||
>
|
||||
{items.map((item, idx) =>
|
||||
item.authMode === 'oauth_subscription'
|
||||
? null
|
||||
: renderSidebarItem(
|
||||
`byok-${item.id}` as SidebarTab,
|
||||
item.name,
|
||||
item.id,
|
||||
selectedTab === `byok-${item.id}`,
|
||||
true,
|
||||
!!form[idx].provider_id
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Local Model Section */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="gap-1 flex flex-col">
|
||||
<button
|
||||
onClick={() => setLocalCollapsed(!localCollapsed)}
|
||||
className="flex items-center justify-between rounded-lg bg-transparent px-3 py-2 transition-colors hover:bg-ds-bg-neutral-default-default"
|
||||
className="rounded-lg px-3 py-2 hover:bg-ds-bg-neutral-default-default flex items-center justify-between bg-transparent transition-colors"
|
||||
>
|
||||
<div className="text-body-sm font-bold text-ds-text-neutral-default-default">
|
||||
{t('setting.local-model')}
|
||||
|
|
@ -2604,7 +2713,7 @@ export default function SettingModels() {
|
|||
)}
|
||||
</button>
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-300 ease-in-out ${
|
||||
className={`ease-in-out overflow-hidden transition-all duration-300 ${
|
||||
localCollapsed
|
||||
? 'max-h-0 opacity-0'
|
||||
: 'max-h-[2000px] opacity-100'
|
||||
|
|
@ -2655,7 +2764,7 @@ export default function SettingModels() {
|
|||
</div>
|
||||
</div>
|
||||
{/* Main Content */}
|
||||
<div className="sticky top-[136px] z-10 min-w-0 flex-1">
|
||||
<div className="min-w-0 sticky top-[136px] z-10 flex-1">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,24 +12,21 @@
|
|||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { ChevronDown, Loader2, RotateCcw } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Loader2, RotateCcw } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { ProviderModelGroup } from '@/lib/providerModels';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Props = {
|
||||
/** Stable id used for "selected" comparison and aria-label scoping. */
|
||||
|
|
@ -44,7 +41,7 @@ type Props = {
|
|||
error: string | null;
|
||||
/** Disable everything when the user hasn't filled in an API key yet. */
|
||||
disabled: boolean;
|
||||
/** Reason to show inside the popover when disabled (e.g. "Enter API Key first"). */
|
||||
/** Reason to show inside the dropdown when disabled (e.g. "Enter API Key first"). */
|
||||
disabledReason?: string;
|
||||
onRefresh: () => void;
|
||||
triggerPlaceholder?: string;
|
||||
|
|
@ -57,6 +54,11 @@ function splitPrefix(id: string): [string, string] {
|
|||
return [id.slice(0, idx), id.slice(idx + 1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Model-type picker for providers that expose a `/models` endpoint
|
||||
* (Nebius, OrcaRouter). A full-width {@link Select} (matching the Eigent Cloud
|
||||
* model select) with a trailing rounded "Refresh" button to re-fetch the list.
|
||||
*/
|
||||
export function ProviderModelCombobox({
|
||||
providerName,
|
||||
title,
|
||||
|
|
@ -70,211 +72,112 @@ export function ProviderModelCombobox({
|
|||
onRefresh,
|
||||
triggerPlaceholder,
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Default the active left-column entry to the provider of the saved value,
|
||||
// falling back to the first provider with at least one model.
|
||||
const initialActiveProvider = useMemo(() => {
|
||||
if (value) {
|
||||
const [prefix] = splitPrefix(value);
|
||||
if (prefix && groups.some((g) => g.provider === prefix)) return prefix;
|
||||
}
|
||||
const first = groups.find((g) => g.models.length > 0);
|
||||
return first?.provider ?? '';
|
||||
}, [value, groups]);
|
||||
|
||||
const [activeProvider, setActiveProvider] = useState<string>(
|
||||
initialActiveProvider
|
||||
);
|
||||
|
||||
// Keep activeProvider sane if `groups` changes (e.g. after a refresh).
|
||||
useEffect(() => {
|
||||
if (!activeProvider && initialActiveProvider) {
|
||||
setActiveProvider(initialActiveProvider);
|
||||
} else if (
|
||||
activeProvider &&
|
||||
groups.length > 0 &&
|
||||
!groups.some((g) => g.provider === activeProvider)
|
||||
) {
|
||||
setActiveProvider(initialActiveProvider);
|
||||
}
|
||||
}, [groups, activeProvider, initialActiveProvider]);
|
||||
|
||||
// Saved value not present in any group — surface a one-row "Current" section.
|
||||
// Saved value not present in any group — surface it as a "Current" entry so
|
||||
// the select can still display and keep the existing selection.
|
||||
const orphanValue = useMemo(() => {
|
||||
if (!value) return null;
|
||||
const known = groups.some((g) => g.models.some((m) => m.id === value));
|
||||
return known ? null : value;
|
||||
}, [value, groups]);
|
||||
|
||||
// Models for the right column: active provider's models filtered by query.
|
||||
const activeModels = useMemo(() => {
|
||||
const group = groups.find((g) => g.provider === activeProvider);
|
||||
if (!group) return [];
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return group.models;
|
||||
return group.models.filter((m) => m.id.toLowerCase().includes(q));
|
||||
}, [groups, activeProvider, query]);
|
||||
|
||||
const hasAnyModels = groups.some((g) => g.models.length > 0);
|
||||
|
||||
// The select is only usable once there is something to pick: an API key must
|
||||
// be set AND the model list must have been fetched (refreshed). A previously
|
||||
// saved value (orphan) still counts so the user can keep their selection.
|
||||
const selectDisabled = disabled || (!hasAnyModels && !orphanValue);
|
||||
|
||||
const emptyMessage = loading
|
||||
? t('setting.loading', { defaultValue: 'Loading...' })
|
||||
: disabled
|
||||
? (disabledReason ??
|
||||
t('setting.enter-api-key-first', {
|
||||
defaultValue: 'Enter API Key first.',
|
||||
}))
|
||||
: t('setting.click-refresh-to-load-models', {
|
||||
defaultValue: 'Click refresh to load models.',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
{title ? (
|
||||
<div className="mb-1.5 flex items-center gap-1 text-body-sm font-bold text-text-heading">
|
||||
<div className="mb-1.5 gap-1 text-body-sm font-bold text-ds-text-neutral-default-default flex items-center">
|
||||
{title}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-label={`${providerName} model type`}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border px-3 text-body-sm transition-colors',
|
||||
'border-input-border-default bg-input-bg-default text-text-heading',
|
||||
'hover:border-input-border-hover focus:border-input-border-focus focus:outline-none',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
error && 'border-input-border-cuation'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'truncate text-left',
|
||||
!value && 'text-input-label-default'
|
||||
)}
|
||||
>
|
||||
{value || triggerPlaceholder || 'Select model'}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 flex-shrink-0 opacity-60" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
<div className="gap-2 flex w-full items-center">
|
||||
<Select
|
||||
value={value || undefined}
|
||||
onValueChange={onChange}
|
||||
disabled={selectDisabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
wrapperClassName="min-w-0 flex-1"
|
||||
state={error ? 'error' : undefined}
|
||||
note={error ?? undefined}
|
||||
aria-label={`${providerName} model type`}
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="Search model..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
|
||||
{!hasAnyModels && !orphanValue ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-text-label">
|
||||
{loading
|
||||
? 'Loading...'
|
||||
: disabled
|
||||
? (disabledReason ?? 'Enter API Key first.')
|
||||
: 'Click the refresh button to load models.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex max-h-80">
|
||||
{/* Left column: provider list */}
|
||||
<div className="w-[120px] flex-shrink-0 overflow-y-auto border-r border-border-secondary py-1">
|
||||
{orphanValue ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveProvider('__orphan__')}
|
||||
className={cn(
|
||||
'flex w-full items-center px-3 py-1.5 text-left text-xs text-text-label transition-colors',
|
||||
activeProvider === '__orphan__'
|
||||
? 'bg-button-transparent-fill-hover text-text-heading'
|
||||
: 'hover:bg-button-transparent-fill-hover'
|
||||
)}
|
||||
>
|
||||
Current
|
||||
</button>
|
||||
) : null}
|
||||
{groups.map((g) => (
|
||||
<button
|
||||
key={g.provider}
|
||||
type="button"
|
||||
onClick={() => setActiveProvider(g.provider)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between px-3 py-1.5 text-left text-xs transition-colors',
|
||||
activeProvider === g.provider
|
||||
? 'bg-button-transparent-fill-hover text-text-heading'
|
||||
: 'text-text-label hover:bg-button-transparent-fill-hover'
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{g.provider}</span>
|
||||
<span className="ml-2 flex-shrink-0 text-text-label opacity-60">
|
||||
{g.models.length}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right column: models for active provider */}
|
||||
<CommandList className="max-h-80 flex-1">
|
||||
{activeProvider === '__orphan__' && orphanValue ? (
|
||||
<CommandItem
|
||||
value={orphanValue}
|
||||
onSelect={() => {
|
||||
onChange(orphanValue);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{orphanValue}</span>
|
||||
</CommandItem>
|
||||
) : activeModels.length > 0 ? (
|
||||
activeModels.map((m) => {
|
||||
<SelectValue
|
||||
placeholder={triggerPlaceholder ?? 'Select model type'}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{!hasAnyModels && !orphanValue ? (
|
||||
<div className="px-3 py-6 text-xs text-ds-text-neutral-muted-default text-center">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{orphanValue ? (
|
||||
<SelectGroup>
|
||||
<SelectLabel>
|
||||
{t('setting.current', { defaultValue: 'Current' })}
|
||||
</SelectLabel>
|
||||
<SelectItem value={orphanValue}>{orphanValue}</SelectItem>
|
||||
</SelectGroup>
|
||||
) : null}
|
||||
{groups.map((g) =>
|
||||
g.models.length > 0 ? (
|
||||
<SelectGroup key={g.provider}>
|
||||
{g.provider ? (
|
||||
<SelectLabel>{g.provider}</SelectLabel>
|
||||
) : null}
|
||||
{g.models.map((m) => {
|
||||
const [, modelName] = splitPrefix(m.id);
|
||||
return (
|
||||
<CommandItem
|
||||
key={m.id}
|
||||
value={m.id}
|
||||
onSelect={() => {
|
||||
onChange(m.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
value === m.id &&
|
||||
'bg-button-transparent-fill-hover'
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{modelName}</span>
|
||||
</CommandItem>
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{modelName}
|
||||
</SelectItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<CommandEmpty>
|
||||
{query.trim() ? 'No matches.' : 'No models.'}
|
||||
</CommandEmpty>
|
||||
)}
|
||||
</CommandList>
|
||||
</div>
|
||||
)}
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
})}
|
||||
</SelectGroup>
|
||||
) : null
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
buttonRadius="full"
|
||||
onClick={onRefresh}
|
||||
disabled={disabled || loading}
|
||||
aria-label={`Refresh ${providerName} models`}
|
||||
className="flex-shrink-0"
|
||||
className="text-body-sm shrink-0"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
<RotateCcw className="!h-4 !w-4" />
|
||||
)}
|
||||
{t('setting.refresh', { defaultValue: 'Refresh' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="text-text-cuation mt-1.5 text-xs">{error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,6 +134,18 @@ interface PageTabState {
|
|||
setScrollToTurnRequest: (
|
||||
request: { projectId: string; taskId: string } | null
|
||||
) => void;
|
||||
|
||||
// ── Inline file preview (project page) ───────────────────────────────────
|
||||
/** Whether the inline file preview column is open beside the chat content. */
|
||||
filePreviewOpen: boolean;
|
||||
/** File currently shown in the inline preview, or null for the empty state. */
|
||||
filePreviewFile: FileInfo | null;
|
||||
/** Open the preview column. Pass a file to show it, or null for the empty state. */
|
||||
openFilePreview: (file?: FileInfo | null) => void;
|
||||
/** Close the preview column (keeps the last file for a subsequent re-open). */
|
||||
closeFilePreview: () => void;
|
||||
/** Toggle the preview column open/closed without changing the selected file. */
|
||||
toggleFilePreview: () => void;
|
||||
}
|
||||
|
||||
export const usePageTabStore = create<PageTabState>()(
|
||||
|
|
@ -321,6 +333,17 @@ export const usePageTabStore = create<PageTabState>()(
|
|||
scrollToTurnRequest: null,
|
||||
setScrollToTurnRequest: (request) =>
|
||||
set({ scrollToTurnRequest: request }),
|
||||
|
||||
filePreviewOpen: false,
|
||||
filePreviewFile: null,
|
||||
openFilePreview: (file) =>
|
||||
set((state) => ({
|
||||
filePreviewOpen: true,
|
||||
filePreviewFile: file === undefined ? state.filePreviewFile : file,
|
||||
})),
|
||||
closeFilePreview: () => set({ filePreviewOpen: false }),
|
||||
toggleFilePreview: () =>
|
||||
set((state) => ({ filePreviewOpen: !state.filePreviewOpen })),
|
||||
}),
|
||||
{
|
||||
name: 'eigent-page-tab',
|
||||
|
|
|
|||
|
|
@ -85,7 +85,9 @@ describe('FileTree', () => {
|
|||
const buttons = container.querySelectorAll('button');
|
||||
expect(buttons.length).toBe(2);
|
||||
buttons.forEach((btn) => {
|
||||
const firstCol = btn.querySelector('[class*="h-4"][class*="w-4"]');
|
||||
// Folder rows wrap the chevron in a `w-4` spacer; file rows render a
|
||||
// `size-4` icon. Both reserve a consistent 16px first column.
|
||||
const firstCol = btn.querySelector('[class*="w-4"], [class*="size-4"]');
|
||||
expect(firstCol).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -133,7 +135,7 @@ describe('FileTree', () => {
|
|||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByRole('button', { name: /src/i }));
|
||||
expect(onToggleFolder).toHaveBeenCalledWith('folder:src');
|
||||
expect(onToggleFolder).toHaveBeenCalledWith('/proj/src');
|
||||
});
|
||||
|
||||
it('calls onSelectFile when file row is clicked', async () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue