ui polish and ux bug fix for new added feature

This commit is contained in:
Douglas 2026-06-26 02:50:24 +01:00
parent 0f99676c48
commit d9fc758e0a
19 changed files with 1595 additions and 1311 deletions

View file

@ -51,6 +51,7 @@ export default [
// Build outputs
'dist/**',
'dist-electron/**',
'**/dist/**',
'build/**',
'release/**',
// Cache

View file

@ -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>

View file

@ -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"

View file

@ -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, '&quot;')}"`
);
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 (
<>

View file

@ -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">

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

View file

@ -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>
);
}

View file

@ -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'}

View file

@ -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>
);

View file

@ -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 (

View file

@ -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 (

View file

@ -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')

View file

@ -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

View file

@ -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
View 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),
};
}

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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',

View file

@ -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 () => {