mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-14 19:23:32 +00:00
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:
commit
ff57b79bdf
146 changed files with 8101 additions and 984 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
173
src/components/ChatBox/BottomBox/ApprovalModeSelect.tsx
Normal file
173
src/components/ChatBox/BottomBox/ApprovalModeSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
src/components/ChatBox/BottomBox/BoxFooter.tsx
Normal file
80
src/components/ChatBox/BottomBox/BoxFooter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
358
src/components/ChatBox/BottomBox/PickerPanel.tsx
Normal file
358
src/components/ChatBox/BottomBox/PickerPanel.tsx
Normal 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')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
147
src/components/ChatBox/BottomBox/ThinkingEffortSelect.tsx
Normal file
147
src/components/ChatBox/BottomBox/ThinkingEffortSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
287
src/components/Session/PreviewPanel/index.tsx
Normal file
287
src/components/Session/PreviewPanel/index.tsx
Normal 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;
|
||||
77
src/components/Session/PreviewPanel/tabKinds.tsx
Normal file
77
src/components/Session/PreviewPanel/tabKinds.tsx
Normal 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];
|
||||
}
|
||||
91
src/components/Session/PreviewPanel/tabs/CanvasTab.tsx
Normal file
91
src/components/Session/PreviewPanel/tabs/CanvasTab.tsx
Normal 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;
|
||||
84
src/components/Session/PreviewPanel/tabs/ChooserTab.tsx
Normal file
84
src/components/Session/PreviewPanel/tabs/ChooserTab.tsx
Normal 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;
|
||||
35
src/components/Session/PreviewPanel/tabs/FileTab.tsx
Normal file
35
src/components/Session/PreviewPanel/tabs/FileTab.tsx
Normal 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;
|
||||
25
src/components/Session/PreviewPanel/tabs/ReviewTab.tsx
Normal file
25
src/components/Session/PreviewPanel/tabs/ReviewTab.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
297
src/components/Session/PreviewPanel/tabs/browser/BrowserTab.tsx
Normal file
297
src/components/Session/PreviewPanel/tabs/browser/BrowserTab.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
190
src/components/TopBar/UpdateButton.tsx
Normal file
190
src/components/TopBar/UpdateButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
43
src/hooks/useIsCompactWidth.ts
Normal file
43
src/hooks/useIsCompactWidth.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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": "تصغير الخطة",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "جارٍ التكوين...",
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 d’objet 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...",
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "計画を最小化",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "設定中...",
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "계획 최소화",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "구성 중...",
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "Свернуть план",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "Конфигурирование...",
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "最小化计划",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "正在配置...",
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "最小化計劃",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "正在配置...",
|
||||
|
||||
|
|
|
|||
|
|
@ -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
109
src/lib/browserUrl.ts
Normal 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
62
src/lib/connectorIcons.ts
Normal 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
169
src/lib/modelConfig.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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>`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue