Merge remote-tracking branch 'origin/main' into fix/tooltips

# Conflicts:
#	src/components/Session/HeaderBox/index.tsx
#	src/components/TopBar/index.tsx
This commit is contained in:
4pmtong 2026-07-17 16:52:22 +08:00
commit ff57b79bdf
146 changed files with 8101 additions and 984 deletions

View file

@ -20,7 +20,7 @@ import { StackProvider, StackTheme } from '@stackframe/react';
import { QueryClientProvider } from '@tanstack/react-query';
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Toaster } from 'sonner';
import { Toaster } from './components/ui/sonner';
import { useBackgroundTaskProcessor } from './hooks/useBackgroundTaskProcessor';
import { useExecutionSubscription } from './hooks/useExecutionSubscription';
import { useRemoteControlBridge } from './hooks/useRemoteControlBridge';
@ -92,7 +92,7 @@ function App() {
return (
<StackProvider app={stackClientApp}>
<StackTheme>{content}</StackTheme>
<Toaster style={{ zIndex: '999999 !important', position: 'fixed' }} />
<Toaster style={{ zIndex: 999999, position: 'fixed' }} />
</StackProvider>
);
}

View file

@ -36,6 +36,11 @@ import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import { INIT_PROVODERS } from '@/lib/llm';
import {
type AgentModelConfigSource,
buildAgentModelConfig,
buildAgentModelConfigFromProvider,
} from '@/lib/modelConfig';
import {
getLocalPlatformName,
LOCAL_MODEL_OPTIONS,
@ -71,11 +76,10 @@ interface McpItem {
type WorkerModelMode = 'eigent' | 'custom' | 'local';
interface WorkerModelOption {
interface WorkerModelOption extends AgentModelConfigSource {
value: string;
label: string;
model_platform: string;
model_type: string;
provider_id?: number;
}
export function AddWorker({
@ -125,6 +129,7 @@ export function AddWorker({
const [workerModelMode, setWorkerModelMode] =
useState<WorkerModelMode>('eigent');
const [workerModelName, setWorkerModelName] = useState('');
const [modelSelectionTouched, setModelSelectionTouched] = useState(false);
const [customModelOptions, setCustomModelOptions] = useState<
WorkerModelOption[]
>([]);
@ -300,6 +305,7 @@ export function AddWorker({
setShowModelConfig(false);
setWorkerModelMode('eigent');
setWorkerModelName('');
setModelSelectionTouched(false);
setCustomModelOptions([]);
setLocalModelOptions([]);
};
@ -322,10 +328,63 @@ export function AddWorker({
setWorkerName(workerInfo.workerInfo?.name || '');
setWorkerDescription(workerInfo.workerInfo?.description || '');
setSelectedTools(workerInfo.workerInfo?.selectedTools || []);
setWorkerModelMode('eigent');
setWorkerModelName('');
setModelSelectionTouched(false);
setShowModelConfig(
Number.isInteger(workerInfo.workerInfo?.model_provider_id)
);
}, [dialogOpen, edit, workerInfo]);
useEffect(() => {
if (
!dialogOpen ||
!edit ||
!showModelConfig ||
!workerInfo ||
modelSelectionTouched
)
return;
const providerId = workerInfo.workerInfo?.model_provider_id;
if (!Number.isInteger(providerId)) return;
const customOption = customModelOptions.find(
(option) => option.provider_id === providerId
);
if (customOption) {
setWorkerModelMode('custom');
setWorkerModelName(customOption.value);
return;
}
const localOption = localModelOptions.find(
(option) => option.provider_id === providerId
);
if (localOption) {
setWorkerModelMode('local');
setWorkerModelName(localOption.value);
}
}, [
customModelOptions,
dialogOpen,
edit,
localModelOptions,
modelSelectionTouched,
showModelConfig,
workerInfo,
]);
useEffect(() => {
if (!showModelConfig) return;
const editingProviderId = workerInfo?.workerInfo?.model_provider_id;
if (
dialogOpen &&
edit &&
Number.isInteger(editingProviderId) &&
!modelSelectionTouched
) {
return;
}
const options = activeWorkerModelOptions;
if (options.length === 0) {
setWorkerModelName('');
@ -334,7 +393,15 @@ export function AddWorker({
if (!options.some((opt) => opt.value === workerModelName)) {
setWorkerModelName(options[0].value);
}
}, [activeWorkerModelOptions, showModelConfig, workerModelName]);
}, [
activeWorkerModelOptions,
dialogOpen,
edit,
modelSelectionTouched,
showModelConfig,
workerInfo,
workerModelName,
]);
useEffect(() => {
if (!showModelConfig) return;
@ -358,6 +425,8 @@ export function AddWorker({
.map((provider: any) => {
const modelType = String(provider.model_type || '');
const providerName = String(provider.provider_name || '');
const agentModelConfig =
buildAgentModelConfigFromProvider(provider);
return {
value: `${providerName}::${modelType}`,
label: modelType
@ -365,6 +434,11 @@ export function AddWorker({
: providerName,
model_platform: providerName,
model_type: modelType,
provider_id: Number(provider.id),
api_key: agentModelConfig.api_key,
api_url: agentModelConfig.api_url,
model_config_dict: agentModelConfig.model_config_dict,
extra_params: agentModelConfig.extra_params,
};
});
@ -373,13 +447,10 @@ export function AddWorker({
localProviderIds.has(provider.provider_name)
)
.map((provider: any) => {
const config = provider.encrypted_config || {};
const modelPlatform = String(
config.model_platform || provider.provider_name || ''
);
const modelType = String(
config.model_type || provider.model_type || ''
);
const agentModelConfig =
buildAgentModelConfigFromProvider(provider);
const modelPlatform = agentModelConfig.model_platform;
const modelType = agentModelConfig.model_type || '';
const platformName = getLocalPlatformName(modelPlatform);
return {
value: `${modelPlatform}::${modelType}`,
@ -388,6 +459,11 @@ export function AddWorker({
: platformName,
model_platform: modelPlatform,
model_type: modelType,
provider_id: Number(provider.id),
api_key: agentModelConfig.api_key,
api_url: agentModelConfig.api_url,
model_config_dict: agentModelConfig.model_config_dict,
extra_params: agentModelConfig.extra_params,
};
});
@ -451,6 +527,23 @@ export function AddWorker({
}
}
}
const selectedModelOption = workerModelOptions[workerModelMode].find(
(option) => option.value === workerModelName
);
const customModelConfig =
showModelConfig && selectedModelOption
? buildAgentModelConfig(selectedModelOption)
: undefined;
const modelProviderId =
showModelConfig && selectedModelOption?.provider_id
? selectedModelOption.provider_id
: showModelConfig &&
edit &&
!modelSelectionTouched &&
Number.isInteger(workerInfo?.workerInfo?.model_provider_id)
? workerInfo?.workerInfo?.model_provider_id
: undefined;
if (edit) {
const newWorkerList = workerList.map((worker) => {
if (worker.type === workerInfo?.type) {
@ -473,6 +566,7 @@ export function AddWorker({
tools: localTool,
mcp_tools: mcpLocal,
selectedTools: JSON.parse(JSON.stringify(selectedTools)),
model_provider_id: modelProviderId,
},
};
return {
@ -503,22 +597,12 @@ export function AddWorker({
tools: localTool,
mcp_tools: mcpLocal,
selectedTools: JSON.parse(JSON.stringify(selectedTools)),
model_provider_id: modelProviderId,
},
};
setWorkerList([...workerList, worker]);
} else {
// Add-worker custom model config is applied to this agent only.
const selectedModelOption = workerModelOptions[workerModelMode].find(
(opt) => opt.value === workerModelName
);
const customModelConfig =
showModelConfig && selectedModelOption
? {
model_platform: selectedModelOption.model_platform,
model_type: selectedModelOption.model_type || undefined,
}
: undefined;
if (activeProjectId) {
fetchPost(`/task/${activeProjectId}/add-agent`, {
name: workerName,
@ -542,6 +626,7 @@ export function AddWorker({
tools: localTool,
mcp_tools: mcpLocal,
selectedTools: JSON.parse(JSON.stringify(selectedTools)),
model_provider_id: modelProviderId,
},
};
setWorkerList([...workerList, worker]);
@ -753,6 +838,7 @@ export function AddWorker({
<Switch
checked={showModelConfig}
onCheckedChange={(checked) => {
setModelSelectionTouched(true);
setShowModelConfig(checked);
if (!checked) {
setWorkerModelName('');
@ -771,9 +857,10 @@ export function AddWorker({
</label>
<Select
value={workerModelMode}
onValueChange={(value) =>
setWorkerModelMode(value as WorkerModelMode)
}
onValueChange={(value) => {
setModelSelectionTouched(true);
setWorkerModelMode(value as WorkerModelMode);
}}
>
<SelectTrigger
className="w-full"
@ -803,7 +890,10 @@ export function AddWorker({
</label>
<Select
value={workerModelName}
onValueChange={setWorkerModelName}
onValueChange={(value) => {
setModelSelectionTouched(true);
setWorkerModelName(value);
}}
>
<SelectTrigger
className="w-full"

View file

@ -0,0 +1,173 @@
// ========= 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. =========
/**
* Approval-mode picker for the chat input bar same pill-trigger shell as
* `ThinkingEffortSelect` / `ModelSelect` / `ProjectModeToggle` so the
* controls read as one family in the `BoxFooter` row.
*
* UI only for now: it holds its own local state and is not wired to the
* human-toolkit / approval backend.
*/
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { Check, ChevronDown, ShieldCheck, TriangleAlert } from 'lucide-react';
import { useState } from 'react';
export type ApprovalMode = 'manual' | 'skip';
interface ApprovalOption {
value: ApprovalMode;
label: string;
icon: typeof ShieldCheck;
}
const APPROVAL_OPTIONS: ApprovalOption[] = [
{ value: 'manual', label: 'Manual Approval', icon: ShieldCheck },
{ value: 'skip', label: 'Skip all approval', icon: TriangleAlert },
];
// Keep in sync with `DropdownMenuContent`'s `w-[180px]` below.
const MENU_CONTENT_WIDTH_CLASS = 'w-[180px]';
const triggerShellClass = cn(
'rounded-xl px-2 py-1 inline-flex max-w-[min(100%,320px)] shrink-0 items-center gap-1.5',
'bg-ds-bg-neutral-default-default text-ds-text-neutral-default-default'
);
export interface ApprovalModeSelectProps {
disabled?: boolean;
/** Shows the current mode in the same read-only shell as the model/mode controls. */
readOnly?: boolean;
/** When true, hides the text label and shows only the icon (narrow footer). */
compact?: boolean;
className?: string;
}
export function ApprovalModeSelect({
disabled,
readOnly = false,
compact = false,
className,
}: ApprovalModeSelectProps) {
const [value, setValue] = useState<ApprovalMode>('manual');
const current =
APPROVAL_OPTIONS.find((o) => o.value === value) ?? APPROVAL_OPTIONS[0];
const CurrentIcon = current.icon;
if (readOnly) {
return (
<div
role="status"
title={current.label}
aria-label={current.label}
className={cn(
triggerShellClass,
'pointer-events-none bg-transparent',
{ 'opacity-50': disabled },
className
)}
>
<span className="inline-flex min-h-[1.25rem] min-w-0 items-center gap-1.5 overflow-hidden">
<CurrentIcon
className="h-3.5 w-3.5 shrink-0 opacity-80"
aria-hidden
/>
{!compact && (
<span className="min-w-0 truncate !text-label-xs font-semibold">
{current.label}
</span>
)}
</span>
</div>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={disabled}
title={current.label}
aria-label={current.label}
aria-haspopup="menu"
className={cn(
triggerShellClass,
'min-w-0 cursor-pointer border-0 text-left',
'justify-between font-semibold transition-colors',
'hover:bg-ds-bg-neutral-subtle-default active:bg-ds-bg-neutral-subtle-default data-[state=open]: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',
'disabled:pointer-events-none disabled:opacity-50',
className
)}
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
<CurrentIcon
className="h-3.5 w-3.5 shrink-0 opacity-80"
aria-hidden
/>
{!compact && (
<span className="min-w-0 flex-1 truncate text-left !text-label-xs text-ds-text-neutral-default-default">
{current.label}
</span>
)}
</span>
<ChevronDown
className="h-3.5 w-3.5 shrink-0 opacity-80"
aria-hidden
strokeWidth={2}
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="top"
sideOffset={4}
collisionPadding={12}
avoidCollisions
className={MENU_CONTENT_WIDTH_CLASS}
>
{APPROVAL_OPTIONS.map((option) => {
const OptionIcon = option.icon;
return (
<DropdownMenuItem
key={option.value}
onSelect={() => setValue(option.value)}
className="flex items-center justify-between gap-2"
>
<span className="flex items-center gap-2">
<OptionIcon
className="h-4 w-4 shrink-0 opacity-80"
aria-hidden
/>
<span className="text-body-sm">{option.label}</span>
</span>
{value === option.value && (
<Check className="h-4 w-4 text-ds-text-success-default-default" />
)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -0,0 +1,80 @@
// ========= 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 { ProjectModeToggle } from '@/components/Workspace/ProjectModeToggle';
import { useIsCompactWidth } from '@/hooks/useIsCompactWidth';
import type { SessionModeType } from '@/types/constants';
import { ModelSelect } from './ModelSelect';
/**
* Below this footer width the session mode control collapses to icon-only so
* everything stays on a single row.
*/
const COMPACT_WIDTH_THRESHOLD = 460;
export interface BoxFooterProps {
/** Left side: single-agent / multi-agent mode control. */
sessionMode: SessionModeType;
onSessionModeChange?: (mode: SessionModeType) => void;
/** Project whose pinned model the model selector reads and writes. */
projectId?: string | null;
/**
* Project-setup controls: interactive on the workspace composer only.
* Once the project has started both controls render read-only.
*/
interactive?: boolean;
disabled?: boolean;
}
/**
* BoxFooter project-setup row under BoxMain in the BottomBox shell.
* Left: session mode control. Right: default/project-pinned model control.
* Stays on a single row; the left control collapses to icon-only when the
* footer gets narrow.
*/
export function BoxFooter({
sessionMode,
onSessionModeChange,
projectId,
interactive = false,
disabled = false,
}: BoxFooterProps) {
const [footerRef, compact] = useIsCompactWidth<HTMLDivElement>(
COMPACT_WIDTH_THRESHOLD
);
return (
<div
ref={footerRef}
className="flex w-full items-center justify-between gap-2 px-3 py-1"
>
<div className="flex min-w-0 shrink items-center gap-1">
<ProjectModeToggle
value={sessionMode}
onValueChange={onSessionModeChange ?? (() => {})}
readOnly={!interactive}
compact={compact}
className="shrink-0"
/>
</div>
<div className="flex shrink-0 items-center gap-1">
<ModelSelect
disabled={disabled}
projectId={projectId}
readOnly={!interactive && !projectId}
/>
</div>
</div>
);
}

View file

@ -13,35 +13,28 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { ProjectModeToggle } from '@/components/Workspace/ProjectModeToggle';
import { TooltipSimple } from '@/components/ui/tooltip';
import { processDroppedFiles } from '@/lib/fileUtils';
import { cn } from '@/lib/utils';
import type { TriggerInput } from '@/types';
import type { SessionModeType } from '@/types/constants';
import {
ArrowRight,
FileText,
Hammer,
Image,
Paperclip,
Plus,
UploadCloud,
WandSparkles,
X,
} from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { ChatInputModelDropdown } from './ChatInputModelDropdown';
import { RichChatInput } from './RichChatInput';
/**
@ -86,12 +79,12 @@ export interface InputboxProps {
privacy?: boolean;
/** Use cloud model in dev */
useCloudModelInDev?: boolean;
/** Session mode for the mode-select row; omit to hide it. */
sessionMode?: SessionModeType;
/** Called when the user changes mode (workspace only). */
onSessionModeChange?: (mode: SessionModeType) => void;
/** Full toggle on workspace; on session chat, only the current mode is shown. */
sessionModeSelectInteractive?: boolean;
/** Connector picker panel state; the toggle button only renders when the callback is provided. */
connectorPanelOpen?: boolean;
onToggleConnectorPanel?: () => void;
/** Skill picker panel state; the toggle button only renders when the callback is provided. */
skillPanelOpen?: boolean;
onToggleSkillPanel?: () => void;
/** Callback when trigger is being created (for placeholder) */
onTriggerCreating?: (triggerData: TriggerInput) => void;
/** Callback when trigger is created successfully */
@ -152,9 +145,10 @@ export const Inputbox = ({
allowDragDrop = false,
privacy = true,
useCloudModelInDev = false,
sessionMode,
onSessionModeChange,
sessionModeSelectInteractive = false,
connectorPanelOpen = false,
onToggleConnectorPanel,
skillPanelOpen = false,
onToggleSkillPanel,
onTriggerCreating: _onTriggerCreating,
onTriggerCreated: _onTriggerCreated,
}: InputboxProps) => {
@ -310,7 +304,7 @@ export const Inputbox = ({
return (
<div
className={cn(
'rounded-3xl border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default p-3 shadow-lg relative flex w-full flex-col items-start border border-solid transition-colors',
'relative flex w-full flex-col items-start rounded-3xl border border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default p-3 transition-colors',
(isFocused || hasContent) &&
'border-ds-border-information-default-default',
isDragging &&
@ -323,7 +317,7 @@ export const Inputbox = ({
onDrop={handleDrop}
>
{isDragging && (
<div className="inset-0 gap-2 rounded-2xl border-ds-border-neutral-strong-default bg-ds-bg-information-subtle-default text-ds-text-neutral-default-default backdrop-blur-sm pointer-events-none absolute z-20 flex flex-col items-center justify-center border-2 border-dashed">
<div className="pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-ds-border-neutral-strong-default bg-ds-bg-information-subtle-default text-ds-text-neutral-default-default backdrop-blur-sm">
<UploadCloud className="h-8 w-8" />
<div className="text-sm font-semibold">
{t('chat.drop-files-to-attach')}
@ -332,14 +326,14 @@ export const Inputbox = ({
)}
{/* Layer 2: File attachments (only show if has files) */}
{files.length > 0 && (
<div className="gap-1 pb-2 relative box-border flex w-full flex-wrap items-start">
<div className="relative box-border flex w-full flex-wrap items-start gap-1 pb-2">
{visibleFiles.map((file) => {
const isHovered = hoveredFilePath === file.filePath;
return (
<div
key={file.filePath}
className={cn(
'max-w-24 gap-0.5 rounded-md bg-ds-bg-neutral-default-default pr-1 relative box-border flex h-auto items-center'
'relative box-border flex h-auto max-w-24 items-center gap-0.5 rounded-md bg-ds-bg-neutral-default-default pr-1'
)}
onMouseEnter={() => setHoveredFilePath(file.filePath)}
onMouseLeave={() =>
@ -352,7 +346,7 @@ export const Inputbox = ({
<a
href="#"
className={cn(
'h-6 w-6 rounded-md flex cursor-pointer items-center justify-center'
'flex h-6 w-6 cursor-pointer items-center justify-center rounded-md'
)}
onClick={(e) => {
e.preventDefault();
@ -371,7 +365,7 @@ export const Inputbox = ({
{/* File Name */}
<p
className={cn(
"my-0 text-xs font-bold leading-tight text-ds-text-neutral-default-default relative min-h-px min-w-px flex-1 overflow-hidden font-['Inter'] overflow-ellipsis whitespace-nowrap"
"relative my-0 min-h-px min-w-px flex-1 overflow-hidden overflow-ellipsis whitespace-nowrap font-['Inter'] text-xs font-bold leading-tight text-ds-text-neutral-default-default"
)}
title={file.fileName}
>
@ -390,14 +384,14 @@ export const Inputbox = ({
buttonContent="text"
textWeight="bold"
buttonRadius="full"
className="rounded-lg bg-ds-bg-neutral-strong-default relative box-border flex h-auto items-center"
className="relative box-border flex h-auto items-center rounded-lg bg-ds-bg-neutral-strong-default"
onMouseEnter={openRemainingPopover}
onMouseLeave={scheduleCloseRemainingPopover}
onClick={(e) => {
e.stopPropagation();
}}
>
<p className="my-0 text-xs font-bold leading-tight text-ds-text-neutral-default-default font-['Inter'] whitespace-nowrap">
<p className="my-0 whitespace-nowrap font-['Inter'] text-xs font-bold leading-tight text-ds-text-neutral-default-default">
{remainingCount}+
</p>
</Button>
@ -406,17 +400,17 @@ export const Inputbox = ({
align="end"
side="right"
sideOffset={4}
className="max-w-40 rounded-lg border-ds-border-neutral-subtle-default bg-ds-bg-neutral-default-default p-1 shadow-perfect !w-auto border-solid"
className="!w-auto max-w-40 rounded-lg border-solid border-ds-border-neutral-subtle-default bg-ds-bg-neutral-default-default p-1 shadow-perfect"
onMouseEnter={openRemainingPopover}
onMouseLeave={scheduleCloseRemainingPopover}
>
<div className="scrollbar-hide gap-1 flex max-h-[176px] flex-col overflow-auto">
<div className="scrollbar-hide flex max-h-[176px] flex-col gap-1 overflow-auto">
{files.slice(maxVisibleFiles).map((file) => {
const isHovered = hoveredFilePath === file.filePath;
return (
<div
key={file.filePath}
className="gap-1 rounded-lg bg-ds-bg-neutral-strong-default px-1 py-0.5 hover:bg-ds-bg-neutral-default-hover flex cursor-pointer items-center transition-colors duration-300"
className="flex cursor-pointer items-center gap-1 rounded-lg bg-ds-bg-neutral-strong-default px-1 py-0.5 transition-colors duration-300 hover:bg-ds-bg-neutral-default-hover"
onMouseEnter={() => setHoveredFilePath(file.filePath)}
onMouseLeave={() =>
setHoveredFilePath((prev) =>
@ -427,7 +421,7 @@ export const Inputbox = ({
<a
href="#"
className={cn(
'h-6 w-6 rounded-md flex cursor-pointer items-center justify-center'
'flex h-6 w-6 cursor-pointer items-center justify-center rounded-md'
)}
onClick={(e) => {
e.preventDefault();
@ -445,7 +439,7 @@ export const Inputbox = ({
getFileIcon(file.fileName)
)}
</a>
<p className="my-0 text-xs font-bold leading-tight text-ds-text-neutral-default-default flex-1 overflow-hidden font-['Inter'] text-ellipsis whitespace-nowrap">
<p className="my-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap font-['Inter'] text-xs font-bold leading-tight text-ds-text-neutral-default-default">
{file.fileName}
</p>
</div>
@ -459,7 +453,7 @@ export const Inputbox = ({
)}
{/* Layer 3: Text input area */}
<div className="gap-2.5 pb-3 relative flex w-full flex-1 items-start justify-center">
<div className="relative flex w-full flex-1 items-start justify-center gap-2.5 pb-3">
<RichChatInput
ref={textareaRef as React.RefObject<HTMLDivElement>}
value={value}
@ -489,67 +483,83 @@ export const Inputbox = ({
</div>
{/* Layer 4: Action buttons */}
<div className="flex w-full items-center justify-between">
{/* Left: Add File Button and Add Trigger Button */}
<div className="gap-2 flex items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex w-full flex-wrap items-center justify-between gap-y-2">
{/* Left: add files/photos + connector picker + skill picker */}
<div className="flex min-w-0 items-center gap-2">
<TooltipSimple content="Attach" side="top">
<Button
type="button"
variant="ghost"
size="xs"
buttonContent="icon-only"
textWeight="bold"
buttonRadius="lg"
disabled={
disabled ||
!privacy ||
useCloudModelInDev ||
typeof onAddFile !== 'function'
}
aria-label={t('chat.input-attach-add-files-or-photos')}
onClick={() => onAddFile?.()}
>
<Paperclip />
</Button>
</TooltipSimple>
{onToggleConnectorPanel && (
<TooltipSimple content="MCPs" side="top">
<Button
type="button"
data-picker-trigger
variant="ghost"
size="xs"
buttonContent="icon-only"
textWeight="bold"
buttonRadius="lg"
disabled={disabled}
aria-label={t('chat.input-attach-menu-trigger')}
aria-haspopup="menu"
aria-label={t('chat.input-add-connector', {
defaultValue: 'Add connectors',
})}
aria-haspopup="true"
aria-expanded={connectorPanelOpen}
className={cn(
connectorPanelOpen && 'bg-ds-bg-neutral-strong-default'
)}
onClick={onToggleConnectorPanel}
>
<Plus />
<Hammer />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
className="min-w-[13.5rem]"
>
<DropdownMenuItem
disabled={
!privacy ||
useCloudModelInDev ||
typeof onAddFile !== 'function'
}
onSelect={() => {
onAddFile?.();
}}
</TooltipSimple>
)}
{onToggleSkillPanel && (
<TooltipSimple content="Skills" side="top">
<Button
type="button"
data-picker-trigger
variant="ghost"
size="xs"
buttonContent="icon-only"
textWeight="bold"
buttonRadius="lg"
disabled={disabled}
aria-label={t('chat.input-add-skill', {
defaultValue: 'Add skills',
})}
aria-haspopup="true"
aria-expanded={skillPanelOpen}
className={cn(
skillPanelOpen && 'bg-ds-bg-neutral-strong-default'
)}
onClick={onToggleSkillPanel}
>
<Paperclip
className="text-ds-icon-neutral-default-default"
aria-hidden
/>
{t('chat.input-attach-add-files-or-photos')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ChatInputModelDropdown
disabled={disabled}
readOnly={
sessionMode !== undefined &&
sessionModeSelectInteractive === false
}
/>
<WandSparkles />
</Button>
</TooltipSimple>
)}
</div>
{/* Right: Session mode (workspace: full toggle; session: current mode only) + send */}
<div className="gap-2 flex items-center">
{sessionMode !== undefined && (
<ProjectModeToggle
value={sessionMode}
onValueChange={onSessionModeChange ?? (() => {})}
readOnly={!sessionModeSelectInteractive}
className="shrink-0"
/>
)}
{/* Right: send */}
<div className="flex shrink-0 items-center gap-2">
<Button
size="xs"
buttonContent="icon-only"

View file

@ -48,6 +48,8 @@ import {
} from '@/shared/modelProviderImages';
import { useAuthStore } from '@/store/authStore';
import { useCloudModelStore } from '@/store/cloudModelStore';
import { useProjectRuntimeStore } from '@/store/projectRuntimeStore';
import { useSpaceStore } from '@/store/spaceStore';
import type { Provider } from '@/types';
import {
@ -57,15 +59,20 @@ import {
Key,
Layers,
Server,
Sparkles,
} from 'lucide-react';
import type { Dispatch, SetStateAction } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
export interface ChatInputModelDropdownProps {
export interface ModelSelectProps {
disabled?: boolean;
/**
* Project whose pinned model this dropdown reads and writes. When set,
* selections update only that Project's captured model; the global
* default model is left untouched.
*/
projectId?: string | null;
/**
* When true, shows the current default model in the same shell as
* `ProjectModeToggle` (readOnly) no chevron, not interactive,
@ -80,10 +87,11 @@ const modelTriggerShellClass = cn(
'bg-ds-bg-neutral-default-default text-ds-text-neutral-default-default'
);
export function ChatInputModelDropdown({
export function ModelSelect({
disabled,
projectId,
readOnly = false,
}: ChatInputModelDropdownProps) {
}: ModelSelectProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const {
@ -105,6 +113,26 @@ export function ChatInputModelDropdown({
const effectiveCloudModelId = useCloudModelStore((state) =>
state.getEffectiveModelId(cloud_model_type)
);
const setProjectModel = useProjectRuntimeStore(
(state) => state.setProjectModel
);
const runtimePinnedSelection = useProjectRuntimeStore((state) =>
projectId
? (state.projects[projectId]?.metadata?.modelSelection ?? null)
: null
);
const spacePinnedSelection = useSpaceStore((state) => {
if (!projectId) return null;
const spaceId = state.projectIdIndex[projectId];
if (!spaceId) return null;
return (
state.projectsBySpaceId[spaceId]?.[projectId]?.metadata?.modelSelection ??
null
);
});
const pinnedSelection = projectId
? (runtimePinnedSelection ?? spacePinnedSelection)
: null;
const cloudModelOptions = useMemo(
() =>
cloudModels.map((model) => ({
@ -276,14 +304,64 @@ export function ChatInputModelDropdown({
}, [refreshCodexStatus]);
const handleCodexSetDefault = useCallback(() => {
if (projectId) {
const codexModelId = codex_model_type || 'gpt-5.5';
setProjectModel(projectId, {
modelType: 'codex_subscription',
codex_model_type: codexModelId,
model_platform: 'openai',
model_type: codexModelId,
});
return;
}
setCloudPrefer(false);
setLocalPrefer(false);
setForm((f) => f.map((fi) => ({ ...fi, prefer: false })));
setModelType('codex_subscription');
}, [setModelType]);
}, [codex_model_type, projectId, setModelType, setProjectModel]);
/** Model name only in the trigger (e.g. "Gemini 3.1 Pro Preview", no cloud/source prefix). */
const triggerModelName = useMemo(() => {
if (pinnedSelection) {
if (pinnedSelection.modelType === 'codex_subscription') {
const pinnedCodexModelType = pinnedSelection.codex_model_type || '';
return `Codex Subscription${pinnedCodexModelType ? ` (${pinnedCodexModelType})` : ''}`;
}
if (pinnedSelection.modelType === 'cloud') {
return getCloudModelDisplayName(
pinnedSelection.cloud_model_type || cloud_model_type
);
}
if (pinnedSelection.modelType === 'custom') {
const idx =
pinnedSelection.provider_id !== undefined
? form.findIndex(
(f) => f.provider_id === pinnedSelection.provider_id
)
: -1;
if (idx !== -1) {
const mt = form[idx].model_type || '';
return `${items[idx].name}${mt ? ` (${mt})` : ''}`;
}
}
if (pinnedSelection.modelType === 'local') {
const platform = Object.keys(localProviderIds).find(
(key) => localProviderIds[key] === pinnedSelection.provider_id
);
if (platform) {
const mt = localTypes[platform] || '';
return `${getLocalPlatformName(platform)}${mt ? ` (${mt})` : ''}`;
}
}
// Providers are still loading (or the pinned provider disappeared):
// fall back to the identifiers captured with the pin.
if (pinnedSelection.model_platform || pinnedSelection.model_type) {
const platformLabel = pinnedSelection.model_platform || '';
const mt = pinnedSelection.model_type || '';
return platformLabel ? `${platformLabel}${mt ? ` (${mt})` : ''}` : mt;
}
}
if (modelType === 'codex_subscription') {
return `Codex Subscription${codex_model_type ? ` (${codex_model_type})` : ''}`;
}
@ -315,8 +393,10 @@ export function ChatInputModelDropdown({
items,
localPrefer,
localPlatform,
localProviderIds,
localTypes,
modelType,
pinnedSelection,
t,
]);
@ -335,6 +415,41 @@ export function ChatInputModelDropdown({
navigate(DEFAULT_MODEL_CONFIGURE_PATH);
return;
}
if (projectId) {
// Pin the choice to this Project only; the global default model
// (and the server-side preferred provider) stays unchanged.
if (category === 'cloud') {
setProjectModel(projectId, {
modelType: 'cloud',
cloud_model_type: modelId,
});
return;
}
if (category === 'custom') {
const idx = items.findIndex((item) => item.id === modelId);
const providerId = idx !== -1 ? form[idx]?.provider_id : undefined;
if (providerId === undefined) return;
setProjectModel(projectId, {
modelType: 'custom',
provider_id: providerId,
model_platform: modelId,
model_type: form[idx]?.model_type || undefined,
});
return;
}
if (category === 'local') {
const providerId = localProviderIds[modelId];
if (providerId === undefined) return;
setProjectModel(projectId, {
modelType: 'local',
provider_id: providerId,
model_platform: modelId,
model_type: localTypes[modelId] || undefined,
});
return;
}
return;
}
await applyDefaultModelSelection({
category,
modelId,
@ -358,13 +473,21 @@ export function ChatInputModelDropdown({
form,
localProviderIds,
localPlatform,
localTypes,
navigate,
projectId,
setProjectModel,
setModelType,
setCloudModelType,
t,
]
);
// Grow the trigger to match the open dropdown's content width (never shrink
// below its own natural content width). Keep in sync with the `w-[180px]`
// on `DropdownMenuContent` below.
const [open, setOpen] = useState(false);
const activeSubTriggerRef = useRef<HTMLElement | null>(null);
// Bottom-align the sub content with the trigger row purely imperatively:
@ -394,9 +517,8 @@ export function ChatInputModelDropdown({
}
)}
>
<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 !text-label-xs font-semibold truncate">
<span className="inline-flex min-h-[1.25rem] min-w-0 items-center gap-1.5 overflow-hidden">
<span className="min-w-0 truncate !text-label-xs font-semibold">
{triggerModelName}
</span>
</span>
@ -406,8 +528,9 @@ export function ChatInputModelDropdown({
return (
<DropdownMenu
onOpenChange={(open) => {
if (open) void fetchCloudModels();
onOpenChange={(next) => {
setOpen(next);
if (next) void fetchCloudModels();
}}
>
<DropdownMenuTrigger asChild>
@ -420,19 +543,17 @@ export function ChatInputModelDropdown({
className={cn(
modelTriggerShellClass,
'min-w-0 cursor-pointer border-0 text-left',
'font-semibold justify-between transition-colors',
'hover:bg-ds-bg-neutral-subtle-hover active:bg-ds-bg-neutral-subtle-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'
'justify-between font-semibold transition-all',
'hover:bg-ds-bg-neutral-subtle-default active:bg-ds-bg-neutral-subtle-default data-[state=open]: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',
'disabled:pointer-events-none disabled:opacity-50',
// While open, only the trigger's content grows to the menu width;
// the chevron stays pinned at the right edge.
open && 'min-w-[180px]'
)}
>
<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 !text-label-xs text-ds-text-neutral-default-default flex-1 truncate text-left">
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
<span className="min-w-0 flex-1 truncate text-center !text-label-xs text-ds-text-neutral-default-default">
{triggerModelName}
</span>
</span>
@ -444,7 +565,7 @@ export function ChatInputModelDropdown({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
align="end"
side="top"
sideOffset={4}
collisionPadding={12}
@ -454,7 +575,7 @@ export function ChatInputModelDropdown({
{import.meta.env.VITE_USE_LOCAL_PROXY !== 'true' && (
<DropdownMenuSub>
<DropdownMenuSubTrigger
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"
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"
onPointerEnter={(e) => {
activeSubTriggerRef.current = e.currentTarget;
}}
@ -465,7 +586,7 @@ export function ChatInputModelDropdown({
className="mt-0.5 h-4 w-4 shrink-0"
aria-hidden
/>
<span className="min-w-0 text-body-sm flex-1 text-left">
<span className="min-w-0 flex-1 text-left text-body-sm">
{t('setting.eigent-cloud')}
</span>
</DropdownMenuSubTrigger>
@ -482,7 +603,11 @@ export function ChatInputModelDropdown({
className="flex items-center justify-between"
>
<span className="text-body-sm">{model.name}</span>
{cloudPrefer && effectiveCloudModelId === model.id && (
{(pinnedSelection
? pinnedSelection.modelType === 'cloud' &&
(pinnedSelection.cloud_model_type ||
effectiveCloudModelId) === model.id
: cloudPrefer && effectiveCloudModelId === model.id) && (
<Check className="h-4 w-4 text-ds-text-success-default-default" />
)}
</DropdownMenuItem>
@ -493,16 +618,16 @@ export function ChatInputModelDropdown({
<DropdownMenuSub>
<DropdownMenuSubTrigger
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"
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"
onPointerEnter={(e) => {
activeSubTriggerRef.current = e.currentTarget;
}}
>
<Layers
className="text-ds-icon-neutral-default-default shrink-0"
className="shrink-0 text-ds-icon-neutral-default-default"
aria-hidden
/>
<span className="min-w-0 text-body-sm flex-1 text-left">
<span className="min-w-0 flex-1 text-left text-body-sm">
{t('setting.custom-model')}
</span>
</DropdownMenuSubTrigger>
@ -524,9 +649,15 @@ export function ChatInputModelDropdown({
const isConfigured = isSubscriptionAuth
? codexStatus.connected
: !!form[idx]?.provider_id;
const isPreferred = isSubscriptionAuth
? modelType === 'codex_subscription'
: form[idx]?.prefer;
const isPreferred = pinnedSelection
? isSubscriptionAuth
? pinnedSelection.modelType === 'codex_subscription'
: pinnedSelection.modelType === 'custom' &&
pinnedSelection.provider_id !== undefined &&
form[idx]?.provider_id === pinnedSelection.provider_id
: isSubscriptionAuth
? modelType === 'codex_subscription'
: form[idx]?.prefer;
const modelImage = getModelImage(item.id);
return (
@ -545,7 +676,7 @@ export function ChatInputModelDropdown({
}}
className="flex items-center justify-between"
>
<div className="gap-2 flex items-center">
<div className="flex items-center gap-2">
{modelImage ? (
<img
src={modelImage}
@ -566,15 +697,15 @@ export function ChatInputModelDropdown({
{item.name}
</span>
</div>
<div className="gap-1 flex items-center">
<div className="flex items-center gap-1">
{!isConfigured && (
<div className="h-2 w-2 bg-ds-text-neutral-subtle-default rounded-full opacity-10" />
<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 bg-ds-text-success-default-default rounded-full" />
<div className="h-2 w-2 rounded-full bg-ds-text-success-default-default" />
)}
</div>
</DropdownMenuItem>
@ -585,16 +716,16 @@ export function ChatInputModelDropdown({
<DropdownMenuSub>
<DropdownMenuSubTrigger
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"
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"
onPointerEnter={(e) => {
activeSubTriggerRef.current = e.currentTarget;
}}
>
<HardDrive
className="text-ds-icon-neutral-default-default shrink-0"
className="shrink-0 text-ds-icon-neutral-default-default"
aria-hidden
/>
<span className="min-w-0 text-body-sm flex-1 text-left">
<span className="min-w-0 flex-1 text-left text-body-sm">
{t('setting.local-model')}
</span>
</DropdownMenuSubTrigger>
@ -604,7 +735,11 @@ export function ChatInputModelDropdown({
>
{LOCAL_MODEL_OPTIONS.map((model) => {
const isConfigured = !!localProviderIds[model.id];
const isPreferred = localPrefer && localPlatform === model.id;
const isPreferred = pinnedSelection
? pinnedSelection.modelType === 'local' &&
pinnedSelection.provider_id !== undefined &&
localProviderIds[model.id] === pinnedSelection.provider_id
: localPrefer && localPlatform === model.id;
const modelImage = getModelImage(`local-${model.id}`);
return (
@ -615,7 +750,7 @@ export function ChatInputModelDropdown({
}}
className="flex items-center justify-between"
>
<div className="gap-2 flex items-center">
<div className="flex items-center gap-2">
{modelImage ? (
<img
src={modelImage}
@ -636,15 +771,15 @@ export function ChatInputModelDropdown({
{model.name}
</span>
</div>
<div className="gap-1 flex items-center">
<div className="flex items-center gap-1">
{!isConfigured && (
<div className="h-2 w-2 bg-ds-text-neutral-subtle-default rounded-full opacity-10" />
<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 bg-ds-text-success-default-default rounded-full" />
<div className="h-2 w-2 rounded-full bg-ds-text-success-default-default" />
)}
</div>
</DropdownMenuItem>

View file

@ -0,0 +1,358 @@
// ========= 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 { proxyFetchGet } from '@/api/http';
import ellipseIcon from '@/assets/mcp/Ellipse-25.svg';
import { Button } from '@/components/ui/button';
import { integrationLeadingIconUrl } from '@/lib/connectorIcons';
import {
RICH_CONNECTOR_STYLE_CLASSES,
RICH_SKILL_STYLE_CLASSES,
connectorNameToToken,
hashSkillLabel,
} from '@/lib/richText';
import { skillNameToDirName } from '@/lib/skillToolkit';
import { cn } from '@/lib/utils';
import { useSkillsStore } from '@/store/skillsStore';
import { Check, Plus, Wrench } from 'lucide-react';
import { Fragment, useEffect, useMemo, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
/**
* An item shown in a picker panel. `token` is the exact string inserted inline
* into the rich chat input when the item is selected (`#skill` / `@connector`).
*/
export interface PickerItem {
id: string;
name: string;
token: string;
}
/** A labelled section within a picker (e.g. built-in vs. your own connectors). */
export interface PickerGroup {
id: string;
/** Section heading; omit for a single ungrouped list (e.g. skills). */
label?: string;
items: PickerItem[];
}
interface PickerPanelProps {
title: string;
groups: PickerGroup[];
/** Current input text — an item is "added" when its token appears in it. */
inputValue: string;
onToggleItem: (item: PickerItem) => void;
/** Leading token tag for a row (`#skill` / `@connector`). */
renderTag: (item: PickerItem) => ReactNode;
/** Leading logo/icon for a row, shown before the item name. Omit for no logo. */
renderLogo?: (item: PickerItem) => ReactNode;
loading?: boolean;
emptyLabel: string;
emptyActionLabel: string;
onEmptyAction: () => void;
}
/**
* Floating list panel shown above BoxMain in the BottomBox shell. Selecting an
* item inserts its token inline into the input; selecting an added item removes
* it. Purely presentational the trigger and open state live in BottomBox.
*/
export function PickerPanel({
title,
groups,
inputValue,
onToggleItem,
renderTag,
renderLogo,
loading = false,
emptyLabel,
emptyActionLabel,
onEmptyAction,
}: PickerPanelProps) {
const nonEmptyGroups = groups.filter((g) => g.items.length > 0);
const totalItems = nonEmptyGroups.reduce((n, g) => n + g.items.length, 0);
return (
<div className="flex w-full flex-col overflow-hidden rounded-2xl border border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default">
{/* Header */}
<div className="flex items-center gap-1 px-3 pb-1 pt-2">
<span className="text-xs font-bold text-ds-text-neutral-muted-default">
{title}
</span>
{totalItems > 0 && (
<span className="text-xs font-bold text-ds-text-neutral-muted-default">
{totalItems}
</span>
)}
</div>
{/* List: max-h-[240px] caps the panel's scrollable area */}
<div className="scrollbar-always-visible flex max-h-[240px] flex-col gap-0.5 overflow-y-auto p-1">
{loading ? (
<>
{[0, 1, 2].map((i) => (
<div
key={i}
className="h-8 w-full animate-pulse rounded-lg bg-ds-bg-neutral-strong-default"
/>
))}
</>
) : totalItems === 0 ? (
<div className="flex w-full items-center justify-between gap-2 px-2 py-2">
<span className="text-xs font-normal text-ds-text-neutral-muted-default">
{emptyLabel}
</span>
<Button
variant="ghost"
size="xs"
buttonContent="text"
onClick={onEmptyAction}
>
{emptyActionLabel}
</Button>
</div>
) : (
nonEmptyGroups.map((group) => (
<Fragment key={group.id}>
{group.label && (
<div className="px-2 pb-0.5 pt-1.5 text-xs font-bold text-ds-text-neutral-muted-default">
{group.label}
</div>
)}
{group.items.map((item) => (
<PickerPanelItem
key={item.id}
item={item}
tag={renderTag(item)}
logo={renderLogo?.(item)}
added={inputValue.includes(item.token)}
onToggle={() => onToggleItem(item)}
/>
))}
</Fragment>
))
)}
</div>
</div>
);
}
interface PickerPanelItemProps {
item: PickerItem;
tag: ReactNode;
logo?: ReactNode;
added: boolean;
onToggle: () => void;
}
function PickerPanelItem({
item,
tag,
logo,
added,
onToggle,
}: PickerPanelItemProps) {
return (
<button
type="button"
aria-pressed={added}
className="group flex w-full items-center gap-2 rounded-xl border-0 bg-ds-bg-neutral-subtle-default px-2 py-1.5 text-left transition-colors hover:bg-ds-bg-neutral-default-default"
onClick={onToggle}
>
{logo && (
<span className="flex h-5 w-5 shrink-0 items-center justify-center">
{logo}
</span>
)}
<span className="min-w-0 flex-1 overflow-hidden overflow-ellipsis whitespace-nowrap text-sm font-medium text-ds-text-neutral-default-default">
{item.name}
</span>
<span className="max-w-[45%] shrink-0 overflow-hidden whitespace-nowrap">
{tag}
</span>
<span className="flex h-5 w-5 shrink-0 items-center justify-center">
{added ? (
<Check size={16} className="text-ds-icon-success-default-default" />
) : (
<Plus
size={16}
className="text-ds-icon-neutral-muted-default opacity-0 transition-opacity group-hover:opacity-100"
/>
)}
</span>
</button>
);
}
interface WiredPickerPanelProps {
inputValue: string;
onToggleItem: (item: PickerItem) => void;
}
/** Built-in integrations excluded from the MCP connector list (mirrors settings). */
const EXCLUDED_BUILTIN_CONNECTORS = ['Search', 'RAG'];
/**
* Full connector list matching the Connectors settings page: built-in
* integrations (`/api/v1/config/info`) plus the user's own MCPs
* (`/api/v1/mcp/users`), shown as two labelled sections.
*/
export function ConnectorPickerPanel({
inputValue,
onToggleItem,
}: WiredPickerPanelProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [builtIn, setBuiltIn] = useState<PickerItem[]>([]);
const [yourMcps, setYourMcps] = useState<PickerItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
Promise.allSettled([
proxyFetchGet('/api/v1/config/info'),
proxyFetchGet('/api/v1/mcp/users'),
])
.then(([infoRes, usersRes]) => {
if (cancelled) return;
if (
infoRes.status === 'fulfilled' &&
infoRes.value &&
typeof infoRes.value === 'object'
) {
setBuiltIn(
Object.keys(infoRes.value)
.filter((key) => !EXCLUDED_BUILTIN_CONNECTORS.includes(key))
.map((key) => ({
id: `builtin-${key}`,
name: key,
token: connectorNameToToken(key),
}))
);
}
if (usersRes.status === 'fulfilled') {
const list = Array.isArray(usersRes.value)
? usersRes.value
: (usersRes.value?.items ?? []);
setYourMcps(
list.map((item: { id: number; mcp_name: string }) => ({
id: `user-${item.id}`,
name: item.mcp_name,
token: connectorNameToToken(item.mcp_name),
}))
);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const groups: PickerGroup[] = [
{
id: 'builtin',
label: t('setting.mcp-sidebar-built-in'),
items: builtIn,
},
{ id: 'yours', label: t('setting.your-own-mcps'), items: yourMcps },
];
return (
<PickerPanel
title={t('chat.input-attach-connectors')}
groups={groups}
inputValue={inputValue}
onToggleItem={onToggleItem}
renderTag={(item) => (
<span
className={cn(
'rounded px-1 py-px text-xs font-medium',
RICH_CONNECTOR_STYLE_CLASSES
)}
>
{item.token}
</span>
)}
renderLogo={(item) => {
if (!item.id.startsWith('builtin-')) {
return (
<Wrench size={16} className="text-ds-icon-neutral-muted-default" />
);
}
const iconUrl = integrationLeadingIconUrl(item.name);
return iconUrl ? (
<img src={iconUrl} alt="" className="h-4 w-4 object-contain" />
) : (
<img src={ellipseIcon} alt="" className="h-3 w-3" />
);
}}
loading={loading}
emptyLabel={t('chat.no-connectors-added')}
emptyActionLabel={t('chat.input-attach-manage-connectors')}
onEmptyAction={() => navigate('/history?tab=connectors')}
/>
);
}
/** Lists the user's enabled skills from the skills store. */
export function SkillPickerPanel({
inputValue,
onToggleItem,
}: WiredPickerPanelProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const skills = useSkillsStore((s) => s.skills);
const items = useMemo(
() =>
skills
.filter((s) => s.enabled)
.map((s) => ({
id: s.id,
name: s.name,
token: `#${s.skillDirName || skillNameToDirName(s.name)}`,
})),
[skills]
);
return (
<PickerPanel
title={t('chat.input-attach-skills')}
groups={[{ id: 'skills', items }]}
inputValue={inputValue}
onToggleItem={onToggleItem}
renderTag={(item) => {
const clsIdx =
hashSkillLabel(item.token) % RICH_SKILL_STYLE_CLASSES.length;
return (
<span
className={cn(
'rounded px-1 py-px text-xs font-medium',
RICH_SKILL_STYLE_CLASSES[clsIdx]
)}
>
{item.token}
</span>
);
}}
emptyLabel={t('chat.no-skills-added')}
emptyActionLabel={t('chat.input-attach-manage-skills')}
onEmptyAction={() => navigate('/history?tab=agents')}
/>
);
}

View file

@ -44,12 +44,12 @@ export function QueuedBox({
return (
<div
className={cn(
'border-solid-80 gap-1 rounded-t-2xl py-1 border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default flex w-full flex-col items-start justify-center border border-b-0',
'border-solid-80 flex w-full flex-col items-start justify-center gap-1 rounded-2xl border border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default py-1',
className
)}
>
{/* Queuing Header Top */}
<div className="gap-1 px-2.5 py-0 relative box-border flex w-full items-center">
<div className="relative box-border flex w-full items-center gap-1 px-2.5 py-0">
{/* Lead Button for expand/collapse */}
<Button
variant="ghost"
@ -71,8 +71,8 @@ export function QueuedBox({
</Button>
{/* Middle - Queued Title */}
<div className="gap-0.5 relative flex min-h-px min-w-px flex-1 items-center">
<div className="mr-1 relative flex shrink-0 flex-col justify-center">
<div className="relative flex min-h-px min-w-px flex-1 items-center gap-0.5">
<div className="relative mr-1 flex shrink-0 flex-col justify-center">
<span className="text-xs font-bold text-ds-text-neutral-default-default">
{queuedMessages.length}
</span>
@ -88,7 +88,7 @@ export function QueuedBox({
{/* Header Content - Accordion Items for queued tasks */}
<div
className={cn(
'scrollbar-always-visible gap-1 px-2 py-0 ease-in-out relative box-border flex w-full flex-col items-start overflow-y-auto transition-all duration-200',
'scrollbar-always-visible relative box-border flex w-full flex-col items-start gap-1 overflow-y-auto px-2 py-0 transition-all duration-200 ease-in-out',
isExpanded && queuedMessages.length > 0
? 'max-h-[156px] opacity-100'
: 'max-h-0 opacity-0'
@ -117,16 +117,16 @@ function QueueingItem({ content, onRemove }: QueueingItemProps) {
return (
<div
className="gap-2 rounded-md px-1 py-1 bg-ds-bg-neutral-strong-default hover:bg-ds-bg-neutral-default-default relative box-border flex w-full cursor-pointer items-center transition-all duration-200"
className="relative box-border flex w-full cursor-pointer items-center gap-2 rounded-md bg-ds-bg-neutral-strong-default px-1 py-1 transition-all duration-200 hover:bg-ds-bg-neutral-default-default"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="h-5 w-5 rounded-md p-0.5 flex shrink-0 items-center justify-center bg-transparent">
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-transparent p-0.5">
<Circle size={16} className="text-ds-icon-neutral-muted-default" />
</div>
<div className="relative flex min-h-px min-w-px flex-1 flex-col justify-center overflow-hidden overflow-ellipsis">
<p className="m-0 text-xs font-normal overflow-hidden overflow-ellipsis whitespace-nowrap">
<p className="m-0 overflow-hidden overflow-ellipsis whitespace-nowrap text-xs font-normal">
{content}
</p>
</div>
@ -136,10 +136,10 @@ function QueueingItem({ content, onRemove }: QueueingItemProps) {
size="xs"
buttonContent="icon-only"
className={cn(
'h-5 w-5 rounded-md p-0.5 shrink-0 transition-all duration-200',
'h-5 w-5 shrink-0 rounded-md p-0.5 transition-all duration-200',
isHovered
? 'translate-x-0 hover:bg-ds-bg-neutral-default-hover opacity-100'
: 'translate-x-2 pointer-events-none opacity-0'
? 'translate-x-0 opacity-100 hover:bg-ds-bg-neutral-default-hover'
: 'pointer-events-none translate-x-2 opacity-0'
)}
onClick={(e) => {
e.preventDefault();

View file

@ -233,10 +233,13 @@ export const RichChatInput = React.forwardRef<
plain.length === 0 ? '' : segmentsToHtml(tokenizeRichPlainText(plain));
el.innerHTML = html || '<br />';
if (restoreOffset !== undefined) {
requestAnimationFrame(() => {
setCaretOffset(el, Math.min(restoreOffset, plain.length));
scrollCaretIntoView(el);
});
// Restore the caret synchronously. Reassigning innerHTML above collapses
// the selection to offset 0; deferring the restore to requestAnimationFrame
// left a full frame during which fast keystrokes were inserted at the
// start (the "cursor jumps to the beginning" bug). Only the scroll, which
// needs layout, stays deferred.
setCaretOffset(el, Math.min(restoreOffset, plain.length));
requestAnimationFrame(() => scrollCaretIntoView(el));
}
}, []);
@ -324,6 +327,48 @@ export const RichChatInput = React.forwardRef<
resizeHeight();
};
/**
* `#skill` / `@connector` chips are atomic (`contenteditable="false"`), so the
* caret can only sit immediately before or after one never inside. Delete
* the whole token in one keypress instead of falling through to the browser's
* native "select the atomic node first" behavior, which can take two presses
* (or none, depending on browser) to actually remove it.
*/
const handleChipAwareDelete = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>): boolean => {
if (e.key !== 'Backspace' && e.key !== 'Delete') return false;
const el = rootRef.current;
if (!el) return false;
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return false;
const caret = getCaretOffset(el);
const segments = tokenizeRichPlainText(value);
let offset = 0;
for (const seg of segments) {
const start = offset;
const end = offset + seg.text.length;
const isChip = seg.type === 'skill' || seg.type === 'connector';
const hit =
isChip &&
((e.key === 'Backspace' && end === caret) ||
(e.key === 'Delete' && start === caret));
if (hit) {
e.preventDefault();
const newValue = value.slice(0, start) + value.slice(end);
internalUpdate.current = true;
onChange(newValue, start);
applyHtml(newValue, start);
resizeHeight();
return true;
}
offset = end;
}
return false;
},
[applyHtml, onChange, resizeHeight, value]
);
const handlePaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
@ -410,7 +455,10 @@ export const RichChatInput = React.forwardRef<
suppressContentEditableWarning
onInput={handleInput}
onPaste={handlePaste}
onKeyDown={onKeyDown}
onKeyDown={(e) => {
if (handleChipAwareDelete(e)) return;
onKeyDown?.(e);
}}
onFocus={onFocus}
onBlur={handleBlur}
onCompositionStart={() => {

View file

@ -0,0 +1,147 @@
// ========= 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. =========
/**
* Reasoning/thinking effort picker for the chat input bar same pill-trigger
* shell as `ModelSelect` / `ProjectModeToggle` so the three controls
* read as one family in the `BoxFooter` row.
*/
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { ThinkingEffort, type ThinkingEffortType } from '@/types/constants';
import { Check, ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export interface ThinkingEffortSelectProps {
value: ThinkingEffortType;
onValueChange?: (effort: ThinkingEffortType) => void;
disabled?: boolean;
/** Shows the current effort in the same read-only shell as the model/mode controls. */
readOnly?: boolean;
className?: string;
}
const EFFORT_OPTIONS: ThinkingEffortType[] = [
ThinkingEffort.LIGHT,
ThinkingEffort.MEDIUM,
ThinkingEffort.HIGH,
ThinkingEffort.EXTRA_HIGH,
ThinkingEffort.ULTRA,
];
// Keep in sync with `DropdownMenuContent`'s `w-[160px]` below.
const MENU_CONTENT_WIDTH_CLASS = 'w-[160px]';
const triggerShellClass = cn(
'rounded-xl px-2 py-1 inline-flex max-w-[min(100%,320px)] shrink-0 items-center gap-1.5',
'bg-ds-bg-neutral-default-default text-ds-text-neutral-default-default'
);
export function ThinkingEffortSelect({
value,
onValueChange,
disabled,
readOnly = false,
className,
}: ThinkingEffortSelectProps) {
const { t } = useTranslation();
const effortLabel = (effort: ThinkingEffortType) =>
t(`layout.thinking-effort-${effort}`);
const currentLabel = effortLabel(value);
if (readOnly) {
return (
<div
role="status"
title={currentLabel}
aria-label={currentLabel}
className={cn(
triggerShellClass,
'pointer-events-none bg-transparent',
{ 'opacity-50': disabled },
className
)}
>
<span className="inline-flex min-h-[1.25rem] min-w-0 items-center gap-1.5 overflow-hidden">
<span className="min-w-0 truncate !text-label-xs font-semibold">
{currentLabel}
</span>
</span>
</div>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={disabled}
title={currentLabel}
aria-label={currentLabel}
aria-haspopup="menu"
className={cn(
triggerShellClass,
'min-w-0 cursor-pointer border-0 text-left',
'justify-between font-semibold transition-colors',
'hover:bg-ds-bg-neutral-subtle-default active:bg-ds-bg-neutral-subtle-default data-[state=open]: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',
'disabled:pointer-events-none disabled:opacity-50',
className
)}
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
<span className="min-w-0 flex-1 truncate text-left !text-label-xs text-ds-text-neutral-default-default">
{currentLabel}
</span>
</span>
<ChevronDown
className="h-3.5 w-3.5 shrink-0 opacity-80"
aria-hidden
strokeWidth={2}
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="top"
sideOffset={4}
collisionPadding={12}
avoidCollisions
className={MENU_CONTENT_WIDTH_CLASS}
>
{EFFORT_OPTIONS.map((effort) => (
<DropdownMenuItem
key={effort}
onSelect={() => onValueChange?.(effort)}
className="flex items-center justify-between"
>
<span className="text-body-sm">{effortLabel(effort)}</span>
{value === effort && (
<Check className="h-4 w-4 text-ds-text-success-default-default" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -35,7 +35,7 @@ export function UsageLimitBanner({
return (
<div
className={cn(
'mb-2 flex min-h-12 w-full items-center justify-between gap-3 rounded-xl border px-4 py-2 shadow-sm',
'flex min-h-12 w-full items-center justify-between gap-3 rounded-xl border px-4 py-2 shadow-sm',
isDanger
? 'border-text-error/30 bg-surface-error-subtle text-text-error'
: 'border-border-warning bg-surface-warning text-text-warning'

View file

@ -13,11 +13,14 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { Button } from '@/components/ui/button';
import { type ChatTaskStatusType } from '@/types/constants';
import { type SessionModeType } from '@/types/constants';
import { TriangleAlert } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { BoxFooter } from './BoxFooter';
import { BoxHeaderConfirm, BoxHeaderSave } from './BoxHeader';
import { FileAttachment, Inputbox, InputboxProps } from './InputBox';
import { ConnectorPickerPanel, SkillPickerPanel } from './PickerPanel';
import { QueuedBox, QueuedMessage } from './QueuedBox';
import {
UsageLimitBanner,
@ -31,10 +34,18 @@ export type BottomBoxState =
| 'running'
| 'finished';
/** Main-slot content, orthogonal to `state`. Future variants (e.g. 'multiSelect') plug in here. */
export type BottomBoxVariant = 'input';
type PickerPanelKind = 'connector' | 'skill';
interface BottomBoxProps {
// General state
state: BottomBoxState;
/** Main-slot variant; defaults to the input composer. */
variant?: BottomBoxVariant;
// Queue-related props
queuedMessages?: QueuedMessage[];
onRemoveQueuedMessage?: (id: string) => void;
@ -48,18 +59,18 @@ interface BottomBoxProps {
onSavePlan?: () => void;
onEdit?: () => void;
// Task info
taskTime?: string;
taskStatus?: ChatTaskStatusType;
// Pause/Resume
onPauseResume?: () => void;
pauseResumeLoading?: boolean;
// Input props
inputProps: Omit<InputboxProps, 'className'> & { className?: string };
usageLimitBanner?: UsageLimitBannerProps | null;
// BoxFooter (project-setup controls: mode + model); omit sessionMode to hide the row.
sessionMode?: SessionModeType;
onSessionModeChange?: (mode: SessionModeType) => void;
/** Interactive during project setup (workspace); once the project starts the row is read-only. */
sessionModeSelectInteractive?: boolean;
/** Project whose pinned model the footer model selector reads and writes. */
modelSelectProjectId?: string | null;
// Loading states
loading?: boolean;
@ -70,6 +81,7 @@ interface BottomBoxProps {
export default function BottomBox({
state,
variant = 'input',
queuedMessages = [],
onRemoveQueuedMessage,
subtitle,
@ -79,6 +91,10 @@ export default function BottomBox({
onEdit,
inputProps,
usageLimitBanner,
sessionMode,
onSessionModeChange,
sessionModeSelectInteractive = false,
modelSelectProjectId,
loading = false,
noModelOverlay = false,
onSelectModel,
@ -86,27 +102,116 @@ export default function BottomBox({
const { t } = useTranslation();
const enableQueuedBox = true; //TODO: Fix the reason of queued box disable in https://github.com/eigent-ai/eigent/issues/684
// Picker panels (connector/skill) float above BoxMain and are mutually exclusive.
const [openPanel, setOpenPanel] = useState<PickerPanelKind | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
const togglePanel = (panel: PickerPanelKind) =>
setOpenPanel((prev) => (prev === panel ? null : panel));
// Close the floating panel on outside click (ignore the trigger buttons,
// which own their own toggle).
useEffect(() => {
if (!openPanel) return;
const onPointerDown = (e: PointerEvent) => {
const target = e.target as HTMLElement | null;
if (
panelRef.current?.contains(target ?? null) ||
target?.closest('[data-picker-trigger]')
) {
return;
}
setOpenPanel(null);
};
document.addEventListener('pointerdown', onPointerDown);
return () => document.removeEventListener('pointerdown', onPointerDown);
}, [openPanel]);
const inputValue = inputProps.value ?? '';
/** Focus the input and drop the caret at the end after a programmatic edit. */
const focusInputEnd = () => {
requestAnimationFrame(() => {
const el = inputProps.textareaRef?.current;
if (!el) return;
el.focus();
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
});
};
/** Append a `#skill` / `@connector` token to the input, space-separated. */
const insertToken = (token: string) => {
const trimmed = inputValue.replace(/\s+$/, '');
const next = (trimmed.length ? `${trimmed} ` : '') + `${token} `;
inputProps.onChange?.(next);
focusInputEnd();
};
/** Remove a previously inserted token (and one adjacent space) from the input. */
const removeToken = (token: string) => {
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const next = inputValue
.replace(new RegExp(`\\s?${escaped}`), '')
.replace(/\s{2,}/g, ' ')
.replace(/^\s+/, '');
inputProps.onChange?.(next);
focusInputEnd();
};
const toggleToken = (token: string) =>
inputValue.includes(token) ? removeToken(token) : insertToken(token);
// Background color reflects current state only
let backgroundClass = 'bg-ds-bg-neutral-subtle-default';
let backgroundClass = 'bg-ds-bg-neutral-default-default';
if (state === 'confirm' || state === 'save')
backgroundClass = 'bg-ds-bg-completed-subtle-default';
backgroundClass = 'bg-ds-bg-completed-default-default';
const showQueuedBox = enableQueuedBox && queuedMessages.length > 0;
const hasOverlay = showQueuedBox || !!usageLimitBanner || !!openPanel;
return (
<div className="relative z-50 flex w-full flex-col rounded-t-2xl bg-ds-bg-neutral-subtle-default backdrop-blur-xl">
{/* QueuedBox overlay (should not affect BoxMain layout) */}
{enableQueuedBox && queuedMessages.length > 0 && (
<div className="pointer-events-auto z-50 px-2">
<QueuedBox
queuedMessages={queuedMessages}
onRemoveQueuedMessage={onRemoveQueuedMessage}
/>
<div className="relative z-50 flex w-full flex-col rounded-3xl bg-ds-bg-neutral-default-default">
{/* Floating overlays: never affect BoxMain layout */}
{hasOverlay && (
<div className="pointer-events-auto absolute inset-x-0 bottom-full z-[60] mb-1 flex flex-col gap-1">
{showQueuedBox && (
<QueuedBox
queuedMessages={queuedMessages}
onRemoveQueuedMessage={onRemoveQueuedMessage}
/>
)}
{usageLimitBanner && <UsageLimitBanner {...usageLimitBanner} />}
{openPanel && (
<div
ref={panelRef}
className="duration-150 animate-in fade-in-0 slide-in-from-bottom-1"
>
{openPanel === 'connector' ? (
<ConnectorPickerPanel
inputValue={inputValue}
onToggleItem={(item) => toggleToken(item.token)}
/>
) : (
<SkillPickerPanel
inputValue={inputValue}
onToggleItem={(item) => toggleToken(item.token)}
/>
)}
</div>
)}
{/* future: human-in-the-loop approval cards mount here */}
</div>
)}
{/* BoxMain */}
<div
className={`relative mb-sm flex w-full flex-col rounded-3xl ${backgroundClass}`}
className={`relative flex w-full flex-col rounded-3xl ${backgroundClass}`}
>
{/* BoxHeader variants */}
{/* BoxHeader variants — project confirmation */}
{state === 'confirm' && (
<BoxHeaderConfirm
subtitle={subtitle}
@ -125,9 +230,27 @@ export default function BottomBox({
/>
)}
{/* Inputbox (always visible) */}
{usageLimitBanner && <UsageLimitBanner {...usageLimitBanner} />}
<Inputbox {...inputProps} />
{/* Main box — variant slot */}
{variant === 'input' && (
<Inputbox
{...inputProps}
connectorPanelOpen={openPanel === 'connector'}
onToggleConnectorPanel={() => togglePanel('connector')}
skillPanelOpen={openPanel === 'skill'}
onToggleSkillPanel={() => togglePanel('skill')}
/>
)}
{/* Box footer — project-setup controls (mode + model); read-only once started */}
{sessionMode !== undefined && (
<BoxFooter
sessionMode={sessionMode}
onSessionModeChange={onSessionModeChange}
projectId={modelSelectProjectId}
interactive={sessionModeSelectInteractive}
disabled={inputProps.disabled}
/>
)}
{noModelOverlay && onSelectModel ? (
<div

View file

@ -80,6 +80,7 @@ export const MarkDown = memo(
const host = useHost();
const electronAPI = host?.electronAPI;
const openFilePreview = usePageTabStore((s) => s.openFilePreview);
const openBrowserPreview = usePageTabStore((s) => s.openBrowserPreview);
const [displayedContent, setDisplayedContent] = useState('');
const [html, setHtml] = useState('');
const [previewImage, setPreviewImage] = useState<string | null>(null);
@ -298,6 +299,22 @@ export const MarkDown = memo(
if (filePath) {
openFilePreview(fileInfoFromPath(filePath));
}
return;
}
// Web links stay inside the session: open them in the preview
// browser of this project. (On the web host, where no embedded
// browser exists, fall back to a regular browser tab.)
const link = target.closest('a[href]');
if (link) {
const href = link.getAttribute('href') ?? '';
if (/^https?:\/\//i.test(href)) {
e.preventDefault();
if (electronAPI) {
openBrowserPreview(href);
} else {
window.open(href, '_blank', 'noopener,noreferrer');
}
}
}
};
@ -307,7 +324,7 @@ export const MarkDown = memo(
return () => {
div.removeEventListener('click', handleContentClick);
};
}, [html, openFilePreview]);
}, [html, openFilePreview, openBrowserPreview, electronAPI]);
return (
<>

View file

@ -22,6 +22,7 @@ import {
ChatTaskStatus,
type ChatTaskStatusType,
SessionMode,
TaskStatus,
} from '@/types/constants';
import { AnimatePresence, motion } from 'framer-motion';
import { Bot, ChevronDown, ChevronRight } from 'lucide-react';
@ -85,6 +86,32 @@ function mergeTaggedAgentLogs(taskAssigning: Agent[] | undefined): TaggedLog[] {
);
}
/**
* The single agent drives its work through a todo list (TODO_STATE). The
* in-progress todo's `active_form` (e.g. "Searching Google for relevant
* papers") is plumbed into that task's `content` in the store. Surface it as
* the live header label so the user sees what the agent is doing *right now*
* instead of a static "CAMEL Agent" tag.
*
* Falls back to the most recently completed step so the label never flashes
* empty between todos or after the run finishes.
*/
export function getSingleAgentActiveForm(
task: { taskAssigning?: Agent[] } | undefined
): string {
const single = task?.taskAssigning?.find((a) => a.type === 'single_agent');
const tasks = single?.tasks ?? [];
const running = tasks.find((tk) => tk.status === TaskStatus.RUNNING);
if (running?.content?.trim()) return running.content.trim();
for (let i = tasks.length - 1; i >= 0; i--) {
const tk = tasks[i]!;
if (tk.status === TaskStatus.COMPLETED && tk.content?.trim()) {
return tk.content.trim();
}
}
return '';
}
function titleCaseMethod(method: string): string {
if (!method) return '';
return method.charAt(0).toUpperCase() + method.slice(1);
@ -611,7 +638,11 @@ function useTaskWorkStoreSnapshot(
return `${log.length}:${last?.step ?? ''}:${msgLen}:${last?.data?.toolkit_name ?? ''}:${last?.data?.method_name ?? ''}`;
})
.join('>');
return `${t.status}|${t.taskTime}|${t.elapsed}|${logDigest}`;
// Single-agent header tracks the live in-progress todo `active_form`
// (carried on each task's `content`). Fold the running/last-completed
// step into the digest so the header re-renders as todos advance.
const activeFormDigest = getSingleAgentActiveForm(t);
return `${t.status}|${t.taskTime}|${t.elapsed}|${logDigest}|${activeFormDigest}`;
},
() => ''
);
@ -959,12 +990,14 @@ const AgentGroupRow = memo(function AgentGroupRow({
open,
onToggle,
isSingleAgent,
singleAgentActiveForm,
}: {
group: AgentGroup;
taskRunning: boolean;
open: boolean;
onToggle: () => void;
isSingleAgent: boolean;
singleAgentActiveForm: string;
}) {
const { agentLabel, progressLabel, latestToolTitle, latestToolRunning } =
getGroupHeaderParts(group);
@ -974,6 +1007,16 @@ const AgentGroupRow = memo(function AgentGroupRow({
const icon = isSingleAgent
? DEFAULT_BOT_ICON
: (agentDisplay?.icon ?? DEFAULT_BOT_ICON);
const useSingleAgentLiveHeader =
isSingleAgent && group.agentType === 'single_agent';
// Single agent: surface the live in-progress `active_form` in place of the
// static "CAMEL Agent" label. Fall back to the static label only when no
// step description is available (never expected while running).
const singleAgentLabel =
useSingleAgentLiveHeader && singleAgentActiveForm
? singleAgentActiveForm
: agentLabel;
const headerParts: string[] = [agentLabel];
if (progressLabel) headerParts.push(`(${progressLabel})`);
@ -986,54 +1029,103 @@ const AgentGroupRow = memo(function AgentGroupRow({
type="button"
aria-expanded={open}
onClick={onToggle}
className="my-1 flex w-fit min-w-0 max-w-full items-center gap-2 px-0 py-1 text-left transition-opacity hover:opacity-80"
className={cn(
'my-1 flex w-fit min-w-0 max-w-full gap-2 px-0 py-1 text-left transition-opacity hover:opacity-80',
useSingleAgentLiveHeader ? 'items-start' : 'items-center'
)}
>
{icon ? (
<span className="flex shrink-0 items-center">{icon}</span>
// my-0.5 centers the 16px icon within the 20px label-sm line so a
// single-line header reads as icon/text center-aligned, while a
// wrapped header keeps the icon pinned to the first line (items-start).
<span
className={cn(
'flex shrink-0 items-center',
useSingleAgentLiveHeader ? 'my-0.5' : ''
)}
>
{icon}
</span>
) : null}
<span className="inline-flex min-w-0 max-w-full items-baseline gap-1.5 truncate">
{headerRunning ? (
<ShinyText
text={headerText}
speed={2.5}
className="truncate !text-label-sm font-normal"
/>
) : (
<>
<span className="text-label-sm font-normal text-ds-text-neutral-muted-default">
{agentLabel}
</span>
{progressLabel ? (
<span className="text-label-sm text-ds-text-neutral-subtle-default">
({progressLabel})
{useSingleAgentLiveHeader ? (
<span className="min-w-0 block max-w-full">
{/* Cross-fade/slide whenever the in-progress step changes so the
header animates from one `active_form` to the next. Wraps onto
multiple lines instead of truncating. */}
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={singleAgentLabel}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.24, ease: CONTENT_EASE }}
className="min-w-0 block break-words whitespace-normal"
>
{headerRunning ? (
<ShinyText
text={singleAgentLabel}
speed={2.5}
className="!text-label-sm font-normal !block break-words whitespace-normal"
/>
) : (
<span className="text-label-sm font-normal text-ds-text-neutral-muted-default block break-words whitespace-normal">
{singleAgentLabel}
</span>
)}
</motion.span>
</AnimatePresence>
</span>
) : (
<span className="inline-flex min-w-0 max-w-full items-baseline gap-1.5 truncate">
{headerRunning ? (
<ShinyText
text={headerText}
speed={2.5}
className="truncate !text-label-sm font-normal"
/>
) : (
<>
<span className="text-label-sm font-normal text-ds-text-neutral-muted-default">
{agentLabel}
</span>
) : null}
{latestToolTitle ? (
<>
{progressLabel ? (
<span className="text-label-sm text-ds-text-neutral-subtle-default">
·
({progressLabel})
</span>
<span className="truncate text-label-sm font-normal text-ds-text-neutral-subtle-default">
{latestToolTitle}
</span>
</>
) : null}
</>
)}
</span>
) : null}
{latestToolTitle ? (
<>
<span className="text-label-sm text-ds-text-neutral-subtle-default">
·
</span>
<span className="truncate text-label-sm font-normal text-ds-text-neutral-subtle-default">
{latestToolTitle}
</span>
</>
) : null}
</>
)}
</span>
)}
{open ? (
<ChevronDown
size={16}
aria-hidden
className="shrink-0 text-ds-icon-neutral-subtle-default"
className={cn(
'shrink-0 text-ds-icon-neutral-subtle-default',
useSingleAgentLiveHeader ? 'my-0.5' : ''
)}
/>
) : (
<ChevronRight
size={16}
aria-hidden
className="shrink-0 text-ds-icon-neutral-subtle-default"
className={cn(
'shrink-0 text-ds-icon-neutral-subtle-default',
useSingleAgentLiveHeader ? 'my-0.5' : ''
)}
/>
)}
</button>
@ -1181,6 +1273,9 @@ export function TaskWorkLogAccordion({
const elapsedMs = useWorkLogElapsedMs(chatStore, taskId, snapshot);
const taskRunning = status === ChatTaskStatus.RUNNING;
const isSingleAgent = task?.sessionMode === SessionMode.SINGLE_AGENT;
const singleAgentActiveForm = isSingleAgent
? getSingleAgentActiveForm(task)
: '';
// Normalize status with task-level context — once the task stops,
// every entry (and any running message/tool) is done regardless of whether
@ -1302,6 +1397,7 @@ export function TaskWorkLogAccordion({
open={isOpen(entry)}
onToggle={() => toggle(entry.id)}
isSingleAgent={isSingleAgent}
singleAgentActiveForm={singleAgentActiveForm}
/>
) : (
<AgentBlockRow

View file

@ -14,6 +14,7 @@
import { useHost } from '@/host';
import {
RICH_CONNECTOR_STYLE_CLASSES,
RICH_SKILL_STYLE_CLASSES,
hashSkillLabel,
httpUrlOrNull,
@ -21,6 +22,7 @@ import {
tokenizeRichPlainText,
} from '@/lib/richText';
import { cn } from '@/lib/utils';
import { usePageTabStore } from '@/store/pageTabStore';
import { Fragment, type ReactNode } from 'react';
/** Same tokens as `UserMessageCard` body (13px / 20px). */
@ -58,7 +60,13 @@ function parseContentWithTags(content: string): ContentNode[] {
return nodes.length > 0 ? nodes : [{ type: 'text', value: content }];
}
function renderMessageRichSegments(text: string, keyPrefix: string): ReactNode {
function renderMessageRichSegments(
text: string,
keyPrefix: string,
/** When set, URL clicks open here (the session's preview browser) instead
* of following the anchor out of the app. */
onOpenUrl?: (url: string) => void
): ReactNode {
return tokenizeRichPlainText(text).map((seg, i) => {
const key = `${keyPrefix}-${i}`;
if (seg.type === 'text') {
@ -73,8 +81,14 @@ function renderMessageRichSegments(text: string, keyPrefix: string): ReactNode {
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-ds-text-information-default-default decoration-ds-border-information-default-default underline underline-offset-2"
onClick={(e) => e.stopPropagation()}
className="text-ds-text-information-default-default underline decoration-ds-border-information-default-default underline-offset-2"
onClick={(e) => {
e.stopPropagation();
if (onOpenUrl) {
e.preventDefault();
onOpenUrl(href);
}
}}
>
{seg.text}
</a>
@ -82,12 +96,25 @@ function renderMessageRichSegments(text: string, keyPrefix: string): ReactNode {
}
return <span key={key}>{seg.text}</span>;
}
if (seg.type === 'connector') {
return (
<span
key={key}
className={cn(
'inline rounded px-0.5 align-baseline font-normal',
RICH_CONNECTOR_STYLE_CLASSES
)}
>
{seg.text}
</span>
);
}
const clsIdx = hashSkillLabel(seg.text) % RICH_SKILL_STYLE_CLASSES.length;
return (
<span
key={key}
className={cn(
'rounded px-0.5 font-normal inline align-baseline',
'inline rounded px-0.5 align-baseline font-normal',
RICH_SKILL_STYLE_CLASSES[clsIdx]
)}
>
@ -115,6 +142,7 @@ export function UserMessageRichContent({
className,
}: UserMessageRichContentProps) {
const host = useHost();
const openBrowserPreview = usePageTabStore((s) => s.openBrowserPreview);
const contentNodes = parseContentWithTags(content);
const handleOpenSkillFolder = (skillName: string) => {
@ -122,6 +150,10 @@ export function UserMessageRichContent({
host?.electronAPI?.openSkillFolder?.(skillName);
};
// Desktop: links open in this project's preview browser; on the web host
// (no embedded browser) the anchor's target=_blank fallback applies.
const handleOpenUrl = host?.electronAPI ? openBrowserPreview : undefined;
const bodyClass =
variant === 'card'
? 'text-ds-text-neutral-default-default font-sans relative z-0 break-words whitespace-pre-wrap'
@ -134,7 +166,7 @@ export function UserMessageRichContent({
if (node.type === 'text') {
return (
<Fragment key={i}>
{renderMessageRichSegments(node.value, `n${i}`)}
{renderMessageRichSegments(node.value, `n${i}`, handleOpenUrl)}
</Fragment>
);
}
@ -151,7 +183,7 @@ export function UserMessageRichContent({
}}
title="Open skill folder"
className={cn(
'mx-0 rounded px-0.5 font-normal inline cursor-pointer align-baseline [font:inherit] hover:opacity-90',
'mx-0 inline cursor-pointer rounded-lg px-1 align-baseline font-normal [font:inherit] hover:opacity-90',
RICH_SKILL_STYLE_CLASSES[clsIdx]
)}
>

View file

@ -156,6 +156,19 @@ export const ProjectChatContainer: React.FC<ProjectChatContainerProps> = ({
}, 0);
}, [chatStore?.activeTaskId]);
// When switching projects, jump to the latest message (bottom) instead of
// staying at the top. Deferred so the switched-to project's messages have
// rendered; instant (not smooth) so we don't scroll through the whole
// history on every switch.
useEffect(() => {
if (!activeProjectId) return;
const timer = setTimeout(() => {
const el = scrollContainerRef.current;
if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'auto' });
}, 100);
return () => clearTimeout(timer);
}, [activeProjectId, scrollContainerRef]);
// Intersection Observer for scroll-based animations
useEffect(() => {
const root = scrollContainerRef.current;
@ -341,7 +354,7 @@ export const ProjectChatContainer: React.FC<ProjectChatContainerProps> = ({
return (
<div className={`relative z-10 w-full ${className}`}>
<div
className="pt-0 mx-auto w-full max-w-[600px]"
className="mx-auto w-full max-w-[600px] pt-0"
style={{ paddingBottom: scrollBottomInsetPx }}
>
<AnimatePresence mode="popLayout">

View file

@ -41,8 +41,7 @@ ChatBox/
├── index.tsx
├── InputBox.tsx
├── RichChatInput.tsx
├── ChatInputModelDropdown.tsx
├── BoxAction.tsx
├── ModelSelect.tsx
├── BoxHeader.tsx
└── QueuedBox.tsx
```
@ -91,9 +90,8 @@ ChatBox/
## BottomBox (`BottomBox/`)
- **`index.tsx`**: Wires `BoxHeader`, `InputBox` / `RichChatInput`, `BoxAction`, `QueuedBox` to task state (pending, running, confirm, etc.).
- **`InputBox` / `RichChatInput` / `ChatInputModelDropdown`**: Text input, model picker, rich input where applicable.
- **`BoxAction`**: Confirm, edit, send, stop, and related actions.
- **`index.tsx`**: Wires `BoxHeader`, `InputBox` / `RichChatInput`, `QueuedBox` to task state (pending, running, confirm, etc.).
- **`InputBox` / `RichChatInput` / `ModelSelect`**: Text input, model picker, rich input where applicable.
- **`BoxHeader`**: Task summary, timing, and header affordances.
- **`QueuedBox`**: Queued user messages when the task pipeline is busy.

View file

@ -86,7 +86,7 @@ export function ExpandedOverlay({
}}
>
<div className="flex w-full max-w-[600px] flex-col">
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-2xl border border-solid border-ds-border-neutral-subtle-disabled bg-ds-bg-splitting-subtle-default">
<div className="mx-2 flex h-full min-h-0 flex-col overflow-hidden rounded-2xl border border-solid border-ds-border-neutral-subtle-disabled bg-ds-bg-splitting-subtle-default">
<div className="flex shrink-0 items-center gap-2 px-3 pt-2">
<div className="min-w-0 flex-1 text-body-sm font-bold text-ds-text-neutral-default-default">
{t('chat.subtasks-planning')}

View file

@ -15,7 +15,6 @@
import {
fetchDelete,
fetchPost,
fetchPut,
proxyFetchDelete,
proxyFetchGet,
uploadFileToBrain,
@ -36,7 +35,11 @@ import { buildProjectContinuationContext } from '@/store/chatStore';
import { usePageTabStore } from '@/store/pageTabStore';
import { useSpaceStore } from '@/store/spaceStore';
import { ExecutionStatus } from '@/types';
import { AgentStep, ChatTaskStatus, SessionMode } from '@/types/constants';
import {
AgentStep,
ChatTaskStatus,
SessionMode,
} from '@/types/constants';
import {
useCallback,
useEffect,
@ -378,7 +381,7 @@ export default function ChatBox(): JSX.Element {
}, [navigate]);
// Task time tracking
const [taskTime, setTaskTime] = useState(
const [, setTaskTime] = useState(
chatStore?.getFormattedTaskTime(chatStore?.activeTaskId as string) ||
'00:00'
);
@ -1030,31 +1033,6 @@ export default function ChatBox(): JSX.Element {
}
};
// Pause/Resume handler
const handlePauseResume = () => {
const taskId = chatStore.activeTaskId as string;
const task = chatStore.tasks[taskId];
const type = task.status === 'running' ? 'pause' : 'resume';
setIsPauseResumeLoading(true);
if (type === 'pause') {
let { taskTime, elapsed } = task;
const now = Date.now();
elapsed += now - taskTime;
chatStore.setElapsed(taskId, elapsed);
chatStore.setTaskTime(taskId, 0);
chatStore.setStatus(taskId, 'pause');
} else {
chatStore.setTaskTime(taskId, Date.now());
chatStore.setStatus(taskId, 'running');
}
fetchPut(`/task/${projectStore.activeProjectId}/take-control`, {
action: type,
});
setIsPauseResumeLoading(false);
};
// Stop task handler - triggers Action.skip_task which preserves context
const handleSkip = async () => {
const taskId = chatStore.activeTaskId as string;
@ -1248,10 +1226,10 @@ export default function ChatBox(): JSX.Element {
const chatColumn = (
<>
{/* Main: scroll (scrollbar on panel edge) + BottomBox overlay when chatting */}
<div className="min-h-0 min-w-0 relative flex flex-1 flex-col overflow-hidden">
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div
ref={scrollContainerRef}
className="scrollbar-always-visible min-h-0 min-w-0 pl-2 flex-1 overflow-x-hidden overflow-y-auto"
className="scrollbar-always-visible min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden pl-2"
>
{hasAnyMessages ? (
<ProjectChatContainer
@ -1262,7 +1240,7 @@ export default function ChatBox(): JSX.Element {
/>
) : (
<div className="mx-auto flex min-h-full w-full max-w-[600px] flex-col">
<div className="gap-1 pb-4 flex flex-1 flex-col items-center justify-end"></div>
<div className="flex flex-1 flex-col items-center justify-end gap-1 pb-4"></div>
{chatStore.activeTaskId && (
<BottomBox
@ -1293,9 +1271,10 @@ export default function ChatBox(): JSX.Element {
textareaRef: textareaRef,
allowDragDrop: true,
useCloudModelInDev: useCloudModelInDev,
sessionMode: effectiveSessionMode,
sessionModeSelectInteractive: false,
}}
sessionMode={effectiveSessionMode}
sessionModeSelectInteractive={false}
modelSelectProjectId={activeProjectId}
/>
)}
</div>
@ -1309,9 +1288,9 @@ export default function ChatBox(): JSX.Element {
<div
ref={bottomBoxOverlayRef}
data-bottom-box-overlay
className="inset-x-0 bottom-0 pointer-events-none absolute z-30 flex justify-center"
className="pointer-events-none absolute inset-x-0 bottom-0 z-30 flex justify-center"
>
<div className="px-2 pointer-events-auto mx-auto w-full max-w-[600px]">
<div className="pointer-events-auto mx-auto w-full max-w-[600px] rounded-t-3xl bg-ds-bg-neutral-subtle-default px-2 pb-1">
<BottomBox
state={getBottomBoxState()}
queuedMessages={queuedMessages}
@ -1349,10 +1328,6 @@ export default function ChatBox(): JSX.Element {
}
}}
onEdit={handleEditQuery}
taskTime={taskTime}
taskStatus={chatStore.tasks[chatStore.activeTaskId]?.status}
onPauseResume={handlePauseResume}
pauseResumeLoading={isPauseResumeLoading}
loading={loading}
inputProps={{
value: message,
@ -1376,9 +1351,10 @@ export default function ChatBox(): JSX.Element {
textareaRef: textareaRef,
allowDragDrop: true,
useCloudModelInDev: useCloudModelInDev,
sessionMode: displaySessionMode,
sessionModeSelectInteractive: false,
}}
sessionMode={displaySessionMode}
sessionModeSelectInteractive={false}
modelSelectProjectId={activeProjectId}
/>
</div>
</div>
@ -1388,7 +1364,7 @@ export default function ChatBox(): JSX.Element {
);
return (
<div className="min-h-0 relative flex h-full w-full flex-1 flex-col overflow-hidden">
<div className="relative flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden">
{chatColumn}
</div>
);

View file

@ -21,7 +21,9 @@ import {
} from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import { useHost } from '@/host';
import { ExternalLink, Loader2 } from 'lucide-react';
import { useAuthStore } from '@/store/authStore';
import { useProjectRuntimeStore } from '@/store/projectRuntimeStore';
import { Download, ExternalLink, Loader2 } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
@ -40,18 +42,83 @@ interface ReportBugDialogProps {
onOpenChange: (open: boolean) => void;
}
type LogKind = 'eigent' | 'camel';
export default function ReportBugDialog({
open,
onOpenChange,
}: ReportBugDialogProps) {
const host = useHost();
const { t } = useTranslation();
const email = useAuthStore((s) => s.email);
const userId = useAuthStore((s) => s.user_id);
const projectStore = useProjectRuntimeStore();
const [description, setDescription] = useState('');
const [steps, setSteps] = useState('');
const [meta, setMeta] = useState<DiagnosticsInfo | null>(null);
const [submitting, setSubmitting] = useState(false);
const [exportingLog, setExportingLog] = useState<LogKind | null>(null);
const hasElectron = Boolean(host?.electronAPI?.exportDiagnosticsZip);
const canDownloadLogs = Boolean(host?.electronAPI?.exportLog);
const handleDownloadLog = useCallback(
async (kind: LogKind) => {
const api = host?.electronAPI;
if (!api || exportingLog) return;
setExportingLog(kind);
try {
let result;
if (kind === 'eigent') {
result = await api.exportLog();
} else {
// Target the task the user last ran so we grab the right camel_logs.
const activeProjectId = projectStore.activeProjectId ?? undefined;
const activeTaskId =
(activeProjectId
? projectStore.peekActiveChatStore(activeProjectId)
: null
)?.getState().activeTaskId ?? undefined;
result = await api.exportCamelLog(
email,
activeTaskId,
activeProjectId,
userId
);
}
if (result?.success && result.savedPath) {
toast.success(
t('layout.support-log-saved', {
defaultValue: 'Log saved to {{path}}',
path: result.savedPath,
})
);
} else if (result?.error === 'no log file') {
toast.error(
t('layout.support-log-empty', {
defaultValue: 'No log files found yet.',
})
);
} else if (result?.error) {
toast.error(
t('layout.support-log-export-failed', {
defaultValue: 'Could not export the log.',
})
);
}
// empty error means the user canceled the save dialog — stay silent
} catch {
toast.error(
t('layout.support-log-export-failed', {
defaultValue: 'Could not export the log.',
})
);
} finally {
setExportingLog(null);
}
},
[email, userId, exportingLog, host?.electronAPI, projectStore, t]
);
useEffect(() => {
if (!open) return;
@ -145,16 +212,16 @@ export default function ReportBugDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
size="md"
className="gap-0 !rounded-xl border-ds-border-neutral-strong-default !bg-ds-bg-neutral-strong-default p-0 shadow-sm sm:max-w-[560px] border"
className="gap-0 !rounded-xl border border-ds-border-neutral-strong-default !bg-ds-bg-neutral-strong-default p-0 shadow-sm sm:max-w-[560px]"
>
<div className="bg-ds-bg-neutral-strong-default rounded-t-xl pl-md pr-12 pt-md pb-2 w-full text-left">
<DialogTitle className="m-0 text-body-md font-bold text-ds-text-neutral-default-default block w-full text-left">
{t('layout.report-bug-dialog-title')}
<div className="w-full rounded-t-xl bg-ds-bg-neutral-strong-default pb-2 pl-md pr-12 pt-md text-left">
<DialogTitle className="m-0 block w-full text-left text-body-md font-bold text-ds-text-neutral-default-default">
{t('layout.support', { defaultValue: 'Support' })}
</DialogTitle>
</div>
<div className="gap-md bg-ds-bg-neutral-strong-default px-md pt-2 pb-md flex max-h-[min(70vh,520px)] flex-col text-left">
<div className="flex max-h-[min(70vh,520px)] flex-col gap-md bg-ds-bg-neutral-strong-default px-md pb-md pt-2 text-left">
{meta && (
<p className="text-body-sm text-ds-text-neutral-subtle-default m-0">
<p className="m-0 text-body-sm text-ds-text-neutral-subtle-default">
{t('layout.report-bug-meta', {
version: meta.version,
os: meta.platform,
@ -162,6 +229,11 @@ export default function ReportBugDialog({
})}
</p>
)}
<h3 className="m-0 text-body-sm font-bold text-ds-text-neutral-default-default">
{t('layout.report-bug-dialog-title', {
defaultValue: 'Report a bug',
})}
</h3>
<label
className="text-body-sm font-medium text-ds-text-neutral-default-default"
htmlFor="report-bug-description"
@ -188,17 +260,96 @@ export default function ReportBugDialog({
placeholder={t('layout.report-bug-field-steps-placeholder')}
className="min-h-[72px] resize-y"
/>
{canDownloadLogs && (
<div className="mt-1 flex flex-col gap-sm border-0 border-t border-solid border-ds-border-neutral-subtle-default pt-md">
<h3 className="m-0 text-body-sm font-bold text-ds-text-neutral-default-default">
{t('layout.support-logs-heading', {
defaultValue: 'Download logs',
})}
</h3>
<div className="flex items-center justify-between gap-md">
<div className="flex min-w-0 flex-col">
<span className="text-body-sm font-medium text-ds-text-neutral-default-default">
{t('layout.support-eigent-log', {
defaultValue: 'Eigent log',
})}
</span>
<span className="text-body-xs text-ds-text-neutral-subtle-default">
{t('layout.support-eigent-log-desc', {
defaultValue: 'Desktop app logs',
})}
</span>
</div>
<Button
variant="secondary"
size="sm"
className="shrink-0"
onClick={() => void handleDownloadLog('eigent')}
disabled={exportingLog !== null}
aria-label={t('layout.support-eigent-log', {
defaultValue: 'Eigent log',
})}
>
{exportingLog === 'eigent' ? (
<Loader2
className="h-4 w-4 shrink-0 animate-spin"
aria-hidden
/>
) : (
<Download className="h-4 w-4 shrink-0" aria-hidden />
)}
{t('layout.support-download', { defaultValue: 'Download' })}
</Button>
</div>
{Boolean(host?.electronAPI?.exportCamelLog) && (
<div className="flex items-center justify-between gap-md">
<div className="flex min-w-0 flex-col">
<span className="text-body-sm font-medium text-ds-text-neutral-default-default">
{t('layout.support-camel-log', {
defaultValue: 'Camel log',
})}
</span>
<span className="text-body-xs text-ds-text-neutral-subtle-default">
{t('layout.support-camel-log-desc', {
defaultValue: 'Agent task logs (CAMEL backend)',
})}
</span>
</div>
<Button
variant="secondary"
size="sm"
className="shrink-0"
onClick={() => void handleDownloadLog('camel')}
disabled={exportingLog !== null || !email}
aria-label={t('layout.support-camel-log', {
defaultValue: 'Camel log',
})}
>
{exportingLog === 'camel' ? (
<Loader2
className="h-4 w-4 shrink-0 animate-spin"
aria-hidden
/>
) : (
<Download className="h-4 w-4 shrink-0" aria-hidden />
)}
{t('layout.support-download', { defaultValue: 'Download' })}
</Button>
</div>
)}
</div>
)}
</div>
<DialogFooter className="!rounded-b-xl p-md gap-sm sm:!flex-col flex !flex-col !border-0 !border-t-0 bg-transparent shadow-none">
<DialogFooter className="flex !flex-col gap-sm !rounded-b-xl !border-0 !border-t-0 bg-transparent p-md shadow-none sm:!flex-col">
{hasElectron && (
<p className="text-body-xs text-ds-text-neutral-subtle-default m-0 w-full text-right">
<p className="m-0 w-full text-right text-body-xs text-ds-text-neutral-subtle-default">
{t('layout.report-bug-zip-hint', {
defaultValue:
'A diagnostics zip will be saved — attach it to the issue.',
})}
</p>
)}
<div className="gap-sm flex w-full flex-row justify-end">
<div className="flex w-full flex-row justify-end gap-sm">
<Button
variant="ghost"
size="md"
@ -215,7 +366,7 @@ export default function ReportBugDialog({
>
{submitting ? (
<Loader2
className="h-4 w-4 animate-spin shrink-0"
className="h-4 w-4 shrink-0 animate-spin"
aria-hidden
/>
) : (

View file

@ -32,6 +32,8 @@ export interface FilePreviewProps {
file: FileInfo | null;
/** Outer surface background class (project page uses default-default). */
surfaceClassName?: string;
/** Remove the standalone rounded/margin frame when hosted in a tab shell. */
embedded?: boolean;
/** Sibling project files, used by the HTML renderer to resolve local assets. */
projectFiles?: FileInfo[];
/** Close the preview column. */
@ -51,6 +53,7 @@ export interface FilePreviewProps {
export function FilePreview({
file,
surfaceClassName = 'bg-ds-bg-neutral-default-default',
embedded = false,
projectFiles = [],
onClose,
onJumpToContext,
@ -248,11 +251,12 @@ export function FilePreview({
}
projectFiles={projectFiles}
surfaceClassName={surfaceClassName}
embedded={embedded}
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">
<div className="flex h-full w-full flex-1 flex-col items-center justify-center gap-3 px-6 text-center text-ds-text-neutral-muted-default">
<FileText className="h-12 w-12 text-ds-icon-neutral-muted-default" />
<p className="text-sm">
{t('chat.no-file-selected', {

View file

@ -627,14 +627,14 @@ export const FileTree: React.FC<FileTreeProps> = ({
onSelectFile(fileInfo);
}
}}
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 ${
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 ${
isRowSelected
? 'bg-ds-bg-neutral-default-default text-ds-text-neutral-default-default'
: 'text-ds-text-neutral-muted-default bg-transparent'
: 'bg-transparent text-ds-text-neutral-muted-default'
}`}
>
{child.isFolder ? (
<span className="w-4 inline-flex shrink-0 items-center justify-start">
<span className="inline-flex w-4 shrink-0 items-center justify-start">
{isExpanded ? (
<ChevronDown className={rowIconClass} />
) : (
@ -653,13 +653,13 @@ export const FileTree: React.FC<FileTreeProps> = ({
)
)}
<span className="min-w-0 text-body-sm font-medium leading-normal flex-1 truncate text-left">
<span className="min-w-0 flex-1 truncate text-left text-body-sm font-medium leading-normal">
{child.name}
</span>
</button>
{hasNested ? (
<div className="ml-4 border-ds-border-neutral-subtle-default pl-1 border-y-0 border-r-0 border-l border-solid">
<div className="ml-4 border-y-0 border-l border-r-0 border-solid border-ds-border-neutral-subtle-default pl-1">
<FileTree
node={child}
level={level + 1}
@ -1599,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="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">
<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">
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="icon-only"
aria-pressed={isFileSidebarOpen}
className="text-ds-icon-neutral-default-default shrink-0"
className="shrink-0 text-ds-icon-neutral-default-default"
aria-label={
isFileSidebarOpen
? t('chat.hide-file-sidebar', {
@ -1635,21 +1635,21 @@ export default function Folder({ data: _data }: { data?: Agent }) {
)}
</Button>
<span
className="min-w-0 text-body-sm font-semibold text-ds-text-neutral-default-default truncate leading-none"
className="min-w-0 truncate text-body-sm font-semibold leading-none text-ds-text-neutral-default-default"
title={folderHeaderTitle}
>
{folderHeaderTitle}
</span>
</div>
<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" />
<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" />
<input
type="text"
value={fileSearchQuery}
onChange={(e) => setFileSearchQuery(e.target.value)}
placeholder={t('chat.search')}
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"
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"
aria-label={t('chat.search')}
/>
</div>
@ -1670,18 +1670,18 @@ export default function Folder({ data: _data }: { data?: Agent }) {
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="border-ds-border-neutral-default-default bg-ds-bg-neutral-strong-default z-50"
className="z-50 border-ds-border-neutral-default-default bg-ds-bg-neutral-strong-default"
>
<DropdownMenuItem
onClick={() => handleOpenInIDE('system')}
className="bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover cursor-pointer"
className="cursor-pointer bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover"
>
<FolderIcon className="size-4 shrink-0" aria-hidden />
{t('chat.open-in-file-manager')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleOpenInIDE('cursor')}
className="bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover cursor-pointer"
className="cursor-pointer bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover"
>
<img
src={cursorIcon}
@ -1693,7 +1693,7 @@ export default function Folder({ data: _data }: { data?: Agent }) {
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleOpenInIDE('vscode')}
className="bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover cursor-pointer"
className="cursor-pointer bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover"
>
<img
src={vsCodeIcon}
@ -1709,11 +1709,11 @@ export default function Folder({ data: _data }: { data?: Agent }) {
</div>
</div>
<div className="min-h-0 flex flex-1 overflow-hidden">
<div className="flex min-h-0 flex-1 overflow-hidden">
{/* sidebar */}
{isFileSidebarOpen ? (
<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">
<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">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@ -1722,7 +1722,7 @@ export default function Folder({ data: _data }: { data?: Agent }) {
size="sm"
buttonContent="text"
>
<span className="min-w-0 font-bold truncate text-left">
<span className="min-w-0 truncate text-left font-bold">
{t('chat.files')}
</span>
<ChevronDown className="size-3.5 shrink-0 opacity-70" />
@ -1731,7 +1731,7 @@ export default function Folder({ data: _data }: { data?: Agent }) {
<DropdownMenuContent
side="bottom"
align="start"
className="border-ds-border-neutral-default-default bg-ds-bg-neutral-strong-default z-50 min-w-[10rem]"
className="z-50 min-w-[10rem] border-ds-border-neutral-default-default bg-ds-bg-neutral-strong-default"
>
<DropdownMenuRadioGroup
value={fileTreeScope}
@ -1741,7 +1741,7 @@ export default function Folder({ data: _data }: { data?: Agent }) {
>
<DropdownMenuRadioItem
value="all"
className="bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover cursor-pointer"
className="cursor-pointer bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover"
>
{t('folder.files-scope-all', {
defaultValue: 'All files',
@ -1749,7 +1749,7 @@ export default function Folder({ data: _data }: { data?: Agent }) {
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value="new"
className="bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover cursor-pointer"
className="cursor-pointer bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover"
>
{t('folder.files-scope-new', {
defaultValue: 'New files',
@ -1760,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="pl-1.5 h-full">
<div className="h-full pl-1.5">
<FileTree
node={sidebarFileTree}
selectedFile={selectedFile}
@ -1883,7 +1883,7 @@ function ImageLoader({ selectedFile }: { selectedFile: FileInfo }) {
if (!src) {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="h-8 w-8 animate-spin mx-auto rounded-full" />
<div className="mx-auto h-8 w-8 animate-spin rounded-full" />
</div>
);
}
@ -1912,7 +1912,7 @@ function AudioLoader({ selectedFile }: { selectedFile: FileInfo }) {
}, [selectedFile]);
return (
<div className="gap-4 px-8 flex w-full flex-col items-center">
<div className="flex w-full flex-col items-center gap-4 px-8">
<p className="text-sm font-medium text-ds-text-neutral-default-default">
{selectedFile.name}
</p>
@ -2802,7 +2802,7 @@ function HtmlRenderer({
if (selectedFile.content && !processedHtml) {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mb-4 h-8 w-8 animate-spin mx-auto rounded-full" />
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full" />
</div>
);
}
@ -2819,7 +2819,7 @@ function HtmlRenderer({
{/* Content area with zoom */}
<div
className="min-h-0 bg-code-surface flex-1 overflow-hidden"
className="min-h-0 flex-1 overflow-hidden bg-code-surface"
onWheel={handleWheel}
>
<div
@ -2859,6 +2859,8 @@ export interface FileViewerPanelProps {
projectFiles: FileInfo[];
/** Outer surface background class. */
surfaceClassName?: string;
/** Remove standalone spacing/radius when rendered inside another panel shell. */
embedded?: boolean;
/** Clicking the breadcrumb (reveal in folder / download remote). Ignored when
* {@link onBreadcrumbSegmentClick} is provided (segments become individually
* clickable instead). */
@ -2889,6 +2891,7 @@ export function FileViewerPanel({
breadcrumbSegments,
projectFiles,
surfaceClassName = 'bg-ds-bg-neutral-subtle-default',
embedded = false,
onRevealFile,
onBreadcrumbSegmentClick,
onDownloadFile,
@ -2901,19 +2904,21 @@ export function FileViewerPanel({
return (
<div
className={`min-w-0 mb-sm rounded-xl flex flex-1 flex-col overflow-hidden ${surfaceClassName}`}
className={`${
embedded ? 'min-w-0' : 'mb-sm min-w-0 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 className="flex flex-shrink-0 items-center justify-between gap-2 py-2 pl-3 pr-2">
<div
onClick={segmentsClickable ? undefined : onRevealFile}
className={`min-w-0 flex flex-1 items-center overflow-hidden ${
className={`flex min-w-0 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"
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',
})}
@ -2925,7 +2930,7 @@ export function FileViewerPanel({
<Fragment key={`${index}-${segment}`}>
{index > 0 ? (
<ChevronRight
className="h-3.5 w-3.5 text-ds-icon-neutral-muted-default shrink-0"
className="h-3.5 w-3.5 shrink-0 text-ds-icon-neutral-muted-default"
aria-hidden
/>
) : null}
@ -2933,7 +2938,7 @@ export function FileViewerPanel({
<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"
className="shrink-0 cursor-pointer font-normal text-ds-text-neutral-muted-default hover:text-ds-text-neutral-default-default hover:underline"
>
{segment}
</button>
@ -2941,8 +2946,8 @@ export function FileViewerPanel({
<span
className={
isLast
? 'font-bold text-ds-text-neutral-default-default shrink-0'
: 'font-normal shrink-0'
? 'shrink-0 font-bold text-ds-text-neutral-default-default'
: 'shrink-0 font-normal'
}
>
{segment}
@ -2953,7 +2958,7 @@ export function FileViewerPanel({
})}
</nav>
</div>
<div className="gap-0.5 flex flex-shrink-0 items-center">
<div className="flex flex-shrink-0 items-center gap-0.5">
<Button
size="icon"
variant="ghost"
@ -2980,7 +2985,7 @@ export function FileViewerPanel({
{/* content */}
<div
className={`min-h-0 flex flex-1 flex-col ${
className={`flex min-h-0 flex-1 flex-col ${
selectedFile?.type === 'html' && !isShowSourceCode
? 'overflow-hidden'
: 'scrollbar-always-visible overflow-y-auto'
@ -2989,8 +2994,8 @@ export function FileViewerPanel({
<div
className={`flex flex-col ${
selectedFile?.type === 'html' && !isShowSourceCode
? 'min-h-0 h-full'
: 'py-2 pl-4 pr-2 min-h-full'
? 'h-full min-h-0'
: 'min-h-full py-2 pl-4 pr-2'
} file-viewer-content`}
>
{selectedFile ? (
@ -3027,9 +3032,9 @@ export function FileViewerPanel({
/>
)
) : selectedFile.type === 'zip' ? (
<div className="text-ds-text-neutral-muted-default flex h-full w-full items-center justify-center">
<div className="flex h-full w-full items-center justify-center text-ds-text-neutral-muted-default">
<div className="text-center">
<FileText className="mb-4 h-12 w-12 text-ds-text-neutral-muted-default mx-auto" />
<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>
@ -3048,14 +3053,14 @@ export function FileViewerPanel({
<ImageLoader selectedFile={selectedFile} />
</div>
) : (
<pre className="font-mono text-sm text-ds-text-neutral-default-default overflow-auto break-words whitespace-pre-wrap">
<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="mb-4 h-8 w-8 animate-spin mx-auto rounded-full"></div>
<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>
@ -3064,9 +3069,9 @@ export function FileViewerPanel({
)
) : (
(emptyState ?? (
<div className="text-ds-text-neutral-muted-default flex h-full w-full flex-1 items-center justify-center">
<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="mb-4 h-12 w-12 text-ds-text-neutral-muted-default mx-auto" />
<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>

View file

@ -67,6 +67,8 @@ export interface ProjectPageSidebarProps {
className?: string;
}
let didAttemptBootSessionResume = false;
export default function ProjectPageSidebar({
chatStore: _chatStore,
className,
@ -314,6 +316,35 @@ export default function ProjectPageSidebar({
]
);
// Boot-time session resume: reopen the last visited Project (the way an
// editor reopens its last workspace) instead of landing on the empty home
// tab. One-shot per renderer boot (launch or reload; the flag is module
// scoped so sidebar remounts within a session never re-trigger it), and
// only from the pristine boot state (default tab, no active Project), so
// deliberately navigating home later is never hijacked. Uses the same
// path as clicking the Project in the sidebar.
useEffect(() => {
if (didAttemptBootSessionResume) return;
didAttemptBootSessionResume = true;
if (activeWorkspaceTab !== 'workforce') return;
if (projectStore.activeProjectId) return;
if (!activeSpaceId) return;
const lastVisitedId =
useSpaceStore.getState().lastVisitedProjectBySpace[activeSpaceId];
if (!lastVisitedId) return;
const lastVisitedMeta = projectMetasForActiveSpace.find(
(project) => project.id === lastVisitedId
);
if (!lastVisitedMeta || !shouldShowProjectInNavList(lastVisitedMeta)) {
return;
}
void selectProject(lastVisitedId);
// One-shot boot effect: later changes to these values must not
// re-trigger a resume.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const navProjects = useMemo(
() =>
projectMetasForActiveSpace

View file

@ -19,8 +19,8 @@ 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, FileText } from 'lucide-react';
import { getSessionPreviewSlice, usePageTabStore } from '@/store/pageTabStore';
import { ArrowLeft, GalleryThumbnails } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export interface HeaderBoxProps {
@ -40,14 +40,18 @@ 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 sessionPreviewOpen = usePageTabStore(
(s) => getSessionPreviewSlice(s).open
);
const toggleSessionPreview = usePageTabStore((s) => s.toggleSessionPreview);
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',
// Own key (not the old file-preview one): the control's meaning changed, so
// stale translations must not carry over.
const windowPreviewTooltip = t('layout.toggle-window-preview-tooltip', {
defaultValue: 'Toggle window preview',
});
if (empty) {
@ -80,7 +84,7 @@ export function HeaderBox({
</TooltipSimple>
</div>
{/* Right: project total token count + file preview toggle */}
{/* Right: project total token count + unified preview toggle */}
<div className="flex items-center gap-2 text-ds-text-neutral-muted-default">
<div className="flex items-center gap-1">
<img src={tokenIcon} alt="" className="h-3.5 w-3.5" />
@ -89,22 +93,22 @@ export function HeaderBox({
<AnimatedTokenNumber value={totalTokens} />
</span>
</div>
<TooltipSimple content={filePreviewTooltip} variant="instant">
<TooltipSimple content={windowPreviewTooltip} variant="instant">
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="icon-only"
onClick={toggleFilePreview}
onClick={toggleSessionPreview}
className={cn(
'no-drag shrink-0 text-ds-text-neutral-muted-default hover:bg-ds-bg-neutral-strong-default',
filePreviewOpen &&
sessionPreviewOpen &&
'bg-ds-bg-neutral-strong-default text-ds-text-neutral-default-default'
)}
aria-label={filePreviewTooltip}
aria-pressed={filePreviewOpen}
aria-label={windowPreviewTooltip}
aria-pressed={sessionPreviewOpen}
>
<FileText className="h-4 w-4" aria-hidden />
<GalleryThumbnails className="h-4 w-4" aria-hidden />
</Button>
</TooltipSimple>
</div>

View file

@ -0,0 +1,287 @@
// ========= 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 { TooltipSimple } from '@/components/ui/tooltip';
import { useHost } from '@/host';
import { cn } from '@/lib/utils';
import { getSessionPreviewSlice, usePageTabStore } from '@/store/pageTabStore';
import { Plus, X } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { previewTabIcon } from './tabKinds';
import { BrowserTab } from './tabs/browser/BrowserTab';
import { CanvasTab } from './tabs/CanvasTab';
import { ChooserTab } from './tabs/ChooserTab';
import { FileTab } from './tabs/FileTab';
import { ReviewTab } from './tabs/ReviewTab';
import { TerminalTab } from './tabs/TerminalTab';
// Tabs render at a comfortable default width and shrink evenly as more are
// added, down to a minimum that keeps the title/close affordance legible.
// Once every tab is at its minimum the tab list scrolls horizontally.
const TAB_DEFAULT_WIDTH = 176;
const TAB_MIN_WIDTH = 92;
export interface PreviewPanelProps {
onJumpToContext?: (file: FileInfo | null) => void;
/**
* False while the display panel's open animation is still running. Browser
* tabs hold their fixed-position webview guest parked until it settles so
* the page doesn't pop in over the chat mid-animation.
*/
displaySettled?: boolean;
}
/**
* Unified preview panel: a tab strip plus a content router that dispatches to
* one component per tab kind (chooser / browser / file / review / terminal /
* canvas). Embedded browsers live in the always-mounted PreviewBrowserLayer;
* this panel only renders their chrome via BrowserTab.
*/
export function PreviewPanel({
onJumpToContext,
displaySettled = true,
}: PreviewPanelProps) {
const { t } = useTranslation();
const host = useHost();
const tabs = usePageTabStore((state) => getSessionPreviewSlice(state).tabs);
const activeTabId = usePageTabStore(
(state) => getSessionPreviewSlice(state).activeTabId
);
const addChooserPreviewTab = usePageTabStore(
(state) => state.addChooserPreviewTab
);
const choosePreviewTabType = usePageTabStore(
(state) => state.choosePreviewTabType
);
const selectSessionPreviewTab = usePageTabStore(
(state) => state.selectSessionPreviewTab
);
const closeSessionPreviewTab = usePageTabStore(
(state) => state.closeSessionPreviewTab
);
const activeTab = useMemo(
() => tabs.find((tab) => tab.id === activeTabId) ?? tabs[0] ?? null,
[activeTabId, tabs]
);
// Embedded browsing relies on the desktop host's <webview> tag; on the web
// the panel still works but URLs open in a regular browser tab.
const isDesktop = Boolean(host?.electronAPI);
const tabListRef = useRef<HTMLDivElement>(null);
const [tabOverflow, setTabOverflow] = useState({ start: false, end: false });
const updateTabOverflow = useCallback(() => {
const el = tabListRef.current;
if (!el) return;
const { scrollLeft, scrollWidth, clientWidth } = el;
setTabOverflow({
start: scrollLeft > 1,
end: scrollLeft + clientWidth < scrollWidth - 1,
});
}, []);
useEffect(() => {
const el = tabListRef.current;
if (!el) return;
updateTabOverflow();
const observer = new ResizeObserver(updateTabOverflow);
observer.observe(el);
el.addEventListener('scroll', updateTabOverflow, { passive: true });
return () => {
observer.disconnect();
el.removeEventListener('scroll', updateTabOverflow);
};
}, [updateTabOverflow, tabs.length]);
if (!activeTab) return null;
// Roving tabindex: only the selected tab is in the Tab order; Left/Right
// (and Home/End) move both selection and focus along the strip.
const handleTabListKeyDown = (event: React.KeyboardEvent) => {
const currentIndex = tabs.findIndex((tab) => tab.id === activeTab.id);
let nextIndex: number;
switch (event.key) {
case 'ArrowLeft':
nextIndex = Math.max(0, currentIndex - 1);
break;
case 'ArrowRight':
nextIndex = Math.min(tabs.length - 1, currentIndex + 1);
break;
case 'Home':
nextIndex = 0;
break;
case 'End':
nextIndex = tabs.length - 1;
break;
default:
return;
}
event.preventDefault();
if (nextIndex === currentIndex) return;
selectSessionPreviewTab(tabs[nextIndex].id);
tabListRef.current
?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
[nextIndex]?.focus();
};
const renderActiveContent = () => {
switch (activeTab.type) {
case 'chooser':
return (
<ChooserTab
onChoose={(kind) => choosePreviewTabType(activeTab.id, kind)}
/>
);
case 'browser':
// Keyed so each browser tab keeps its own address state.
return (
<BrowserTab
key={activeTab.id}
tab={activeTab}
isDesktop={isDesktop}
viewportSettled={displaySettled}
/>
);
case 'file':
return <FileTab tab={activeTab} onJumpToContext={onJumpToContext} />;
case 'review':
return <ReviewTab />;
case 'terminal':
return <TerminalTab />;
case 'canvas':
return <CanvasTab key={activeTab.id} />;
default:
return null;
}
};
return (
<div className="flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden px-1 pb-2">
<div className="flex h-[44px] shrink-0 items-center justify-start gap-1 px-1.5">
<div className="relative flex min-w-0 items-center">
<div
ref={tabListRef}
role="tablist"
aria-label={t('layout.preview-tabs', {
defaultValue: 'Preview tabs',
})}
onKeyDown={handleTabListKeyDown}
className="scrollbar-hide flex h-7 w-full min-w-0 items-center gap-1.5 overflow-x-auto overflow-y-hidden"
>
{tabs.map((tab) => {
const selected = tab.id === activeTab.id;
const Icon = previewTabIcon(tab.type);
return (
<div
key={tab.id}
style={{
flex: `0 1 ${TAB_DEFAULT_WIDTH}px`,
minWidth: TAB_MIN_WIDTH,
maxWidth: TAB_DEFAULT_WIDTH,
}}
className={cn(
// Every tab uses the selected colors; unselected tabs are
// just dimmed (40% at rest, 80% on hover) so selection
// reads as full opacity rather than a color change.
'group relative h-7 rounded-lg bg-ds-bg-neutral-strong-default text-ds-text-neutral-default-default transition-opacity',
selected ? 'opacity-100' : 'opacity-40 hover:opacity-80'
)}
>
<button
type="button"
role="tab"
aria-selected={selected}
tabIndex={selected ? 0 : -1}
onClick={() => selectSessionPreviewTab(tab.id)}
className="flex h-full w-full min-w-0 cursor-pointer items-center gap-2 border-0 bg-transparent px-2 text-left text-inherit"
>
<Icon className="h-4 w-4 shrink-0" aria-hidden />
<span className="truncate text-sm font-medium">
{tab.title}
</span>
</button>
<button
type="button"
aria-label={t('layout.close-preview-tab', {
defaultValue: 'Close tab',
})}
onClick={(event) => {
event.stopPropagation();
closeSessionPreviewTab(tab.id);
}}
className={cn(
'absolute inset-y-0 right-1 top-1/2 z-10 flex h-5 w-5 -translate-y-1/2 cursor-pointer items-center justify-center rounded-lg border-0 px-1 text-inherit opacity-0 transition-opacity group-hover:opacity-100',
// Keyboard users can't hover — reveal on focus too.
'focus-visible:opacity-100 group-focus-within:opacity-100',
'bg-ds-bg-neutral-subtle-default hover:bg-ds-bg-neutral-muted-default'
)}
>
<X className="h-3.5 w-3.5" aria-hidden />
</button>
</div>
);
})}
</div>
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-y-0 left-0 w-6 backdrop-blur-[2px] transition-opacity duration-150',
tabOverflow.start ? 'opacity-100' : 'opacity-0'
)}
style={{
maskImage: 'linear-gradient(to right, black, transparent)',
WebkitMaskImage: 'linear-gradient(to right, black, transparent)',
}}
/>
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-y-0 right-0 w-8 backdrop-blur-[2px] transition-opacity duration-150',
tabOverflow.end ? 'opacity-100' : 'opacity-0'
)}
style={{
maskImage: 'linear-gradient(to left, black, transparent)',
WebkitMaskImage: 'linear-gradient(to left, black, transparent)',
}}
/>
</div>
<TooltipSimple
content={t('layout.add-preview-tab', { defaultValue: 'New tab' })}
>
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="icon-only"
onClick={addChooserPreviewTab}
aria-label={t('layout.add-preview-tab', {
defaultValue: 'New tab',
})}
className="shrink-0"
>
<Plus className="h-4 w-4" aria-hidden />
</Button>
</TooltipSimple>
</div>
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden rounded-xl border-solid border-ds-border-neutral-subtle-disabled bg-ds-bg-neutral-subtle-default">
{renderActiveContent()}
</div>
</div>
);
}
export default PreviewPanel;

View file

@ -0,0 +1,77 @@
// ========= 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 type { PreviewTabKind, SessionPreviewTab } from '@/store/pageTabStore';
import {
ClipboardCheck,
FileText,
Globe,
type LucideIcon,
PanelsTopLeft,
Shapes,
SquareTerminal,
} from 'lucide-react';
export interface PreviewKindMeta {
kind: PreviewTabKind;
icon: LucideIcon;
/** i18n key + fallback used for the tab title and chooser row label. */
labelKey: string;
defaultLabel: string;
/** One-line description shown in the chooser. */
descriptionKey: string;
defaultDescription: string;
}
/**
* The content kinds the chooser offers, in display order. Single source of
* truth for icon + copy so the tab strip and chooser never drift.
*
* `review`, `terminal`, and `canvas` are reserved tab types (their components
* and store plumbing exist, and persisted tabs still render) but are hidden
* from the chooser until a later version ships their content. Re-add their
* entries here when that lands.
*/
export const PREVIEW_TAB_KINDS: PreviewKindMeta[] = [
{
kind: 'browser',
icon: Globe,
labelKey: 'layout.preview-kind-browser',
defaultLabel: 'Browser',
descriptionKey: 'layout.preview-kind-browser-desc',
defaultDescription: 'Open and navigate web pages in an embedded browser.',
},
{
kind: 'file',
icon: FileText,
labelKey: 'layout.preview-kind-file',
defaultLabel: 'Files',
descriptionKey: 'layout.preview-kind-file-desc',
defaultDescription: 'Preview files produced or referenced in this session.',
},
];
const KIND_ICONS: Record<SessionPreviewTab['type'], LucideIcon> = {
chooser: PanelsTopLeft,
browser: Globe,
file: FileText,
review: ClipboardCheck,
terminal: SquareTerminal,
canvas: Shapes,
};
/** Icon for any tab (including the chooser) — used by the tab strip. */
export function previewTabIcon(type: SessionPreviewTab['type']): LucideIcon {
return KIND_ICONS[type];
}

View file

@ -0,0 +1,91 @@
// ========= 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 {
addEdge,
Background,
BackgroundVariant,
type Connection,
Controls,
type Edge,
MiniMap,
type Node,
ReactFlow,
ReactFlowProvider,
useEdgesState,
useNodesState,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { useCallback } from 'react';
const INITIAL_NODES: Node[] = [
{
id: 'start',
type: 'input',
position: { x: 0, y: 40 },
data: { label: 'Start' },
},
{
id: 'idea',
position: { x: 220, y: 160 },
data: { label: 'Idea' },
},
];
const INITIAL_EDGES: Edge[] = [
{ id: 'start-idea', source: 'start', target: 'idea' },
];
function CanvasFlow() {
const [nodes, , onNodesChange] = useNodesState(INITIAL_NODES);
const [edges, setEdges, onEdgesChange] = useEdgesState(INITIAL_EDGES);
const onConnect = useCallback(
(connection: Connection) => setEdges((eds) => addEdge(connection, eds)),
[setEdges]
);
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
fitView
proOptions={{ hideAttribution: true }}
className="bg-ds-bg-neutral-default-default"
>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
<MiniMap pannable zoomable className="!bg-ds-bg-neutral-subtle-default" />
<Controls />
</ReactFlow>
);
}
/**
* Free-form React Flow canvas. Each canvas tab keeps its own flow state in its
* own ReactFlowProvider so it stays isolated from the workspace workflow graph.
*/
export function CanvasTab() {
return (
<div className="h-full min-h-0 w-full overflow-hidden">
<ReactFlowProvider>
<CanvasFlow />
</ReactFlowProvider>
</div>
);
}
export default CanvasTab;

View file

@ -0,0 +1,84 @@
// ========= 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 { cn } from '@/lib/utils';
import type { PreviewTabKind } from '@/store/pageTabStore';
import { ChevronRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { PREVIEW_TAB_KINDS } from '../tabKinds';
export interface ChooserTabProps {
/** Open the given content kind (replaces this chooser tab in place). */
onChoose: (kind: PreviewTabKind) => void;
}
/**
* The default starter tab. Lists every content kind as a vertical row; picking
* one turns this tab into that kind via the store's `choosePreviewTabType`.
*/
export function ChooserTab({ onChoose }: ChooserTabProps) {
const { t } = useTranslation();
return (
<div className="flex h-full min-h-0 w-full flex-col items-center justify-center overflow-y-auto p-4">
<div className="w-full max-w-[420px]">
<p className="mb-3 px-1 text-sm font-medium text-ds-text-neutral-muted-default">
{t('layout.preview-chooser-title', {
defaultValue: 'Open a new view',
})}
</p>
<div className="flex flex-col gap-1.5">
{PREVIEW_TAB_KINDS.map(
({
kind,
icon: Icon,
labelKey,
defaultLabel,
descriptionKey,
defaultDescription,
}) => (
<button
key={kind}
type="button"
onClick={() => onChoose(kind)}
className={cn(
'group flex w-full items-center gap-3 rounded-xl border-solid border-transparent bg-ds-bg-neutral-default-default px-3 py-2.5 text-left transition-colors',
'hover:border-ds-border-neutral-default-default hover:bg-ds-bg-neutral-default-hover'
)}
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-ds-bg-neutral-subtle-default text-ds-text-neutral-default-default">
<Icon className="h-[18px] w-[18px]" aria-hidden />
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="text-sm font-medium text-ds-text-neutral-default-default">
{t(labelKey, { defaultValue: defaultLabel })}
</span>
<span className="truncate text-xs text-ds-text-neutral-muted-default">
{t(descriptionKey, { defaultValue: defaultDescription })}
</span>
</span>
<ChevronRight
className="h-4 w-4 shrink-0 text-ds-text-neutral-muted-default opacity-0 transition-opacity group-hover:opacity-100"
aria-hidden
/>
</button>
)
)}
</div>
</div>
</div>
);
}
export default ChooserTab;

View file

@ -0,0 +1,35 @@
// ========= 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 { FilePreview } from '@/components/Folder/FilePreview';
import type { SessionFileTab } from '@/store/pageTabStore';
export interface FileTabProps {
tab: SessionFileTab;
onJumpToContext?: (file: FileInfo | null) => void;
}
/** File preview surface for one file tab. */
export function FileTab({ tab, onJumpToContext }: FileTabProps) {
return (
<FilePreview
file={tab.file}
embedded
surfaceClassName="bg-ds-bg-neutral-default-default"
onJumpToContext={onJumpToContext}
/>
);
}
export default FileTab;

View file

@ -0,0 +1,25 @@
// ========= 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. =========
/**
* Review surface intentionally blank for now. Content (diff/change review)
* will be added later; this reserves the tab type and its container.
*/
export function ReviewTab() {
return (
<div className="flex h-full min-h-0 w-full flex-col overflow-hidden" />
);
}
export default ReviewTab;

View file

@ -14,33 +14,25 @@
import { useTranslation } from 'react-i18next';
interface BoxActionProps {
/** Task status for determining what button to show */
status?: 'running' | 'finished' | 'pending' | 'pause';
/** Task time display */
taskTime?: string;
/** Callback for pause/resume */
onPauseResume?: () => void;
/** Loading state for pause/resume */
pauseResumeLoading?: boolean;
className?: string;
}
export function BoxAction({
status: _status,
taskTime: _taskTime,
onPauseResume: _onPauseResume,
pauseResumeLoading: _pauseResumeLoading = false,
className,
}: BoxActionProps) {
/**
* Terminal surface. Rendered as a terminal-styled placeholder for now; the
* container and tab type are in place so a live PTY / agent terminal (the
* existing xterm `Terminal` component) can be dropped in later.
*/
export function TerminalTab() {
const { t } = useTranslation();
return (
<div
className={`z-50 flex items-center justify-between gap-sm pl-4 ${className || ''}`}
>
{/* Placeholder for future actions */}
<div></div>
<div className="flex h-full min-h-0 w-full flex-col overflow-hidden bg-ds-bg-neutral-strong-default">
<div className="min-h-0 flex-1 overflow-auto p-3 font-mono text-body-sm text-ds-text-neutral-muted-default">
<div className="text-ds-text-neutral-default-default">Eigent:~$</div>
<div className="mt-1 opacity-70">
{t('layout.preview-terminal-placeholder', {
defaultValue: 'Terminal output will appear here.',
})}
</div>
</div>
</div>
);
}
export default TerminalTab;

View file

@ -0,0 +1,297 @@
// ========= 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 { TooltipSimple } from '@/components/ui/tooltip';
import { useHost } from '@/host';
import { normalizeBrowserUrl } from '@/lib/browserUrl';
import { cn } from '@/lib/utils';
import { type SessionBrowserTab, usePageTabStore } from '@/store/pageTabStore';
import { ArrowLeft, ArrowRight, ExternalLink, RefreshCw } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { getPreviewWebview } from './webviewRegistry';
export interface BrowserTabProps {
tab: SessionBrowserTab;
/** Desktop host embeds a real <webview>; web falls back to opening tabs. */
isDesktop: boolean;
/**
* False while the display panel is still animating open. The viewport is
* only published once settled so the fixed-position guest (which the panel's
* clip-path can't clip) doesn't appear over the chat mid-animation.
*/
viewportSettled?: boolean;
}
/**
* Browser chrome (toolbar + address bar) for one browser tab. The page itself
* is a `<webview>` guest owned by PreviewBrowserLayer; this component publishes
* the rect the guest should fill via `previewBrowserViewport` and drives
* navigation through the webview registry. Keyed by tab id upstream so each
* browser tab keeps its own address state.
*/
export function BrowserTab({
tab,
isDesktop,
viewportSettled = true,
}: BrowserTabProps) {
const { t } = useTranslation();
const host = useHost();
const updateBrowserPreviewTab = usePageTabStore(
(state) => state.updateBrowserPreviewTab
);
const setPreviewBrowserViewport = usePageTabStore(
(state) => state.setPreviewBrowserViewport
);
const containerRef = useRef<HTMLDivElement>(null);
const [addressInput, setAddressInput] = useState(tab.url);
const [addressError, setAddressError] = useState<string | null>(null);
const [addressFocused, setAddressFocused] = useState(false);
const nav = tab.navigation;
// Follow the page's URL in the address bar — but never while the user is in
// the field, so redirects/SPA navigation events can't clobber their typing.
// (Unsubmitted edits revert to the page URL on blur.)
useEffect(() => {
if (addressFocused) return;
setAddressInput(tab.url);
setAddressError(null);
}, [tab.url, addressFocused]);
const navigateTo = useCallback(
async (rawUrl: string) => {
const normalized = normalizeBrowserUrl(rawUrl);
if (!normalized.ok) {
setAddressError(normalized.error);
return;
}
setAddressError(null);
setAddressInput(normalized.url);
if (!isDesktop) {
// Web host: no embedded view — open in a regular browser tab instead.
updateBrowserPreviewTab(tab.id, { url: normalized.url });
window.open(normalized.url, '_blank', 'noopener,noreferrer');
return;
}
const element = getPreviewWebview(tab.webviewId);
if (element?.loadURL) {
// Guest already mounted: navigate it in place (keeps history).
try {
await element.loadURL(normalized.url);
} catch (error) {
setAddressError(
error instanceof Error ? error.message : 'Unable to open this URL'
);
}
return;
}
// No guest yet (blank tab): setting the URL mounts one in the layer.
updateBrowserPreviewTab(tab.id, { url: normalized.url });
},
[isDesktop, tab.id, tab.webviewId, updateBrowserPreviewTab]
);
const openExternal = useCallback(
async (rawUrl: string) => {
const normalized = normalizeBrowserUrl(rawUrl);
if (!normalized.ok) {
setAddressError(normalized.error);
return;
}
setAddressError(null);
if (isDesktop && host?.electronAPI?.openExternal) {
const result = await host.electronAPI.openExternal(normalized.url);
if (result && result.success === false) {
setAddressError(result.error || 'Unable to open this URL');
}
return;
}
window.open(normalized.url, '_blank', 'noopener,noreferrer');
},
[host?.electronAPI, isDesktop]
);
// Publish this container's rect so the layer can position the guest over it.
// Held back until the panel animation settles; cleared on unmount (switching
// tabs / closing) so guests park, not float.
useEffect(() => {
if (!isDesktop || !viewportSettled) return;
const container = containerRef.current;
if (!container) {
setPreviewBrowserViewport(null);
return;
}
const publish = () => {
const rect = container.getBoundingClientRect();
setPreviewBrowserViewport({
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
});
};
publish();
const observer = new ResizeObserver(publish);
observer.observe(container);
window.addEventListener('resize', publish);
return () => {
observer.disconnect();
window.removeEventListener('resize', publish);
setPreviewBrowserViewport(null);
};
}, [isDesktop, viewportSettled, tab.id, setPreviewBrowserViewport]);
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex h-[44px] shrink-0 items-center gap-1.5 px-2">
<TooltipSimple
content={t('layout.browser-back', { defaultValue: 'Back' })}
>
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="icon-only"
disabled={!isDesktop || !nav?.canGoBack}
onClick={() => getPreviewWebview(tab.webviewId)?.goBack?.()}
aria-label={t('layout.browser-back', { defaultValue: 'Back' })}
>
<ArrowLeft className="h-4 w-4" aria-hidden />
</Button>
</TooltipSimple>
<TooltipSimple
content={t('layout.browser-forward', { defaultValue: 'Forward' })}
>
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="icon-only"
disabled={!isDesktop || !nav?.canGoForward}
onClick={() => getPreviewWebview(tab.webviewId)?.goForward?.()}
aria-label={t('layout.browser-forward', {
defaultValue: 'Forward',
})}
>
<ArrowRight className="h-4 w-4" aria-hidden />
</Button>
</TooltipSimple>
<TooltipSimple
content={t('layout.browser-reload', { defaultValue: 'Reload' })}
>
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="icon-only"
disabled={!isDesktop || !tab.url}
onClick={() => getPreviewWebview(tab.webviewId)?.reload?.()}
aria-label={t('layout.browser-reload', { defaultValue: 'Reload' })}
>
<RefreshCw
className={cn('h-4 w-4', nav?.isLoading && 'animate-spin')}
aria-hidden
/>
</Button>
</TooltipSimple>
<form
className="flex min-w-0 flex-1 items-center"
onSubmit={(event) => {
event.preventDefault();
void navigateTo(addressInput);
}}
>
<input
type="text"
value={addressInput}
onChange={(event) => {
setAddressInput(event.target.value);
if (addressError) setAddressError(null);
}}
onFocus={() => setAddressFocused(true)}
onBlur={() => setAddressFocused(false)}
placeholder={t('layout.browser-url-placeholder', {
defaultValue: 'Enter a URL',
})}
aria-label={t('layout.browser-url-placeholder', {
defaultValue: 'Enter a URL',
})}
aria-invalid={Boolean(addressError)}
className={cn(
'placeholder:text-input-label-default/10 h-[28px] w-full min-w-0 rounded-xl border-none bg-ds-bg-neutral-subtle-default px-3 text-body-sm text-ds-text-neutral-default-default outline-none transition-colors',
'hover:bg-ds-bg-neutral-subtle-default hover:ring-1 hover:ring-ds-ring-neutral-strong-default hover:ring-offset-0',
'focus:bg-ds-bg-neutral-subtle-default focus:ring-1 focus:ring-ds-ring-brand-default-focus focus:ring-offset-0',
addressError
? 'border-ds-border-status-error-default-default'
: 'border-ds-border-neutral-default-default'
)}
/>
</form>
<TooltipSimple
content={t('layout.browser-open-external', {
defaultValue: 'Open externally',
})}
>
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="icon-only"
disabled={!addressInput}
onClick={() => openExternal(addressInput)}
aria-label={t('layout.browser-open-external', {
defaultValue: 'Open externally',
})}
>
<ExternalLink className="h-4 w-4" aria-hidden />
</Button>
</TooltipSimple>
</div>
{addressError ? (
<p className="text-ds-text-danger-default-default shrink-0 px-3 py-1 text-xs">
{addressError}
</p>
) : null}
{/* The <webview> guest is positioned over this container by
PreviewBrowserLayer via the published viewport rect. */}
<div
ref={containerRef}
className="relative min-h-0 flex-1 overflow-hidden bg-ds-bg-neutral-strong-default"
>
{!tab.url ? (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-ds-text-neutral-muted-default">
{t('layout.browser-blank', {
defaultValue: 'Enter a URL to start browsing.',
})}
</div>
) : !isDesktop ? (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-ds-text-neutral-muted-default">
{t('layout.browser-desktop-only', {
defaultValue:
'Embedded browsing is available in the desktop app. This URL opened in your system browser.',
})}
</div>
) : null}
</div>
</div>
);
}
export default BrowserTab;

View file

@ -0,0 +1,310 @@
// ========= 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 { useHost } from '@/host';
import {
type PreviewBrowserViewport,
type SessionBrowserNavigationState,
type SessionBrowserTab,
usePageTabStore,
} from '@/store/pageTabStore';
import { useEffect, useRef, useState } from 'react';
import {
type PreviewWebviewElement,
registerPreviewWebview,
unregisterPreviewWebview,
} from './webviewRegistry';
/**
* Hosts the preview panel's embedded browsers as Electron `<webview>` tags.
*
* Why a separate, always-mounted layer instead of rendering inside the panel:
* - `<webview>` is a DOM element, so dropdowns/dialogs overlay it naturally
* (unlike a native WebContentsView, which paints above the whole document).
* - A `<webview>` reloads whenever it is detached or reparented. Keeping the
* elements here positioned over the panel via `previewBrowserViewport`,
* parked offscreen when not visible preserves each guest's page state and
* full back/forward history across panel close, tab hops, and project
* switches for the lifetime of the workspace page.
*
* Mount once (in the workspace page). Renders nothing on the web host.
*/
/** Above page content, below portaled overlays (menus z-50, tooltips z-100). */
const GUEST_Z_INDEX = 30;
/** Matches the preview panel's rounded-xl browser container. */
const GUEST_RADIUS = 12;
/** Guest fade when (un)covering its viewport — softens show/park hops. */
const GUEST_FADE_MS = 160;
/**
* Guests of projects that have been out of scope this long are destroyed to
* reclaim their renderer processes. The tab's URL is persisted, so returning
* to an evicted project simply reloads its pages (history is lost the price
* of not keeping every visited project's Chromium processes alive forever).
*/
const IDLE_PROJECT_EVICT_MS = 10 * 60_000;
const EVICT_SWEEP_INTERVAL_MS = 60_000;
const PARKED_STYLE: React.CSSProperties = {
position: 'fixed',
left: -10_000,
top: 0,
width: 1024,
height: 640,
visibility: 'hidden',
pointerEvents: 'none',
};
function visibleStyle(
viewport: PreviewBrowserViewport,
shown: boolean
): React.CSSProperties {
return {
position: 'fixed',
left: viewport.x,
top: viewport.y,
width: viewport.width,
height: viewport.height,
zIndex: GUEST_Z_INDEX,
borderRadius: GUEST_RADIUS,
overflow: 'hidden',
opacity: shown ? 1 : 0,
transition: `opacity ${GUEST_FADE_MS}ms ease`,
};
}
function browserTitle(state: SessionBrowserNavigationState): string {
const explicitTitle = state.title.trim();
if (explicitTitle) return explicitTitle;
if (!state.url || state.url.startsWith('about:')) return 'New tab';
try {
return new URL(state.url).hostname || 'New tab';
} catch {
return 'New tab';
}
}
const GUEST_EVENTS = [
'did-navigate',
'did-navigate-in-page',
'did-start-loading',
'did-stop-loading',
'page-title-updated',
'dom-ready',
] as const;
interface PreviewGuestProps {
projectId: string;
tab: SessionBrowserTab;
visible: boolean;
viewport: PreviewBrowserViewport | null;
}
function PreviewGuest({
projectId,
tab,
visible,
viewport,
}: PreviewGuestProps) {
const containerRef = useRef<HTMLDivElement>(null);
// The guest reloads if its src attribute changes, so freeze the mount-time
// URL; later navigation happens on the element (loadURL / link clicks) and
// flows back into the store through the events below.
const initialUrlRef = useRef(tab.url);
const tabIdRef = useRef(tab.id);
useEffect(() => {
tabIdRef.current = tab.id;
}, [tab.id]);
// Show/park with a short opacity fade instead of an instant hop:
// 'parked' → offscreen, 'faded' → over the viewport at opacity 0,
// 'shown' → opacity 1. `lastViewport` remembers the rect the guest was
// last shown at so the fade-out happens in place even after the panel
// stops publishing a viewport (tab switch / project switch unmounts the
// publisher in the same commit that hides the guest).
const showing = visible && viewport !== null;
const [lastViewport, setLastViewport] =
useState<PreviewBrowserViewport | null>(null);
useEffect(() => {
if (viewport) setLastViewport(viewport);
}, [viewport]);
const [phase, setPhase] = useState<'parked' | 'faded' | 'shown'>('parked');
useEffect(() => {
if (showing) {
setPhase('faded');
// Double rAF so the opacity-0 frame commits before the fade-in starts.
let raf2 = 0;
const raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => setPhase('shown'));
});
return () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
};
}
setPhase((current) => (current === 'parked' ? 'parked' : 'faded'));
const timer = window.setTimeout(() => setPhase('parked'), GUEST_FADE_MS);
return () => window.clearTimeout(timer);
}, [showing]);
useEffect(() => {
const container = containerRef.current;
if (!container || !initialUrlRef.current) return;
const element = document.createElement('webview') as PreviewWebviewElement;
element.setAttribute('src', initialUrlRef.current);
element.setAttribute('partition', 'persist:session-preview');
// Popups are redirected into the same guest by the main process
// (did-attach-webview → setWindowOpenHandler), so target=_blank links work.
element.setAttribute('allowpopups', 'true');
element.style.display = 'flex';
element.style.width = '100%';
element.style.height = '100%';
const notify = () => {
// Guest methods throw until the webview is attached; treat as no state.
let state: SessionBrowserNavigationState;
try {
state = {
url: element.getURL?.() ?? '',
title: element.getTitle?.() ?? '',
isLoading: element.isLoading?.() ?? false,
canGoBack: element.canGoBack?.() ?? false,
canGoForward: element.canGoForward?.() ?? false,
};
} catch {
return;
}
const url = state.url.startsWith('about:') ? '' : state.url;
usePageTabStore
.getState()
.updateBrowserPreviewTabIn(projectId, tabIdRef.current, {
title: browserTitle(state),
navigation: { ...state, url },
// Guests mount only while the tab has a URL, so never write an
// empty one back (early events can fire before a location exists).
...(url ? { url } : {}),
});
};
GUEST_EVENTS.forEach((event) => element.addEventListener(event, notify));
container.appendChild(element);
registerPreviewWebview(tab.webviewId, element);
return () => {
GUEST_EVENTS.forEach((event) =>
element.removeEventListener(event, notify)
);
unregisterPreviewWebview(tab.webviewId);
element.remove();
};
}, [projectId, tab.webviewId]);
const rect = viewport ?? lastViewport;
return (
<div
ref={containerRef}
data-preview-webview-id={tab.webviewId}
style={
phase === 'parked' || !rect
? PARKED_STYLE
: visibleStyle(rect, phase === 'shown')
}
/>
);
}
export function PreviewBrowserLayer() {
const host = useHost();
const byProject = usePageTabStore((s) => s.sessionPreviewByProject);
const scopeProjectId = usePageTabStore((s) => s.sessionPreviewProjectId);
const viewport = usePageTabStore((s) => s.previewBrowserViewport);
// Persisted slices may reference many projects; only mount guests for
// projects the user has actually visited this run so startup stays light.
const [activatedProjects, setActivatedProjects] = useState<Set<string>>(
() => new Set()
);
useEffect(() => {
if (!scopeProjectId) return;
setActivatedProjects((current) => {
if (current.has(scopeProjectId)) return current;
const next = new Set(current);
next.add(scopeProjectId);
return next;
});
}, [scopeProjectId]);
// Each guest is a full renderer process, so don't keep every visited
// project's guests alive forever: track when a project leaves scope and
// deactivate it (unmounting its guests) once it has idled long enough.
const idleSinceRef = useRef(new Map<string, number>());
useEffect(() => {
if (!scopeProjectId) return;
const idleSince = idleSinceRef.current;
idleSince.delete(scopeProjectId);
return () => {
idleSince.set(scopeProjectId, Date.now());
};
}, [scopeProjectId]);
useEffect(() => {
const timer = window.setInterval(() => {
setActivatedProjects((current) => {
let next: Set<string> | null = null;
for (const projectId of current) {
const idleSince = idleSinceRef.current.get(projectId);
if (
idleSince === undefined ||
Date.now() - idleSince < IDLE_PROJECT_EVICT_MS
) {
continue;
}
next ??= new Set(current);
next.delete(projectId);
idleSinceRef.current.delete(projectId);
}
return next ?? current;
});
}, EVICT_SWEEP_INTERVAL_MS);
return () => window.clearInterval(timer);
}, []);
// Embedded browsing needs the desktop host's <webview> tag.
if (!host?.electronAPI) return null;
const guests: React.ReactNode[] = [];
for (const [projectId, slice] of Object.entries(byProject)) {
if (!activatedProjects.has(projectId)) continue;
for (const tab of slice.tabs) {
if (tab.type !== 'browser' || !tab.url) continue;
const visible =
projectId === scopeProjectId &&
slice.open &&
slice.activeTabId === tab.id;
guests.push(
<PreviewGuest
key={tab.webviewId}
projectId={projectId}
tab={tab}
visible={visible}
viewport={viewport}
/>
);
}
}
return <>{guests}</>;
}
export default PreviewBrowserLayer;

View file

@ -0,0 +1,58 @@
// ========= 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. =========
/**
* Minimal surface of Electron's `<webview>` tag that the preview uses. Typed
* locally (instead of importing electron types) so the renderer stays
* host-agnostic: on the web the element never mounts and none of this runs.
* All methods are optional they only exist once the guest is attached.
*/
export interface PreviewWebviewElement extends HTMLElement {
src?: string;
loadURL?: (url: string) => Promise<void>;
getURL?: () => string;
getTitle?: () => string;
isLoading?: () => boolean;
canGoBack?: () => boolean;
canGoForward?: () => boolean;
goBack?: () => void;
goForward?: () => void;
reload?: () => void;
stop?: () => void;
}
/**
* Live `<webview>` elements keyed by the store's webviewId. The browser layer
* registers elements as they mount; the preview panel's toolbar and address
* bar drive navigation through this without owning the elements (which must
* outlive the panel so guests keep their history).
*/
const registry = new Map<string, PreviewWebviewElement>();
export function registerPreviewWebview(
webviewId: string,
element: PreviewWebviewElement
): void {
registry.set(webviewId, element);
}
export function unregisterPreviewWebview(webviewId: string): void {
registry.delete(webviewId);
}
export function getPreviewWebview(
webviewId: string
): PreviewWebviewElement | undefined {
return registry.get(webviewId);
}

View file

@ -13,14 +13,14 @@
// ========= 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 { PreviewPanel } from '@/components/Session/PreviewPanel';
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';
import { getSessionPreviewSlice, usePageTabStore } from '@/store/pageTabStore';
import { useProjectRuntimeStore } from '@/store/projectRuntimeStore';
import { useSpaceStore } from '@/store/spaceStore';
import {
@ -28,6 +28,7 @@ import {
SessionMode,
type SessionModeType,
} from '@/types/constants';
import { AnimatePresence, motion } from 'framer-motion';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { SessionSidePanel } from './SessionSidePanel';
import {
@ -35,16 +36,15 @@ import {
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.
*/
/** Maximum width the resizable chat column can reclaim while display is open. */
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;
const DISPLAY_PANEL_EASE: [number, number, number, number] = [0.16, 1, 0.3, 1];
/** Display panel open/close animation duration (framer transition below). */
const DISPLAY_PANEL_ANIMATION_MS = 300;
/**
* Active Project: header + chat (left) and a mode-dependent side panel (right).
@ -60,9 +60,19 @@ 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 closeSessionPreview = usePageTabStore((s) => s.closeSessionPreview);
const setSessionPreviewProject = usePageTabStore(
(s) => s.setSessionPreviewProject
);
const sessionPreviewProjectId = usePageTabStore(
(s) => s.sessionPreviewProjectId
);
// Only trust the preview slice once the scope points at this project — on
// switch the scope effect below lags the first render by one frame, and
// rendering the stale slice would flash the previous project's browser.
const previewOpen =
usePageTabStore((s) => getSessionPreviewSlice(s).open) &&
sessionPreviewProjectId === (projectStore.activeProjectId ?? null);
const activeProjectId = projectStore.activeProjectId;
const isHistoryLoadingActiveProject = useProjectRuntimeStore((s) =>
activeProjectId
@ -229,45 +239,65 @@ 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.
// Chat/display sizing. When display opens the chat collapses to its minimum;
// display takes the remaining room before the independently folded side panel.
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.
// Point the preview store at this project. Its saved tabs (and, within this
// app run, the live webviews behind them) are restored on switch-back —
// webviews are intentionally NOT destroyed here so history survives.
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]);
setSessionPreviewProject(activeProjectId ?? null);
}, [activeProjectId, setSessionPreviewProject]);
// The preview is a project-page concern; close it when leaving that tab or
// switching projects so it never lingers over an unrelated view.
// Last chat width the user dragged to; reopening display restores it instead
// of resetting to the minimum.
const userChatWidthRef = useRef<number | null>(null);
// Opening display auto-folds the session side panel and collapses chat
// (to the user's remembered width, if they resized before).
useEffect(() => {
if (activeWorkspaceTab !== 'project') {
closeFilePreview();
if (previewOpen) {
setIsSidePanelVisible(false);
setChatWidth(userChatWidthRef.current ?? CHAT_MIN_WIDTH);
}
}, [activeWorkspaceTab, activeProjectId, closeFilePreview]);
}, [previewOpen]);
// Embedded browser guests are `position: fixed` in a separate layer, so the
// panel's clip-path entrance can't clip them. Hold the guest parked until
// the entrance finishes (then it fades in over the settled rect) instead of
// letting the page pop in full-size over the chat mid-animation.
const [displaySettled, setDisplaySettled] = useState(false);
useEffect(() => {
if (!previewOpen) {
setDisplaySettled(false);
return;
}
const timer = window.setTimeout(
() => setDisplaySettled(true),
DISPLAY_PANEL_ANIMATION_MS + 20
);
return () => window.clearTimeout(timer);
}, [previewOpen]);
const handlePreviewResizeStart = useCallback(
(e: React.PointerEvent) => {
e.preventDefault();
const rowWidth =
chatRowRef.current?.getBoundingClientRect().width ?? window.innerWidth;
const sidePanelWidth =
document.getElementById('session-side-panel')?.getBoundingClientRect()
.width ?? 0;
// Chat never exceeds its priority max width, and always leaves room for
// the preview's minimum width.
// display's minimum width plus the independent session panel.
const maxChat = Math.max(
CHAT_MIN_WIDTH,
Math.min(CHAT_PRIORITY_WIDTH, rowWidth - PREVIEW_MIN_WIDTH)
Math.min(
CHAT_PRIORITY_WIDTH,
rowWidth - sidePanelWidth - PREVIEW_MIN_WIDTH
)
);
const startX = e.clientX;
const startWidth = chatWidth;
@ -278,6 +308,7 @@ export default function Session({ isNewProject = false }: SessionProps) {
maxChat,
Math.max(CHAT_MIN_WIDTH, startWidth + (ev.clientX - startX))
);
userChatWidthRef.current = next;
setChatWidth(next);
};
const onUp = () => {
@ -303,9 +334,9 @@ export default function Session({ isNewProject = false }: SessionProps) {
setActiveWorkspaceTab('inbox', {
clearInboxForProjectId: activeProjectId ?? null,
});
closeFilePreview();
closeSessionPreview();
},
[selectedTurn, setActiveWorkspaceTab, activeProjectId, closeFilePreview]
[selectedTurn, setActiveWorkspaceTab, activeProjectId, closeSessionPreview]
);
const toggleExpandedOverlay = useCallback(() => {
@ -336,10 +367,10 @@ export default function Session({ isNewProject = false }: SessionProps) {
if (isNewProject) {
return (
<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">
<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">
<HeaderBox empty />
<div className="min-h-0 min-w-0 flex flex-1 flex-col overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<Workspace
variant="new-project"
embedded
@ -352,7 +383,7 @@ export default function Session({ isNewProject = false }: SessionProps) {
<div
id="session-side-panel"
className={cn(
'min-h-0 ease-out flex shrink-0 flex-col overflow-hidden transition-[width] duration-200',
'flex min-h-0 shrink-0 flex-col overflow-hidden transition-[width] duration-200 ease-out',
isSidePanelVisible
? SESSION_SIDE_PANEL_EXPANDED_OUTER_CLASS
: cn(SESSION_SIDE_PANEL_FOLDED_OUTER_CLASS, 'rounded-l-xl')
@ -365,59 +396,83 @@ export default function Session({ isNewProject = false }: SessionProps) {
}
return (
<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">
<div
ref={chatRowRef}
className="flex h-full min-h-0 w-full min-w-0 flex-1 flex-row overflow-hidden"
>
{/* Chat content: owns the project header and folds when display opens. */}
<div
style={previewOpen ? { width: chatWidth } : undefined}
className={cn(
'flex min-h-0 min-w-0 flex-col overflow-hidden',
previewOpen ? 'shrink-0' : 'flex-1',
!isResizingPreview && 'transition-[width] duration-200 ease-out'
)}
>
{chatStore.activeTaskId && hasAnyMessages && (
<HeaderBox
totalTokens={chatStore.tasks[chatStore.activeTaskId]?.tokens || 0}
/>
)}
<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'
)}
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<ChatBox />
</div>
</div>
<AnimatePresence initial={false}>
{previewOpen && (
<motion.div
key="session-display-content"
initial={{
clipPath: 'inset(0 0 0 100%)',
opacity: 0,
}}
animate={{
clipPath: 'inset(0 0 0 0%)',
opacity: 1,
flexGrow: 1,
}}
exit={{
clipPath: 'inset(0 0 0 100%)',
opacity: 0,
flexGrow: 0,
}}
transition={{ duration: 0.3, ease: DISPLAY_PANEL_EASE }}
style={{ transformOrigin: 'right center' }}
className="flex min-h-0 min-w-0 flex-1 overflow-hidden"
>
<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',
// Transparent 2px rail with a centered line and wider hit area.
'relative z-10 flex w-[2px] shrink-0 cursor-col-resize items-center justify-center bg-transparent transition-all hover:bg-ds-bg-brand-subtle-default',
"before:absolute before:inset-y-0 before:-left-1 before:-right-1 before:content-['']",
'after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 after:bg-ds-bg-neutral-default-default 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>
{/* Display content: middle column between chat and session. */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{activeProjectId ? (
<PreviewPanel
displaySettled={displaySettled}
onJumpToContext={handleJumpToContext}
/>
) : null}
</div>
</motion.div>
)}
</AnimatePresence>
<div
id="session-side-panel"
className={cn(
'min-h-0 ease-out flex shrink-0 flex-col overflow-hidden transition-[width] duration-200',
'flex min-h-0 shrink-0 flex-col overflow-hidden transition-[width] duration-200 ease-out',
isSidePanelVisible
? SESSION_SIDE_PANEL_EXPANDED_OUTER_CLASS
: cn(SESSION_SIDE_PANEL_FOLDED_OUTER_CLASS, 'rounded-l-xl')

View file

@ -0,0 +1,190 @@
// ========= 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 { TooltipSimple } from '@/components/ui/tooltip';
import { useHost } from '@/host';
import type { ProgressInfo } from 'electron-updater';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
// Survives TopBar remounts so a background download is only auto-started once
// per app session; after that the slot falls back to a manual button.
const AUTO_DOWNLOAD_KEY = 'eigent-update-auto-download-started';
type UpdatePhase = 'idle' | 'available' | 'downloading' | 'downloaded';
/**
* Software-update slot at the left end of the top bar's trailing button group.
* idle (auto background download with inline progress) "Launch new version".
*/
export default function UpdateButton() {
const { t } = useTranslation();
const host = useHost();
const ipc = host?.ipcRenderer;
const [phase, setPhase] = useState<UpdatePhase>('idle');
const [progress, setProgress] = useState(0);
const [failed, setFailed] = useState(false);
const startDownload = useCallback(() => {
setFailed(false);
setProgress(0);
setPhase('downloading');
void ipc?.invoke('start-download');
}, [ipc]);
useEffect(() => {
if (!ipc) return;
const onUpdateCanAvailable = (
_event: Electron.IpcRendererEvent,
info: VersionInfo
) => {
setPhase((current) => {
if (current !== 'idle') return current;
return info.update ? 'available' : 'idle';
});
};
const onDownloadProgress = (
_event: Electron.IpcRendererEvent,
info: ProgressInfo
) => {
setFailed(false);
setProgress(info.percent ?? 0);
setPhase((current) =>
current === 'downloaded' ? current : 'downloading'
);
};
const onUpdateDownloaded = () => {
setPhase('downloaded');
};
const onUpdateError = () => {
// Inline retry affordance instead of a toast; only relevant mid-download.
setPhase((current) => {
if (current !== 'downloading') return current;
setFailed(true);
return 'available';
});
};
ipc.on('update-can-available', onUpdateCanAvailable);
ipc.on('download-progress', onDownloadProgress);
ipc.on('update-downloaded', onUpdateDownloaded);
ipc.on('update-error', onUpdateError);
void ipc.invoke('check-update');
return () => {
ipc.off('update-can-available', onUpdateCanAvailable);
ipc.off('download-progress', onDownloadProgress);
ipc.off('update-downloaded', onUpdateDownloaded);
ipc.off('update-error', onUpdateError);
};
}, [ipc]);
// Auto-start the background download the first time an update is seen.
useEffect(() => {
if (phase !== 'available' || failed) return;
if (sessionStorage.getItem(AUTO_DOWNLOAD_KEY)) return;
sessionStorage.setItem(AUTO_DOWNLOAD_KEY, '1');
startDownload();
}, [phase, failed, startDownload]);
if (phase === 'idle') return null;
if (phase === 'downloading') {
const percent = Math.round(progress);
const label = t('update.downloading', {
defaultValue: 'Downloading update',
});
return (
<TooltipSimple content={label} side="bottom" align="end">
<div
className="no-drag flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2"
role="progressbar"
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
>
<div className="h-1.5 w-16 shrink-0 overflow-hidden rounded-full bg-ds-bg-neutral-strong-default">
<div
className="h-full rounded-full bg-ds-bg-brand-default-default transition-all duration-200 ease-out"
style={{ width: `${percent}%` }}
/>
</div>
<span className="text-xs tabular-nums text-ds-text-neutral-subtle-default">
{percent}%
</span>
</div>
</TooltipSimple>
);
}
if (phase === 'downloaded') {
const label = t('update.launch-new-version', {
defaultValue: 'Launch new version',
});
return (
<TooltipSimple
content={t('update.click-to-install-update', {
defaultValue: 'Click to install update',
})}
side="bottom"
align="end"
>
<Button
type="button"
variant="primary"
size="sm"
className="no-drag shrink-0 rounded-full px-3"
onClick={() => void ipc?.invoke('quit-and-install')}
aria-label={label}
>
{label}
</Button>
</TooltipSimple>
);
}
// 'available' after the session already auto-downloaded once, or after a
// download error: manual (re)start.
const label = t('layout.update', { defaultValue: 'Update' });
return (
<TooltipSimple
content={
failed
? t('update.update-failed-retry', {
defaultValue: 'Update failed — click to retry',
})
: label
}
side="bottom"
align="end"
>
<Button
type="button"
variant="primary"
size="sm"
className="no-drag shrink-0 rounded-full px-3"
onClick={startDownload}
aria-label={label}
>
{label}
</Button>
</TooltipSimple>
);
}

View file

@ -21,6 +21,7 @@ import { type HistoryTabId } from '@/components/Dashboard/HistoryTabsNav';
import InviteCodeDialog from '@/components/Dialog/InviteCodeDialog';
import ReportBugDialog from '@/components/Dialog/ReportBugDialog';
import { SpaceSwitchDropdown } from '@/components/ProjectPageSidebar/SpaceSwitchDropdown';
import UpdateButton from '@/components/TopBar/UpdateButton';
import AlertDialog from '@/components/ui/alertDialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@ -167,8 +168,6 @@ function HeaderWin() {
const appearance = useAuthStore((state) => state.appearance);
const email = useAuthStore((s) => s.email);
const userId = useAuthStore((s) => s.user_id);
const [packageUpdateAvailable, setPackageUpdateAvailable] = useState(false);
const ipcRenderer = host?.ipcRenderer;
const { isInstalling, installationState } = useInstallationUI();
const _isInstallationActive =
isInstalling || installationState === 'waiting-backend';
@ -179,35 +178,6 @@ function HeaderWin() {
setPlatform(p);
}, [host]);
useEffect(() => {
const ipc = ipcRenderer;
if (!ipc) return;
const onUpdateCanAvailable = (
_event: Electron.IpcRendererEvent,
info: VersionInfo
) => {
setPackageUpdateAvailable(Boolean(info.update));
};
const onUpdateDownloaded = () => {
setPackageUpdateAvailable(false);
};
ipc.on('update-can-available', onUpdateCanAvailable);
ipc.on('update-downloaded', onUpdateDownloaded);
void ipc.invoke('check-update');
return () => {
ipc.off('update-can-available', onUpdateCanAvailable);
ipc.off('update-downloaded', onUpdateDownloaded);
};
}, [ipcRenderer]);
const handleStartDownload = useCallback(() => {
void ipcRenderer?.invoke('start-download');
}, [ipcRenderer]);
const isHistoryRoute = useMemo(() => {
const path = location.pathname.replace(/\/$/, '') || '/';
return path === '/history' || path.endsWith('/history');
@ -639,7 +609,9 @@ function HeaderWin() {
platform === 'darwin' && 'px-1.5'
} no-drag relative z-50 flex h-7 shrink-0 items-center`}
>
<div className="flex h-full shrink-0 items-center">
<div className="flex h-full shrink-0 items-center gap-0.5">
{/* Update slot: hidden → background download progress → launch new version */}
<UpdateButton />
<TooltipSimple
content={t('layout.support')}
side="bottom"
@ -741,25 +713,6 @@ function HeaderWin() {
</motion.div>
)}
</AnimatePresence>
{packageUpdateAvailable && (
<TooltipSimple
content={t('layout.update')}
side="bottom"
align="end"
variant="instant"
>
<Button
type="button"
variant="primary"
size="sm"
className="no-drag shrink-0 rounded-full px-3"
onClick={handleStartDownload}
aria-label={t('layout.update')}
>
{t('layout.update')}
</Button>
</TooltipSimple>
)}
</div>
</div>
</div>

View file

@ -12,7 +12,7 @@
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { Bird, CodeXml, FileText, Globe, Image } from 'lucide-react';
import { Bird, Bot, CodeXml, FileText, Globe, Image } from 'lucide-react';
import type { ReactNode } from 'react';
export type WorkflowAgentType =
@ -22,6 +22,11 @@ export type WorkflowAgentType =
| 'multi_modal_agent'
| 'social_media_agent';
/** Backend agent name used by Single Agent SkillToolkit (`Agents.single_agent`). */
export const SINGLE_AGENT_ID = 'single_agent' as const;
export type SkillScopeAgentType = WorkflowAgentType | typeof SINGLE_AGENT_ID;
export interface AgentDisplayInfo {
name: string;
icon: ReactNode;
@ -132,12 +137,95 @@ export const WORKFLOW_AGENT_LIST: {
},
];
/** Get display info (name + icon) by agent name; returns undefined if not a workflow agent. */
/**
* Agents shown in Skills "Select agent access".
* Always includes Single Agent first so skill scope can target `single_agent`
* the same way backend SkillToolkit / Agents.single_agent does.
*/
export const SKILL_SCOPE_AGENT_LIST: {
id: SkillScopeAgentType;
name: string;
icon: ReactNode;
}[] = [
{
id: SINGLE_AGENT_ID,
// Product label (layout.workspace-session-single-agent). Value stays `single_agent`.
name: 'Single Agent',
icon: <Bot size={16} className="text-ds-text-neutral-default-default" />,
},
...WORKFLOW_AGENT_LIST,
];
export type SkillScopeAgentOption = {
value: string;
label: string;
};
/**
* Canonicalize skill-scope agent ids so UI + backend agree.
* Accepts legacy aliases such as `Agents.single_agent`.
*/
export function normalizeSkillScopeAgentId(agentName?: string | null): string {
const raw = String(agentName ?? '').trim();
if (!raw) return '';
const lowered = raw.toLowerCase();
if (lowered === SINGLE_AGENT_ID || lowered === 'agents.single_agent') {
return SINGLE_AGENT_ID;
}
return raw;
}
/**
* Build the Skills page agent-access options:
* Single Agent (stable) + workforce workers + custom workers (deduped by value).
*/
export function buildSkillScopeAgentOptions(
workerList: Array<{ name: string }> = []
): SkillScopeAgentOption[] {
const baseAgents: SkillScopeAgentOption[] = SKILL_SCOPE_AGENT_LIST.filter(
(a) => a.id !== 'social_media_agent'
).map((a) => ({ value: a.id, label: a.name }));
// Guarantee Single Agent stays first even if list construction changes later.
const combined: SkillScopeAgentOption[] = [];
const seen = new Set<string>();
for (const agent of baseAgents) {
const value = normalizeSkillScopeAgentId(agent.value) || agent.value;
if (seen.has(value)) continue;
seen.add(value);
combined.push({ value, label: agent.label });
}
for (const worker of workerList) {
const value = normalizeSkillScopeAgentId(worker.name);
if (!value || seen.has(value)) continue;
seen.add(value);
const label =
value === SINGLE_AGENT_ID
? 'Single Agent'
: String(worker.name || value).trim() || value;
combined.push({ value, label });
}
// Last-resort: if somehow dropped, prepend Single Agent.
if (!combined.some((agent) => agent.value === SINGLE_AGENT_ID)) {
combined.unshift({ value: SINGLE_AGENT_ID, label: 'Single Agent' });
} else if (combined[0]?.value !== SINGLE_AGENT_ID) {
const single = combined.find((agent) => agent.value === SINGLE_AGENT_ID)!;
combined.splice(combined.indexOf(single), 1);
combined.unshift(single);
}
return combined;
}
/** Get display info (name + icon) by agent name; returns undefined if unknown. */
export function getWorkflowAgentDisplay(
agentName: string
): { name: string; icon: ReactNode } | undefined {
const entry = WORKFLOW_AGENT_LIST.find(
(a) => a.id.toLowerCase() === agentName.toLowerCase()
const normalized = normalizeSkillScopeAgentId(agentName);
const entry = SKILL_SCOPE_AGENT_LIST.find(
(a) => a.id.toLowerCase() === normalized.toLowerCase()
);
if (!entry) return undefined;
return { name: entry.name, icon: entry.icon };

View file

@ -63,6 +63,8 @@ export interface ProjectModeToggleProps {
* bar (see model read-only chip).
*/
readOnly?: boolean;
/** When true, hides the text label and shows only the leading icon (narrow footer). */
compact?: boolean;
}
export function ProjectModeToggle({
@ -70,6 +72,7 @@ export function ProjectModeToggle({
onValueChange,
className,
readOnly = false,
compact = false,
}: ProjectModeToggleProps) {
const { t } = useTranslation();
const chevronScale = useAnimationControls();
@ -82,6 +85,7 @@ export function ProjectModeToggle({
const isSingle = value === SessionMode.SINGLE_AGENT;
const nextMode = isSingle ? SessionMode.WORKFORCE : SessionMode.SINGLE_AGENT;
const label = isSingle ? labelSingle : labelWorkforce;
const LeadingIcon = isSingle ? Joystick : Gamepad2;
const toggle = () => onValueChange(nextMode);
@ -92,8 +96,6 @@ export function ProjectModeToggle({
})();
}, [chevronScale]);
const LeadingIcon = isSingle ? Joystick : Gamepad2;
const shellClass = cn(
'rounded-xl px-2 py-1 inline-flex items-center gap-1.5',
'bg-ds-bg-neutral-default-default text-ds-text-neutral-default-default',
@ -116,7 +118,8 @@ export function ProjectModeToggle({
return (
<div
role="status"
aria-label={modeAriaLabel}
aria-label={`${modeAriaLabel}: ${label}`}
title={compact ? label : undefined}
className={cn(shellClass, 'pointer-events-none bg-transparent')}
>
<span className="inline-flex min-h-[1.25rem] items-center gap-1.5 overflow-hidden">
@ -125,7 +128,9 @@ export function ProjectModeToggle({
strokeWidth={2}
aria-hidden
/>
<span className="!text-label-xs font-semibold">{label}</span>
{!compact && (
<span className="!text-label-xs font-semibold">{label}</span>
)}
</span>
</div>
);
@ -137,10 +142,11 @@ export function ProjectModeToggle({
<motion.button
type="button"
aria-label={`${modeAriaLabel}: ${label}. ${cycleHint}`}
title={compact ? label : undefined}
className={cn(
shellClass,
'cursor-pointer border-0 text-left',
'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'
'hover: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'
)}
onPointerDown={pulseChevrons}
onClick={toggle}
@ -160,9 +166,11 @@ export function ProjectModeToggle({
strokeWidth={2}
aria-hidden
/>
<span className="whitespace-nowrap !text-label-xs font-semibold">
{label}
</span>
{!compact && (
<span className="whitespace-nowrap !text-label-xs font-semibold">
{label}
</span>
)}
</motion.span>
</AnimatePresence>
</span>

View file

@ -390,9 +390,6 @@ export default function Workspace({
'Legacy Spaces are read-only. Create a new Space to start a Project.',
})
: t('layout.project-task-placeholder'),
sessionMode: effectiveSessionMode,
onSessionModeChange: setActiveProjectMode,
sessionModeSelectInteractive: true,
});
const taskAssigning =
@ -515,6 +512,9 @@ export default function Workspace({
noModelOverlay={!hasModel}
onSelectModel={() => navigate('/history?tab=agents')}
inputProps={buildComposerInputProps()}
sessionMode={effectiveSessionMode}
onSessionModeChange={setActiveProjectMode}
sessionModeSelectInteractive
/>
</div>
<AddWorker

View file

@ -13,6 +13,7 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { useTheme } from 'next-themes';
import { createPortal } from 'react-dom';
import { Toaster as Sonner } from 'sonner';
type ToasterProps = React.ComponentProps<typeof Sonner>;
@ -49,7 +50,7 @@ const FOLD_AT_THREE_CSS = `
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme();
return (
const toaster = (
<>
<style>{FOLD_AT_THREE_CSS}</style>
<Sonner
@ -70,6 +71,12 @@ const Toaster = ({ ...props }: ToasterProps) => {
/>
</>
);
// Render into <body>, not inside #root. #root has `backdrop-filter`, which
// creates a stacking context that would trap toasts *below* dialog/overlay
// portals (also body-level) no matter how high their z-index is.
if (typeof document === 'undefined') return toaster;
return createPortal(toaster, document.body);
};
export { Toaster };

View file

@ -1,114 +0,0 @@
// ========= 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 { Progress } from '@/components/ui/progress';
import { useHost } from '@/host';
import type { ProgressInfo } from 'electron-updater';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
const Update = () => {
const [downloadProgress, setDownloadProgress] = useState<number>(0);
const [isDownloading, setIsDownloading] = useState<boolean>(false);
const { t } = useTranslation();
const host = useHost();
const ipc = host?.ipcRenderer;
const checkUpdate = useCallback(() => {
void ipc?.invoke('check-update');
}, [ipc]);
const onUpdateError = useCallback(
(_event: Electron.IpcRendererEvent, err: ErrorType) => {
toast.dismiss('download-progress');
setIsDownloading(false);
setDownloadProgress(0);
toast.error(t('update.update-error'), {
description: err.message,
});
},
[t]
);
const onDownloadProgress = useCallback(
(_event: Electron.IpcRendererEvent, progress: ProgressInfo) => {
setIsDownloading(true);
setDownloadProgress(progress.percent ?? 0);
},
[]
);
// listen to download progress and update toast
useEffect(() => {
if (isDownloading) {
toast.custom(
(_toastId) => (
<div className="rounded-lg bg-ds-bg-neutral-inverse-default p-4 shadow-lg w-[300px]">
<div className="mb-2 text-sm font-medium">
{t('update.downloading-update')}
</div>
<Progress value={downloadProgress} className="mb-2" />
<div className="text-xs text-gray-500">
{Math.round(downloadProgress)}% {t('update.complete')}
</div>
</div>
),
{
id: 'download-progress',
duration: Infinity,
position: 'bottom-right',
}
);
}
}, [downloadProgress, isDownloading, t]);
const onUpdateDownloaded = useCallback(
(_event: Electron.IpcRendererEvent) => {
toast.dismiss('download-progress');
setIsDownloading(false);
toast.success(t('update.download-completed'), {
description: t('update.click-to-install-update'),
action: {
label: t('update.install'),
onClick: () => void ipc?.invoke('quit-and-install'),
},
duration: Infinity,
});
},
[t, ipc]
);
useEffect(() => {
if (sessionStorage.getItem('updateElectronShown')) {
return;
}
sessionStorage.setItem('updateElectronShown', '1');
ipc?.on('update-error', onUpdateError);
ipc?.on('download-progress', onDownloadProgress);
ipc?.on('update-downloaded', onUpdateDownloaded);
checkUpdate();
return () => {
ipc?.off('update-error', onUpdateError);
ipc?.off('download-progress', onDownloadProgress);
ipc?.off('update-downloaded', onUpdateDownloaded);
};
}, [ipc, onUpdateError, onDownloadProgress, onUpdateDownloaded, checkUpdate]);
return null;
};
export default Update;

View file

@ -0,0 +1,43 @@
// ========= 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 { useEffect, useRef, useState } from 'react';
/**
* Tracks whether an element's own width has dropped below `threshold` px, using
* a ResizeObserver so it reacts to container (not viewport) resizes e.g. side
* panels opening beside the chat composer. Returns a ref to attach and the
* current compact flag; state only flips when it crosses the threshold, so it
* won't re-render on every resize tick.
*/
export function useIsCompactWidth<T extends HTMLElement>(threshold: number) {
const ref = useRef<T>(null);
const [compact, setCompact] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el || typeof ResizeObserver === 'undefined') return;
const update = (width: number) => setCompact(width < threshold);
update(el.getBoundingClientRect().width);
const observer = new ResizeObserver((entries) => {
for (const entry of entries) update(entry.contentRect.width);
});
observer.observe(el);
return () => observer.disconnect();
}, [threshold]);
return [ref, compact] as const;
}

View file

@ -103,6 +103,11 @@
"input-attach-open-browser": "فتح متصفح",
"input-attach-manage-browsers": "إدارة المتصفحات",
"input-attach-menu-trigger": "إضافة ملفات أو صور، أو فتح المهارات أو الموصلات أو المتصفح من القائمة",
"input-attach-connectors": "الموصلات",
"input-add-connector": "إضافة موصلات",
"input-add-skill": "إضافة مهارات",
"no-connectors-added": "لم تتم إضافة أي موصلات بعد",
"no-skills-added": "لم تتم إضافة أي مهارات بعد",
"subtasks-planning": "تخطيط المهام الفرعية",
"expand-plan": "توسيع الخطة",
"minimize-plan": "تصغير الخطة",

View file

@ -514,5 +514,20 @@
"onboarding-setup-bg-ruled": "خطوط",
"onboarding-setup-bg-dotted": "منقط",
"onboarding-setup-bg-dashed": "متقطع",
"onboarding-setup-get-started": "ابدأ الآن"
"onboarding-setup-get-started": "ابدأ الآن",
"thinking-effort-label": "مستوى التفكير",
"thinking-effort-light": "خفيف",
"thinking-effort-medium": "متوسط",
"thinking-effort-high": "مرتفع",
"thinking-effort-extra_high": "مرتفع جدًا",
"thinking-effort-ultra": "فائق",
"support-logs-heading": "Download logs",
"support-eigent-log": "Eigent log",
"support-eigent-log-desc": "Desktop app logs",
"support-camel-log": "Camel log",
"support-camel-log-desc": "Agent task logs (CAMEL backend)",
"support-download": "Download",
"support-log-saved": "Log saved to {{path}}",
"support-log-empty": "No log files found yet.",
"support-log-export-failed": "Could not export the log."
}

View file

@ -198,6 +198,9 @@
"api-key-setting": "إعداد مفتاح API",
"api-host-setting": "إعداد مضيف API",
"model-type-setting": "إعداد نوع النموذج",
"model-parameters-setting": "معلمات النموذج (JSON، اختياري)",
"model-parameters-placeholder": "أدخل معلمات النموذج ككائن JSON، مثال: {\"temperature\": 0.7, \"top_p\": 1, \"max_tokens\": 4096}",
"model-parameters-must-be-valid-json-object": "يجب أن تكون معلمات النموذج كائن JSON صالحًا.",
"please-select": "يرجى الاختيار",
"configuring": "جارٍ التكوين...",

View file

@ -8,5 +8,8 @@
"complete": "مكتمل",
"download-completed": "اكتمل التنزيل",
"click-to-install-update": "انقر لتثبيت التحديث",
"install": "تثبيت"
"install": "تثبيت",
"downloading": "Downloading update",
"launch-new-version": "Launch new version",
"update-failed-retry": "Update failed — click to retry"
}

View file

@ -103,6 +103,11 @@
"input-attach-open-browser": "Browser öffnen",
"input-attach-manage-browsers": "Browser verwalten",
"input-attach-menu-trigger": "Dateien oder Fotos hinzufügen oder Skills, Connectors oder Browser über das Menü öffnen",
"input-attach-connectors": "Connectors",
"input-add-connector": "Connectors hinzufügen",
"input-add-skill": "Skills hinzufügen",
"no-connectors-added": "Noch keine Connectors hinzugefügt",
"no-skills-added": "Noch keine Skills hinzugefügt",
"subtasks-planning": "Teilaufgabenplanung",
"expand-plan": "Plan erweitern",
"minimize-plan": "Plan minimieren",

View file

@ -514,5 +514,20 @@
"onboarding-setup-bg-ruled": "Liniert",
"onboarding-setup-bg-dotted": "Gepunktet",
"onboarding-setup-bg-dashed": "Gestrichelt",
"onboarding-setup-get-started": "Loslegen"
"onboarding-setup-get-started": "Loslegen",
"thinking-effort-label": "Denkaufwand",
"thinking-effort-light": "Niedrig",
"thinking-effort-medium": "Mittel",
"thinking-effort-high": "Hoch",
"thinking-effort-extra_high": "Sehr hoch",
"thinking-effort-ultra": "Ultra",
"support-logs-heading": "Download logs",
"support-eigent-log": "Eigent log",
"support-eigent-log-desc": "Desktop app logs",
"support-camel-log": "Camel log",
"support-camel-log-desc": "Agent task logs (CAMEL backend)",
"support-download": "Download",
"support-log-saved": "Log saved to {{path}}",
"support-log-empty": "No log files found yet.",
"support-log-export-failed": "Could not export the log."
}

View file

@ -300,6 +300,9 @@
"api-key-setting": "API-Schlüssel-Einstellung",
"api-host-setting": "API-Host-Einstellung",
"model-type-setting": "Modelltyp-Einstellung",
"model-parameters-setting": "Modellparameter (JSON, optional)",
"model-parameters-placeholder": "Modellparameter als JSON-Objekt eingeben, z. B. {\"temperature\": 0.7, \"top_p\": 1, \"max_tokens\": 4096}",
"model-parameters-must-be-valid-json-object": "Modellparameter müssen ein gültiges JSON-Objekt sein.",
"please-select": "Bitte auswählen",
"configuring": "Wird konfiguriert...",

View file

@ -8,5 +8,8 @@
"complete": "Abgeschlossen",
"download-completed": "Download abgeschlossen",
"click-to-install-update": "Klicken Sie, um das Update zu installieren",
"install": "Installieren"
"install": "Installieren",
"downloading": "Downloading update",
"launch-new-version": "Launch new version",
"update-failed-retry": "Update failed — click to retry"
}

View file

@ -103,6 +103,11 @@
"input-attach-open-browser": "Open a browser",
"input-attach-manage-browsers": "Manage browsers",
"input-attach-menu-trigger": "Add files or photos, or open Skills, Connectors, or Browser from the menu",
"input-attach-connectors": "Connectors",
"input-add-connector": "Add connectors",
"input-add-skill": "Add skills",
"no-connectors-added": "No connectors added yet",
"no-skills-added": "No skills added yet",
"subtasks-planning": "Subtasks Planning",
"expand-plan": "Expand plan",
"minimize-plan": "Minimize plan",

View file

@ -96,6 +96,15 @@
"notifications-empty": "No notifications yet.",
"notification-panel-dismiss": "Dismiss",
"support": "Support",
"support-logs-heading": "Download logs",
"support-eigent-log": "Eigent log",
"support-eigent-log-desc": "Desktop app logs",
"support-camel-log": "Camel log",
"support-camel-log-desc": "Agent task logs (CAMEL backend)",
"support-download": "Download",
"support-log-saved": "Log saved to {{path}}",
"support-log-empty": "No log files found yet.",
"support-log-export-failed": "Could not export the log.",
"log-file-empty": "Log file is empty",
"new": "New",
"new-project": "New Project",
@ -514,5 +523,11 @@
"onboarding-setup-bg-ruled": "Ruled",
"onboarding-setup-bg-dotted": "Dotted",
"onboarding-setup-bg-dashed": "Dashed",
"onboarding-setup-get-started": "Get Started"
"onboarding-setup-get-started": "Get Started",
"thinking-effort-label": "Thinking effort",
"thinking-effort-light": "Light",
"thinking-effort-medium": "Medium",
"thinking-effort-high": "High",
"thinking-effort-extra_high": "Extra High",
"thinking-effort-ultra": "Ultra"
}

View file

@ -226,6 +226,9 @@
"api-key-setting": "API Key Setting",
"api-host-setting": "API Host Setting",
"model-type-setting": "Model Type Setting",
"model-parameters-setting": "Model Parameters (JSON) (Optional)",
"model-parameters-placeholder": "Enter model parameters as a JSON object, e.g. {\"temperature\": 0.7, \"top_p\": 1, \"max_tokens\": 4096}",
"model-parameters-must-be-valid-json-object": "Model parameters must be a valid JSON object.",
"please-select": "Please select",
"configuring": "Configuring...",

View file

@ -8,5 +8,8 @@
"complete": "Complete",
"download-completed": "Download completed",
"click-to-install-update": "Click to install update",
"install": "Install"
"install": "Install",
"downloading": "Downloading update",
"launch-new-version": "Launch new version",
"update-failed-retry": "Update failed — click to retry"
}

View file

@ -103,6 +103,11 @@
"input-attach-open-browser": "Abrir un navegador",
"input-attach-manage-browsers": "Gestionar navegadores",
"input-attach-menu-trigger": "Añadir archivos o fotos o abrir Habilidades, Conectores o Navegador desde el menú",
"input-attach-connectors": "Conectores",
"input-add-connector": "Añadir conectores",
"input-add-skill": "Añadir habilidades",
"no-connectors-added": "Aún no se han añadido conectores",
"no-skills-added": "Aún no se han añadido habilidades",
"subtasks-planning": "Planificación de subtareas",
"expand-plan": "Expandir plan",
"minimize-plan": "Minimizar plan",

View file

@ -514,5 +514,20 @@
"onboarding-setup-bg-ruled": "Rayado",
"onboarding-setup-bg-dotted": "Punteado",
"onboarding-setup-bg-dashed": "Discontinuo",
"onboarding-setup-get-started": "Empezar"
"onboarding-setup-get-started": "Empezar",
"thinking-effort-label": "Esfuerzo de razonamiento",
"thinking-effort-light": "Bajo",
"thinking-effort-medium": "Medio",
"thinking-effort-high": "Alto",
"thinking-effort-extra_high": "Muy alto",
"thinking-effort-ultra": "Ultra",
"support-logs-heading": "Download logs",
"support-eigent-log": "Eigent log",
"support-eigent-log-desc": "Desktop app logs",
"support-camel-log": "Camel log",
"support-camel-log-desc": "Agent task logs (CAMEL backend)",
"support-download": "Download",
"support-log-saved": "Log saved to {{path}}",
"support-log-empty": "No log files found yet.",
"support-log-export-failed": "Could not export the log."
}

View file

@ -300,6 +300,9 @@
"api-key-setting": "Configuración de clave API",
"api-host-setting": "Configuración de host API",
"model-type-setting": "Configuración de tipo de modelo",
"model-parameters-setting": "Parámetros del modelo (JSON, opcional)",
"model-parameters-placeholder": "Introduce los parámetros del modelo como un objeto JSON, p. ej. {\"temperature\": 0.7, \"top_p\": 1, \"max_tokens\": 4096}",
"model-parameters-must-be-valid-json-object": "Los parámetros del modelo deben ser un objeto JSON válido.",
"please-select": "Por favor seleccione",
"configuring": "Configurando...",

View file

@ -8,5 +8,8 @@
"complete": "Completo",
"download-completed": "Descarga completada",
"click-to-install-update": "Click para instalar actualización",
"install": "Instalar"
"install": "Instalar",
"downloading": "Downloading update",
"launch-new-version": "Launch new version",
"update-failed-retry": "Update failed — click to retry"
}

View file

@ -103,6 +103,11 @@
"input-attach-open-browser": "Ouvrir un navigateur",
"input-attach-manage-browsers": "Gérer les navigateurs",
"input-attach-menu-trigger": "Ajouter fichiers ou photos ou ouvrir Compétences, Connecteurs ou Navigateur depuis le menu",
"input-attach-connectors": "Connecteurs",
"input-add-connector": "Ajouter des connecteurs",
"input-add-skill": "Ajouter des compétences",
"no-connectors-added": "Aucun connecteur ajouté pour le moment",
"no-skills-added": "Aucune compétence ajoutée pour le moment",
"subtasks-planning": "Planification des sous-tâches",
"expand-plan": "Développer le plan",
"minimize-plan": "Réduire le plan",

View file

@ -514,5 +514,20 @@
"onboarding-setup-bg-ruled": "Ligné",
"onboarding-setup-bg-dotted": "Pointillé",
"onboarding-setup-bg-dashed": "Tirets",
"onboarding-setup-get-started": "Commencer"
"onboarding-setup-get-started": "Commencer",
"thinking-effort-label": "Effort de réflexion",
"thinking-effort-light": "Léger",
"thinking-effort-medium": "Moyen",
"thinking-effort-high": "Élevé",
"thinking-effort-extra_high": "Très élevé",
"thinking-effort-ultra": "Ultra",
"support-logs-heading": "Download logs",
"support-eigent-log": "Eigent log",
"support-eigent-log-desc": "Desktop app logs",
"support-camel-log": "Camel log",
"support-camel-log-desc": "Agent task logs (CAMEL backend)",
"support-download": "Download",
"support-log-saved": "Log saved to {{path}}",
"support-log-empty": "No log files found yet.",
"support-log-export-failed": "Could not export the log."
}

View file

@ -300,6 +300,9 @@
"api-key-setting": "Paramètre de clé API",
"api-host-setting": "Paramètre d'hôte API",
"model-type-setting": "Paramètre de type de modèle",
"model-parameters-setting": "Paramètres du modèle (JSON, facultatif)",
"model-parameters-placeholder": "Saisissez les paramètres du modèle sous forme dobjet JSON, par ex. {\"temperature\": 0.7, \"top_p\": 1, \"max_tokens\": 4096}",
"model-parameters-must-be-valid-json-object": "Les paramètres du modèle doivent être un objet JSON valide.",
"please-select": "Veuillez sélectionner",
"configuring": "Configuration...",

View file

@ -8,5 +8,8 @@
"complete": "Terminé",
"download-completed": "Téléchargement terminé",
"click-to-install-update": "Cliquez pour installer la mise à jour",
"install": "Installer"
"install": "Installer",
"downloading": "Downloading update",
"launch-new-version": "Launch new version",
"update-failed-retry": "Update failed — click to retry"
}

View file

@ -103,6 +103,11 @@
"input-attach-open-browser": "Apri un browser",
"input-attach-manage-browsers": "Gestisci i browser",
"input-attach-menu-trigger": "Aggiungi file o foto o apri Skill, Connettori o Browser dal menu",
"input-attach-connectors": "Connettori",
"input-add-connector": "Aggiungi connettori",
"input-add-skill": "Aggiungi skill",
"no-connectors-added": "Nessun connettore aggiunto",
"no-skills-added": "Nessuna skill aggiunta",
"subtasks-planning": "Pianificazione delle sottoattività",
"expand-plan": "Espandi piano",
"minimize-plan": "Riduci piano",

View file

@ -514,5 +514,20 @@
"onboarding-setup-bg-ruled": "Righe",
"onboarding-setup-bg-dotted": "Punteggiato",
"onboarding-setup-bg-dashed": "Tratteggiato",
"onboarding-setup-get-started": "Inizia"
"onboarding-setup-get-started": "Inizia",
"thinking-effort-label": "Impegno di ragionamento",
"thinking-effort-light": "Basso",
"thinking-effort-medium": "Medio",
"thinking-effort-high": "Alto",
"thinking-effort-extra_high": "Molto alto",
"thinking-effort-ultra": "Ultra",
"support-logs-heading": "Download logs",
"support-eigent-log": "Eigent log",
"support-eigent-log-desc": "Desktop app logs",
"support-camel-log": "Camel log",
"support-camel-log-desc": "Agent task logs (CAMEL backend)",
"support-download": "Download",
"support-log-saved": "Log saved to {{path}}",
"support-log-empty": "No log files found yet.",
"support-log-export-failed": "Could not export the log."
}

View file

@ -300,6 +300,9 @@
"api-key-setting": "Impostazione chiave API",
"api-host-setting": "Impostazione host API",
"model-type-setting": "Impostazione tipo di modello",
"model-parameters-setting": "Parametri del modello (JSON, facoltativo)",
"model-parameters-placeholder": "Inserisci i parametri del modello come oggetto JSON, ad es. {\"temperature\": 0.7, \"top_p\": 1, \"max_tokens\": 4096}",
"model-parameters-must-be-valid-json-object": "I parametri del modello devono essere un oggetto JSON valido.",
"please-select": "Seleziona",
"configuring": "Configurazione in corso...",

View file

@ -8,5 +8,8 @@
"complete": "Completo",
"download-completed": "Download completato",
"click-to-install-update": "Clicca per installare l'aggiornamento",
"install": "Installa"
"install": "Installa",
"downloading": "Downloading update",
"launch-new-version": "Launch new version",
"update-failed-retry": "Update failed — click to retry"
}

View file

@ -103,6 +103,11 @@
"input-attach-open-browser": "ブラウザを開く",
"input-attach-manage-browsers": "ブラウザを管理",
"input-attach-menu-trigger": "ファイル・写真の追加、またはメニューからスキル・コネクタ・ブラウザを開く",
"input-attach-connectors": "コネクタ",
"input-add-connector": "コネクタを追加",
"input-add-skill": "スキルを追加",
"no-connectors-added": "コネクタはまだ追加されていません",
"no-skills-added": "スキルはまだ追加されていません",
"subtasks-planning": "サブタスクの計画",
"expand-plan": "計画を展開",
"minimize-plan": "計画を最小化",

View file

@ -514,5 +514,20 @@
"onboarding-setup-bg-ruled": "罫線",
"onboarding-setup-bg-dotted": "点線",
"onboarding-setup-bg-dashed": "破線",
"onboarding-setup-get-started": "はじめる"
"onboarding-setup-get-started": "はじめる",
"thinking-effort-label": "思考強度",
"thinking-effort-light": "軽度",
"thinking-effort-medium": "中程度",
"thinking-effort-high": "高度",
"thinking-effort-extra_high": "非常に高い",
"thinking-effort-ultra": "ウルトラ",
"support-logs-heading": "Download logs",
"support-eigent-log": "Eigent log",
"support-eigent-log-desc": "Desktop app logs",
"support-camel-log": "Camel log",
"support-camel-log-desc": "Agent task logs (CAMEL backend)",
"support-download": "Download",
"support-log-saved": "Log saved to {{path}}",
"support-log-empty": "No log files found yet.",
"support-log-export-failed": "Could not export the log."
}

View file

@ -301,6 +301,9 @@
"api-key-setting": "APIキー設定",
"api-host-setting": "APIホスト設定",
"model-type-setting": "モデルタイプ設定",
"model-parameters-setting": "モデルパラメータJSON、任意",
"model-parameters-placeholder": "モデルパラメータを JSON オブジェクトで入力(例:{\"temperature\": 0.7, \"top_p\": 1, \"max_tokens\": 4096}",
"model-parameters-must-be-valid-json-object": "モデルパラメータは有効な JSON オブジェクトである必要があります。",
"please-select": "選択してください",
"configuring": "設定中...",

View file

@ -8,5 +8,8 @@
"complete": "完了",
"download-completed": "ダウンロードが完了しました",
"click-to-install-update": "クリックしてアップデートをインストールしてください",
"install": "インストール"
"install": "インストール",
"downloading": "Downloading update",
"launch-new-version": "Launch new version",
"update-failed-retry": "Update failed — click to retry"
}

View file

@ -103,6 +103,11 @@
"input-attach-open-browser": "브라우저 열기",
"input-attach-manage-browsers": "브라우저 관리",
"input-attach-menu-trigger": "파일·사진 추가 또는 메뉴에서 스킬, 커넥터, 브라우저 열기",
"input-attach-connectors": "커넥터",
"input-add-connector": "커넥터 추가",
"input-add-skill": "스킬 추가",
"no-connectors-added": "아직 추가된 커넥터가 없습니다",
"no-skills-added": "아직 추가된 스킬이 없습니다",
"subtasks-planning": "하위 작업 계획",
"expand-plan": "계획 펼치기",
"minimize-plan": "계획 최소화",

View file

@ -514,5 +514,20 @@
"onboarding-setup-bg-ruled": "줄",
"onboarding-setup-bg-dotted": "점선",
"onboarding-setup-bg-dashed": "파선",
"onboarding-setup-get-started": "시작하기"
"onboarding-setup-get-started": "시작하기",
"thinking-effort-label": "사고 강도",
"thinking-effort-light": "낮음",
"thinking-effort-medium": "보통",
"thinking-effort-high": "높음",
"thinking-effort-extra_high": "매우 높음",
"thinking-effort-ultra": "울트라",
"support-logs-heading": "Download logs",
"support-eigent-log": "Eigent log",
"support-eigent-log-desc": "Desktop app logs",
"support-camel-log": "Camel log",
"support-camel-log-desc": "Agent task logs (CAMEL backend)",
"support-download": "Download",
"support-log-saved": "Log saved to {{path}}",
"support-log-empty": "No log files found yet.",
"support-log-export-failed": "Could not export the log."
}

View file

@ -301,6 +301,9 @@
"api-key-setting": "API 키 설정",
"api-host-setting": "API 호스트 설정",
"model-type-setting": "모델 타입 설정",
"model-parameters-setting": "모델 매개변수(JSON, 선택 사항)",
"model-parameters-placeholder": "모델 매개변수를 JSON 객체로 입력하세요. 예: {\"temperature\": 0.7, \"top_p\": 1, \"max_tokens\": 4096}",
"model-parameters-must-be-valid-json-object": "모델 매개변수는 유효한 JSON 객체여야 합니다.",
"please-select": "선택하세요",
"configuring": "구성 중...",

View file

@ -8,5 +8,8 @@
"complete": "완료",
"download-completed": "다운로드 완료",
"click-to-install-update": "업데이트 설치를 클릭하세요",
"install": "설치"
"install": "설치",
"downloading": "Downloading update",
"launch-new-version": "Launch new version",
"update-failed-retry": "Update failed — click to retry"
}

View file

@ -103,6 +103,11 @@
"input-attach-open-browser": "Открыть браузер",
"input-attach-manage-browsers": "Управлять браузерами",
"input-attach-menu-trigger": "Добавить файлы или фото или открыть Навыки, Коннекторы или Браузер из меню",
"input-attach-connectors": "Коннекторы",
"input-add-connector": "Добавить коннекторы",
"input-add-skill": "Добавить навыки",
"no-connectors-added": "Коннекторы пока не добавлены",
"no-skills-added": "Навыки пока не добавлены",
"subtasks-planning": "Планирование подзадач",
"expand-plan": "Развернуть план",
"minimize-plan": "Свернуть план",

View file

@ -514,5 +514,20 @@
"onboarding-setup-bg-ruled": "Линии",
"onboarding-setup-bg-dotted": "Пунктир",
"onboarding-setup-bg-dashed": "Штрихи",
"onboarding-setup-get-started": "Начать"
"onboarding-setup-get-started": "Начать",
"thinking-effort-label": "Глубина размышлений",
"thinking-effort-light": "Лёгкая",
"thinking-effort-medium": "Средняя",
"thinking-effort-high": "Высокая",
"thinking-effort-extra_high": "Очень высокая",
"thinking-effort-ultra": "Ультра",
"support-logs-heading": "Download logs",
"support-eigent-log": "Eigent log",
"support-eigent-log-desc": "Desktop app logs",
"support-camel-log": "Camel log",
"support-camel-log-desc": "Agent task logs (CAMEL backend)",
"support-download": "Download",
"support-log-saved": "Log saved to {{path}}",
"support-log-empty": "No log files found yet.",
"support-log-export-failed": "Could not export the log."
}

View file

@ -300,6 +300,9 @@
"api-key-setting": "Настройка API-ключа",
"api-host-setting": "Настройка API-хоста",
"model-type-setting": "Настройка типа модели",
"model-parameters-setting": "Параметры модели (JSON, необязательно)",
"model-parameters-placeholder": "Введите параметры модели как объект JSON, например {\"temperature\": 0.7, \"top_p\": 1, \"max_tokens\": 4096}",
"model-parameters-must-be-valid-json-object": "Параметры модели должны быть допустимым объектом JSON.",
"please-select": "Пожалуйста, выберите",
"configuring": "Конфигурирование...",

View file

@ -8,5 +8,8 @@
"complete": "Завершено",
"download-completed": "Загрузка завершена",
"click-to-install-update": "Нажмите, чтобы установить обновление",
"install": "Установить"
"install": "Установить",
"downloading": "Downloading update",
"launch-new-version": "Launch new version",
"update-failed-retry": "Update failed — click to retry"
}

View file

@ -103,6 +103,11 @@
"input-attach-open-browser": "打开浏览器",
"input-attach-manage-browsers": "管理浏览器",
"input-attach-menu-trigger": "添加文件、照片,或通过菜单打开技能、连接器、浏览器",
"input-attach-connectors": "连接器",
"input-add-connector": "添加连接器",
"input-add-skill": "添加技能",
"no-connectors-added": "尚未添加连接器",
"no-skills-added": "尚未添加技能",
"subtasks-planning": "子任务规划",
"expand-plan": "展开计划",
"minimize-plan": "最小化计划",

View file

@ -514,5 +514,20 @@
"onboarding-setup-bg-ruled": "横线",
"onboarding-setup-bg-dotted": "点线",
"onboarding-setup-bg-dashed": "虚线",
"onboarding-setup-get-started": "开始使用"
"onboarding-setup-get-started": "开始使用",
"thinking-effort-label": "思考强度",
"thinking-effort-light": "轻度",
"thinking-effort-medium": "中度",
"thinking-effort-high": "高度",
"thinking-effort-extra_high": "极高",
"thinking-effort-ultra": "超高",
"support-logs-heading": "Download logs",
"support-eigent-log": "Eigent log",
"support-eigent-log-desc": "Desktop app logs",
"support-camel-log": "Camel log",
"support-camel-log-desc": "Agent task logs (CAMEL backend)",
"support-download": "Download",
"support-log-saved": "Log saved to {{path}}",
"support-log-empty": "No log files found yet.",
"support-log-export-failed": "Could not export the log."
}

View file

@ -225,6 +225,9 @@
"api-key-setting": "API 密钥设置",
"api-host-setting": "API Host 设置",
"model-type-setting": "模型类型设置",
"model-parameters-setting": "模型参数JSON可选",
"model-parameters-placeholder": "以 JSON 对象输入模型参数,例如 {\"temperature\": 0.7, \"top_p\": 1, \"max_tokens\": 4096}",
"model-parameters-must-be-valid-json-object": "模型参数必须是有效的 JSON 对象。",
"please-select": "请选择",
"configuring": "正在配置...",

View file

@ -8,5 +8,8 @@
"complete": "完成",
"download-completed": "下载完成",
"click-to-install-update": "点击安装更新",
"install": "安装"
"install": "安装",
"downloading": "Downloading update",
"launch-new-version": "Launch new version",
"update-failed-retry": "Update failed — click to retry"
}

View file

@ -103,6 +103,11 @@
"input-attach-open-browser": "開啟瀏覽器",
"input-attach-manage-browsers": "管理瀏覽器",
"input-attach-menu-trigger": "新增檔案、相片,或從選單開啟技能、連接器、瀏覽器",
"input-attach-connectors": "連接器",
"input-add-connector": "新增連接器",
"input-add-skill": "新增技能",
"no-connectors-added": "尚未新增連接器",
"no-skills-added": "尚未新增技能",
"subtasks-planning": "子任務規劃",
"expand-plan": "展開計劃",
"minimize-plan": "最小化計劃",

View file

@ -514,5 +514,20 @@
"onboarding-setup-bg-ruled": "橫線",
"onboarding-setup-bg-dotted": "點線",
"onboarding-setup-bg-dashed": "虛線",
"onboarding-setup-get-started": "開始使用"
"onboarding-setup-get-started": "開始使用",
"thinking-effort-label": "思考強度",
"thinking-effort-light": "輕度",
"thinking-effort-medium": "中度",
"thinking-effort-high": "高度",
"thinking-effort-extra_high": "極高",
"thinking-effort-ultra": "超高",
"support-logs-heading": "Download logs",
"support-eigent-log": "Eigent log",
"support-eigent-log-desc": "Desktop app logs",
"support-camel-log": "Camel log",
"support-camel-log-desc": "Agent task logs (CAMEL backend)",
"support-download": "Download",
"support-log-saved": "Log saved to {{path}}",
"support-log-empty": "No log files found yet.",
"support-log-export-failed": "Could not export the log."
}

View file

@ -216,6 +216,9 @@
"api-key-setting": "API 金鑰設定",
"api-host-setting": "API 主機設定",
"model-type-setting": "模型類型設定",
"model-parameters-setting": "模型參數JSON選填",
"model-parameters-placeholder": "以 JSON 物件輸入模型參數,例如 {\"temperature\": 0.7, \"top_p\": 1, \"max_tokens\": 4096}",
"model-parameters-must-be-valid-json-object": "模型參數必須是有效的 JSON 物件。",
"please-select": "請選擇",
"configuring": "正在配置...",

View file

@ -8,5 +8,8 @@
"complete": "完成",
"download-completed": "下載完成",
"click-to-install-update": "點擊安裝更新",
"install": "安裝"
"install": "安裝",
"downloading": "Downloading update",
"launch-new-version": "Launch new version",
"update-failed-retry": "Update failed — click to retry"
}

109
src/lib/browserUrl.ts Normal file
View file

@ -0,0 +1,109 @@
// ========= 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. =========
export type BrowserUrlValidationResult =
| { ok: true; url: string }
| { ok: false; error: string };
/**
* Hosts that default to plain HTTP when no scheme is typed: loopback and
* RFC 1918 private-network targets (dev servers, LAN devices). Everything
* else including public IPs defaults to HTTPS.
*/
const LOOPBACK_OR_PRIVATE_HOST_PATTERN =
/^(localhost|127(?:\.\d{1,3}){3}|\[::1\]|0\.0\.0\.0|10(?:\.\d{1,3}){3}|192\.168(?:\.\d{1,3}){2}|172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2})(?::\d+)?(?:\/|$)/i;
/** Where non-URL input is sent as a search query. */
const WEB_SEARCH_URL = 'https://www.google.com/search?q=';
/**
* Scheme-less input we can navigate to directly: a dotted hostname or IP,
* localhost, a bracketed IPv6 literal, or a bare host:port with or without
* a path. Anything else (plain words, questions) becomes a web search.
*/
function looksNavigable(input: string): boolean {
if (/\s/.test(input)) return false;
const host = input.split(/[/?#]/, 1)[0] ?? '';
return (
host.includes('.') ||
/^localhost(?::\d+)?$/i.test(host) ||
/^\[[0-9a-f:.]+\](?::\d+)?$/i.test(host) ||
/^[a-z0-9-]+:\d+$/i.test(host)
);
}
/**
* Normalize user-entered destinations to a canonical HTTP(S) URL.
* Bare hostnames default to HTTPS; loopback/private hosts use HTTP. Input
* that isn't URL-shaped becomes a web search, like a real address bar
* except explicit non-HTTP schemes, which are rejected outright.
*/
export function normalizeBrowserUrl(input: string): BrowserUrlValidationResult {
const trimmed = input.trim();
if (!trimmed) {
return { ok: false, error: 'URL is required' };
}
const colonIndex = trimmed.indexOf(':');
if (colonIndex > 0) {
const afterColon = trimmed.slice(colonIndex + 1);
if (afterColon.startsWith('//')) {
if (!/^https?:\/\//i.test(trimmed)) {
return { ok: false, error: 'Only HTTP and HTTPS URLs are supported' };
}
} else if (/^[a-z]/i.test(afterColon) && !/\s/.test(trimmed)) {
return { ok: false, error: 'Only HTTP and HTTPS URLs are supported' };
}
}
let candidate = trimmed;
if (!/^https?:\/\//i.test(candidate)) {
if (!looksNavigable(candidate)) {
return {
ok: true,
url: `${WEB_SEARCH_URL}${encodeURIComponent(trimmed)}`,
};
}
candidate = `${LOOPBACK_OR_PRIVATE_HOST_PATTERN.test(candidate) ? 'http' : 'https'}://${candidate}`;
}
let parsed: URL;
try {
parsed = new URL(candidate);
} catch {
return { ok: false, error: 'Invalid URL' };
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { ok: false, error: 'Only HTTP and HTTPS URLs are supported' };
}
return { ok: true, url: parsed.href };
}
/** Returns canonical href when valid, otherwise null. */
export function canonicalizeBrowserUrl(input: string): string | null {
const result = normalizeBrowserUrl(input);
if (!result.ok) return null;
try {
const parsed = new URL(result.url);
if (parsed.pathname !== '/' && parsed.pathname.endsWith('/')) {
parsed.pathname = parsed.pathname.slice(0, -1);
}
return parsed.href;
} catch {
return result.url;
}
}

62
src/lib/connectorIcons.ts Normal file
View file

@ -0,0 +1,62 @@
// ========= 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 cursorIcon from '@/assets/icon/cursor.svg';
import githubIcon from '@/assets/icon/github.svg';
import googleCalendarIcon from '@/assets/icon/google_calendar.svg';
import googleGmailIcon from '@/assets/icon/google_gmail.svg';
import larkIcon from '@/assets/icon/lark.png';
import linkedinIcon from '@/assets/icon/linkedin.svg';
import notionIcon from '@/assets/icon/notion.svg';
import ragIcon from '@/assets/icon/rag.svg';
import redditIcon from '@/assets/icon/reddit.svg';
import slackIcon from '@/assets/icon/slack.svg';
import telegramIcon from '@/assets/icon/telegram.svg';
import vsCodeIcon from '@/assets/icon/vs-code.svg';
import whatsappIcon from '@/assets/icon/whatsapp.svg';
import xIcon from '@/assets/icon/x.svg';
/**
* Brand logo per built-in integration key, keyed by the lowercased,
* trimmed name returned from `/api/v1/config/info` (matches the Connectors
* settings page). Shared so any connector-picking UI (Settings, chat input
* picker, etc.) shows the same logos.
*/
export const INTEGRATION_ICON_BY_KEY: Record<string, string> = {
notion: notionIcon,
slack: slackIcon,
'google calendar': googleCalendarIcon,
gmail: googleGmailIcon,
'google gmail': googleGmailIcon,
linkedin: linkedinIcon,
lark: larkIcon,
rag: ragIcon,
telegram: telegramIcon,
whatsapp: whatsappIcon,
x: xIcon,
'x(twitter)': xIcon,
twitter: xIcon,
reddit: redditIcon,
github: githubIcon,
cursor: cursorIcon,
'vs code': vsCodeIcon,
vscode: vsCodeIcon,
};
/** Looks up a built-in integration's brand logo by name (case/whitespace-insensitive). */
export function integrationLeadingIconUrl(
integrationKey: string
): string | undefined {
return INTEGRATION_ICON_BY_KEY[integrationKey.toLowerCase().trim()];
}

169
src/lib/modelConfig.ts Normal file
View file

@ -0,0 +1,169 @@
// ========= 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. =========
export const MODEL_CONFIG_DICT_KEY = 'model_config_dict' as const;
export type ModelConfigDict = Record<string, unknown>;
export type AgentModelConfig = {
model_platform: string;
model_type?: string;
api_key?: string;
api_url?: string;
model_config_dict?: ModelConfigDict;
extra_params?: Record<string, unknown>;
};
export type AgentModelConfigSource = {
model_platform: string;
model_type: string;
api_key?: string;
api_url?: string;
model_config_dict?: ModelConfigDict;
extra_params?: Record<string, unknown>;
};
export type StoredModelProvider = {
provider_name?: unknown;
model_type?: unknown;
api_key?: unknown;
endpoint_url?: unknown;
api_url?: unknown;
encrypted_config?: unknown;
};
export type ModelConfigJsonErrorCode = 'invalid_json' | 'not_object';
export class ModelConfigJsonError extends Error {
readonly code: ModelConfigJsonErrorCode;
constructor(code: ModelConfigJsonErrorCode, message: string) {
super(message);
this.name = 'ModelConfigJsonError';
this.code = code;
}
}
function isObjectRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** Parse the BYOK model-parameters field. Blank input represents no overrides. */
export function parseModelConfigJson(input: string): ModelConfigDict {
if (!input.trim()) return {};
let parsed: unknown;
try {
parsed = JSON.parse(input);
} catch {
throw new ModelConfigJsonError(
'invalid_json',
'Model parameters must be valid JSON.'
);
}
if (!isObjectRecord(parsed)) {
throw new ModelConfigJsonError(
'not_object',
'Model parameters must be a JSON object.'
);
}
return parsed;
}
/** Format persisted model parameters for the BYOK JSON editor. */
export function formatModelConfigJson(value: unknown): string {
return JSON.stringify(isObjectRecord(value) ? value : {}, null, 2);
}
/**
* Separate the reserved CAMEL model configuration from provider constructor
* parameters stored in the provider's existing `encrypted_config` JSON field.
*/
export function splitProviderConfig(config: unknown): {
modelConfigDict: ModelConfigDict;
extraParams: Record<string, unknown>;
} {
if (!isObjectRecord(config)) {
return { modelConfigDict: {}, extraParams: {} };
}
const { [MODEL_CONFIG_DICT_KEY]: storedModelConfig, ...extraParams } = config;
return {
modelConfigDict: isObjectRecord(storedModelConfig)
? { ...storedModelConfig }
: {},
extraParams,
};
}
/** Build the provider JSON while preventing extra parameters from shadowing the reserved key. */
export function buildProviderConfig(
extraParams: Record<string, unknown>,
modelConfigDict: ModelConfigDict
): Record<string, unknown> {
const { extraParams: sanitizedExtraParams } =
splitProviderConfig(extraParams);
if (Object.keys(modelConfigDict).length === 0) {
return sanitizedExtraParams;
}
return {
...sanitizedExtraParams,
[MODEL_CONFIG_DICT_KEY]: { ...modelConfigDict },
};
}
/** Build the per-agent payload while preserving explicit empty provider maps. */
export function buildAgentModelConfig(
source: AgentModelConfigSource
): AgentModelConfig {
const config: AgentModelConfig = {
model_platform: source.model_platform,
...(source.model_type ? { model_type: source.model_type } : {}),
};
if (source.api_key !== undefined) config.api_key = source.api_key;
if (source.api_url !== undefined) config.api_url = source.api_url;
if (source.model_config_dict !== undefined) {
config.model_config_dict = { ...source.model_config_dict };
}
if (source.extra_params !== undefined) {
config.extra_params = { ...source.extra_params };
}
return config;
}
/** Resolve a stored custom/local provider into a transient per-agent config. */
export function buildAgentModelConfigFromProvider(
provider: StoredModelProvider
): AgentModelConfig {
const { modelConfigDict, extraParams } = splitProviderConfig(
provider.encrypted_config
);
return buildAgentModelConfig({
model_platform: String(
extraParams.model_platform || provider.provider_name || ''
),
model_type: String(extraParams.model_type || provider.model_type || ''),
api_key: String(provider.api_key ?? ''),
api_url: String(provider.endpoint_url || provider.api_url || ''),
model_config_dict: modelConfigDict,
extra_params: extraParams,
});
}

View file

@ -12,15 +12,31 @@
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
/** Shared by `RichChatInput` and `UserMessageRichContent` (URLs, #skills). */
export type RichSegment = { type: 'text' | 'url' | 'skill'; text: string };
/** Shared by `RichChatInput` and `UserMessageRichContent` (URLs, #skills, @connectors). */
export type RichSegment = {
type: 'text' | 'url' | 'skill' | 'connector';
text: string;
};
/** Chip styling shared by the input HTML and the message-body renderer. */
export const RICH_CONNECTOR_STYLE_CLASSES =
'text-ds-text-information-default-default bg-ds-bg-neutral-default-default';
/** `@token` inserted into the input for a connector; spaces collapse to `_`. */
export function connectorNameToToken(name: string): string {
const slug = name
.trim()
.replace(/\s+/g, '_')
.replace(/[^A-Za-z0-9_-]/g, '');
return `@${slug || 'connector'}`;
}
/** Hash-stable palette: semantic “other” tones (not default body text). Shared by input + message body. */
export const RICH_SKILL_STYLE_CLASSES = [
'text-ds-text-success-default-default bg-ds-bg-neutral-subtle-disabled',
'text-ds-text-warning-default-default bg-ds-bg-neutral-subtle-disabled',
'text-ds-text-terminal-default-default bg-ds-bg-neutral-subtle-disabled',
'text-ds-text-document-default-default bg-ds-bg-neutral-subtle-disabled',
'text-ds-text-success-default-default bg-ds-bg-neutral-default-default',
'text-ds-text-warning-default-default bg-ds-bg-neutral-default-default',
'text-ds-text-terminal-default-default bg-ds-bg-neutral-default-default',
'text-ds-text-document-default-default bg-ds-bg-neutral-default-default',
] as const;
export function hashSkillLabel(label: string): number {
@ -38,8 +54,17 @@ export function trimUrlTail(raw: string): string {
}
const URL_AT_START = /^(https?:\/\/[^\s<>"']+|www\.[^\s<>"']+)/i;
const SKILL_AT_START = /^#([a-zA-Z0-9_-]+)/;
const CONNECTOR_AT_START = /^@([A-Za-z0-9_-]+)/;
/** Plain-text tokenizer: http(s) / www URLs and #skill tokens (alphanumeric + _-). */
/** True when `@` here begins a connector token rather than an email tail (`me@host`). */
function isConnectorStart(text: string, at: number): boolean {
const prev = text[at - 1];
if (prev && /[A-Za-z0-9_]/.test(prev)) return false;
return CONNECTOR_AT_START.test(text.slice(at));
}
/** Plain-text tokenizer: URLs, #skill tokens, and @connector tokens. */
export function tokenizeRichPlainText(text: string): RichSegment[] {
const out: RichSegment[] = [];
let i = 0;
@ -62,7 +87,7 @@ export function tokenizeRichPlainText(text: string): RichSegment[] {
}
if (slice[0] === '#') {
const skillMatch = slice.match(/^#([a-zA-Z0-9_-]+)/);
const skillMatch = slice.match(SKILL_AT_START);
if (skillMatch) {
out.push({ type: 'skill', text: skillMatch[0] });
i += skillMatch[0].length;
@ -70,11 +95,21 @@ export function tokenizeRichPlainText(text: string): RichSegment[] {
}
}
if (slice[0] === '@' && isConnectorStart(text, i)) {
const connMatch = slice.match(CONNECTOR_AT_START);
if (connMatch) {
out.push({ type: 'connector', text: connMatch[0] });
i += connMatch[0].length;
continue;
}
}
let j = i + 1;
while (j < len) {
const tail = text.slice(j);
if (URL_AT_START.test(tail)) break;
if (text[j] === '#' && /^#([a-zA-Z0-9_-]+)/.test(tail)) break;
if (text[j] === '#' && SKILL_AT_START.test(tail)) break;
if (text[j] === '@' && isConnectorStart(text, j)) break;
j++;
}
out.push({ type: 'text', text: text.slice(i, j) });
@ -127,11 +162,17 @@ export function segmentsToHtml(segments: RichSegment[]): string {
} else {
parts.push(safe);
}
} else if (seg.type === 'connector') {
parts.push(
`<span data-rich-connector="1" contenteditable="false" class="rounded px-0.5 py-px font-medium ${RICH_CONNECTOR_STYLE_CLASSES}">${escapeHtml(
seg.text
)}</span>`
);
} else {
const idx = hashSkillLabel(seg.text) % RICH_SKILL_STYLE_CLASSES.length;
const cls = RICH_SKILL_STYLE_CLASSES[idx];
parts.push(
`<span data-rich-skill="1" class="rounded px-0.5 py-px font-medium ${cls}">${escapeHtml(seg.text)}</span>`
`<span data-rich-skill="1" contenteditable="false" class="rounded px-0.5 py-px font-medium ${cls}">${escapeHtml(seg.text)}</span>`
);
}
}

View file

@ -19,6 +19,12 @@ import {
proxyFetchPost,
proxyFetchPut,
} from '@/api/http';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { Button } from '@/components/ui/button';
import {
Dialog,
@ -44,9 +50,16 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { createHost } from '@/host/createHost';
import { SITE_URL } from '@/lib';
import { INIT_PROVODERS } from '@/lib/llm';
import {
buildProviderConfig,
formatModelConfigJson,
parseModelConfigJson,
splitProviderConfig,
} from '@/lib/modelConfig';
import { getProviderValid, toProviderValidStatus } from '@/lib/providerStatus';
import { useAuthStore } from '@/store/authStore';
import { useCloudModelStore } from '@/store/cloudModelStore';
@ -161,6 +174,7 @@ export default function SettingModels() {
apiHost: p.apiHost,
is_valid: p.is_valid ?? false,
model_type: p.model_type ?? '',
modelConfigJson: '',
externalConfig: p.externalConfig
? p.externalConfig.map((ec) => ({ ...ec }))
: undefined,
@ -196,6 +210,7 @@ export default function SettingModels() {
apiKey?: string;
apiHost?: string;
model_type?: string;
modelConfigJson?: string;
externalConfig?: string;
}[]
>(() =>
@ -401,6 +416,9 @@ export default function SettingModels() {
(p: any) => p.provider_name === item.id
);
if (found) {
const { modelConfigDict } = splitProviderConfig(
found.encrypted_config
);
return {
...fi,
provider_id: found.id,
@ -410,6 +428,10 @@ export default function SettingModels() {
is_valid: getProviderValid(found),
prefer: found.prefer ?? false,
model_type: found.model_type ?? '',
modelConfigJson:
Object.keys(modelConfigDict).length > 0
? formatModelConfigJson(modelConfigDict)
: '',
externalConfig: fi.externalConfig
? fi.externalConfig.map((ec) => {
if (
@ -633,9 +655,16 @@ export default function SettingModels() {
};
const handleVerify = async (idx: number) => {
const { apiKey, apiHost, externalConfig, model_type, provider_id } =
form[idx];
const {
apiKey,
apiHost,
externalConfig,
model_type,
modelConfigJson,
provider_id,
} = form[idx];
let hasError = false;
let modelConfigDict: Record<string, unknown> = {};
const newErrors = [...errors];
if (items[idx].id !== 'local' && items[idx].id !== 'aws-bedrock-converse') {
if (!apiKey || apiKey.trim() === '') {
@ -657,6 +686,15 @@ export default function SettingModels() {
} else {
newErrors[idx].model_type = '';
}
try {
modelConfigDict = parseModelConfigJson(modelConfigJson);
newErrors[idx].modelConfigJson = '';
} catch {
newErrors[idx].modelConfigJson = t(
'setting.model-parameters-must-be-valid-json-object'
);
hasError = true;
}
setErrors(newErrors);
if (hasError) {
showConfigCardRing('error');
@ -666,20 +704,34 @@ export default function SettingModels() {
showConfigCardRing('configuring');
setLoading(idx);
const item = items[idx];
let external: any = {};
if (form[idx]?.externalConfig) {
form[idx]?.externalConfig.map((item) => {
const external: Record<string, unknown> = {};
if (externalConfig) {
externalConfig.forEach((item) => {
external[item.key] = item.value;
});
}
console.log(form[idx]);
setForm((currentForm) =>
currentForm.map((entry, entryIdx) =>
entryIdx === idx
? {
...entry,
modelConfigJson:
Object.keys(modelConfigDict).length > 0
? formatModelConfigJson(modelConfigDict)
: '',
}
: entry
)
);
try {
const res = await fetchPost('/model/validate', {
model_platform: item.id,
model_type: form[idx].model_type,
api_key: form[idx].apiKey || null,
url: form[idx].apiHost,
model_config_dict: modelConfigDict,
extra_params: external,
});
if (res.is_tool_calls && res.is_valid) {
@ -724,13 +776,8 @@ export default function SettingModels() {
endpoint_url: form[idx].apiHost,
is_valid: toProviderValidStatus(true),
model_type: form[idx].model_type,
encrypted_config: buildProviderConfig(external, modelConfigDict),
};
if (externalConfig) {
data.encrypted_config = {};
externalConfig.forEach((ec) => {
data.encrypted_config[ec.key] = ec.value;
});
}
try {
if (provider_id) {
await proxyFetchPut(`/api/v1/provider/${provider_id}`, data);
@ -747,6 +794,9 @@ export default function SettingModels() {
(p: any) => p.provider_name === item.id
);
if (found) {
const { modelConfigDict } = splitProviderConfig(
found.encrypted_config
);
return {
...fi,
provider_id: found.id,
@ -756,6 +806,10 @@ export default function SettingModels() {
is_valid: getProviderValid(found),
prefer: found.prefer ?? false,
model_type: found.model_type ?? fi.model_type ?? '',
modelConfigJson:
Object.keys(modelConfigDict).length > 0
? formatModelConfigJson(modelConfigDict)
: '',
externalConfig: fi.externalConfig
? fi.externalConfig.map((ec) => {
if (
@ -1123,6 +1177,7 @@ export default function SettingModels() {
apiHost: item.apiHost,
is_valid: false,
model_type: '',
modelConfigJson: '',
externalConfig: item.externalConfig
? item.externalConfig.map((ec) => ({ ...ec, value: '' }))
: undefined,
@ -1133,7 +1188,14 @@ export default function SettingModels() {
);
setErrors((prev) =>
prev.map((er, i) =>
i === idx ? ({ apiKey: '', apiHost: '', model_type: '' } as any) : er
i === idx
? ({
apiKey: '',
apiHost: '',
model_type: '',
modelConfigJson: '',
} as any)
: er
)
);
if (activeModelIdx === idx) {
@ -1286,13 +1348,13 @@ export default function SettingModels() {
<button
key={tabId}
onClick={() => setSelectedTab(tabId)}
className={`rounded-xl px-3 py-2 flex w-full items-center justify-between transition-all duration-200 ${isSubItem ? 'pl-3' : ''} ${
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 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="gap-3 flex items-center justify-center">
<div className="flex items-center justify-center gap-3">
{modelImage ? (
<img
src={modelImage}
@ -1330,7 +1392,7 @@ export default function SettingModels() {
/>
)
: isConfigured && (
<div className="m-1 h-2 w-2 bg-ds-text-success-default-default shrink-0 rounded-full" />
<div className="m-1 h-2 w-2 shrink-0 rounded-full bg-ds-text-success-default-default" />
)}
</button>
);
@ -1477,7 +1539,7 @@ export default function SettingModels() {
if (selectedTab === 'cloud') {
if (import.meta.env.VITE_USE_LOCAL_PROXY === 'true') {
return (
<div className="h-64 text-ds-text-neutral-muted-default flex items-center justify-center">
<div className="flex h-64 items-center justify-center text-ds-text-neutral-muted-default">
{t('setting.cloud-not-available-in-local-proxy')}
</div>
);
@ -1489,13 +1551,13 @@ export default function SettingModels() {
const trialTotalLimit =
Number(subscription?.trial_total_credits_limit) || 1000;
return (
<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">
<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">
{t('setting.eigent-cloud')}
</div>
<div className="gap-2 flex items-center">
<div className="flex items-center gap-2">
{cloudPrefer ? (
<Button
variant="primary"
@ -1550,7 +1612,7 @@ export default function SettingModels() {
onClick={() => {
window.location.href = `${SITE_URL}/pricing`;
}}
className="text-body-sm text-ds-text-neutral-muted-default cursor-pointer underline"
className="cursor-pointer text-body-sm text-ds-text-neutral-muted-default underline"
>
{t('setting.pricing-options')}
</span>
@ -1560,9 +1622,9 @@ export default function SettingModels() {
</div>
</div>
{/*Content Area*/}
<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">
<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">
<span>{t('setting.credits')}:</span>
{loadingCredits ? (
<Loader2 className="h-4 w-4 animate-spin" />
@ -1571,7 +1633,7 @@ export default function SettingModels() {
)}
</div>
{isTrialing && (
<p className="m-0 text-label-sm leading-5 text-text-label max-w-[560px]">
<p className="m-0 max-w-[560px] text-label-sm leading-5 text-text-label">
{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.",
@ -1583,7 +1645,7 @@ export default function SettingModels() {
<button
type="button"
onClick={() => setTrialUpgradeDialogOpen(true)}
className="p-0 text-label-sm font-medium text-text-body cursor-pointer border-0 bg-transparent underline"
className="cursor-pointer border-0 bg-transparent p-0 text-label-sm font-medium text-text-body underline"
>
{t('setting.upgrade', { defaultValue: 'Upgrade' })}
</button>{' '}
@ -1653,9 +1715,9 @@ export default function SettingModels() {
/>
</DialogContent>
</Dialog>
<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">
<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">
{t('setting.select-model-type')}
</span>
</div>
@ -1697,12 +1759,12 @@ export default function SettingModels() {
return (
<ConfigModelCard status={configCardRing}>
<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="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="text-body-base my-2 font-bold text-ds-text-neutral-default-default">
{item.name}
</div>
<div className="gap-2 flex items-center">
<div className="flex items-center gap-2">
{isConnected ? (
isDefault ? (
<Button
@ -1745,10 +1807,10 @@ export default function SettingModels() {
{item.description}
</div>
</div>
<div className="gap-4 px-6 pb-4 flex w-full flex-col">
<div className="flex w-full flex-col gap-4 px-6 pb-4">
{/* 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">
<div className="flex w-full items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-body-sm text-ds-text-neutral-default-default">
{isConnected
? codexStatus.account_label ||
@ -1766,7 +1828,7 @@ export default function SettingModels() {
</span>
) : null}
</div>
<div className="ml-4 gap-2 flex shrink-0 items-center">
<div className="ml-4 flex shrink-0 items-center gap-2">
{isConnected ? (
<Button
variant="secondary"
@ -1802,9 +1864,9 @@ export default function SettingModels() {
</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">
<div className="flex w-full items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center">
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-body-sm">
{t('setting.model-type')}
</span>
</div>
@ -1824,12 +1886,12 @@ export default function SettingModels() {
return (
<ConfigModelCard status={configCardRing}>
<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="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="text-body-base my-2 font-bold text-ds-text-neutral-default-default">
{item.name}
</div>
<div className="gap-2 flex items-center">
<div className="flex items-center gap-2">
{form[idx].prefer ? (
<Button
variant="primary"
@ -1869,9 +1931,9 @@ export default function SettingModels() {
</Button>
)}
{form[idx].provider_id ? (
<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-success-default-default" />
) : (
<div className="h-2 w-2 bg-ds-text-neutral-default-default shrink-0 rounded-full opacity-10" />
<div className="h-2 w-2 shrink-0 rounded-full bg-ds-text-neutral-default-default opacity-10" />
)}
</div>
</div>
@ -1892,7 +1954,7 @@ export default function SettingModels() {
) : null}
</div>
</div>
<div className="gap-4 px-6 flex w-full flex-col items-center">
<div className="flex w-full flex-col items-center gap-4 px-6">
{/* API Key Setting */}
<Input
id={`apiKey-${item.id}`}
@ -2004,11 +2066,50 @@ export default function SettingModels() {
}}
/>
)}
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="model-parameters" className="border-none">
<AccordionTrigger className="bg-transparent px-0 py-2 hover:no-underline">
<span className="text-body-sm font-medium text-ds-text-neutral-default-default">
{t('setting.model-parameters-setting')}
</span>
</AccordionTrigger>
<AccordionContent>
<Textarea
id={`modelParameters-${item.id}`}
variant="enhanced"
state={errors[idx]?.modelConfigJson ? 'error' : 'default'}
note={errors[idx]?.modelConfigJson ?? undefined}
placeholder={t('setting.model-parameters-placeholder')}
value={form[idx].modelConfigJson}
rows={5}
spellCheck={false}
className="font-mono"
onChange={(e) => {
const value = e.target.value;
setForm((currentForm) =>
currentForm.map((entry, entryIdx) =>
entryIdx === idx
? { ...entry, modelConfigJson: value }
: entry
)
);
setErrors((currentErrors) =>
currentErrors.map((entry, entryIdx) =>
entryIdx === idx
? { ...entry, modelConfigJson: '' }
: entry
)
);
}}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
{/* externalConfig render */}
{item.externalConfig &&
form[idx].externalConfig &&
form[idx].externalConfig.map((ec, ecIdx) => (
<div key={ec.key} className="gap-4 flex h-full w-full flex-col">
<div key={ec.key} className="flex h-full w-full flex-col gap-4">
{ec.options && ec.options.length > 0 ? (
<Select
value={ec.value}
@ -2099,7 +2200,7 @@ export default function SettingModels() {
))}
</div>
{/* Action Button */}
<div className="gap-2 px-6 py-4 flex justify-end">
<div className="flex justify-end gap-2 px-6 py-4">
<Button
variant="ghost"
tone="neutral"
@ -2145,9 +2246,9 @@ export default function SettingModels() {
return (
<ConfigModelCard status={configCardRing}>
<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="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="text-body-base my-2 font-bold text-ds-text-neutral-default-default">
{getLocalPlatformName(platform)}
</div>
@ -2177,7 +2278,7 @@ export default function SettingModels() {
onClick={() => handleLocalSwitch(true)}
className={
isConfigured
? 'bg-ds-bg-neutral-default-hover !text-ds-text-neutral-muted-default hover:bg-ds-bg-neutral-default-active shadow-none'
? 'bg-ds-bg-neutral-default-hover !text-ds-text-neutral-muted-default shadow-none hover:bg-ds-bg-neutral-default-active'
: ''
}
>
@ -2188,14 +2289,14 @@ export default function SettingModels() {
)}
</div>
{isConfigured ? (
<div className="h-2 w-2 bg-text-success rounded-full" />
<div className="h-2 w-2 rounded-full bg-text-success" />
) : (
<div className="h-2 w-2 bg-text-label rounded-full opacity-10" />
<div className="h-2 w-2 rounded-full bg-text-label opacity-10" />
)}
</div>
</div>
{/* Model Endpoint URL Setting */}
<div className="gap-4 px-6 flex w-full flex-col items-center">
<div className="flex w-full flex-col items-center gap-4 px-6">
<Input
size="default"
title={t('setting.model-endpoint-url')}
@ -2237,8 +2338,8 @@ export default function SettingModels() {
note={localError ?? undefined}
/>
{isModelListPlatform ? (
<div className="gap-1 flex w-full flex-col">
<div className="gap-2 flex w-full items-end">
<div className="flex w-full flex-col gap-1">
<div className="flex w-full items-end gap-2">
<div className="flex-1">
<Select
value={currentType}
@ -2336,7 +2437,7 @@ export default function SettingModels() {
)}
</div>
{/* Action Button */}
<div className="gap-2 px-6 py-4 flex justify-end">
<div className="flex justify-end gap-2 px-6 py-4">
<Button
variant="ghost"
tone="neutral"
@ -2371,8 +2472,8 @@ export default function SettingModels() {
return (
<div className="m-auto flex h-auto w-full flex-1 flex-col">
{/* Header Section */}
<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="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="flex flex-col">
<div className="text-heading-sm font-bold text-ds-text-neutral-default-default">
{t('setting.models')}
@ -2381,10 +2482,10 @@ export default function SettingModels() {
</div>
</div>
{/* Content Section */}
<div className="mb-8 gap-6 flex flex-col">
<div className="mb-8 flex flex-col gap-6">
{/* Default Model Cascading Dropdown */}
<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="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="text-body-base font-bold text-ds-text-neutral-default-default">
{t('setting.models-default-setting-title')}
</div>
@ -2398,11 +2499,11 @@ export default function SettingModels() {
}}
>
<DropdownMenuTrigger asChild>
<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">
<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">
{getDefaultModelDisplayText()}
</span>
<ChevronDown className="h-4 w-4 !text-ds-text-brand-inverse-default flex-shrink-0" />
<ChevronDown className="h-4 w-4 flex-shrink-0 !text-ds-text-brand-inverse-default" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[180px]">
@ -2470,7 +2571,7 @@ export default function SettingModels() {
}}
className="flex items-center justify-between"
>
<div className="gap-2 flex items-center">
<div className="flex items-center gap-2">
{modelImage ? (
<img
src={modelImage}
@ -2491,15 +2592,15 @@ export default function SettingModels() {
{item.name}
</span>
</div>
<div className="gap-1 flex items-center">
<div className="flex items-center gap-1">
{!isConfigured && (
<div className="h-2 w-2 bg-text-label rounded-full opacity-10" />
<div className="h-2 w-2 rounded-full bg-text-label opacity-10" />
)}
{isPreferred && (
<Check className="h-4 w-4 text-ds-text-status-completed-strong-default" />
)}
{isConfigured && !isPreferred && (
<div className="h-2 w-2 bg-text-success rounded-full" />
<div className="h-2 w-2 rounded-full bg-text-success" />
)}
</div>
</DropdownMenuItem>
@ -2531,7 +2632,7 @@ export default function SettingModels() {
}
className="flex items-center justify-between"
>
<div className="gap-2 flex items-center">
<div className="flex items-center gap-2">
{modelImage ? (
<img
src={modelImage}
@ -2552,15 +2653,15 @@ export default function SettingModels() {
{model.name}
</span>
</div>
<div className="gap-1 flex items-center">
<div className="flex items-center gap-1">
{!isConfigured && (
<div className="h-2 w-2 bg-text-label rounded-full opacity-10" />
<div className="h-2 w-2 rounded-full bg-text-label opacity-10" />
)}
{isPreferred && (
<Check className="h-4 w-4 text-ds-text-status-completed-strong-default" />
)}
{isConfigured && !isPreferred && (
<div className="h-2 w-2 bg-text-success rounded-full" />
<div className="h-2 w-2 rounded-full bg-text-success" />
)}
</div>
</DropdownMenuItem>
@ -2573,17 +2674,17 @@ export default function SettingModels() {
</div>
{/* Content Section with Sidebar */}
<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">
<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">
{t('setting.models-configuration')}
</div>
<div className="px-3 flex w-full flex-row items-start justify-between">
<div className="flex w-full flex-row items-start justify-between px-3">
{/* Sidebar */}
<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">
<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">
{/* Eigent Cloud Section */}
<div className="gap-1 flex flex-col">
<div className="flex flex-col gap-1">
<div className="px-3 py-2 text-body-sm font-bold text-ds-text-neutral-default-default">
{t('setting.eigent-cloud')}
</div>
@ -2603,21 +2704,21 @@ export default function SettingModels() {
)}
</div>
{/* Custom Model Section */}
<div className="gap-1 flex flex-col">
<div className="flex flex-col gap-1">
<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">
<div className="flex flex-col gap-2">
{/* Subscription sub-accordion (OAuth login providers) */}
{items.some(
(item) => item.authMode === 'oauth_subscription'
) && (
<div className="gap-1 flex flex-col">
<div className="flex flex-col gap-1">
<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"
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-medium text-ds-text-neutral-muted-default">
{t('setting.subscription', {
@ -2631,7 +2732,7 @@ export default function SettingModels() {
)}
</button>
<div
className={`ease-in-out overflow-hidden transition-all duration-300 ${
className={`overflow-hidden transition-all duration-300 ease-in-out ${
subscriptionCollapsed
? 'max-h-0 opacity-0'
: 'max-h-[2000px] opacity-100'
@ -2656,12 +2757,12 @@ export default function SettingModels() {
{items.some(
(item) => item.authMode !== 'oauth_subscription'
) && (
<div className="gap-1 flex flex-col">
<div className="flex flex-col gap-1">
<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"
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-medium text-ds-text-neutral-muted-default">
{t('setting.byok', { defaultValue: 'BYOK' })}
@ -2673,7 +2774,7 @@ export default function SettingModels() {
)}
</button>
<div
className={`ease-in-out overflow-hidden transition-all duration-300 ${
className={`overflow-hidden transition-all duration-300 ease-in-out ${
byokGroupCollapsed
? 'max-h-0 opacity-0'
: 'max-h-[2000px] opacity-100'
@ -2698,10 +2799,10 @@ export default function SettingModels() {
</div>
{/* Local Model Section */}
<div className="gap-1 flex flex-col">
<div className="flex flex-col gap-1">
<button
onClick={() => setLocalCollapsed(!localCollapsed)}
className="rounded-lg px-3 py-2 hover:bg-ds-bg-neutral-default-default flex items-center justify-between bg-transparent transition-colors"
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.local-model')}
@ -2713,7 +2814,7 @@ export default function SettingModels() {
)}
</button>
<div
className={`ease-in-out overflow-hidden transition-all duration-300 ${
className={`overflow-hidden transition-all duration-300 ease-in-out ${
localCollapsed
? 'max-h-0 opacity-0'
: 'max-h-[2000px] opacity-100'
@ -2764,7 +2865,7 @@ export default function SettingModels() {
</div>
</div>
{/* Main Content */}
<div className="min-w-0 sticky top-[136px] z-10 flex-1">
<div className="sticky top-[136px] z-10 min-w-0 flex-1">
{renderContent()}
</div>
</div>

View file

@ -22,8 +22,9 @@ import {
import { Switch } from '@/components/ui/switch';
import { TooltipSimple } from '@/components/ui/tooltip';
import {
buildSkillScopeAgentOptions,
getWorkflowAgentDisplay,
WORKFLOW_AGENT_LIST,
normalizeSkillScopeAgentId,
} from '@/components/WorkFlow/agents';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import { useWorkerList } from '@/store/authStore';
@ -64,6 +65,16 @@ type SkillListItemProps =
| SkillListItemDefaultProps
| SkillListItemPlaceholderProps;
function selectedAgentsInclude(
selectedAgents: string[],
agentValue: string
): boolean {
const target = normalizeSkillScopeAgentId(agentValue);
return selectedAgents.some(
(agent) => normalizeSkillScopeAgentId(agent) === target
);
}
export default function SkillListItem(props: SkillListItemProps) {
const { t } = useTranslation();
const navigate = useNavigate();
@ -72,27 +83,10 @@ export default function SkillListItem(props: SkillListItemProps) {
const workerList = useWorkerList();
const [scopeOpen, setScopeOpen] = useState(false);
type AgentOption = {
value: string;
label: string;
};
const allAgents = useMemo(() => {
const workflowAgents: AgentOption[] = WORKFLOW_AGENT_LIST.filter(
(a) => a.id !== 'social_media_agent'
).map((a) => ({ value: a.id, label: a.name }));
const workerAgents: AgentOption[] = workerList.map((w) => ({
value: w.name,
label: w.name,
}));
const combined = [...workflowAgents];
workerAgents.forEach((agent) => {
if (!combined.some((a) => a.value === agent.value)) {
combined.push(agent);
}
});
return combined;
}, [workerList]);
const allAgents = useMemo(
() => buildSkillScopeAgentOptions(workerList),
[workerList]
);
if (props.variant === 'placeholder') {
const isClickable = props.onAddClick != null;
@ -100,7 +94,7 @@ export default function SkillListItem(props: SkillListItemProps) {
<div
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
className={`focus-visible:ring-ring gap-3 rounded-2xl bg-ds-bg-neutral-subtle-default px-6 py-8 flex w-full flex-col flex-wrap items-center justify-center transition-colors focus:outline-none focus-visible:ring-2 ${isClickable ? 'hover:bg-ds-bg-neutral-strong-hover cursor-pointer' : ''}`}
className={`focus-visible:ring-ring flex w-full flex-col flex-wrap items-center justify-center gap-3 rounded-2xl bg-ds-bg-neutral-subtle-default px-6 py-8 transition-colors focus:outline-none focus-visible:ring-2 ${isClickable ? 'cursor-pointer hover:bg-ds-bg-neutral-strong-hover' : ''}`}
onClick={isClickable ? props.onAddClick : undefined}
onKeyDown={
isClickable
@ -152,9 +146,10 @@ export default function SkillListItem(props: SkillListItemProps) {
};
const handleToggleAgent = (agentValue: string) => {
const canonicalValue = normalizeSkillScopeAgentId(agentValue) || agentValue;
if (isAllAgentsSelected) {
const newSelectedAgents = allAgents
.filter((a) => a.value !== agentValue)
.filter((a) => a.value !== canonicalValue)
.map((a) => a.value);
handleScopeChange({
isGlobal: false,
@ -163,10 +158,27 @@ export default function SkillListItem(props: SkillListItemProps) {
return;
}
const isSelected = skill.scope.selectedAgents.includes(agentValue);
const isSelected = selectedAgentsInclude(
skill.scope.selectedAgents,
canonicalValue
);
const newSelectedAgents = isSelected
? skill.scope.selectedAgents.filter((a) => a !== agentValue)
: [...skill.scope.selectedAgents, agentValue];
? skill.scope.selectedAgents.filter(
(a) => normalizeSkillScopeAgentId(a) !== canonicalValue
)
: [
...skill.scope.selectedAgents.map(
(a) => normalizeSkillScopeAgentId(a) || a
),
canonicalValue,
].filter(
(value, index, list) =>
list.findIndex(
(item) =>
normalizeSkillScopeAgentId(item) ===
normalizeSkillScopeAgentId(value)
) === index
);
handleScopeChange({
isGlobal: false,
selectedAgents: newSelectedAgents,
@ -181,17 +193,17 @@ export default function SkillListItem(props: SkillListItemProps) {
return (
<div
className={`rounded-2xl bg-ds-bg-neutral-subtle-default p-4 w-full flex-1 flex-col justify-between transition-colors ${skill.isExample && !skill.enabled ? 'opacity-50' : ''}`}
className={`w-full flex-1 flex-col justify-between rounded-2xl bg-ds-bg-neutral-subtle-default p-4 transition-colors ${skill.isExample && !skill.enabled ? 'opacity-50' : ''}`}
>
{/* Row 1: Name / Actions */}
<div className="flex items-center justify-between">
<div className="min-w-0 gap-2 flex items-center">
<span className="text-body-base font-bold text-ds-text-neutral-default-default truncate">
<div className="flex min-w-0 items-center gap-2">
<span className="text-body-base truncate font-bold text-ds-text-neutral-default-default">
{skill.name}
</span>
</div>
<div className="gap-md flex flex-shrink-0 items-center">
<div className="flex flex-shrink-0 items-center gap-md">
<Switch
checked={skill.enabled}
onCheckedChange={() => toggleSkill(skill.id)}
@ -231,17 +243,17 @@ export default function SkillListItem(props: SkillListItemProps) {
{/* Row 2: Description - 5 lines max, hover shows full */}
<TooltipSimple
content={skill.description}
className="max-w-sm break-words whitespace-pre-wrap"
className="max-w-sm whitespace-pre-wrap break-words"
>
<div className="w-full cursor-default">
<p className="text-body-sm text-ds-text-neutral-muted-default line-clamp-5 overflow-hidden break-words">
<p className="line-clamp-5 overflow-hidden break-words text-body-sm text-ds-text-neutral-muted-default">
{skill.description}
</p>
</div>
</TooltipSimple>
{/* Row 3: Added time / Skill scope */}
<div className="gap-2 flex flex-col items-start">
<div className="flex flex-col items-start gap-2">
<Button
variant="ghost"
size="sm"
@ -255,14 +267,14 @@ export default function SkillListItem(props: SkillListItemProps) {
</Button>
{scopeOpen && (
<div className="gap-2 border-ds-border-neutral-default-default pt-4 flex w-full flex-wrap items-center border-x-0 border-t-[0.5px] border-b-0 border-solid">
<div className="flex w-full flex-wrap items-center gap-2 border-x-0 border-b-0 border-t-[0.5px] border-solid border-ds-border-neutral-default-default pt-4">
{/* All agents as first tab; then each agent toggle */}
<button
type="button"
onClick={handleToggleAllAgents}
className={`gap-2 bg-ds-bg-neutral-subtle-default px-2 py-1 text-label-xs font-medium text-ds-text-neutral-default-default inline-flex items-center rounded-full transition-opacity hover:opacity-100 [&>svg]:shrink-0 ${
className={`inline-flex items-center gap-2 rounded-full bg-ds-bg-neutral-subtle-default px-2 py-1 text-label-xs font-medium text-ds-text-neutral-default-default transition-opacity hover:opacity-100 [&>svg]:shrink-0 ${
isAllAgentsSelected
? '[&>svg]:text-ds-icon-status-completed-default-default opacity-100'
? 'opacity-100 [&>svg]:text-ds-icon-status-completed-default-default'
: 'opacity-60 [&>svg]:text-inherit'
}`}
>
@ -277,7 +289,7 @@ export default function SkillListItem(props: SkillListItemProps) {
{allAgents.map((agent) => {
const isSelected =
isAllAgentsSelected ||
skill.scope.selectedAgents.includes(agent.value);
selectedAgentsInclude(skill.scope.selectedAgents, agent.value);
const display = getWorkflowAgentDisplay(agent.value);
const icon = display?.icon ?? (
<Bot size={16} className="shrink-0 text-inherit" />
@ -287,9 +299,9 @@ export default function SkillListItem(props: SkillListItemProps) {
key={agent.value}
type="button"
onClick={() => handleToggleAgent(agent.value)}
className={`gap-2 bg-ds-bg-neutral-subtle-default px-2 py-1 text-label-xs font-medium text-ds-text-neutral-default-default inline-flex items-center rounded-full transition-opacity hover:opacity-100 [&>svg]:shrink-0 ${
className={`inline-flex items-center gap-2 rounded-full bg-ds-bg-neutral-subtle-default px-2 py-1 text-label-xs font-medium text-ds-text-neutral-default-default transition-opacity hover:opacity-100 [&>svg]:shrink-0 ${
isSelected
? '[&>svg]:text-ds-icon-status-completed-default-default opacity-100'
? 'opacity-100 [&>svg]:text-ds-icon-status-completed-default-default'
: 'opacity-50 [&>svg]:text-inherit'
}`}
>

View file

@ -21,21 +21,7 @@ import {
proxyFetchPost,
proxyFetchPut,
} from '@/api/http';
import cursorIcon from '@/assets/icon/cursor.svg';
import githubIcon from '@/assets/icon/github.svg';
import googleIcon from '@/assets/icon/google.svg';
import googleCalendarIcon from '@/assets/icon/google_calendar.svg';
import googleGmailIcon from '@/assets/icon/google_gmail.svg';
import larkIcon from '@/assets/icon/lark.png';
import linkedinIcon from '@/assets/icon/linkedin.svg';
import notionIcon from '@/assets/icon/notion.svg';
import ragIcon from '@/assets/icon/rag.svg';
import redditIcon from '@/assets/icon/reddit.svg';
import slackIcon from '@/assets/icon/slack.svg';
import telegramIcon from '@/assets/icon/telegram.svg';
import vsCodeIcon from '@/assets/icon/vs-code.svg';
import whatsappIcon from '@/assets/icon/whatsapp.svg';
import xIcon from '@/assets/icon/x.svg';
import ellipseIcon from '@/assets/mcp/Ellipse-25.svg';
import SearchInput from '@/components/Dashboard/SearchInput';
import { Button } from '@/components/ui/button';
@ -52,6 +38,7 @@ import {
type IntegrationItem,
} from '@/hooks/useIntegrationManagement';
import { capitalizeFirstLetter, getProxyBaseURL } from '@/lib';
import { integrationLeadingIconUrl } from '@/lib/connectorIcons';
import { useAuthStore } from '@/store/authStore';
import { motion } from 'framer-motion';
import {
@ -90,31 +77,6 @@ const COMING_SOON_NAMES = [
'Github',
] as const;
const INTEGRATION_ICON_BY_KEY: Record<string, string> = {
notion: notionIcon,
slack: slackIcon,
'google calendar': googleCalendarIcon,
gmail: googleGmailIcon,
'google gmail': googleGmailIcon,
linkedin: linkedinIcon,
lark: larkIcon,
rag: ragIcon,
telegram: telegramIcon,
whatsapp: whatsappIcon,
x: xIcon,
'x(twitter)': xIcon,
twitter: xIcon,
reddit: redditIcon,
github: githubIcon,
cursor: cursorIcon,
'vs code': vsCodeIcon,
vscode: vsCodeIcon,
};
function integrationLeadingIconUrl(integrationKey: string): string | undefined {
return INTEGRATION_ICON_BY_KEY[integrationKey.toLowerCase().trim()];
}
export default function SettingMCP() {
const { checkAgentTool } = useAuthStore();
const { t } = useTranslation();

View file

@ -29,7 +29,6 @@ import {
} from '@/components/ProjectPageSidebar/constants';
import SessionGroup from '@/components/Session/SessionGroup';
import TriggerPanel from '@/components/Trigger';
import UpdateElectron from '@/components/update';
import Workspace from '@/components/Workspace';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import { useHost } from '@/host';
@ -57,6 +56,7 @@ import {
} from '../components/Trigger/Triggers';
import Session from '@/components/Session';
import { PreviewBrowserLayer } from '@/components/Session/PreviewPanel/tabs/browser/PreviewBrowserLayer';
import {
ResizableHandle,
ResizablePanel,
@ -752,16 +752,16 @@ export default function WorkspacePage() {
return (
<ReactFlowProvider>
<div className="min-h-0 px-1 pb-1 pt-10 flex h-full flex-row overflow-hidden">
<div className="flex h-full min-h-0 flex-row overflow-hidden px-1 pb-1 pt-10">
<div
ref={shellPanelGroupRef}
className="min-h-0 min-w-0 rounded-2xl bg-ds-bg-neutral-subtle-default h-full w-full flex-1"
className="h-full min-h-0 w-full min-w-0 flex-1 rounded-2xl bg-ds-bg-neutral-subtle-default"
>
<ResizablePanelGroup
ref={shellPanelGroupImperativeRef}
id="home-shell-panel-group"
direction="horizontal"
className="min-h-0 gap-0 h-full w-full"
className="h-full min-h-0 w-full gap-0"
onLayout={handleShellPanelLayout}
>
<ResizablePanel
@ -769,14 +769,14 @@ export default function WorkspacePage() {
defaultSize={24}
minSize={sidebarPct.rail}
maxSize={sidebarPct.max}
className="min-h-0 min-w-0 pl-1 py-1"
className="min-h-0 min-w-0 py-1 pl-1"
>
<ProjectPageSidebar chatStore={chatStore} />
</ResizablePanel>
<ResizableHandle
className={cn(
'after:bg-ds-bg-neutral-default-default w-[2px] shrink-0 bg-transparent after:transition-all',
'hover:bg-ds-bg-brand-subtle-default transition-all',
'w-[2px] shrink-0 bg-transparent after:bg-ds-bg-neutral-default-default after:transition-all',
'transition-all hover:bg-ds-bg-brand-subtle-default',
'data-[resize-handle-state=drag]:after:bg-ds-bg-brand-default-focus'
)}
/>
@ -789,7 +789,7 @@ export default function WorkspacePage() {
<motion.div
layout
transition={{ layout: HOME_MAIN_LAYOUT_SPRING }}
className="min-h-0 min-w-0 gap-4 relative flex h-full w-full flex-col overflow-hidden"
className="relative flex h-full min-h-0 w-full min-w-0 flex-col gap-4 overflow-hidden"
>
<div className={mainPanelShellClass}>
{renderActiveWorkspaceTab()}
@ -798,7 +798,10 @@ export default function WorkspacePage() {
</ResizablePanel>
</ResizablePanelGroup>
</div>
<UpdateElectron />
{/* Always mounted: hosts preview <webview> guests so their pages and
history survive panel close, workspace-tab hops, and project
switches. Renders nothing on the web host. */}
<PreviewBrowserLayer />
</div>
</ReactFlowProvider>
);

View file

@ -46,7 +46,7 @@ interface AuthInfo {
token: string;
username: string;
email: string;
user_id: number;
user_id?: number | null;
}
// auth state interface
@ -129,6 +129,23 @@ interface AuthState {
checkAgentTool: (tool: string) => void;
}
/**
* User id from the access token's payload. The local auto-login response
* carries no explicit user id, but the token it returns does; without an
* id every server-owned Space is filtered out client-side and the app
* degrades to an empty legacy-only workspace.
*/
const userIdFromToken = (token: string | null): number | null => {
if (!token) return null;
try {
const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(atob(base64));
return typeof payload.id === 'number' ? payload.id : null;
} catch {
return null;
}
};
// random default model selection
const getRandomDefaultModel = (): CloudModelType => {
return LEGACY_DEFAULT_CLOUD_MODEL_ID;
@ -213,19 +230,20 @@ const authStore = create<AuthState>()(
// auth related methods
setAuth: ({ token, username, email, user_id }) => {
const resolvedUserId = user_id ?? userIdFromToken(token);
const previousUserId = get().user_id;
if (previousUserId != null && previousUserId !== user_id) {
if (previousUserId != null && previousUserId !== resolvedUserId) {
void clearAllCachedProjects(previousUserId);
}
useSpaceStore.getState().resetForUser(user_id);
useSpaceStore.getState().resetForUser(resolvedUserId);
set({
token,
username,
email,
user_id,
user_id: resolvedUserId,
authEnvironmentKey: getAuthEnvironmentKey(),
});
hydrateSpacesForUser(user_id);
hydrateSpacesForUser(resolvedUserId);
},
logout: () => {
@ -379,7 +397,9 @@ const authStore = create<AuthState>()(
}),
{
name: 'auth-storage',
version: 10,
// Bump so migrate re-runs for existing sessions that still need the
// user-id repair; a matching version skips migrate and stays unrepaired.
version: 11,
migrate: (persistedState, _version) => {
const s = persistedState as
| {
@ -403,7 +423,12 @@ const authStore = create<AuthState>()(
const environmentMatches =
s.authEnvironmentKey === currentEnvironmentKey;
const authState = environmentMatches
? {}
? typeof s.token === 'string' && s.user_id == null
? // Existing session persisted without a user id (the local
// auto-login response never included one): recover it from
// the token so owned Spaces are not filtered out.
{ user_id: userIdFromToken(s.token) }
: {}
: {
token: null,
username: null,

View file

@ -28,6 +28,10 @@ import { showCreditsToast } from '@/components/Toast/creditsToast';
import { showStorageToast } from '@/components/Toast/storageToast';
import type { AppHost } from '@/host/types';
import { generateUniqueId, uploadLog } from '@/lib';
import {
buildAgentModelConfigFromProvider,
splitProviderConfig,
} from '@/lib/modelConfig';
import {
normalizeRemoteSubAgentProvider,
REMOTE_SUB_AGENT_PROVIDER_ID,
@ -95,6 +99,18 @@ const hasApiCode = (value: unknown, code: string) =>
let _host: AppHost | null = null;
// Per-step request_usage tokens keyed by `${taskId}:${agentId}`; needed
// because deactivate_agent.tokens is zeroed under request-level reporting.
const requestUsageStepTokens = new Map<string, number>();
const clearRequestUsageStepTokens = (taskId: string) => {
for (const key of requestUsageStepTokens.keys()) {
if (key.startsWith(`${taskId}:`)) {
requestUsageStepTokens.delete(key);
}
}
};
export function injectHost(host: AppHost | null): void {
_host = host;
}
@ -760,7 +776,10 @@ const AUTO_CONFIRM_TIMEOUT_MS = 30000;
const activeSSEControllers: Record<string, AbortController> = {};
const FINAL_OUTPUT_FILE_PATH_REGEX =
/(?:[A-Za-z]:)?[\\/][^\s`"'<>|*]+?\.[A-Za-z0-9]{1,12}(?=$|[\s`"'<>|*),;:\]}])/g;
/(?<![A-Za-z0-9:\\/])(?:[A-Za-z]:)?[\\/][^\s`"'<>|*]+?\.[A-Za-z0-9]{1,12}(?=$|[\s`"'<>|*),;:\]}])/g;
const FINAL_OUTPUT_SANDBOX_SCHEME_REGEX =
/(^|[^A-Za-z0-9_+.-])sandbox:(?=(?:[A-Za-z]:)?[\\/])/gi;
const FINAL_OUTPUT_FILE_EXTENSIONS = new Set([
'csv',
@ -841,7 +860,7 @@ function buildRemoteFileInfoPath({
return `${baseURL.replace(/\/$/, '')}/files/stream?${params.toString()}`;
}
function extractFinalOutputFileList(
export function extractFinalOutputFileList(
content: string,
projectId?: string,
email?: string,
@ -853,8 +872,12 @@ function extractFinalOutputFileList(
const fileInfos: FileInfo[] = [];
const seen = new Set<string>();
const parseableContent = content.replace(
FINAL_OUTPUT_SANDBOX_SCHEME_REGEX,
'$1'
);
for (const match of content.matchAll(FINAL_OUTPUT_FILE_PATH_REGEX)) {
for (const match of parseableContent.matchAll(FINAL_OUTPUT_FILE_PATH_REGEX)) {
const filePath = normalizeOutputPath(match[0]);
if (!filePath || filePath.startsWith('//') || filePath.includes('://')) {
continue;
@ -903,7 +926,16 @@ function getFileInfoIdentities(file: FileInfo): string[] {
.map((value) => normalizeOutputPath(value as string).toLowerCase());
}
function mergeFileInfoLists(
function isLegacySandboxDrivePath(
existingPath: string,
extractedPath: string
): boolean {
const normalizedExisting = normalizeOutputPath(existingPath).toLowerCase();
const normalizedExtracted = normalizeOutputPath(extractedPath).toLowerCase();
return normalizedExisting === `x:${normalizedExtracted}`;
}
export function mergeFileInfoLists(
existingFileList: FileInfo[],
extractedFileList: FileInfo[]
): FileInfo[] {
@ -922,9 +954,13 @@ function mergeFileInfoLists(
return;
}
if (file.isRemote && !merged[existingIndex].isRemote) {
const existingFile = merged[existingIndex];
if (
(file.isRemote && !existingFile.isRemote) ||
isLegacySandboxDrivePath(existingFile.path, file.path)
) {
merged[existingIndex] = {
...merged[existingIndex],
...existingFile,
...file,
};
mergedIdentities[existingIndex] = identities;
@ -1499,22 +1535,54 @@ const chatStore = (initial?: Partial<ChatStore>) =>
}
}
// Reuse the model captured on this Project (if any) so follow-up runs
// keep the conversation's model even when the global default changed.
const pinnedModelSelection =
!type && project_id ? projectStore.getProjectModel(project_id) : null;
const effectiveModelType = pinnedModelSelection?.modelType ?? modelType;
let resolvedProviderId: number | undefined;
let resolvedCloudModelId: string | undefined;
let resolvedCodexModelId: string | undefined;
// get current model
let apiModel = {
api_key: '',
model_type: '',
model_platform: '',
api_url: '',
model_config_dict: {},
extra_params: {},
auth_source: undefined as 'codex_subscription' | undefined,
};
if (!type && (modelType === 'custom' || modelType === 'local')) {
const res = await proxyFetchGet('/api/v1/providers', {
prefer: true,
});
const providerList = res.items || [];
console.log('providerList', providerList);
const provider = providerList[0];
if (
!type &&
(effectiveModelType === 'custom' || effectiveModelType === 'local')
) {
let provider: any = null;
if (pinnedModelSelection?.provider_id !== undefined) {
try {
const res = await proxyFetchGet('/api/v1/providers');
const providerList = Array.isArray(res) ? res : res.items || [];
provider =
providerList.find(
(p: { id: number }) => p.id === pinnedModelSelection.provider_id
) || null;
} catch (error) {
console.error('Failed to load pinned model provider:', error);
}
if (!provider) {
toast.warning(
'The model used earlier in this conversation is no longer available. Falling back to the default model.'
);
}
}
if (!provider) {
const res = await proxyFetchGet('/api/v1/providers', {
prefer: true,
});
const providerList = res.items || [];
provider = providerList[0];
}
if (!provider) {
finishStartupFailure();
@ -1523,22 +1591,31 @@ const chatStore = (initial?: Partial<ChatStore>) =>
);
}
const { modelConfigDict, extraParams } = splitProviderConfig(
provider.encrypted_config
);
apiModel = {
api_key: provider.api_key,
model_type: provider.model_type,
model_platform: provider.provider_name,
api_url: provider.endpoint_url || provider.api_url,
extra_params: provider.encrypted_config,
model_config_dict: modelConfigDict,
extra_params: extraParams,
auth_source: undefined,
};
} else if (!type && modelType === 'cloud') {
resolvedProviderId = provider.id;
} else if (!type && effectiveModelType === 'cloud') {
const requestedCloudModelId =
pinnedModelSelection?.cloud_model_type || cloud_model_type;
const cloudModelStore = getCloudModelStore();
let resolvedCloudModel =
cloudModelStore.resolveCloudModel(cloud_model_type);
let resolvedCloudModel = cloudModelStore.resolveCloudModel(
requestedCloudModelId
);
if (!resolvedCloudModel || resolvedCloudModel.source !== 'selected') {
await cloudModelStore.fetchCloudModels(true);
resolvedCloudModel =
getCloudModelStore().resolveCloudModel(cloud_model_type);
resolvedCloudModel = getCloudModelStore().resolveCloudModel(
requestedCloudModelId
);
}
if (!resolvedCloudModel) {
finishStartupFailure();
@ -1554,7 +1631,10 @@ const chatStore = (initial?: Partial<ChatStore>) =>
console.warn(message);
toast.warning(message);
}
if (resolvedCloudModel.model.id !== cloud_model_type) {
if (
!pinnedModelSelection &&
resolvedCloudModel.model.id !== cloud_model_type
) {
getAuthStore().setCloudModelType(resolvedCloudModel.model.id);
}
@ -1598,23 +1678,50 @@ const chatStore = (initial?: Partial<ChatStore>) =>
model_type: resolvedCloudModel.model.model_type,
model_platform: resolvedCloudModel.model.model_platform,
api_url: res.api_url,
model_config_dict: {},
extra_params: {},
auth_source: undefined,
};
} else if (!type && modelType === 'codex_subscription') {
resolvedCloudModelId = resolvedCloudModel.model.id;
} else if (!type && effectiveModelType === 'codex_subscription') {
const codexModelId =
pinnedModelSelection?.codex_model_type ||
codex_model_type ||
'gpt-5.5';
apiModel = {
api_key: '',
model_type: codex_model_type || 'gpt-5.5',
model_type: codexModelId,
model_platform: 'openai',
api_url: '',
model_config_dict: {},
extra_params: {},
auth_source: 'codex_subscription',
};
resolvedCodexModelId = codexModelId;
}
// Capture the resolved model on the Project so later runs (including
// conversations reloaded from history) keep using it.
if (!type && project_id && apiModel.model_platform) {
projectStore.setProjectModel(project_id, {
modelType: effectiveModelType,
...(resolvedCloudModelId
? { cloud_model_type: resolvedCloudModelId }
: {}),
...(resolvedCodexModelId
? { codex_model_type: resolvedCodexModelId }
: {}),
...(resolvedProviderId !== undefined
? { provider_id: resolvedProviderId }
: {}),
model_platform: apiModel.model_platform,
model_type: apiModel.model_type,
});
}
// Get search engine configuration for custom mode
let searchConfig: Record<string, string> = {};
if (!type && modelType === 'custom') {
if (!type && effectiveModelType === 'custom') {
try {
const configsRes = await proxyFetchGet('/api/v1/configs');
const configs = Array.isArray(configsRes) ? configsRes : [];
@ -1666,12 +1773,61 @@ const chatStore = (initial?: Partial<ChatStore>) =>
}
}
const workerProviderIds = !type
? workerList
.map((worker) => worker.workerInfo?.model_provider_id)
.filter((providerId): providerId is number =>
Number.isInteger(providerId)
)
: [];
const workerProvidersById = new Map<number, any>();
if (workerProviderIds.length > 0) {
let workerProviderList: any[];
try {
const providersRes = await proxyFetchGet('/api/v1/providers');
workerProviderList = Array.isArray(providersRes)
? providersRes
: providersRes.items || [];
} catch (error) {
finishStartupFailure();
throw new Error(
'Failed to load the model provider configured for a worker.',
{ cause: error }
);
}
workerProviderList.forEach((provider) => {
workerProvidersById.set(Number(provider.id), provider);
});
const missingWorker = workerList.find((worker) => {
const providerId = worker.workerInfo?.model_provider_id;
return (
Number.isInteger(providerId) &&
!workerProvidersById.has(providerId as number)
);
});
if (missingWorker) {
finishStartupFailure();
throw new Error(
`The model provider configured for worker "${missingWorker.name}" is no longer available. Please edit the worker and select another model.`
);
}
}
const addWorkers = workerList.map((worker) => {
const providerId = worker.workerInfo?.model_provider_id;
const provider = Number.isInteger(providerId)
? workerProvidersById.get(providerId as number)
: undefined;
return {
name: worker.workerInfo?.name,
description: worker.workerInfo?.description,
tools: worker.workerInfo?.tools,
mcp_tools: worker.workerInfo?.mcp_tools,
custom_model_config: provider
? buildAgentModelConfigFromProvider(provider)
: undefined,
};
});
@ -1719,7 +1875,7 @@ const chatStore = (initial?: Partial<ChatStore>) =>
language: systemLanguage,
model_platform: apiModel.model_platform,
model_type: apiModel.model_type,
api_url: modelType === 'cloud' ? 'cloud' : apiModel.api_url,
api_url: effectiveModelType === 'cloud' ? 'cloud' : apiModel.api_url,
max_retries: 3,
file_save_path: 'string',
installed_mcp: 'string',
@ -1762,6 +1918,15 @@ const chatStore = (initial?: Partial<ChatStore>) =>
// Create AbortController for this task's SSE connection
// First check if there's already an active SSE connection for this task
if (activeSSEControllers[newTaskId] && type === 'replay') {
// A history replay must never tear down a live run's stream: the
// ongoing run is the fresher state, and aborting it kills the run
// on the backend. Leave the live connection alone.
console.warn(
`Task ${newTaskId} already has an active SSE connection, skipping history replay`
);
return;
}
if (activeSSEControllers[newTaskId]) {
console.warn(
`Task ${newTaskId} already has an active SSE connection, aborting old one`
@ -1813,6 +1978,7 @@ const chatStore = (initial?: Partial<ChatStore>) =>
model_type: apiModel.model_type,
api_key: apiModel.api_key,
api_url: apiModel.api_url,
model_config_dict: apiModel.model_config_dict,
extra_params: apiModel.extra_params,
auth_source: apiModel.auth_source,
installed_mcp: { mcpServers: {} },
@ -2075,7 +2241,10 @@ const chatStore = (initial?: Partial<ChatStore>) =>
language: systemLanguage,
model_platform: apiModel.model_platform,
model_type: apiModel.model_type,
api_url: modelType === 'cloud' ? 'cloud' : apiModel.api_url,
api_url:
effectiveModelType === 'cloud'
? 'cloud'
: apiModel.api_url,
max_retries: 3,
file_save_path: 'string',
installed_mcp: 'string',
@ -2674,6 +2843,21 @@ const chatStore = (initial?: Partial<ChatStore>) =>
return;
}
// Request-level token usage updates (non-stream mode)
if (agentMessages.step === AgentStep.REQUEST_USAGE) {
if (agentMessages.data.tokens) {
addTokens(currentTaskId, agentMessages.data.tokens);
const stepKey = `${currentTaskId}:${agentMessages.data.agent_id}`;
requestUsageStepTokens.set(
stepKey,
agentMessages.data.step_total_tokens ||
(requestUsageStepTokens.get(stepKey) || 0) +
agentMessages.data.tokens
);
}
return;
}
// Activate agent
if (
agentMessages.step === AgentStep.ACTIVATE_AGENT ||
@ -2684,6 +2868,18 @@ const chatStore = (initial?: Partial<ChatStore>) =>
if (agentMessages.data.tokens) {
addTokens(currentTaskId, agentMessages.data.tokens);
}
// Consume the step's request_usage tokens before any early
// return below, so entries are cleaned up even for agents that
// never appear in taskAssigning.
let stepTokens = 0;
if (agentMessages.step === AgentStep.DEACTIVATE_AGENT) {
const stepKey = `${currentTaskId}:${agentMessages.data.agent_id}`;
stepTokens =
agentMessages.data.tokens ||
requestUsageStepTokens.get(stepKey) ||
0;
requestUsageStepTokens.delete(stepKey);
}
const { state, agent_id, process_task_id } = agentMessages.data;
if (!state && !agent_id && !process_task_id) return;
const agentIndex = taskAssigning.findIndex(
@ -2763,8 +2959,9 @@ const chatStore = (initial?: Partial<ChatStore>) =>
// and tokens are used (indicating actual response generation, not just classification)
const isQuestionConfirmAgent =
agentMessages.data.agent_name === 'question_confirm_agent';
const hasTokens =
agentMessages.data.tokens && agentMessages.data.tokens > 0;
// Per-step tokens (not the task total) so an errored/empty
// step is not mistaken for a real reply.
const hasTokens = stepTokens > 0;
const isNotClassificationAnswer =
agentMessages.data.message &&
agentMessages.data.message.trim().toLowerCase() !== 'yes' &&
@ -3350,6 +3547,19 @@ const chatStore = (initial?: Partial<ChatStore>) =>
role: 'agent',
content: `❌ **Error**: ${errorMessage}`,
});
// Record the tokens consumed before the failure so the run's
// spend is not lost from the history row (a failed run
// otherwise stays at zero tokens forever).
if (!type && historyId && !isProjectBusyError) {
const tokensSoFar = getTokens(currentTaskId);
if (tokensSoFar > 0) {
proxyFetchPut(`/api/v1/chat/history/${historyId}`, {
tokens: tokensSoFar,
}).catch((err) => {
console.warn('History token update failed on error:', err);
});
}
}
uploadLog(currentTaskId, type);
// Update trigger execution status to Failed on error
updateTriggerExecutionStatus(
@ -3491,6 +3701,7 @@ const chatStore = (initial?: Partial<ChatStore>) =>
if (endTokens > 0 && getTokens(currentTaskId) === 0) {
addTokens(currentTaskId, endTokens);
}
clearRequestUsageStepTokens(currentTaskId);
if (!currentTaskId || !tasks[currentTaskId]) return;
const endMessage = resolveEndMessageText(

View file

@ -12,6 +12,7 @@
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { canonicalizeBrowserUrl, normalizeBrowserUrl } from '@/lib/browserUrl';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
@ -32,6 +33,236 @@ export const WorkspaceTab = {
export type WorkspaceTabId = (typeof WorkspaceTab)[keyof typeof WorkspaceTab];
export interface SessionBrowserNavigationState {
url: string;
title: string;
isLoading: boolean;
canGoBack: boolean;
canGoForward: boolean;
}
export interface SessionBrowserTab {
id: string;
type: 'browser';
title: string;
url: string;
webviewId: string;
navigation: SessionBrowserNavigationState;
}
export interface SessionFileTab {
id: string;
type: 'file';
title: string;
file: FileInfo | null;
}
/** Blank starter tab that lets the user pick which content type to open. */
export interface SessionChooserTab {
id: string;
type: 'chooser';
title: string;
}
/** Code/diff review surface (content wired up later). */
export interface SessionReviewTab {
id: string;
type: 'review';
title: string;
}
export interface SessionTerminalTab {
id: string;
type: 'terminal';
title: string;
}
/** Free-form React Flow canvas. */
export interface SessionCanvasTab {
id: string;
type: 'canvas';
title: string;
}
export type SessionPreviewTab =
| SessionChooserTab
| SessionBrowserTab
| SessionFileTab
| SessionReviewTab
| SessionTerminalTab
| SessionCanvasTab;
/**
* Content types the chooser can open. `chooser` is intentionally excluded
* it is the picker itself, not a destination.
*/
export type PreviewTabKind = Exclude<SessionPreviewTab['type'], 'chooser'>;
export interface PreviewBrowserViewport {
x: number;
y: number;
width: number;
height: number;
}
/**
* Per-project preview panel state. Keyed by project id so switching sessions
* restores the tabs (and, within an app run, the live webviews behind them).
*/
export interface SessionPreviewSlice {
open: boolean;
tabs: SessionPreviewTab[];
activeTabId: string | null;
}
const EMPTY_SESSION_PREVIEW: SessionPreviewSlice = {
open: false,
tabs: [],
activeTabId: null,
};
/**
* The preview slice for the currently scoped project. The per-project record
* is the single source of truth; components derive their view through this
* selector (e.g. `usePageTabStore((s) => getSessionPreviewSlice(s).tabs)`)
* instead of reading mirrored flat fields, so state can never drift.
*/
export function getSessionPreviewSlice(state: {
sessionPreviewProjectId: string | null;
sessionPreviewByProject: Record<string, SessionPreviewSlice>;
}): SessionPreviewSlice {
const projectId = state.sessionPreviewProjectId;
return (
(projectId ? state.sessionPreviewByProject[projectId] : undefined) ??
EMPTY_SESSION_PREVIEW
);
}
let sessionPreviewTabSequence = 0;
// Random per-run seed so ids never collide with tabs restored from persistence.
const sessionPreviewTabIdSeed = Math.random().toString(36).slice(2, 8);
function nextSessionPreviewTabId(type: SessionPreviewTab['type']): string {
sessionPreviewTabSequence += 1;
return `${type}-${sessionPreviewTabIdSeed}-${sessionPreviewTabSequence}`;
}
function createBrowserPreviewTab(projectId: string | null): SessionBrowserTab {
const id = nextSessionPreviewTabId('browser');
return {
id,
type: 'browser',
title: 'New tab',
url: '',
// Project-scoped so each session keeps its own native webviews (and their
// navigation history) alive while the app runs.
webviewId: `session-preview:${projectId ?? 'global'}:${id}`,
navigation: {
url: '',
title: '',
isLoading: false,
canGoBack: false,
canGoForward: false,
},
};
}
function createFilePreviewTab(file: FileInfo | null = null): SessionFileTab {
return {
id: nextSessionPreviewTabId('file'),
type: 'file',
title: file?.name || 'Open file',
file,
};
}
function createChooserPreviewTab(): SessionChooserTab {
return {
id: nextSessionPreviewTabId('chooser'),
type: 'chooser',
title: 'New tab',
};
}
/** Placeholder tab title for a URL until the page reports its real one. */
function browserTabTitleForUrl(url: string): string {
try {
return new URL(url).hostname || 'New tab';
} catch {
return 'New tab';
}
}
/** Build a fresh tab of the requested kind. Browser tabs need the project id. */
function createPreviewTabOfKind(
kind: PreviewTabKind,
projectId: string | null
): SessionPreviewTab {
switch (kind) {
case 'browser':
return createBrowserPreviewTab(projectId);
case 'file':
return createFilePreviewTab();
case 'review':
return {
id: nextSessionPreviewTabId('review'),
type: 'review',
title: 'Review',
};
case 'terminal':
return {
id: nextSessionPreviewTabId('terminal'),
type: 'terminal',
title: 'Terminal',
};
case 'canvas':
return {
id: nextSessionPreviewTabId('canvas'),
type: 'canvas',
title: 'Canvas',
};
}
}
function createInitialSessionPreviewTabs(): {
tabs: SessionPreviewTab[];
activeTabId: string;
} {
// Open onto the chooser so the user picks what the first tab becomes.
const chooser = createChooserPreviewTab();
return { tabs: [chooser], activeTabId: chooser.id };
}
/**
* Strip runtime-only navigation state before persisting: after an app restart
* the native webview (and its history) is gone, so only url/title survive.
*/
function sanitizeSessionPreviewForPersist(
slices: Record<string, SessionPreviewSlice>
): Record<string, SessionPreviewSlice> {
const result: Record<string, SessionPreviewSlice> = {};
for (const [projectId, slice] of Object.entries(slices)) {
result[projectId] = {
...slice,
tabs: slice.tabs.map((tab) =>
tab.type === 'browser'
? {
...tab,
navigation: {
url: tab.url,
title: tab.title,
isLoading: false,
canGoBack: false,
canGoForward: false,
},
}
: tab
),
};
}
return result;
}
interface PageTabState {
activeTab: 'tasks' | 'trigger';
setActiveTab: (tab: 'tasks' | 'trigger') => void;
@ -135,17 +366,96 @@ interface PageTabState {
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. */
// ── Inline session preview (project page) ─────────────────────────────────
/**
* Project whose preview slice mutations and `getSessionPreviewSlice` reads
* target. Set by the Session page on mount/switch; while unset, preview
* mutations are dropped (there is nowhere durable to record them).
*/
sessionPreviewProjectId: string | null;
/**
* Preview panel state per project the single source of truth, persisted
* so sessions restore. Read the scoped slice via `getSessionPreviewSlice`.
*/
sessionPreviewByProject: Record<string, SessionPreviewSlice>;
/** Point the preview scope at a project; its saved slice becomes current. */
setSessionPreviewProject: (projectId: string | null) => void;
/**
* Window-fixed rect the active embedded browser should occupy, published by
* the preview panel while a browser tab is visible. `null` parks all guests.
* The always-mounted PreviewBrowserLayer positions `<webview>` elements from
* this so guests (and their history) survive panel close / project switch.
*/
previewBrowserViewport: PreviewBrowserViewport | null;
setPreviewBrowserViewport: (rect: PreviewBrowserViewport | null) => void;
/** Toggle the unified preview panel (opens onto the chooser tab). */
toggleSessionPreview: () => void;
/** Add and activate a blank chooser tab (the "+" button). */
addChooserPreviewTab: () => void;
/**
* Turn a tab (typically the chooser) into the chosen content kind, in place.
* Falls back to appending if the target tab no longer exists.
*/
choosePreviewTabType: (tabId: string, kind: PreviewTabKind) => void;
/** Open a file in a deduplicated file tab (reuses a blank starter tab). */
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;
/**
* Open a URL in this project's preview browser the default target for
* links mentioned in chat content, so they stay inside the session instead
* of jumping to the system browser. Reuses a tab already on that URL, then
* a blank starter tab (chooser or empty browser); otherwise appends.
*/
openBrowserPreview: (url: string) => void;
selectSessionPreviewTab: (tabId: string) => void;
closeSessionPreviewTab: (tabId: string) => void;
updateBrowserPreviewTab: (
tabId: string,
patch: Partial<Omit<SessionBrowserTab, 'id' | 'type' | 'webviewId'>>
) => void;
/**
* Same as updateBrowserPreviewTab but addressed to an explicit project
* used by the browser layer, whose guests emit navigation events even for
* projects that are not the current preview scope.
*/
updateBrowserPreviewTabIn: (
projectId: string,
tabId: string,
patch: Partial<Omit<SessionBrowserTab, 'id' | 'type' | 'webviewId'>>
) => void;
closeSessionPreview: () => void;
resetSessionPreview: () => void;
}
type SetPageTabState = (
partial:
| Partial<PageTabState>
| ((state: PageTabState) => Partial<PageTabState> | PageTabState)
) => void;
/**
* Apply a preview mutation to the scoped project's slice. The updater receives
* the current slice; return `null` to bail without changes. No project scope
* no-op (the Session page sets the scope before any preview UI is reachable).
*/
function setSessionPreviewSlice(
set: SetPageTabState,
updater: (
slice: SessionPreviewSlice,
state: PageTabState
) => SessionPreviewSlice | null
) {
set((state) => {
const projectId = state.sessionPreviewProjectId;
if (!projectId) return state;
const slice = updater(getSessionPreviewSlice(state), state);
if (!slice) return state;
return {
sessionPreviewByProject: {
...state.sessionPreviewByProject,
[projectId]: slice,
},
};
});
}
export const usePageTabStore = create<PageTabState>()(
@ -334,16 +644,219 @@ export const usePageTabStore = create<PageTabState>()(
setScrollToTurnRequest: (request) =>
set({ scrollToTurnRequest: request }),
filePreviewOpen: false,
filePreviewFile: null,
sessionPreviewProjectId: null,
sessionPreviewByProject: {},
previewBrowserViewport: null,
setPreviewBrowserViewport: (rect) =>
set({ previewBrowserViewport: rect }),
setSessionPreviewProject: (projectId) =>
set((state) =>
state.sessionPreviewProjectId === projectId
? state
: { sessionPreviewProjectId: projectId }
),
toggleSessionPreview: () =>
setSessionPreviewSlice(set, (slice) => {
if (slice.open) return { ...slice, open: false };
if (slice.tabs.length > 0) return { ...slice, open: true };
const initial = createInitialSessionPreviewTabs();
return {
open: true,
tabs: initial.tabs,
activeTabId: initial.activeTabId,
};
}),
addChooserPreviewTab: () =>
setSessionPreviewSlice(set, (slice) => {
const tab = createChooserPreviewTab();
return {
open: true,
tabs: [...slice.tabs, tab],
activeTabId: tab.id,
};
}),
choosePreviewTabType: (tabId, kind) =>
setSessionPreviewSlice(set, (slice, state) => {
const tab = createPreviewTabOfKind(
kind,
state.sessionPreviewProjectId
);
const index = slice.tabs.findIndex(
(candidate) => candidate.id === tabId
);
const tabs = [...slice.tabs];
if (index >= 0) {
// Replace the chooser in place so the tab keeps its position.
tabs[index] = tab;
} else {
tabs.push(tab);
}
return { open: true, tabs, activeTabId: tab.id };
}),
openFilePreview: (file) =>
set((state) => ({
filePreviewOpen: true,
filePreviewFile: file === undefined ? state.filePreviewFile : file,
setSessionPreviewSlice(set, (slice) => {
const targetFile = file ?? null;
const previewTabs = slice.tabs;
const matchingTab = targetFile
? previewTabs.find(
(tab) =>
tab.type === 'file' && tab.file?.path === targetFile.path
)
: previewTabs.find(
(tab) => tab.type === 'file' && tab.file === null
);
if (matchingTab) {
return {
open: true,
tabs: previewTabs,
activeTabId: matchingTab.id,
};
}
// Reuse a "blank" starter tab (empty file, or the chooser) in place —
// preferring the active one — so opening a file doesn't pile up tabs.
const isReusable = (tab: SessionPreviewTab) =>
tab.type === 'chooser' ||
(tab.type === 'file' && tab.file === null);
const reuseIndex = (() => {
const activeIndex = previewTabs.findIndex(
(tab) => tab.id === slice.activeTabId && isReusable(tab)
);
return activeIndex >= 0
? activeIndex
: previewTabs.findIndex(isReusable);
})();
if (reuseIndex >= 0) {
const tabs = [...previewTabs];
tabs[reuseIndex] = createFilePreviewTab(targetFile);
return { open: true, tabs, activeTabId: tabs[reuseIndex].id };
}
const tab = createFilePreviewTab(targetFile);
return {
open: true,
tabs: [...previewTabs, tab],
activeTabId: tab.id,
};
}),
openBrowserPreview: (url) =>
setSessionPreviewSlice(set, (slice, state) => {
const normalized = normalizeBrowserUrl(url);
if (!normalized.ok) return null;
const canonical = canonicalizeBrowserUrl(normalized.url);
// A tab already showing this URL (live page or pending load) — focus it.
const existing = slice.tabs.find(
(tab) =>
tab.type === 'browser' &&
canonicalizeBrowserUrl(tab.navigation.url || tab.url) ===
canonical
);
if (existing) {
return { ...slice, open: true, activeTabId: existing.id };
}
const title = browserTabTitleForUrl(normalized.url);
// Reuse a blank starter tab (empty browser, or the chooser) in
// place — preferring the active one — so links don't pile up tabs.
const isReusable = (tab: SessionPreviewTab) =>
tab.type === 'chooser' || (tab.type === 'browser' && !tab.url);
const reuseIndex = (() => {
const activeIndex = slice.tabs.findIndex(
(tab) => tab.id === slice.activeTabId && isReusable(tab)
);
return activeIndex >= 0
? activeIndex
: slice.tabs.findIndex(isReusable);
})();
if (reuseIndex >= 0) {
const reused = slice.tabs[reuseIndex];
const tabs = [...slice.tabs];
tabs[reuseIndex] =
reused.type === 'browser'
? // Keep the tab (and its webviewId): setting the URL mounts
// its guest in the browser layer.
{
...reused,
url: normalized.url,
title,
navigation: { ...reused.navigation, url: normalized.url },
}
: {
...createBrowserPreviewTab(state.sessionPreviewProjectId),
url: normalized.url,
title,
};
return { open: true, tabs, activeTabId: tabs[reuseIndex].id };
}
const tab: SessionBrowserTab = {
...createBrowserPreviewTab(state.sessionPreviewProjectId),
url: normalized.url,
title,
};
return {
open: true,
tabs: [...slice.tabs, tab],
activeTabId: tab.id,
};
}),
selectSessionPreviewTab: (tabId) =>
setSessionPreviewSlice(set, (slice) =>
slice.tabs.some((tab) => tab.id === tabId)
? { ...slice, activeTabId: tabId }
: null
),
closeSessionPreviewTab: (tabId) =>
setSessionPreviewSlice(set, (slice) => {
const closingIndex = slice.tabs.findIndex((tab) => tab.id === tabId);
if (closingIndex < 0) return null;
const tabs = slice.tabs.filter((tab) => tab.id !== tabId);
if (tabs.length === 0) {
return { open: false, tabs: [], activeTabId: null };
}
if (slice.activeTabId !== tabId) {
return { ...slice, tabs };
}
const nextTab = tabs[Math.min(closingIndex, tabs.length - 1)];
return { ...slice, tabs, activeTabId: nextTab.id };
}),
updateBrowserPreviewTab: (tabId, patch) =>
setSessionPreviewSlice(set, (slice) => ({
...slice,
tabs: slice.tabs.map((tab) =>
tab.id === tabId && tab.type === 'browser'
? { ...tab, ...patch }
: tab
),
})),
updateBrowserPreviewTabIn: (projectId, tabId, patch) =>
set((state) => {
const slice = state.sessionPreviewByProject[projectId];
if (!slice) return state;
return {
sessionPreviewByProject: {
...state.sessionPreviewByProject,
[projectId]: {
...slice,
tabs: slice.tabs.map((tab) =>
tab.id === tabId && tab.type === 'browser'
? { ...tab, ...patch }
: tab
),
},
},
};
}),
closeSessionPreview: () =>
setSessionPreviewSlice(set, (slice) => ({ ...slice, open: false })),
resetSessionPreview: () =>
setSessionPreviewSlice(set, () => ({
open: false,
tabs: [],
activeTabId: null,
})),
closeFilePreview: () => set({ filePreviewOpen: false }),
toggleFilePreview: () =>
set((state) => ({ filePreviewOpen: !state.filePreviewOpen })),
}),
{
name: 'eigent-page-tab',
@ -366,6 +879,9 @@ export const usePageTabStore = create<PageTabState>()(
projectSidebarFolded: state.projectSidebarFolded,
customAgentFolderPathByProjectId:
state.customAgentFolderPathByProjectId,
sessionPreviewByProject: sanitizeSessionPreviewForPersist(
state.sessionPreviewByProject
),
}),
}
)

View file

@ -12,15 +12,40 @@
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { beforeEach, describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useProjectStore } from './projectStore';
import { SPACE_SCHEMA_VERSION, useSpaceStore } from './spaceStore';
const { hasActiveSSEConnectionMock } = vi.hoisted(() => ({
hasActiveSSEConnectionMock: vi.fn(),
}));
vi.mock('@/service/spaceApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/spaceApi')>();
return {
...actual,
proxyUpdateSpaceProject: vi.fn().mockResolvedValue({}),
};
});
vi.mock('./chatStore', async (importOriginal) => {
const actual = await importOriginal<typeof import('./chatStore')>();
return {
...actual,
hasActiveSSEConnection: hasActiveSSEConnectionMock,
};
});
describe('projectStore runtime shape', () => {
beforeEach(() => {
vi.clearAllMocks();
hasActiveSSEConnectionMock.mockReturnValue(false);
useProjectStore.setState({
activeProjectId: null,
projects: {},
navLeadByProjectId: {},
historyLoadingProjectIds: {},
staleProjectIds: new Set(),
});
useSpaceStore.setState({
activeSpaceId: 'space_test',
@ -66,4 +91,103 @@ describe('projectStore runtime shape', () => {
expect(tasks.task_b).toBeDefined();
expect(firstRun?.chatStore.getState().activeTaskId).toBe('task_b');
});
it('stores and returns the per-project model selection', () => {
const projectId = useProjectStore
.getState()
.createProject('Test Project', undefined, 'project_model_test');
expect(useProjectStore.getState().getProjectModel(projectId)).toBeNull();
useProjectStore.getState().setProjectModel(projectId, {
modelType: 'cloud',
cloud_model_type: 'model_a',
model_platform: 'platform_a',
model_type: 'model_a',
});
const selection = useProjectStore.getState().getProjectModel(projectId);
expect(selection).toEqual({
modelType: 'cloud',
cloud_model_type: 'model_a',
model_platform: 'platform_a',
model_type: 'model_a',
});
// The pin must also reach the persisted space meta so it survives an
// app restart (the runtime project store is not persisted).
const meta = useSpaceStore.getState().getProjectMeta(projectId);
expect(meta?.metadata?.modelSelection).toEqual(selection);
});
it('falls back to the space meta when the runtime project is gone', () => {
const projectId = useProjectStore
.getState()
.createProject('Test Project', undefined, 'project_meta_fallback');
useProjectStore.getState().setProjectModel(projectId, {
modelType: 'custom',
provider_id: 7,
model_platform: 'platform_b',
model_type: 'model_b',
});
// Simulate a restart: runtime projects are wiped, space meta persists.
useProjectStore.setState({ activeProjectId: null, projects: {} });
const selection = useProjectStore.getState().getProjectModel(projectId);
expect(selection).toEqual({
modelType: 'custom',
provider_id: 7,
model_platform: 'platform_b',
model_type: 'model_b',
});
});
it('keeps stale project runtime while one of its tasks has an active SSE stream', () => {
const projectId = useProjectStore
.getState()
.createProject('Stale Project', undefined, 'project_stale_live');
useProjectStore.getState().appendInitChatStore(projectId, 'task_live');
useProjectStore.setState({
staleProjectIds: new Set([projectId]),
});
hasActiveSSEConnectionMock.mockImplementation((taskIds: string[]) =>
taskIds.includes('task_live')
);
useProjectStore.getState()._evictStaleOnTransition('project_next');
expect(useProjectStore.getState().projects[projectId]).toBeDefined();
expect(useProjectStore.getState().staleProjectIds.has(projectId)).toBe(
true
);
expect(hasActiveSSEConnectionMock).toHaveBeenCalledWith(
expect.arrayContaining(['task_live'])
);
});
it('evicts a stale project runtime on a later transition after active SSE is gone', () => {
const projectId = useProjectStore
.getState()
.createProject('Stale Project', undefined, 'project_stale_safe');
useProjectStore.getState().appendInitChatStore(projectId, 'task_finished');
useProjectStore.setState({
staleProjectIds: new Set([projectId]),
});
hasActiveSSEConnectionMock.mockReturnValue(false);
useProjectStore.getState()._evictStaleOnTransition('project_next');
expect(useProjectStore.getState().projects[projectId]).toBeUndefined();
expect(useProjectStore.getState().staleProjectIds.has(projectId)).toBe(
false
);
expect(useSpaceStore.getState().getProjectMeta(projectId)).toBeDefined();
expect(hasActiveSSEConnectionMock).toHaveBeenCalledWith(
expect.arrayContaining(['task_finished'])
);
});
});

View file

@ -24,6 +24,7 @@ import type { SessionNavLeadPresentation } from '@/lib/sessionNavLead';
import { getSessionNavLeadPresentation } from '@/lib/sessionNavLead';
import { isPlaceholderProjectName } from '@/lib/spaceLabel';
import type { ServerProject } from '@/service/spaceApi';
import { proxyUpdateSpaceProject } from '@/service/spaceApi';
import {
ChatTaskStatus,
TaskStatus,
@ -31,7 +32,11 @@ import {
} from '@/types/constants';
import { create } from 'zustand';
import { getAuthStore } from './authStore';
import { createChatStoreInstance, VanillaChatStore } from './chatStore';
import {
createChatStoreInstance,
hasActiveSSEConnection,
VanillaChatStore,
} from './chatStore';
import {
projectMetaFromServer,
useSpaceStore,
@ -134,6 +139,21 @@ interface TaskQueue {
processing?: boolean;
}
/**
* Model selection captured for a Project so follow-up runs reuse the same
* model instead of the global default. Field names mirror the provider /
* history payloads (`model_platform`, `model_type`) and authStore state
* (`modelType`, `cloud_model_type`, `codex_model_type`).
*/
interface ProjectModelSelection {
modelType: 'cloud' | 'local' | 'custom' | 'codex_subscription';
cloud_model_type?: string;
codex_model_type?: string;
provider_id?: number;
model_platform?: string;
model_type?: string;
}
interface ProjectMetadata {
tags?: string[];
priority?: 'low' | 'medium' | 'high';
@ -154,6 +174,8 @@ interface ProjectMetadata {
*/
historyId?: string;
historyDisplayName?: string;
/** Per-Project model pin; reused by startTask for follow-up runs. */
modelSelection?: ProjectModelSelection;
serverSynced?: boolean;
autoCreatedPlaceholder?: boolean;
}
@ -426,6 +448,13 @@ interface ProjectStore {
//History ID
setHistoryId: (projectId: string, historyId: string) => void;
getHistoryId: (projectId: string | null) => string | null;
// Per-Project model selection
setProjectModel: (
projectId: string,
modelSelection: ProjectModelSelection
) => void;
getProjectModel: (projectId: string | null) => ProjectModelSelection | null;
}
// Helper function to check if a project is empty/unused
@ -1042,6 +1071,18 @@ const projectStore = create<ProjectStore>()((set, get) => ({
) {
return;
}
// Never evict a project that still has a live run. Eviction drops the
// runtime chat stores, so returning to the project rebuilds it from
// history and replays the ongoing task id -- which aborts the live
// run's stream and kills the run on the backend. Keep the stale flag
// so the eviction simply happens on a later, safe transition.
const outgoingProject = get().projects[previousProjectId];
const outgoingTaskIds = Object.values(
outgoingProject?.chatStores ?? {}
).flatMap((chatStore) => Object.keys(chatStore.getState().tasks));
if (hasActiveSSEConnection(outgoingTaskIds)) {
return;
}
// _evictProjectRuntime handles staleProjectIds cleanup itself.
get()._evictProjectRuntime(previousProjectId);
},
@ -1987,6 +2028,84 @@ const projectStore = create<ProjectStore>()((set, get) => ({
return project.metadata?.historyId || null;
},
setProjectModel: (
projectId: string,
modelSelection: ProjectModelSelection
) => {
const { projects } = get();
if (!projects[projectId]) {
console.warn(`Project ${projectId} not found for setting model`);
return;
}
const previous = projects[projectId].metadata?.modelSelection;
if (
previous &&
previous.modelType === modelSelection.modelType &&
previous.cloud_model_type === modelSelection.cloud_model_type &&
previous.codex_model_type === modelSelection.codex_model_type &&
previous.provider_id === modelSelection.provider_id &&
previous.model_platform === modelSelection.model_platform &&
previous.model_type === modelSelection.model_type
) {
return;
}
set((state) => ({
projects: {
...state.projects,
[projectId]: {
...state.projects[projectId],
metadata: {
...state.projects[projectId].metadata,
modelSelection,
},
updatedAt: Date.now(),
},
},
}));
const updatedProject = get().projects[projectId];
if (updatedProject) {
upsertSpaceProjectMetaFromProject(updatedProject);
}
// Server metadata is shallow-merged, so persisting only the pin keeps the
// selection across restarts and space re-syncs (best-effort).
const spaceId =
updatedProject?.spaceId ??
useSpaceStore.getState().getProjectMeta(projectId)?.spaceId;
if (spaceId) {
void proxyUpdateSpaceProject(spaceId, projectId, {
metadata: { modelSelection },
}).catch((error) => {
console.warn(
`Failed to persist model selection for project ${projectId}:`,
error
);
});
}
},
getProjectModel: (projectId: string | null) => {
if (!projectId) {
return null;
}
const { projects } = get();
const runtimeSelection = projects[projectId]?.metadata?.modelSelection;
if (runtimeSelection) {
return runtimeSelection;
}
// Runtime store is not persisted; fall back to the space meta, which is
// persisted locally and hydrated from the server.
return (
useSpaceStore.getState().getProjectMeta(projectId)?.metadata
?.modelSelection ?? null
);
},
isEmptyProject: (project: Project) => {
return isEmptyProject(project);
},
@ -2068,6 +2187,7 @@ export type {
CreateProjectOptions,
Project,
ProjectMetadata,
ProjectModelSelection,
ProjectStore,
TaskQueue,
};

Some files were not shown because too many files have changed in this diff Show more