update workspace page structure with subpage content

This commit is contained in:
Douglas 2026-05-07 16:22:16 +01:00
parent 913cebafd4
commit 2e712e013f
10 changed files with 1098 additions and 561 deletions

View file

@ -263,7 +263,7 @@ export function UserMessageCard({
<UserMessageRichContent content={content} variant="card" />
{canClamp && !expanded && (
<div
className="inset-x-0 bottom-0 h-14 bg-ds-bg-neutral-default-default pointer-events-none absolute z-[1]"
className="inset-x-0 bottom-0 h-14 bg-ds-bg-neutral-subtle-default pointer-events-none absolute z-[1]"
style={USER_MESSAGE_FOLD_FADE_STYLE}
aria-hidden
/>

View file

@ -1,243 +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 {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { usePageTabStore } from '@/store/pageTabStore';
import { motion } from 'framer-motion';
import { PenLine, ScrollText } from 'lucide-react';
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { PROJECT_SIDEBAR_FOLD_SPRING } from './constants';
import { WORKSPACE_TAB_LABEL_CLASS, workspaceTabButtonClass } from './NavTab';
const MEMORY_STORAGE_KEY = 'eigent-sidebar-instructions-memory-on';
const INSTRUCTIONS_ACCORDION_STORAGE_KEY =
'eigent-sidebar-instructions-accordion-open';
function readMemoryInitial(): boolean {
if (typeof window === 'undefined') return true;
const v = window.localStorage.getItem(MEMORY_STORAGE_KEY);
if (v === null) return true;
return v === 'true';
}
function readInstructionsAccordionPreference(): string | undefined {
if (typeof window === 'undefined') return 'instructions';
const v = window.localStorage.getItem(INSTRUCTIONS_ACCORDION_STORAGE_KEY);
if (v === null) return 'instructions';
return v === 'true' ? 'instructions' : undefined;
}
const accordionItemClass = cn(
'border-none rounded-xl transition-colors',
'data-[state=open]:bg-ds-bg-neutral-subtle-default'
);
const accordionTriggerClass = cn(
workspaceTabButtonClass(false),
'hover:no-underline',
'hover:bg-ds-bg-neutral-subtle-default',
'py-0 min-h-8',
'[&>svg:last-child]:text-ds-icon-neutral-muted-default [&>svg:last-child]:shrink-0'
);
/** Project sidebar: Instructions accordion (NavTab-aligned). */
export function HeaderAction() {
const { t } = useTranslation();
const setActiveWorkspaceTab = usePageTabStore((s) => s.setActiveWorkspaceTab);
const requestWorkspaceChatFocus = usePageTabStore(
(s) => s.requestWorkspaceChatFocus
);
const projectSidebarFolded = usePageTabStore((s) => s.projectSidebarFolded);
const setProjectSidebarFolded = usePageTabStore(
(s) => s.setProjectSidebarFolded
);
const openInstructionsAfterExpandRef = useRef(false);
const [memoryOn, setMemoryOn] = useState(readMemoryInitial);
const [instructionsOpen, setInstructionsOpen] = useState<string | undefined>(
readInstructionsAccordionPreference
);
useLayoutEffect(() => {
if (projectSidebarFolded) {
setInstructionsOpen(undefined);
} else if (openInstructionsAfterExpandRef.current) {
openInstructionsAfterExpandRef.current = false;
setInstructionsOpen('instructions');
} else {
setInstructionsOpen(readInstructionsAccordionPreference());
}
}, [projectSidebarFolded]);
useEffect(() => {
window.localStorage.setItem(MEMORY_STORAGE_KEY, String(memoryOn));
}, [memoryOn]);
useEffect(() => {
if (projectSidebarFolded) return;
window.localStorage.setItem(
INSTRUCTIONS_ACCORDION_STORAGE_KEY,
instructionsOpen === 'instructions' ? 'true' : 'false'
);
}, [instructionsOpen, projectSidebarFolded]);
const handleImportWorkforceTemplate = useCallback(() => {
setActiveWorkspaceTab('workforce');
}, [setActiveWorkspaceTab]);
const handleEditInstructions = useCallback(() => {
requestWorkspaceChatFocus();
}, [requestWorkspaceChatFocus]);
const instructionsLabel = t('layout.instructions', {
defaultValue: 'Instructions',
});
const instructionsHint = t('layout.instructions-rules-tone', {
defaultValue: 'Rules & Tone',
});
const memoryLabel = t('layout.memory', { defaultValue: 'Memory' });
const memoryOnLabel = t('layout.memory-on', { defaultValue: 'On' });
const memoryOffLabel = t('layout.memory-off', { defaultValue: 'Off' });
const workforceSettingLabel = t('layout.workforce-setting', {
defaultValue: 'Workforce Setting',
});
const selectLabel = t('layout.select', { defaultValue: 'Select' });
const editInstructionsLabel = t('layout.edit-instructions', {
defaultValue: 'Edit instructions',
});
return (
<div className="min-w-0 flex items-stretch">
<div className="no-drag min-w-0 w-full overflow-hidden">
<Accordion
type="single"
collapsible
value={instructionsOpen ?? ''}
onValueChange={(v) => {
if (projectSidebarFolded) return;
const next = v || undefined;
setInstructionsOpen(next);
}}
className="w-full"
>
<AccordionItem value="instructions" className={accordionItemClass}>
<AccordionTrigger
className={cn(
accordionTriggerClass,
projectSidebarFolded && '[&>svg:last-child]:hidden'
)}
title={
projectSidebarFolded ? String(instructionsLabel) : undefined
}
onClick={(e) => {
if (!projectSidebarFolded) return;
openInstructionsAfterExpandRef.current = true;
setProjectSidebarFolded(false);
e.preventDefault();
}}
>
<span
className={cn(
'min-w-0 flex flex-1 items-center overflow-hidden',
projectSidebarFolded ? 'gap-0' : 'gap-3'
)}
>
<ScrollText
className="h-4 w-4 text-ds-icon-neutral-muted-default shrink-0"
aria-hidden
/>
<motion.span
className={cn(WORKSPACE_TAB_LABEL_CLASS, 'min-w-0 flex-1')}
initial={false}
animate={{
opacity: projectSidebarFolded ? 0 : 1,
maxWidth: projectSidebarFolded ? 0 : 1600,
}}
transition={PROJECT_SIDEBAR_FOLD_SPRING}
aria-hidden={projectSidebarFolded}
>
{instructionsLabel}
</motion.span>
</span>
</AccordionTrigger>
<AccordionContent className="px-3 py-3">
<div className="-mr-1 gap-3 pl-7 flex flex-col">
<div className="min-w-0 gap-2 rounded-lg hover:bg-ds-bg-neutral-subtle-default hover:ring-ds-bg-neutral-subtle-default hover:ring-offset-ds-bg-neutral-subtle-default flex items-center justify-between hover:ring-2 hover:ring-offset-2">
<span className="min-w-0 text-body-sm font-medium text-ds-text-neutral-muted-default flex-1">
{instructionsHint}
</span>
<Button
type="button"
variant="ghost"
size="xs"
buttonContent="icon-only"
className="no-drag shrink-0"
aria-label={editInstructionsLabel}
onClick={handleEditInstructions}
>
<PenLine className="h-4 w-4 shrink-0" aria-hidden />
</Button>
</div>
<div className="min-w-0 gap-2 rounded-lg hover:bg-ds-bg-neutral-subtle-default hover:ring-ds-bg-neutral-subtle-default hover:ring-offset-ds-bg-neutral-subtle-default flex items-center justify-between hover:ring-2 hover:ring-offset-2">
<span className="min-w-0 text-body-sm font-medium text-ds-text-neutral-muted-default">
{memoryLabel}
</span>
<Button
type="button"
variant="secondary"
size="xs"
className="no-drag shrink-0"
onClick={() => setMemoryOn((v) => !v)}
aria-pressed={memoryOn}
aria-label={`${memoryLabel}: ${memoryOn ? memoryOnLabel : memoryOffLabel}`}
>
{memoryOn ? memoryOnLabel : memoryOffLabel}
</Button>
</div>
<div className="min-w-0 gap-2 rounded-lg hover:bg-ds-bg-neutral-subtle-default hover:ring-ds-bg-neutral-subtle-default hover:ring-offset-ds-bg-neutral-subtle-default flex items-center justify-between hover:ring-2 hover:ring-offset-2">
<span className="min-w-0 text-body-sm font-medium text-ds-text-neutral-muted-default">
{workforceSettingLabel}
</span>
<Button
type="button"
variant="secondary"
size="xs"
className="no-drag shrink-0"
onClick={handleImportWorkforceTemplate}
aria-label={`${workforceSettingLabel}: ${selectLabel}`}
>
{selectLabel}
</Button>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
);
}

View file

@ -12,14 +12,11 @@
// 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 { cn } from '@/lib/utils';
import { LayoutGrid, Plus } from 'lucide-react';
import { Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { SIDEBAR_TOOLTIP_CONTENT_CLASS } from './constants';
import { NavListSessionRows, type NavListSession } from './NavListSessionRows';
import { NavTab, workspaceTabButtonClass } from './NavTab';
import { NavTab } from './NavTab';
export {
NAV_LIST_SESSIONS_RECENT_MAX,
@ -32,33 +29,26 @@ export interface NavListProps {
activeSessionId?: string | null;
onSessionClick?: (sessionId: string) => void;
onDeleteSession?: (sessionId: string) => void;
/** Top row: workspace tab — switches to workforce view. */
workspaceActive: boolean;
onWorkspaceClick: () => void;
/** Trailing + control (e.g. create task + focus session). */
onNewSession: () => void;
/** Icon-only rail: match other sidebar `NavTab`s. */
folded: boolean;
className?: string;
}
/** Workspace row (split: tab + new session) and a flat scrollable session column. */
/** New Session row and a flat scrollable session column. */
export function NavList({
sessions,
activeSessionId,
onSessionClick,
onDeleteSession,
workspaceActive,
onWorkspaceClick,
onNewSession,
folded,
className,
}: NavListProps) {
const { t } = useTranslation();
const workspaceLabel = t('triggers.workspace');
const newSessionLabel = t('layout.sessions-start-new', {
defaultValue: 'Start new session',
defaultValue: 'New Session',
});
return (
@ -70,62 +60,16 @@ export function NavList({
>
<div className="min-w-0 gap-2 flex w-full flex-col">
<NavTab
layout="split"
active={workspaceActive}
onClick={onWorkspaceClick}
leading={<LayoutGrid className="h-4 w-4 shrink-0" aria-hidden />}
label={workspaceLabel}
endAction={
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="icon-only"
className={cn(
'no-drag mr-1 rounded-xl hover:bg-ds-bg-neutral-strong-default shrink-0',
'focus-visible:ring-ds-border-neutral-default-default focus-visible:z-10 focus-visible:ring-2 focus-visible:outline-none'
)}
aria-label={newSessionLabel}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onNewSession();
}}
>
<Plus
className="h-4 w-4 text-ds-icon-neutral-muted-default"
aria-hidden
/>
</Button>
}
tooltip={workspaceLabel}
active={false}
onClick={onNewSession}
leading={<Plus className="h-4 w-4 shrink-0" aria-hidden />}
label={newSessionLabel}
tooltip={newSessionLabel}
tooltipEnabledWhenCollapsed={!folded}
folded={folded}
ariaLabel={workspaceLabel}
ariaCurrentPage={workspaceActive}
ariaLabel={newSessionLabel}
ariaCurrentPage={false}
/>
{folded ? (
<TooltipSimple
content={newSessionLabel}
side="right"
align="center"
enabled
className={SIDEBAR_TOOLTIP_CONTENT_CLASS}
>
<button
type="button"
onClick={onNewSession}
className={cn(workspaceTabButtonClass(false), 'gap-0 w-full')}
aria-label={newSessionLabel}
>
<Plus
className="h-4 w-4 text-ds-icon-neutral-muted-default shrink-0"
aria-hidden
/>
</button>
</TooltipSimple>
) : null}
</div>
<div

View file

@ -25,12 +25,11 @@ import { usePageTabStore } from '@/store/pageTabStore';
import { useProjectStore } from '@/store/projectStore';
import { useTriggerStore } from '@/store/triggerStore';
import { ChatTaskStatus } from '@/types/constants';
import { Inbox, Plus, Zap, ZapOff } from 'lucide-react';
import { Inbox, LayoutGrid, Plus, Zap, ZapOff } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { HeaderAction } from './HeaderAction';
import { NavList } from './NavList';
import {
NavTab,
@ -59,6 +58,9 @@ export default function ProjectPageSidebar({
}: ProjectPageSidebarProps) {
const activeWorkspaceTab = usePageTabStore((s) => s.activeWorkspaceTab);
const setActiveWorkspaceTab = usePageTabStore((s) => s.setActiveWorkspaceTab);
const requestWorkspaceChatFocus = usePageTabStore(
(s) => s.requestWorkspaceChatFocus
);
const requestOpenTriggerAddDialog = usePageTabStore(
(s) => s.requestOpenTriggerAddDialog
);
@ -257,8 +259,9 @@ export default function ProjectPageSidebar({
const handleNewSession = useCallback(() => {
chatStore.create();
setActiveWorkspaceTab('session');
}, [chatStore, setActiveWorkspaceTab]);
setActiveWorkspaceTab('workforce');
requestWorkspaceChatFocus();
}, [chatStore, setActiveWorkspaceTab, requestWorkspaceChatFocus]);
return (
<>
@ -276,9 +279,20 @@ export default function ProjectPageSidebar({
<div className="min-h-0 min-w-0 flex h-full w-full max-w-full flex-col overflow-x-hidden">
<div className="min-h-0 min-w-0 flex flex-1 flex-col overflow-hidden">
<div className="gap-2 flex w-full shrink-0 flex-col">
<HeaderAction />
<div className="gap-2 min-w-0 flex w-full flex-col">
<NavTab
active={activeWorkspaceTab === 'workforce'}
onClick={() => setActiveWorkspaceTab('workforce')}
leading={
<LayoutGrid className="h-4 w-4 shrink-0" aria-hidden />
}
label="Cowork"
tooltip="Cowork"
tooltipEnabledWhenCollapsed={!projectSidebarFolded}
folded={projectSidebarFolded}
ariaLabel="Cowork"
ariaCurrentPage={activeWorkspaceTab === 'workforce'}
/>
<NavTab
active={activeWorkspaceTab === 'inbox'}
onClick={() => {
@ -403,8 +417,6 @@ export default function ProjectPageSidebar({
setActiveWorkspaceTab('session');
}}
onDeleteSession={handleDeleteSession}
workspaceActive={activeWorkspaceTab === 'workforce'}
onWorkspaceClick={() => setActiveWorkspaceTab('workforce')}
onNewSession={handleNewSession}
folded={projectSidebarFolded}
/>

View file

@ -0,0 +1,83 @@
// ========= 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 {
NavListSessionRows,
type NavListSession,
} from '@/components/ProjectPageSidebar/NavList';
import { taskIdToCreatedMs } from '@/lib/chatTaskIdTime';
import { getSessionNavLeadPresentation } from '@/lib/sessionNavLead';
import type { ChatStore } from '@/store/chatStore';
import { ChatTaskStatus } from '@/types/constants';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
interface WorkspaceAllSessionsProps {
tasks: ChatStore['tasks'];
activeTaskId?: string | null;
onSelectSession: (sessionId: string) => void;
onDeleteSession: (sessionId: string) => void;
}
export function WorkspaceAllSessions({
tasks,
activeTaskId,
onSelectSession,
onDeleteSession,
}: WorkspaceAllSessionsProps) {
const { t } = useTranslation();
const sessions: NavListSession[] = useMemo(() => {
const entries = Object.entries(tasks)
.filter(([, task]) => {
return (
(task.messages?.length || 0) > 0 ||
task.hasMessages ||
task.status !== ChatTaskStatus.PENDING
);
})
.map(([id, task]) => ({
id,
title:
task.summaryTask?.trim() ||
t('layout.sessions-untitled', { defaultValue: 'Untitled session' }),
sessionLead: getSessionNavLeadPresentation(task),
}));
entries.sort((a, b) => taskIdToCreatedMs(b.id) - taskIdToCreatedMs(a.id));
return entries;
}, [tasks, t]);
return (
<div className="min-h-0 min-w-0 flex h-full w-full flex-col overflow-hidden">
<div className="m-0 min-h-0 gap-0.5 p-2 max-w-3xl mx-auto flex w-full flex-1 flex-col overflow-y-auto">
{sessions.length === 0 ? (
<p className="text-ds-text-neutral-muted-default m-0 px-3 py-6 text-body-sm text-center">
{t('layout.sessions-create-task-hint', {
defaultValue: 'Create a task to start a session',
})}
</p>
) : (
<NavListSessionRows
sessions={sessions}
activeSessionId={activeTaskId}
onSessionClick={onSelectSession}
onDeleteSession={onDeleteSession}
folded={false}
panelListHover
/>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,289 @@
// ========= 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 {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { Check, PenLine } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
const ONBOARDING_KEY = 'eigent-workspace-onboarding-checked';
const ONBOARDING_STEPS = [
{
id: 1,
title: 'Connect your everyday tools',
subtitle:
'The more Eigent understands your setup, the more useful it becomes.',
},
{
id: 2,
title: 'Build your workforce team',
subtitle: 'Add workers to shape a team of agents for your projects.',
},
{
id: 3,
title: 'Ask Eigent to create something',
subtitle:
'Ask Eigent to create a spreadsheet, document, presentation, dashboard, or anything else you need.',
},
{
id: 4,
title: 'Schedule a recurring task',
subtitle:
'Turn repeat work into automatic workflows. Great for reminders, reports, check-ins, and regular updates.',
},
] as const;
function readCheckedSteps(): Set<number> {
try {
const v = localStorage.getItem(ONBOARDING_KEY);
return v ? new Set(JSON.parse(v) as number[]) : new Set();
} catch {
return new Set();
}
}
interface StepCardProps {
id: number;
title: string;
subtitle: string;
checked: boolean;
onClick: () => void;
}
function StepCard({ id, title, subtitle, checked, onClick }: StepCardProps) {
return (
<button
type="button"
className={cn(
'group rounded-xl p-2 gap-2 flex w-full items-start text-left transition-colors',
checked
? 'cursor-default'
: 'hover:bg-ds-bg-neutral-strong-default cursor-pointer'
)}
onClick={checked ? undefined : onClick}
aria-pressed={checked}
>
{/* Circle */}
<div
className={cn(
'mt-0.5 h-4 w-4 flex shrink-0 items-center justify-center rounded-full',
checked
? 'bg-ds-bg-status-completed-subtle-default'
: 'bg-ds-bg-neutral-muted-default'
)}
>
{checked ? (
<Check
className="h-2.5 w-2.5 !text-ds-text-status-completed-strong-default"
aria-hidden
/>
) : (
<span className="font-bold text-ds-text-neutral-muted-default text-[8px] leading-none">
{id}
</span>
)}
</div>
{/* Text */}
<div className="min-w-0 flex flex-1 flex-col">
<span
className={cn(
'!text-body-sm font-semibold',
checked
? 'text-ds-text-neutral-muted-default'
: 'text-ds-text-neutral-default-default'
)}
>
{title}
</span>
<span className="mt-1 text-label-xs text-ds-text-neutral-muted-default">
{subtitle}
</span>
</div>
</button>
);
}
export interface WorkspaceCoworkPanelProps {
memoryOn: boolean;
onMemoryToggle: () => void;
onEditInstructions: () => void;
onWorkforceSetting: () => void;
}
export function WorkspaceCoworkPanel({
memoryOn,
onMemoryToggle,
onEditInstructions,
onWorkforceSetting,
}: WorkspaceCoworkPanelProps) {
const { t } = useTranslation();
const [checkedSteps, setCheckedSteps] =
useState<Set<number>>(readCheckedSteps);
const [accordionOpen, setAccordionOpen] = useState<string | undefined>(
undefined
);
const allChecked = checkedSteps.size >= ONBOARDING_STEPS.length;
useEffect(() => {
localStorage.setItem(ONBOARDING_KEY, JSON.stringify([...checkedSteps]));
}, [checkedSteps]);
const handleCheckStep = (id: number) => {
setCheckedSteps((prev) => {
const next = new Set(prev);
next.add(id);
return next;
});
};
const instructionsHint = t('layout.instructions-rules-tone', {
defaultValue: 'Rules & Tone',
});
const memoryLabel = t('layout.memory', { defaultValue: 'Memory' });
const memoryOnLabel = t('layout.memory-on', { defaultValue: 'On' });
const memoryOffLabel = t('layout.memory-off', { defaultValue: 'Off' });
const workforceSettingLabel = t('layout.workforce-setting', {
defaultValue: 'Workforce Setting',
});
const selectLabel = t('layout.select', { defaultValue: 'Select' });
const editInstructionsLabel = t('layout.edit-instructions', {
defaultValue: 'Edit instructions',
});
return (
<div className="flex h-full flex-col overflow-hidden">
{/* Settings area */}
<div className="px-2 py-1 gap-0.5 rounded-2xl bg-ds-bg-neutral-default-default flex shrink-0 flex-col">
{/* Panel title */}
<div className="px-2 py-1.5 shrink-0">
<span className="text-body-sm font-semibold text-ds-text-neutral-default-default">
Welcome to Eigent
</span>
</div>
<div className="min-w-0 gap-2 rounded-lg px-2 py-1.5 hover:bg-ds-bg-neutral-strong-default flex items-center justify-between">
<span className="min-w-0 text-body-sm font-medium text-ds-text-neutral-muted-default flex-1">
{instructionsHint}
</span>
<Button
type="button"
variant="ghost"
size="xs"
buttonContent="icon-only"
className="no-drag shrink-0"
aria-label={editInstructionsLabel}
onClick={onEditInstructions}
>
<PenLine className="h-4 w-4 shrink-0" aria-hidden />
</Button>
</div>
<div className="min-w-0 gap-2 rounded-lg px-2 py-1.5 hover:bg-ds-bg-neutral-strong-default flex items-center justify-between">
<span className="min-w-0 text-body-sm font-medium text-ds-text-neutral-muted-default">
{memoryLabel}
</span>
<Button
type="button"
variant="secondary"
size="xs"
className="no-drag shrink-0"
onClick={onMemoryToggle}
aria-pressed={memoryOn}
aria-label={`${memoryLabel}: ${memoryOn ? memoryOnLabel : memoryOffLabel}`}
>
{memoryOn ? memoryOnLabel : memoryOffLabel}
</Button>
</div>
<div className="min-w-0 gap-2 rounded-lg px-2 py-1.5 hover:bg-ds-bg-neutral-strong-default flex items-center justify-between">
<span className="min-w-0 text-body-sm font-medium text-ds-text-neutral-muted-default">
{workforceSettingLabel}
</span>
<Button
type="button"
variant="secondary"
size="xs"
className="no-drag shrink-0"
onClick={onWorkforceSetting}
aria-label={`${workforceSettingLabel}: ${selectLabel}`}
>
{selectLabel}
</Button>
</div>
</div>
{/* Scrollable onboarding area */}
<div className="min-h-0 py-2 flex flex-col">
{allChecked ? (
<Accordion
type="single"
collapsible
value={accordionOpen ?? ''}
onValueChange={(v) => setAccordionOpen(v || undefined)}
>
<AccordionItem
value="onboarding"
className="rounded-xl data-[state=open]:bg-ds-bg-neutral-default-default overflow-hidden border-none"
>
<AccordionTrigger className="px-4 py-2.5 text-body-sm font-medium hover:bg-ds-bg-neutral-strong-default rounded-xl [&>svg]:text-ds-icon-neutral-muted-default hover:no-underline data-[state=open]:rounded-b-none">
<div className="gap-2 min-w-0 flex items-center">
<span className="text-ds-text-neutral-default-default truncate">
Getting started
</span>
<span className="text-body-xs text-ds-text-neutral-muted-default shrink-0">
{checkedSteps.size}/{ONBOARDING_STEPS.length}
</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-2">
<div className="gap-1 pb-1 flex flex-col">
{ONBOARDING_STEPS.map((step) => (
<StepCard
key={step.id}
id={step.id}
title={step.title}
subtitle={step.subtitle}
checked={true}
onClick={() => {}}
/>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
) : (
<div className="gap-1 flex flex-col">
{ONBOARDING_STEPS.map((step) => (
<StepCard
key={step.id}
id={step.id}
title={step.title}
subtitle={step.subtitle}
checked={checkedSteps.has(step.id)}
onClick={() => handleCheckStep(step.id)}
/>
))}
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,191 @@
// ========= 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 larkIcon from '@/assets/icon/lark.png';
import telegramIcon from '@/assets/icon/telegram.svg';
import whatsappIcon from '@/assets/icon/whatsapp.svg';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import type { WebSocketConnectionStatus } from '@/store/triggerStore';
import { useTriggerStore } from '@/store/triggerStore';
import { Copy, MonitorSmartphone } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
function statusDotClass(status: WebSocketConnectionStatus): string {
switch (status) {
case 'connected':
return 'bg-green-500';
case 'connecting':
return 'bg-yellow-400 animate-pulse';
case 'unhealthy':
case 'disconnected':
default:
return 'bg-red-500';
}
}
interface DispatchChannelCardProps {
name: string;
icon?: string;
leading?: ReactNode;
disabled?: boolean;
connectionStatus?: WebSocketConnectionStatus;
badgeText?: string;
action?: {
label: string;
icon?: ReactNode;
onClick: () => void;
};
}
function DispatchChannelCard({
name,
icon,
leading,
disabled,
connectionStatus,
badgeText,
action,
}: DispatchChannelCardProps) {
return (
<div
className={[
'gap-3 p-4 border-ds-border-neutral-subtle-default rounded-2xl bg-ds-bg-neutral-default-default border',
'min-h-40 flex flex-col justify-between',
disabled
? 'pointer-events-none cursor-not-allowed opacity-50 select-none'
: '',
].join(' ')}
>
<div className="gap-2 min-w-0 flex w-full items-center justify-between">
<div className="gap-2 flex items-center">
{leading}
{icon && (
<img
src={icon}
alt=""
className="h-5 w-5 rounded shrink-0 object-contain"
aria-hidden
/>
)}
<span className="text-body-sm font-semibold text-ds-text-neutral-default-default truncate">
{name}
</span>
</div>
{connectionStatus && (
<span
className={`h-2.5 w-2.5 shrink-0 rounded-full ${statusDotClass(connectionStatus)}`}
aria-hidden
/>
)}
</div>
<div className="gap-2 flex items-center justify-between">
{badgeText ? (
<Badge variant="secondary" size="xs">
{badgeText}
</Badge>
) : (
<div />
)}
{action && (
<Button
type="button"
variant="secondary"
size="xs"
buttonContent="text"
className="no-drag gap-1.5 shrink-0"
onClick={action.onClick}
>
{action.icon}
{action.label}
</Button>
)}
</div>
</div>
);
}
export function WorkspaceDispatch() {
const { t } = useTranslation();
const wsConnectionStatus = useTriggerStore((s) => s.wsConnectionStatus);
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(window.location.href);
toast.success(
t('layout.dispatch-link-copied', { defaultValue: 'Link copied' })
);
} catch {
toast.error(
t('layout.dispatch-copy-failed', {
defaultValue: 'Failed to copy link',
})
);
}
};
return (
<div className="min-h-0 flex h-full w-full flex-col overflow-y-auto">
<div className="gap-3 p-4 max-w-3xl mx-auto grid h-full w-full grid-cols-2 grid-rows-2">
<DispatchChannelCard
name={t('layout.workspace-work-with-remote-control', {
defaultValue: 'Remote control',
})}
leading={
<MonitorSmartphone
className="h-4 w-4 text-ds-text-neutral-muted-default shrink-0"
aria-hidden
/>
}
connectionStatus={wsConnectionStatus}
action={{
label: t('layout.dispatch-copy-link', {
defaultValue: 'Copy link',
}),
icon: <Copy className="h-3.5 w-3.5" aria-hidden />,
onClick: handleCopyLink,
}}
/>
<DispatchChannelCard
icon={telegramIcon}
name={t('layout.channels-telegram', { defaultValue: 'Telegram' })}
disabled
badgeText={t('layout.dispatch-coming-soon', {
defaultValue: 'Coming soon',
})}
/>
<DispatchChannelCard
icon={larkIcon}
name={t('layout.channels-lark', { defaultValue: 'Lark' })}
disabled
badgeText={t('layout.dispatch-coming-soon', {
defaultValue: 'Coming soon',
})}
/>
<DispatchChannelCard
icon={whatsappIcon}
name={t('layout.channels-whatsapp', { defaultValue: 'WhatsApp' })}
disabled
badgeText={t('layout.dispatch-coming-soon', {
defaultValue: 'Coming soon',
})}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,66 @@
// ========= 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, useState } from 'react';
import { useTranslation } from 'react-i18next';
interface WorkspaceInstructionMdProps {
projectId: string;
}
function storageKey(projectId: string) {
return `eigent-instructions-md-${projectId}`;
}
export function WorkspaceInstructionMd({
projectId,
}: WorkspaceInstructionMdProps) {
const { t } = useTranslation();
const [content, setContent] = useState<string>(
() => localStorage.getItem(storageKey(projectId)) ?? ''
);
useEffect(() => {
localStorage.setItem(storageKey(projectId), content);
}, [content, projectId]);
useEffect(() => {
const onSave = (e: Event) => {
const custom = e as CustomEvent<{ projectId?: string }>;
if (!custom.detail?.projectId || custom.detail.projectId !== projectId)
return;
localStorage.setItem(storageKey(projectId), content);
};
window.addEventListener('workspace-instruction-md-save', onSave);
return () => {
window.removeEventListener('workspace-instruction-md-save', onSave);
};
}, [content, projectId]);
return (
<div className="min-h-0 flex h-full w-full flex-col items-center justify-center overflow-hidden">
<textarea
className="min-h-0 p-6 font-mono text-body-sm text-ds-text-neutral-default-default placeholder:text-ds-text-neutral-muted-default max-w-3xl w-full flex-1 resize-none border-none bg-transparent outline-none"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={t('layout.instruction-md-placeholder', {
defaultValue:
'Write your rules & tone instructions here in Markdown...',
})}
spellCheck={false}
/>
</div>
);
}

View file

@ -23,17 +23,23 @@ import { BASE_WORKFLOW_AGENTS } from '@/components/WorkFlow/baseWorkers';
import { isBaseWorkflowAgent } from '@/components/Workspace/FoldedAgentCard';
import { SingleAgentList } from '@/components/Workspace/SingleAgentList';
import { WorkforceAgentList } from '@/components/Workspace/WorkforceAgentList';
import { WorkspaceAllSessions } from '@/components/Workspace/WorkspaceAllSessions';
import { WorkspaceCoworkPanel } from '@/components/Workspace/WorkspaceCoworkPanel';
import { WorkspaceDispatch } from '@/components/Workspace/WorkspaceDispatch';
import { WorkspaceExamplePrompts } from '@/components/Workspace/WorkspaceExamplePrompts';
import { WorkspaceInstructionMd } from '@/components/Workspace/WorkspaceInstructionMd';
import { WorkspaceProjectPicker } from '@/components/Workspace/WorkspaceProjectPicker';
import { WorkspaceRecentSessions } from '@/components/Workspace/WorkspaceRecentSessions';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import { useModelConfigCheck } from '@/hooks/useModelConfigCheck';
import { useHost } from '@/host';
import { cn } from '@/lib/utils';
import { useAuthStore, useWorkerList } from '@/store/authStore';
import { usePageTabStore } from '@/store/pageTabStore';
import { useProjectStore } from '@/store/projectStore';
import { SessionMode } from '@/types/constants';
import { Cast, MonitorSmartphone } from 'lucide-react';
import { AnimatePresence, motion } from 'framer-motion';
import { ArrowLeft, Cast, MonitorSmartphone, ScrollText } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
@ -41,6 +47,15 @@ import { toast } from 'sonner';
const EMPTY_TASK_ASSIGNING: Agent[] = [];
const MEMORY_STORAGE_KEY = 'eigent-sidebar-instructions-memory-on';
function readMemoryInitial(): boolean {
if (typeof window === 'undefined') return true;
const v = window.localStorage.getItem(MEMORY_STORAGE_KEY);
if (v === null) return true;
return v === 'true';
}
/**
* Workspace tab: project landing with a centered task input.
* After the user starts a task, it switches to the session tab.
@ -69,6 +84,10 @@ export default function Workspace() {
return true;
}, [activeProject, customAgentFolderPath, isEmptyProject]);
const setActiveWorkspaceTab = usePageTabStore((s) => s.setActiveWorkspaceTab);
const activeWorkspaceTab = usePageTabStore((s) => s.activeWorkspaceTab);
const workspaceChatFocusRequestId = usePageTabStore(
(s) => s.workspaceChatFocusRequestId
);
const sessionSidePanelMode = usePageTabStore(
(s) => s.sessionSidePanelMode ?? SessionMode.WORKFORCE
);
@ -85,18 +104,47 @@ export default function Workspace() {
const [editingWorkerAgent, setEditingWorkerAgent] = useState<Agent | null>(
null
);
const [workspaceWorkWithPanelOpen, setWorkspaceWorkWithPanelOpen] =
useState(false);
const [leftPanelTab, setLeftPanelTab] = useState<
'instructions' | 'workWith' | null
>(null);
type WorkspaceSubPage = 'all-sessions' | 'instruction-md' | 'dispatch' | null;
const [workspaceSubPage, setWorkspaceSubPage] =
useState<WorkspaceSubPage>(null);
const SUB_PAGE_TITLES: Record<NonNullable<WorkspaceSubPage>, string> = {
'all-sessions': t('layout.sessions-full-title', {
defaultValue: 'All sessions',
}),
'instruction-md': t('layout.instructions-rules-tone', {
defaultValue: 'Rules & Tone',
}),
dispatch: t('layout.workspace-work-with-title', {
defaultValue: 'Dispatch',
}),
};
const [memoryOn, setMemoryOn] = useState(readMemoryInitial);
const textareaRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!workspaceWorkWithPanelOpen) return;
window.localStorage.setItem(MEMORY_STORAGE_KEY, String(memoryOn));
}, [memoryOn]);
useEffect(() => {
if (workspaceChatFocusRequestId === 0) return;
if (activeWorkspaceTab !== 'workforce') return;
const focusTimer = window.setTimeout(() => {
textareaRef.current?.focus();
}, 180);
return () => window.clearTimeout(focusTimer);
}, [workspaceChatFocusRequestId, activeWorkspaceTab]);
useEffect(() => {
if (!leftPanelTab) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setWorkspaceWorkWithPanelOpen(false);
if (e.key === 'Escape') setLeftPanelTab(null);
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [workspaceWorkWithPanelOpen]);
}, [leftPanelTab]);
useEffect(() => {
if (
@ -264,257 +312,404 @@ export default function Workspace() {
const activeAgentId = chatStore.tasks[chatStore.activeTaskId]?.activeAgent;
const workWithPanelToggleLabel = workspaceWorkWithPanelOpen
? t('layout.workspace-work-with-panel-hide', {
defaultValue: 'Hide Work with panel',
})
: t('layout.workspace-work-with-panel-show', {
defaultValue: 'Show Work with panel',
});
const instructionsLabel = t('layout.instructions', {
defaultValue: 'Instructions',
});
const workWithLabel = t('layout.workspace-work-with-title', {
defaultValue: 'Work with',
});
return (
<div className="min-h-0 relative flex h-full w-full flex-col">
<div className="px-3 relative z-50 flex h-[44px] w-full shrink-0 flex-row items-center justify-start">
<TooltipSimple content={workWithPanelToggleLabel} delayDuration={300}>
<div className="min-h-0 flex h-full w-full flex-col">
{/* Header toolbar */}
<div className="px-3 gap-1 relative flex h-[44px] w-full shrink-0 flex-row items-center justify-start">
{workspaceSubPage !== null && (
<>
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="text"
onClick={() => setWorkspaceSubPage(null)}
className="no-drag shrink-0"
aria-label={t('layout.back-to-workspace-tooltip', {
defaultValue: 'Back to workspace',
})}
>
<ArrowLeft aria-hidden />
Back
</Button>
</>
)}
{workspaceSubPage !== null && (
<div className="pointer-events-none absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<span className="!text-label-sm font-semibold text-ds-text-neutral-default-default block max-w-[60vw] truncate text-center">
{SUB_PAGE_TITLES[workspaceSubPage]}
</span>
</div>
)}
<div className="flex-1" />
{workspaceSubPage === 'instruction-md' && activeProjectId && (
<Button
type="button"
variant="ghost"
variant="primary"
size="sm"
buttonContent="icon-only"
onClick={() => setWorkspaceWorkWithPanelOpen((open) => !open)}
aria-expanded={workspaceWorkWithPanelOpen}
aria-controls="workspace-work-with-panel"
className="no-drag text-ds-text-neutral-muted-default hover:bg-ds-bg-neutral-strong-default shrink-0"
aria-label={workWithPanelToggleLabel}
buttonContent="text"
className="no-drag shrink-0"
onClick={() => {
window.dispatchEvent(
new CustomEvent('workspace-instruction-md-save', {
detail: { projectId: activeProjectId },
})
);
}}
>
<Cast className="h-4 w-4" aria-hidden />
Save
</Button>
</TooltipSimple>
</div>
<div className="min-h-0 relative z-0 flex w-full flex-1 flex-col items-stretch overflow-hidden">
<div className="min-h-0 px-3 flex w-full flex-1 flex-col">
<div className="mx-auto flex w-full max-w-[600px] shrink-0 flex-col">
<div className="min-w-0 flex min-h-[50vh] w-full flex-col justify-end">
<div className="mb-8 flex w-full justify-center">
<WorkspaceProjectPicker />
</div>
<span className="mb-8 text-heading-lg font-bold text-ds-text-neutral-default-default w-full text-center">
{sessionSidePanelMode === SessionMode.SINGLE_AGENT
? t('layout.workspace-cowork-single-agent', {
defaultValue: 'Cowork with Single Agent',
})
: t('layout.workspace-cowork-workforce', {
defaultValue: 'Cowork with Workforce',
})}
</span>
<div className="mb-8 px-5 flex w-full justify-center">
{sessionSidePanelMode === SessionMode.SINGLE_AGENT ? (
<SingleAgentList />
) : (
<WorkforceAgentList
sortedAgents={sortedAgents}
activeAgentId={activeAgentId}
onSelectAgent={onSelectAgent}
onEditWorkerFromMenu={onEditWorkerFromMenu}
onDuplicateUserAgent={onDuplicateUserAgent}
onDeleteUserAgent={onDeleteUserAgent}
onAddWorker={() => setAddWorkerDialogOpen(true)}
/>
)}
{workspaceSubPage === null && (
<>
<TooltipSimple content={instructionsLabel} delayDuration={300}>
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="icon-only"
onClick={() =>
setLeftPanelTab((prev) =>
prev === 'instructions' ? null : 'instructions'
)
}
aria-expanded={leftPanelTab === 'instructions'}
aria-controls="workspace-right-panel"
className={cn(
'no-drag shrink-0',
leftPanelTab === 'instructions'
? 'bg-ds-bg-neutral-strong-default'
: 'hover:bg-ds-bg-neutral-strong-default'
)}
</div>
<div className="w-full">
<BottomBox
state="input"
queuedMessages={[]}
onRemoveQueuedMessage={() => {}}
noModelOverlay={!hasModel}
onSelectModel={() => navigate('/history?tab=agents')}
inputProps={{
value: message,
onChange: setMessage,
onSend: handleSend,
files:
chatStore.tasks[chatStore.activeTaskId]?.attaches?.map(
(f) => ({
fileName: f.fileName,
filePath: f.filePath,
})
) || [],
onFilesChange: (files) =>
chatStore.setAttaches(
chatStore.activeTaskId as string,
files as any
),
onAddFile: handleFileSelect,
disabled: !hasModel,
textareaRef,
allowDragDrop: true,
useCloudModelInDev,
placeholder: t('layout.project-task-placeholder', {
defaultValue: 'Describe what you want to accomplish...',
}),
sessionMode: sessionSidePanelMode,
onSessionModeChange: setSessionSidePanelMode,
sessionModeSelectInteractive: true,
}}
/>
</div>
<AddWorker
isOpen={addWorkerDialogOpen}
onOpenChange={setAddWorkerDialogOpen}
/>
{editingWorkerAgent && (
<AddWorker
edit
workerInfo={editingWorkerAgent}
isOpen={true}
onOpenChange={(open) => {
if (!open) setEditingWorkerAgent(null);
}}
/>
aria-label={instructionsLabel}
>
<ScrollText aria-hidden />
</Button>
</TooltipSimple>
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="text"
tone="default"
textWeight="medium"
onClick={() =>
setLeftPanelTab((prev) =>
prev === 'workWith' ? null : 'workWith'
)
}
aria-expanded={leftPanelTab === 'workWith'}
aria-controls="workspace-right-panel"
className={cn(
'no-drag !text-label-sm gap-2 shrink-0',
leftPanelTab === 'workWith'
? 'bg-ds-bg-neutral-subtle-default'
: 'hover:bg-ds-bg-neutral-subtle-hover'
)}
</div>
</div>
<div
className="min-h-0 pt-6 flex w-full flex-1 flex-col overflow-y-auto"
id="workspace-bottom-group"
>
{showWorkspaceExamplePrompts ? (
<WorkspaceExamplePrompts
onSelectPrompt={setMessage}
disabled={!hasModel}
/>
) : (
<WorkspaceRecentSessions
tasks={chatStore.tasks}
activeTaskId={chatStore.activeTaskId}
onSelectSession={(id) => {
chatStore.setActiveTaskId(id);
setActiveWorkspaceTab('session');
}}
onOpenAllSessions={() => setActiveWorkspaceTab('sessions')}
/>
)}
</div>
</div>
aria-label="Dispatch"
>
<Cast aria-hidden />
Dispatch
</Button>
</>
)}
</div>
{workspaceWorkWithPanelOpen ? (
<>
<button
type="button"
className="inset-0 absolute z-40 cursor-default bg-transparent backdrop-blur-[1px]"
aria-label={t('layout.workspace-work-with-dismiss-overlay', {
defaultValue: 'Dismiss',
})}
onClick={() => setWorkspaceWorkWithPanelOpen(false)}
{/* Body: main content + right panel */}
<div className="min-h-0 flex flex-1 flex-row overflow-hidden">
{/* Sub-pages */}
{workspaceSubPage === 'all-sessions' && (
<WorkspaceAllSessions
tasks={chatStore.tasks}
activeTaskId={chatStore.activeTaskId}
onSelectSession={(id) => {
chatStore.setActiveTaskId(id);
setActiveWorkspaceTab('session');
setWorkspaceSubPage(null);
}}
onDeleteSession={(id) => {
if (!window.confirm(t('layout.delete-task-confirmation'))) return;
const wasActive = chatStore.activeTaskId === id;
chatStore.removeTask(id);
if (wasActive) setActiveWorkspaceTab('workforce');
setWorkspaceSubPage(null);
}}
/>
<div
id="workspace-work-with-panel"
role="dialog"
aria-modal="true"
aria-labelledby="workspace-work-with-heading"
className="left-0 top-8 ease-out animate-in fade-in-0 slide-in-from-left-2 absolute z-50 flex max-h-[calc(100%-2.75rem)] w-[300px] flex-col overflow-y-auto duration-200"
>
<div className="gap-3 p-3 flex flex-col">
<div className="min-w-0 rounded-xl border-ds-border-neutral-subtle-default bg-ds-bg-neutral-subtle-default p-3 flex flex-col border border-solid">
<span
id="workspace-work-with-heading"
className="text-body-sm font-semibold text-ds-text-neutral-default-default"
)}
{workspaceSubPage === 'instruction-md' && activeProjectId && (
<WorkspaceInstructionMd
key={activeProjectId}
projectId={activeProjectId}
/>
)}
{workspaceSubPage === 'dispatch' && <WorkspaceDispatch />}
{/* Main content + right panel (hidden when sub-page is active) */}
{workspaceSubPage === null && (
<>
<div className="min-h-0 min-w-0 relative z-0 flex flex-1 flex-col items-stretch overflow-hidden">
<div className="min-h-0 px-3 flex w-full flex-1 flex-col">
<div className="mx-auto flex w-full max-w-[600px] shrink-0 flex-col">
<div className="min-w-0 flex min-h-[50vh] w-full flex-col justify-end">
<div className="mb-8 flex w-full justify-center">
<WorkspaceProjectPicker />
</div>
<span className="mb-8 text-heading-lg font-bold text-ds-text-neutral-default-default w-full text-center">
{sessionSidePanelMode === SessionMode.SINGLE_AGENT
? t('layout.workspace-cowork-single-agent', {
defaultValue: 'Cowork with Single Agent',
})
: t('layout.workspace-cowork-workforce', {
defaultValue: 'Cowork with Workforce',
})}
</span>
<div className="mb-8 px-5 flex w-full justify-center">
{sessionSidePanelMode === SessionMode.SINGLE_AGENT ? (
<SingleAgentList />
) : (
<WorkforceAgentList
sortedAgents={sortedAgents}
activeAgentId={activeAgentId}
onSelectAgent={onSelectAgent}
onEditWorkerFromMenu={onEditWorkerFromMenu}
onDuplicateUserAgent={onDuplicateUserAgent}
onDeleteUserAgent={onDeleteUserAgent}
onAddWorker={() => setAddWorkerDialogOpen(true)}
/>
)}
</div>
<div className="w-full">
<BottomBox
state="input"
queuedMessages={[]}
onRemoveQueuedMessage={() => {}}
noModelOverlay={!hasModel}
onSelectModel={() => navigate('/history?tab=agents')}
inputProps={{
value: message,
onChange: setMessage,
onSend: handleSend,
files:
chatStore.tasks[
chatStore.activeTaskId
]?.attaches?.map((f) => ({
fileName: f.fileName,
filePath: f.filePath,
})) || [],
onFilesChange: (files) =>
chatStore.setAttaches(
chatStore.activeTaskId as string,
files as any
),
onAddFile: handleFileSelect,
disabled: !hasModel,
textareaRef,
allowDragDrop: true,
useCloudModelInDev,
placeholder: t('layout.project-task-placeholder', {
defaultValue:
'Describe what you want to accomplish...',
}),
sessionMode: sessionSidePanelMode,
onSessionModeChange: setSessionSidePanelMode,
sessionModeSelectInteractive: true,
}}
/>
</div>
<AddWorker
isOpen={addWorkerDialogOpen}
onOpenChange={setAddWorkerDialogOpen}
/>
{editingWorkerAgent && (
<AddWorker
edit
workerInfo={editingWorkerAgent}
isOpen={true}
onOpenChange={(open) => {
if (!open) setEditingWorkerAgent(null);
}}
/>
)}
</div>
</div>
<div
className="min-h-0 pt-6 flex w-full flex-1 flex-col overflow-y-auto"
id="workspace-bottom-group"
>
{t('layout.workspace-work-with-title', {
defaultValue: 'Work with',
})}
</span>
<div className="mt-3 gap-1 flex flex-col">
<Button
type="button"
variant="ghost"
tone="default"
emphasis="default"
size="sm"
buttonContent="text"
className="no-drag gap-2 justify-start"
>
<MonitorSmartphone
className="h-4 w-4 text-ds-text-neutral-muted-default shrink-0"
aria-hidden
{showWorkspaceExamplePrompts ? (
<WorkspaceExamplePrompts
onSelectPrompt={setMessage}
disabled={!hasModel}
/>
{t('layout.workspace-work-with-remote-control', {
defaultValue: 'Remote control',
})}
</Button>
<Button
type="button"
variant="ghost"
tone="default"
emphasis="default"
size="sm"
buttonContent="text"
className="no-drag gap-2 justify-start"
aria-label={t('layout.channels-telegram', {
defaultValue: 'Telegram',
})}
>
<img
src={telegramIcon}
alt=""
className="h-4 w-4 shrink-0 object-contain"
aria-hidden
) : (
<WorkspaceRecentSessions
tasks={chatStore.tasks}
activeTaskId={chatStore.activeTaskId}
onSelectSession={(id) => {
chatStore.setActiveTaskId(id);
setActiveWorkspaceTab('session');
}}
onOpenAllSessions={() =>
setWorkspaceSubPage('all-sessions')
}
/>
{t('layout.channels-telegram', {
defaultValue: 'Telegram',
})}
</Button>
<Button
type="button"
variant="ghost"
tone="default"
emphasis="default"
size="sm"
buttonContent="text"
className="no-drag gap-2 justify-start"
aria-label={t('layout.channels-lark', {
defaultValue: 'Lark',
})}
>
<img
src={larkIcon}
alt=""
className="h-4 w-4 rounded-lg shrink-0 object-contain"
aria-hidden
/>
{t('layout.channels-lark', { defaultValue: 'Lark' })}
</Button>
<Button
type="button"
variant="ghost"
tone="default"
emphasis="default"
size="sm"
buttonContent="text"
className="no-drag gap-2 justify-start"
aria-label={t('layout.channels-whatsapp', {
defaultValue: 'WhatsApp',
})}
>
<img
src={whatsappIcon}
alt=""
className="h-4 w-4 shrink-0 object-contain"
aria-hidden
/>
{t('layout.channels-whatsapp', {
defaultValue: 'WhatsApp',
})}
</Button>
)}
</div>
</div>
</div>
</div>
</>
) : null}
{/* Right slide-in panel */}
<AnimatePresence initial={false}>
{leftPanelTab !== null && (
<motion.div
key="workspace-right-panel"
id="workspace-right-panel"
initial={{ width: 0 }}
animate={{ width: 288 }}
exit={{ width: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="pr-1 z-0 shrink-0 overflow-hidden"
>
<div className="flex h-full w-[280px] flex-col overflow-hidden">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={leftPanelTab}
initial={{ x: 12, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -12, opacity: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="flex h-full flex-col"
>
{leftPanelTab === 'instructions' ? (
<WorkspaceCoworkPanel
memoryOn={memoryOn}
onMemoryToggle={() => setMemoryOn((v) => !v)}
onEditInstructions={() => {
setLeftPanelTab(null);
setWorkspaceSubPage('instruction-md');
}}
onWorkforceSetting={() =>
setActiveWorkspaceTab('workforce')
}
/>
) : (
<>
<div className="px-2 py-1 rounded-2xl bg-ds-bg-neutral-default-default flex shrink-0 flex-col">
<span className="px-2 py-1.5 text-body-sm font-semibold text-ds-text-neutral-default-default">
{workWithLabel}
</span>
<div className="gap-2 py-1.5 flex flex-col">
<Button
type="button"
variant="ghost"
tone="default"
emphasis="default"
size="sm"
buttonContent="text"
className="no-drag gap-2 justify-start"
onClick={() => {
setLeftPanelTab(null);
setWorkspaceSubPage('dispatch');
}}
>
<MonitorSmartphone
className="h-4 w-4 text-ds-text-neutral-muted-default shrink-0"
aria-hidden
/>
{t(
'layout.workspace-work-with-remote-control',
{
defaultValue: 'Remote control',
}
)}
</Button>
<Button
type="button"
variant="ghost"
tone="default"
emphasis="default"
size="sm"
buttonContent="text"
className="no-drag gap-2 justify-start"
aria-label={t('layout.channels-telegram', {
defaultValue: 'Telegram',
})}
>
<img
src={telegramIcon}
alt=""
className="h-4 w-4 shrink-0 object-contain"
aria-hidden
/>
{t('layout.channels-telegram', {
defaultValue: 'Telegram',
})}
</Button>
<Button
type="button"
variant="ghost"
tone="default"
emphasis="default"
size="sm"
buttonContent="text"
className="no-drag gap-2 justify-start"
aria-label={t('layout.channels-lark', {
defaultValue: 'Lark',
})}
>
<img
src={larkIcon}
alt=""
className="h-4 w-4 rounded-lg shrink-0 object-contain"
aria-hidden
/>
{t('layout.channels-lark', {
defaultValue: 'Lark',
})}
</Button>
<Button
type="button"
variant="ghost"
tone="default"
emphasis="default"
size="sm"
buttonContent="text"
className="no-drag gap-2 justify-start"
aria-label={t('layout.channels-whatsapp', {
defaultValue: 'WhatsApp',
})}
>
<img
src={whatsappIcon}
alt=""
className="h-4 w-4 shrink-0 object-contain"
aria-hidden
/>
{t('layout.channels-whatsapp', {
defaultValue: 'WhatsApp',
})}
</Button>
</div>
</div>
</>
)}
</motion.div>
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
</>
)}
</div>
</div>
);
}

View file

@ -68,10 +68,10 @@ import type {
const HOME_MAIN_LAYOUT_SPRING = PROJECT_SIDEBAR_FOLD_SPRING;
/** Sidebar width bounds (react-resizable-panels uses %; derived from shell width). */
const SIDEBAR_MIN_PX = 240;
const SIDEBAR_MAX_PX = 400;
const SIDEBAR_MIN_PX = 230;
const SIDEBAR_MAX_PX = 320;
/** Default expanded sidebar width when nothing is stored (px). */
const DEFAULT_SIDEBAR_WIDTH_PX = 288;
const DEFAULT_SIDEBAR_WIDTH_PX = 230;
const SIDEBAR_WIDTH_STORAGE_KEY = 'eigent-home-sidebar-width-px';
function clampPct(n: number): number {