mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-05-29 19:15:39 +00:00
restrucutre components
This commit is contained in:
parent
c0bad6ad98
commit
ae2c08e618
9 changed files with 1023 additions and 687 deletions
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { WorkSpaceMenu } from '../WorkSpaceMenu';
|
||||
import { WorkSpaceMenu } from '../WorkspaceMenu';
|
||||
|
||||
interface BottomBarProps {
|
||||
onToggleChatBox?: () => void;
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import {
|
|||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import BottomBox from './BottomBox';
|
||||
import { HeaderBox } from './HeaderBox';
|
||||
|
|
@ -59,7 +59,8 @@ export default function ChatBox(): JSX.Element {
|
|||
const workspaceChatFocusRequestId = usePageTabStore(
|
||||
(s) => s.workspaceChatFocusRequestId
|
||||
);
|
||||
const [hasModel, setHasModel] = useState(true);
|
||||
const [hasModel, setHasModel] = useState(false);
|
||||
const [isConfigLoaded, setIsConfigLoaded] = useState(false);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const bottomBoxOverlayRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollBottomInsetPx, setScrollBottomInsetPx] = useState(
|
||||
|
|
@ -68,9 +69,10 @@ export default function ChatBox(): JSX.Element {
|
|||
/** Assumed true once past login/onboarding; in-chat banner can still opt-in via PUT. */
|
||||
const [privacy, setPrivacy] = useState(true);
|
||||
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
// const [privacyDialogOpen, setPrivacyDialogOpen] = useState(false);
|
||||
const { modelType } = useAuthStore();
|
||||
const [useCloudModelInDev, setUseCloudModelInDev] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
// Only show warning message, don't block functionality
|
||||
if (
|
||||
|
|
@ -90,29 +92,54 @@ export default function ChatBox(): JSX.Element {
|
|||
return () => clearTimeout(focusTimer);
|
||||
}, [workspaceChatFocusRequestId]);
|
||||
|
||||
// Shared function to check model configuration
|
||||
const checkModelConfig = useCallback(async () => {
|
||||
try {
|
||||
if (modelType === 'cloud') {
|
||||
const res = await proxyFetchGet('/api/v1/user/key');
|
||||
setHasModel(!!res.value);
|
||||
} else if (modelType === 'local' || modelType === 'custom') {
|
||||
const res = await proxyFetchGet('/api/v1/providers', { prefer: true });
|
||||
const providerList = res.items || [];
|
||||
setHasModel(providerList.length > 0);
|
||||
} else {
|
||||
setHasModel(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check model config:', err);
|
||||
setHasModel(false);
|
||||
} finally {
|
||||
setIsConfigLoaded(true);
|
||||
}
|
||||
}, [modelType]);
|
||||
|
||||
// Check model config on mount and when modelType changes
|
||||
useEffect(() => {
|
||||
proxyFetchGet('/api/configs').catch((err) =>
|
||||
console.error('Failed to fetch configs:', err)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Refresh privacy status when dialog closes
|
||||
// useEffect(() => {
|
||||
// if (!privacyDialogOpen) {
|
||||
// proxyFetchGet("/api/user/privacy")
|
||||
// .then((res) => {
|
||||
// let _privacy = 0;
|
||||
// Object.keys(res).forEach((key) => {
|
||||
// if (!res[key]) {
|
||||
// _privacy++;
|
||||
// return;
|
||||
// }
|
||||
// });
|
||||
// setPrivacy(_privacy === 0 ? true : false);
|
||||
// })
|
||||
// .catch((err) => console.error("Failed to fetch settings:", err));
|
||||
// }
|
||||
// }, [privacyDialogOpen]);
|
||||
checkModelConfig();
|
||||
}, [modelType, checkModelConfig]);
|
||||
|
||||
// Re-check model config when returning from settings page
|
||||
useEffect(() => {
|
||||
if (location.pathname === '/') {
|
||||
checkModelConfig();
|
||||
}
|
||||
}, [location.pathname, checkModelConfig]);
|
||||
|
||||
// Also check when window gains focus (user returns from settings)
|
||||
useEffect(() => {
|
||||
const handleFocus = () => {
|
||||
checkModelConfig();
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleFocus);
|
||||
return () => {
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
};
|
||||
}, [checkModelConfig]);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const share_token = searchParams.get('share_token');
|
||||
const skill_prompt = searchParams.get('skill_prompt');
|
||||
|
|
|
|||
189
src/components/ProjectPageSidebar/BottomAction.tsx
Normal file
189
src/components/ProjectPageSidebar/BottomAction.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
// ========= 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 folderIcon from '@/assets/Folder.svg';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CircleHelp } from 'lucide-react';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
function hubIconTabClass(active: boolean): string {
|
||||
return cn(
|
||||
'no-drag h-8 w-full min-w-0 rounded-xl bg-surface-primary',
|
||||
'hover:bg-surface-tertiary flex cursor-pointer items-center justify-center transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-secondary',
|
||||
active && 'bg-surface-tertiary'
|
||||
);
|
||||
}
|
||||
|
||||
const rowButtonBaseClass =
|
||||
'no-drag h-8 rounded-xl hover:bg-surface-tertiary min-w-0 flex shrink-0 items-center text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-secondary';
|
||||
|
||||
const rowButtonClass = cn(rowButtonBaseClass, 'gap-3 px-3 w-full');
|
||||
|
||||
const PROJECT_HUB_DROPDOWN_CONTENT_CLASS = cn(
|
||||
'min-w-[11rem] -mb-2 flex flex-col gap-1 rounded-xl border-0 bg-fill-default p-1 shadow-md'
|
||||
);
|
||||
|
||||
const PROJECT_HUB_DROPDOWN_CONTENT_STYLE: CSSProperties = {
|
||||
border: 'none',
|
||||
borderRadius: 'var(--borderRadius-rounded-xl, 12px)',
|
||||
background: 'var(--fill-default, #FFF)',
|
||||
};
|
||||
|
||||
const PROJECT_HUB_DROPDOWN_ITEM_CLASS = cn(
|
||||
'flex h-9 min-h-9 w-full shrink-0 cursor-pointer select-none items-center rounded-xl px-3 py-0 text-body-sm font-medium text-text-label outline-none',
|
||||
'hover:bg-surface-secondary hover:text-text-label',
|
||||
'data-[highlighted]:bg-surface-secondary data-[highlighted]:text-text-label',
|
||||
'focus:bg-surface-secondary focus:text-text-label'
|
||||
);
|
||||
|
||||
export interface BottomActionProps {
|
||||
collapsed: boolean;
|
||||
onOpenModels: () => void;
|
||||
modelsAriaLabel: string;
|
||||
modelModeLine: string;
|
||||
modelDetailLine: string;
|
||||
helpMenuOpen: boolean;
|
||||
onHelpMenuOpenChange: (open: boolean) => void;
|
||||
helpAriaLabel: string;
|
||||
onContactSupport: () => void;
|
||||
onReportBug: () => void;
|
||||
onDownloadLogs: () => void;
|
||||
contactSupportLabel: string;
|
||||
reportBugLabel: string;
|
||||
downloadLogsLabel: string;
|
||||
}
|
||||
|
||||
/** Bottom rail: primary model summary cell + help menu, responsive grid. */
|
||||
export function BottomAction({
|
||||
collapsed,
|
||||
onOpenModels,
|
||||
modelsAriaLabel,
|
||||
modelModeLine,
|
||||
modelDetailLine,
|
||||
helpMenuOpen,
|
||||
onHelpMenuOpenChange,
|
||||
helpAriaLabel,
|
||||
onContactSupport,
|
||||
onReportBug,
|
||||
onDownloadLogs,
|
||||
contactSupportLabel,
|
||||
reportBugLabel,
|
||||
downloadLogsLabel,
|
||||
}: BottomActionProps) {
|
||||
return (
|
||||
<div className="border-border-secondary pt-2 mt-auto w-full shrink-0 border-t">
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 gap-1 grid w-full overflow-hidden',
|
||||
collapsed ? 'grid-cols-1' : 'grid-cols-[minmax(0,3fr)_minmax(0,1fr)]'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'min-h-0 min-w-0 overflow-hidden',
|
||||
collapsed && 'max-h-0 pointer-events-none overflow-hidden opacity-0'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenModels}
|
||||
title={`${modelModeLine}\n${modelDetailLine}`}
|
||||
className={cn(
|
||||
rowButtonClass,
|
||||
'bg-surface-primary w-full',
|
||||
'focus-visible:ring-border-secondary focus-visible:ring-2 focus-visible:outline-none'
|
||||
)}
|
||||
aria-label={modelsAriaLabel}
|
||||
>
|
||||
<span
|
||||
className="h-7 w-7 flex shrink-0 items-center justify-center"
|
||||
aria-hidden
|
||||
>
|
||||
<img
|
||||
src={folderIcon}
|
||||
alt=""
|
||||
className="h-7 w-7 mt-1 shrink-0 object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0 flex flex-1 flex-col justify-center leading-none">
|
||||
<div className="bg-surface-information rounded-md px-1 w-fit">
|
||||
<span className="text-text-information text-label-xs font-semibold leading-tight truncate text-nowrap">
|
||||
{modelModeLine}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-text-secondary leading-tight px-1 truncate text-[10px]">
|
||||
{modelDetailLine}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 flex w-full">
|
||||
<DropdownMenu open={helpMenuOpen} onOpenChange={onHelpMenuOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
hubIconTabClass(helpMenuOpen),
|
||||
collapsed && 'px-3 justify-start'
|
||||
)}
|
||||
aria-label={helpAriaLabel}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<CircleHelp
|
||||
className="h-4 w-4 text-icon-primary shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="right"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
alignOffset={8}
|
||||
className={PROJECT_HUB_DROPDOWN_CONTENT_CLASS}
|
||||
style={PROJECT_HUB_DROPDOWN_CONTENT_STYLE}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className={PROJECT_HUB_DROPDOWN_ITEM_CLASS}
|
||||
onSelect={onContactSupport}
|
||||
>
|
||||
{contactSupportLabel}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={PROJECT_HUB_DROPDOWN_ITEM_CLASS}
|
||||
onSelect={onReportBug}
|
||||
>
|
||||
{reportBugLabel}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={PROJECT_HUB_DROPDOWN_ITEM_CLASS}
|
||||
onSelect={onDownloadLogs}
|
||||
>
|
||||
{downloadLogsLabel}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
src/components/ProjectPageSidebar/HeaderAction.tsx
Normal file
146
src/components/ProjectPageSidebar/HeaderAction.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// ========= 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 { TooltipSimple } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PanelLeft, PanelLeftClose, Plus } from 'lucide-react';
|
||||
|
||||
/** Hub tile shell without whole-area hover (split controls handle their own hover). */
|
||||
function hubIconTabShellClass(active: boolean): string {
|
||||
return cn(
|
||||
'no-drag w-full min-w-0 rounded-xl bg-surface-primary transition-colors',
|
||||
active && 'bg-surface-tertiary'
|
||||
);
|
||||
}
|
||||
|
||||
export interface HeaderActionProps {
|
||||
collapsed: boolean;
|
||||
onToggleCollapsed: () => void;
|
||||
expandAriaLabel: string;
|
||||
expandTooltip: string;
|
||||
collapseAriaLabel: string;
|
||||
collapseTooltip: string;
|
||||
historySidebarOpen: boolean;
|
||||
activeTaskTitle: string;
|
||||
onCenterClick: () => void;
|
||||
newProjectAriaLabel: string;
|
||||
newProjectTooltip: string;
|
||||
onNewProject: () => void;
|
||||
}
|
||||
|
||||
/** Project sidebar top bar: collapse | active title | new project (or expand when collapsed). */
|
||||
export function HeaderAction({
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
expandAriaLabel,
|
||||
expandTooltip,
|
||||
collapseAriaLabel,
|
||||
collapseTooltip,
|
||||
historySidebarOpen,
|
||||
activeTaskTitle,
|
||||
onCenterClick,
|
||||
newProjectAriaLabel,
|
||||
newProjectTooltip,
|
||||
onNewProject,
|
||||
}: HeaderActionProps) {
|
||||
if (collapsed) {
|
||||
return (
|
||||
<TooltipSimple content={expandTooltip} side="right" align="center">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'no-drag h-8 min-h-8 rounded-xl flex w-full shrink-0 cursor-pointer items-center justify-start',
|
||||
'hover:bg-surface-tertiary border-0 bg-transparent',
|
||||
'focus-visible:ring-border-secondary focus-visible:z-10 focus-visible:ring-2 focus-visible:outline-none',
|
||||
'px-3'
|
||||
)}
|
||||
onClick={onToggleCollapsed}
|
||||
aria-label={expandAriaLabel}
|
||||
>
|
||||
<PanelLeft
|
||||
className="h-4 w-4 text-icon-primary shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0 flex items-stretch">
|
||||
<div
|
||||
className={cn(
|
||||
hubIconTabShellClass(historySidebarOpen),
|
||||
'h-8 min-h-8 min-w-0 flex flex-1 flex-row overflow-hidden'
|
||||
)}
|
||||
>
|
||||
<TooltipSimple content={collapseTooltip} side="bottom" align="start">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'no-drag min-h-0 w-10 flex h-full shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent transition-colors',
|
||||
historySidebarOpen
|
||||
? 'hover:brightness-[0.98]'
|
||||
: 'hover:bg-surface-tertiary',
|
||||
'focus-visible:ring-border-secondary focus-visible:z-10 focus-visible:ring-2 focus-visible:outline-none'
|
||||
)}
|
||||
onClick={onToggleCollapsed}
|
||||
aria-label={collapseAriaLabel}
|
||||
>
|
||||
<PanelLeftClose
|
||||
className="h-4 w-4 text-icon-primary shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</TooltipSimple>
|
||||
<TooltipSimple content={activeTaskTitle} side="bottom" align="center">
|
||||
<button
|
||||
id="sidebar-active-task-title-btn"
|
||||
type="button"
|
||||
className={cn(
|
||||
'no-drag min-h-0 min-w-0 border-border-tertiary flex h-full flex-1 cursor-pointer items-center border-x border-t border-b-0 border-solid bg-transparent text-left transition-colors',
|
||||
historySidebarOpen
|
||||
? 'hover:brightness-[0.98]'
|
||||
: 'hover:bg-surface-tertiary',
|
||||
'focus-visible:ring-border-secondary focus-visible:z-10 focus-visible:ring-2 focus-visible:outline-none'
|
||||
)}
|
||||
onClick={onCenterClick}
|
||||
aria-expanded={historySidebarOpen}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<span className="min-w-0 text-text-body text-body-sm font-bold flex-1 truncate">
|
||||
{activeTaskTitle}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipSimple>
|
||||
<TooltipSimple content={newProjectTooltip} side="bottom" align="end">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'no-drag w-10 flex h-full shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent transition-colors',
|
||||
historySidebarOpen
|
||||
? 'hover:brightness-[0.98]'
|
||||
: 'hover:bg-surface-tertiary',
|
||||
'focus-visible:ring-border-secondary focus-visible:z-10 focus-visible:ring-2 focus-visible:outline-none'
|
||||
)}
|
||||
onClick={onNewProject}
|
||||
aria-label={newProjectAriaLabel}
|
||||
>
|
||||
<Plus className="h-4 w-4 text-icon-primary shrink-0" aria-hidden />
|
||||
</button>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
268
src/components/ProjectPageSidebar/NavTab.tsx
Normal file
268
src/components/ProjectPageSidebar/NavTab.tsx
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
// ========= 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 {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { TooltipSimple } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WebSocketConnectionStatus } from '@/store/triggerStore';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/** Workspace tabs: layout identical expanded/folded so the leading icon does not jump — text clips as the rail narrows. */
|
||||
export function workspaceTabButtonClass(active: boolean): string {
|
||||
return cn(
|
||||
'no-drag h-8 min-h-8 w-full min-w-0 shrink-0 rounded-xl cursor-pointer flex items-center justify-start gap-3 px-3 text-left outline-none overflow-hidden',
|
||||
'hover:bg-surface-tertiary focus-visible:ring-2 focus-visible:ring-border-secondary focus-visible:outline-none',
|
||||
active && 'bg-surface-tertiary'
|
||||
);
|
||||
}
|
||||
|
||||
export const WORKSPACE_TAB_LABEL_CLASS =
|
||||
'min-w-0 flex-1 truncate text-text-label text-body-sm font-medium';
|
||||
|
||||
const SPLIT_MAIN_BUTTON_CLASS =
|
||||
'no-drag min-h-8 min-w-0 gap-3 rounded-xl py-0 pl-3 pr-1 relative flex flex-1 items-center text-left outline-none focus-visible:ring-border-secondary hover:bg-transparent focus-visible:z-10 focus-visible:ring-2 focus-visible:outline-none';
|
||||
|
||||
const SPLIT_OUTER_EXTRA_CLASS =
|
||||
'min-w-0 gap-0 !p-0 relative flex items-stretch overflow-visible';
|
||||
|
||||
export function triggerListenerLeadIconClass(
|
||||
status: WebSocketConnectionStatus
|
||||
): string {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'text-green-500';
|
||||
case 'connecting':
|
||||
return 'text-yellow-500 animate-pulse';
|
||||
case 'unhealthy':
|
||||
return 'text-orange-500';
|
||||
case 'disconnected':
|
||||
default:
|
||||
return 'text-icon-secondary';
|
||||
}
|
||||
}
|
||||
|
||||
export interface NavTabReconnectSuffixProps {
|
||||
wsConnectionStatus: WebSocketConnectionStatus;
|
||||
reconnectHint: string;
|
||||
reconnectButtonLabel: string;
|
||||
onReconnect: () => void;
|
||||
}
|
||||
|
||||
/** Optional right control for {@link NavTab} `layout="split"` (e.g. triggers reconnect). */
|
||||
export function NavTabReconnectSuffix({
|
||||
wsConnectionStatus,
|
||||
reconnectHint,
|
||||
reconnectButtonLabel,
|
||||
onReconnect,
|
||||
}: NavTabReconnectSuffixProps) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'no-drag text-icon-secondary hover:bg-surface-tertiary h-8 w-8 rounded-xl flex shrink-0 items-center justify-center transition-colors outline-none',
|
||||
'focus-visible:ring-border-secondary focus-visible:z-10 focus-visible:ring-2 focus-visible:outline-none'
|
||||
)}
|
||||
aria-label={reconnectHint}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
'h-3.5 w-3.5',
|
||||
wsConnectionStatus === 'connecting' && 'animate-spin'
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-4" side="right" align="start">
|
||||
<div className="gap-3 flex flex-col">
|
||||
<p className="text-body-sm text-text-body">{reconnectHint}</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="w-full items-center justify-center"
|
||||
onClick={onReconnect}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
wsConnectionStatus === 'connecting' && 'animate-spin'
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
{reconnectButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export type NavTabLayout = 'simple' | 'split';
|
||||
|
||||
export interface NavTabProps {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
leading: ReactNode;
|
||||
label: ReactNode;
|
||||
/** Tag or secondary affordance after the label. */
|
||||
trailing?: ReactNode;
|
||||
showNotificationDot?: boolean;
|
||||
notificationDotClassName?: string;
|
||||
/** Inbox-style dot vs triggers-style attention dot. */
|
||||
notificationDotTone?: 'default' | 'attention';
|
||||
/**
|
||||
* `simple` — one full-width control (default).
|
||||
* `split` — shell row with a primary control plus optional `suffix` (e.g. extra icon button).
|
||||
*/
|
||||
layout?: NavTabLayout;
|
||||
suffix?: ReactNode;
|
||||
collapsed: boolean;
|
||||
tooltip: string;
|
||||
tooltipEnabledWhenCollapsed?: boolean;
|
||||
ariaLabel?: string;
|
||||
ariaCurrentPage?: boolean;
|
||||
/** Merged onto the outer control (`button` when simple, shell `div` when split). */
|
||||
className?: string;
|
||||
/** When `layout="split"`, extra classes on the primary `button` only. */
|
||||
mainButtonClassName?: string;
|
||||
}
|
||||
|
||||
function tabMainInner({
|
||||
leading,
|
||||
label,
|
||||
trailing,
|
||||
showNotificationDot,
|
||||
notificationDotClassName,
|
||||
notificationDotTone = 'default',
|
||||
}: Pick<
|
||||
NavTabProps,
|
||||
| 'leading'
|
||||
| 'label'
|
||||
| 'trailing'
|
||||
| 'showNotificationDot'
|
||||
| 'notificationDotClassName'
|
||||
| 'notificationDotTone'
|
||||
>): ReactNode {
|
||||
return (
|
||||
<>
|
||||
{leading}
|
||||
<span className={WORKSPACE_TAB_LABEL_CLASS}>{label}</span>
|
||||
{trailing}
|
||||
{showNotificationDot && (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-full transition-all duration-300',
|
||||
notificationDotTone === 'attention'
|
||||
? 'bg-text-error'
|
||||
: 'bg-red-500',
|
||||
notificationDotClassName
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Project page sidebar rail tab: leading icon, label, optional trailing chip, optional dot, optional split suffix.
|
||||
* Add new tabs by composing `leading` / `trailing` / `suffix`; use `layout="split"` when the row needs a separate end control.
|
||||
*/
|
||||
export function NavTab({
|
||||
active,
|
||||
onClick,
|
||||
leading,
|
||||
label,
|
||||
trailing,
|
||||
showNotificationDot,
|
||||
notificationDotClassName,
|
||||
notificationDotTone = 'default',
|
||||
layout = 'simple',
|
||||
suffix,
|
||||
collapsed,
|
||||
tooltip,
|
||||
tooltipEnabledWhenCollapsed = false,
|
||||
ariaLabel,
|
||||
ariaCurrentPage,
|
||||
className,
|
||||
mainButtonClassName,
|
||||
}: NavTabProps) {
|
||||
const inner = tabMainInner({
|
||||
leading,
|
||||
label,
|
||||
trailing,
|
||||
showNotificationDot,
|
||||
notificationDotClassName,
|
||||
notificationDotTone,
|
||||
});
|
||||
|
||||
const tooltipEnabled = tooltipEnabledWhenCollapsed ? collapsed : true;
|
||||
|
||||
if (layout === 'split') {
|
||||
return (
|
||||
<TooltipSimple
|
||||
content={tooltip}
|
||||
side="right"
|
||||
align="center"
|
||||
enabled={tooltipEnabled}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
workspaceTabButtonClass(active),
|
||||
SPLIT_OUTER_EXTRA_CLASS,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(SPLIT_MAIN_BUTTON_CLASS, mainButtonClassName)}
|
||||
aria-label={ariaLabel}
|
||||
aria-current={ariaCurrentPage ? 'page' : undefined}
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
{suffix}
|
||||
</div>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipSimple
|
||||
content={tooltip}
|
||||
side="right"
|
||||
align="center"
|
||||
enabled={tooltipEnabled}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(workspaceTabButtonClass(active), className)}
|
||||
aria-label={ariaLabel}
|
||||
aria-current={ariaCurrentPage ? 'page' : undefined}
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
216
src/components/ProjectPageSidebar/TaskList.tsx
Normal file
216
src/components/ProjectPageSidebar/TaskList.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
// ========= 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 {
|
||||
getTaskListShelfTone,
|
||||
type TaskListShelfTone,
|
||||
} from '@/lib/taskLifecycleUi';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ChatStore } from '@/store/chatStore';
|
||||
import { SquarePen } from 'lucide-react';
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
const SHELF_TONE_ROW_CLASS: Record<TaskListShelfTone, string> = {
|
||||
splitting: 'bg-input-bg-spliting hover:brightness-[0.98]',
|
||||
running: 'bg-input-bg-confirm hover:brightness-[0.98]',
|
||||
default: 'bg-transparent hover:bg-surface-tertiary',
|
||||
};
|
||||
|
||||
/** Horizontal drift speed for task query hover (~6px/s, capped) — readable marquee, not a snap. */
|
||||
const TASK_QUERY_SCROLL_PX_PER_SEC = 16;
|
||||
const TASK_QUERY_SCROLL_MIN_MS = 10_000;
|
||||
const TASK_QUERY_SCROLL_MAX_MS = 90_000;
|
||||
|
||||
function taskQueryScrollDurationMs(scrollPx: number): number {
|
||||
if (scrollPx <= 0) return 300;
|
||||
const proportional = (scrollPx / TASK_QUERY_SCROLL_PX_PER_SEC) * 1000;
|
||||
return Math.min(
|
||||
TASK_QUERY_SCROLL_MAX_MS,
|
||||
Math.max(TASK_QUERY_SCROLL_MIN_MS, Math.round(proportional))
|
||||
);
|
||||
}
|
||||
|
||||
function taskUserQueryLabel(task: ChatStore['tasks'][string]): string {
|
||||
const firstUser = task.messages.find((m) => m.role === 'user');
|
||||
const text = firstUser?.content?.trim() ?? '';
|
||||
return text || '…';
|
||||
}
|
||||
|
||||
function TaskQueryScrollLabel({
|
||||
queryLabel,
|
||||
rowHovered,
|
||||
}: {
|
||||
queryLabel: string;
|
||||
rowHovered: boolean;
|
||||
}) {
|
||||
const outerRef = useRef<HTMLDivElement>(null);
|
||||
const innerRef = useRef<HTMLSpanElement>(null);
|
||||
const [scrollPx, setScrollPx] = useState(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const outer = outerRef.current;
|
||||
const inner = innerRef.current;
|
||||
if (!outer || !inner) return;
|
||||
|
||||
const measure = () => {
|
||||
setScrollPx(Math.max(0, inner.scrollWidth - outer.clientWidth));
|
||||
};
|
||||
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(outer);
|
||||
return () => ro.disconnect();
|
||||
}, [queryLabel]);
|
||||
|
||||
const slide = rowHovered && scrollPx > 0;
|
||||
const slideMs = taskQueryScrollDurationMs(scrollPx);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={outerRef}
|
||||
className={cn('text-text-label min-w-0 w-full overflow-hidden')}
|
||||
>
|
||||
<span
|
||||
ref={innerRef}
|
||||
title={queryLabel}
|
||||
className={cn(
|
||||
'text-body-sm font-normal inline-block whitespace-nowrap first-letter:uppercase',
|
||||
'transition-[transform]',
|
||||
slide ? 'ease-linear' : 'ease-out duration-300'
|
||||
)}
|
||||
style={{
|
||||
transform: slide ? `translateX(-${scrollPx}px)` : 'translateX(0)',
|
||||
transitionDuration: slide ? `${slideMs}ms` : undefined,
|
||||
}}
|
||||
>
|
||||
{queryLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskListRow({
|
||||
task,
|
||||
firstUserMessageId,
|
||||
active,
|
||||
setScrollToQueryId,
|
||||
}: {
|
||||
task: ChatStore['tasks'][string];
|
||||
firstUserMessageId: string | null;
|
||||
active: boolean;
|
||||
setScrollToQueryId: (id: string) => void;
|
||||
}) {
|
||||
const [rowHovered, setRowHovered] = useState(false);
|
||||
const queryLabel = taskUserQueryLabel(task);
|
||||
const shelfTone = getTaskListShelfTone(task);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (firstUserMessageId) {
|
||||
setScrollToQueryId(firstUserMessageId);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => setRowHovered(true)}
|
||||
onMouseLeave={() => setRowHovered(false)}
|
||||
className={cn(
|
||||
'no-drag h-8 rounded-xl min-w-0 gap-3 px-3 relative flex w-full max-w-full shrink-0 cursor-pointer items-center text-left transition-colors',
|
||||
SHELF_TONE_ROW_CLASS[shelfTone]
|
||||
)}
|
||||
aria-current={active ? 'true' : undefined}
|
||||
>
|
||||
<TaskQueryScrollLabel queryLabel={queryLabel} rowHovered={rowHovered} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export type TaskListEntry = {
|
||||
chatId: string;
|
||||
taskId: string;
|
||||
task: ChatStore['tasks'][string];
|
||||
firstUserMessageId: string | null;
|
||||
};
|
||||
|
||||
export interface TaskListProps {
|
||||
collapsed: boolean;
|
||||
entries: TaskListEntry[];
|
||||
activeTaskId: string | null | undefined;
|
||||
setScrollToQueryId: (id: string) => void;
|
||||
title: string;
|
||||
emptyLabel: string;
|
||||
addButtonAriaLabel: string;
|
||||
onAddClick: () => void;
|
||||
}
|
||||
|
||||
export function TaskList({
|
||||
collapsed,
|
||||
entries,
|
||||
activeTaskId,
|
||||
setScrollToQueryId,
|
||||
title,
|
||||
emptyLabel,
|
||||
addButtonAriaLabel,
|
||||
onAddClick,
|
||||
}: TaskListProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'min-h-0 min-w-0 flex w-full flex-col overflow-hidden',
|
||||
collapsed ? 'max-h-0 pointer-events-none flex-none' : 'min-h-0 flex-1'
|
||||
)}
|
||||
style={{ minHeight: 0 }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'gap-2 pl-3 pr-1.5 pb-1.5 pt-0 flex w-full shrink-0 items-center justify-between',
|
||||
collapsed && 'hidden'
|
||||
)}
|
||||
>
|
||||
<span className="text-text-label min-w-0 text-xs font-semibold truncate">
|
||||
{title}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
buttonContent="icon-only"
|
||||
className="text-icon-primary shrink-0"
|
||||
aria-label={addButtonAriaLabel}
|
||||
onClick={onAddClick}
|
||||
>
|
||||
<SquarePen className="size-3.5" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 w-full flex-1 overflow-x-hidden overflow-y-auto">
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-text-label px-3 text-xs w-full">{emptyLabel}</p>
|
||||
) : (
|
||||
<div className="gap-2 min-w-0 flex w-full flex-col">
|
||||
{entries.map(({ chatId, taskId, task, firstUserMessageId }) => (
|
||||
<TaskListRow
|
||||
key={`${chatId}-${taskId}`}
|
||||
task={task}
|
||||
firstUserMessageId={firstUserMessageId}
|
||||
active={activeTaskId === taskId}
|
||||
setScrollToQueryId={setScrollToQueryId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,7 +13,6 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { proxyFetchGet } from '@/api/http';
|
||||
import folderIcon from '@/assets/Folder.svg';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -21,56 +20,27 @@ import {
|
|||
DialogContentSection,
|
||||
DialogHeader,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { TooltipSimple } from '@/components/ui/tooltip';
|
||||
import {
|
||||
getTaskListShelfTone,
|
||||
type TaskListShelfTone,
|
||||
} from '@/lib/taskLifecycleUi';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import type { ChatStore } from '@/store/chatStore';
|
||||
import { usePageTabStore } from '@/store/pageTabStore';
|
||||
import { useProjectStore } from '@/store/projectStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import {
|
||||
useTriggerStore,
|
||||
type WebSocketConnectionStatus,
|
||||
} from '@/store/triggerStore';
|
||||
import { useTriggerStore } from '@/store/triggerStore';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
CircleHelp,
|
||||
Inbox,
|
||||
LayoutGrid,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
SquarePen,
|
||||
Zap,
|
||||
ZapOff,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from 'react';
|
||||
import { Inbox, LayoutGrid, Zap, ZapOff } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { BottomAction } from './BottomAction';
|
||||
import { HeaderAction } from './HeaderAction';
|
||||
import {
|
||||
NavTab,
|
||||
NavTabReconnectSuffix,
|
||||
triggerListenerLeadIconClass,
|
||||
} from './NavTab';
|
||||
import { TaskList } from './TaskList';
|
||||
|
||||
/** Match History.tsx tab normalization for sidebar “active hub” styling */
|
||||
const HISTORY_TAB_ALIASES: Record<string, string> = {
|
||||
|
|
@ -101,35 +71,6 @@ function folderPathBasename(path: string): string {
|
|||
return parts[parts.length - 1] || normalized;
|
||||
}
|
||||
|
||||
const PROJECT_HUB_DROPDOWN_CONTENT_CLASS = cn(
|
||||
'min-w-[11rem] -mb-2 flex flex-col gap-1 rounded-xl border-0 bg-fill-default p-1 shadow-md'
|
||||
);
|
||||
|
||||
const PROJECT_HUB_DROPDOWN_CONTENT_STYLE: CSSProperties = {
|
||||
border: 'none',
|
||||
borderRadius: 'var(--borderRadius-rounded-xl, 12px)',
|
||||
background: 'var(--fill-default, #FFF)',
|
||||
};
|
||||
|
||||
const PROJECT_HUB_DROPDOWN_ITEM_CLASS = cn(
|
||||
'flex h-9 min-h-9 w-full shrink-0 cursor-pointer select-none items-center rounded-xl px-3 py-0 text-body-sm font-medium text-text-label outline-none',
|
||||
'hover:bg-surface-secondary hover:text-text-label',
|
||||
'data-[highlighted]:bg-surface-secondary data-[highlighted]:text-text-label',
|
||||
'focus:bg-surface-secondary focus:text-text-label'
|
||||
);
|
||||
|
||||
function taskUserQueryLabel(task: ChatStore['tasks'][string]): string {
|
||||
const firstUser = task.messages.find((m) => m.role === 'user');
|
||||
const text = firstUser?.content?.trim() ?? '';
|
||||
return text || '…';
|
||||
}
|
||||
|
||||
const SHELF_TONE_ROW_CLASS: Record<TaskListShelfTone, string> = {
|
||||
splitting: 'bg-input-bg-spliting hover:brightness-[0.98]',
|
||||
running: 'bg-input-bg-confirm hover:brightness-[0.98]',
|
||||
default: 'bg-transparent hover:bg-surface-tertiary',
|
||||
};
|
||||
|
||||
const PROJECT_SIDEBAR_WIDTH_PX = 240;
|
||||
/** Folded rail: tab row needs pl-3 + icon + pr-3 (no outer sidebar horizontal padding). */
|
||||
const PROJECT_SIDEBAR_FOLDED_WIDTH_PX = 40;
|
||||
|
|
@ -149,136 +90,6 @@ const SIDEBAR_LAYOUT_TWEEN = {
|
|||
ease: [0.22, 1, 0.36, 1] as [number, number, number, number],
|
||||
};
|
||||
|
||||
/** Workspace tabs: layout identical expanded/folded so the leading icon does not jump — text clips as the rail narrows. */
|
||||
const workspaceTabButtonClass = (active: boolean) =>
|
||||
cn(
|
||||
'no-drag h-8 min-h-8 w-full min-w-0 shrink-0 rounded-xl cursor-pointer flex items-center justify-start gap-3 px-3 text-left outline-none overflow-hidden',
|
||||
'hover:bg-surface-tertiary focus-visible:ring-2 focus-visible:ring-border-secondary focus-visible:outline-none',
|
||||
active && 'bg-surface-tertiary'
|
||||
);
|
||||
|
||||
const workspaceTabLabelClass =
|
||||
'min-w-0 flex-1 truncate text-text-label text-body-sm font-medium';
|
||||
|
||||
function triggerListenerLeadIconClass(
|
||||
status: WebSocketConnectionStatus
|
||||
): string {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'text-green-500';
|
||||
case 'connecting':
|
||||
return 'text-yellow-500 animate-pulse';
|
||||
case 'unhealthy':
|
||||
return 'text-orange-500';
|
||||
case 'disconnected':
|
||||
default:
|
||||
return 'text-icon-secondary';
|
||||
}
|
||||
}
|
||||
|
||||
/** Horizontal drift speed for task query hover (~6px/s, capped) — readable marquee, not a snap. */
|
||||
const TASK_QUERY_SCROLL_PX_PER_SEC = 16;
|
||||
const TASK_QUERY_SCROLL_MIN_MS = 10_000;
|
||||
const TASK_QUERY_SCROLL_MAX_MS = 90_000;
|
||||
|
||||
function taskQueryScrollDurationMs(scrollPx: number): number {
|
||||
if (scrollPx <= 0) return 300;
|
||||
const proportional = (scrollPx / TASK_QUERY_SCROLL_PX_PER_SEC) * 1000;
|
||||
return Math.min(
|
||||
TASK_QUERY_SCROLL_MAX_MS,
|
||||
Math.max(TASK_QUERY_SCROLL_MIN_MS, Math.round(proportional))
|
||||
);
|
||||
}
|
||||
|
||||
function TaskQueryScrollLabel({
|
||||
queryLabel,
|
||||
rowHovered,
|
||||
}: {
|
||||
queryLabel: string;
|
||||
rowHovered: boolean;
|
||||
}) {
|
||||
const outerRef = useRef<HTMLDivElement>(null);
|
||||
const innerRef = useRef<HTMLSpanElement>(null);
|
||||
const [scrollPx, setScrollPx] = useState(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const outer = outerRef.current;
|
||||
const inner = innerRef.current;
|
||||
if (!outer || !inner) return;
|
||||
|
||||
const measure = () => {
|
||||
setScrollPx(Math.max(0, inner.scrollWidth - outer.clientWidth));
|
||||
};
|
||||
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(outer);
|
||||
return () => ro.disconnect();
|
||||
}, [queryLabel]);
|
||||
|
||||
const slide = rowHovered && scrollPx > 0;
|
||||
const slideMs = taskQueryScrollDurationMs(scrollPx);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={outerRef}
|
||||
className={cn('text-text-label min-w-0 w-full overflow-hidden')}
|
||||
>
|
||||
<span
|
||||
ref={innerRef}
|
||||
title={queryLabel}
|
||||
className={cn(
|
||||
'text-body-sm font-normal inline-block whitespace-nowrap first-letter:uppercase',
|
||||
'transition-[transform]',
|
||||
slide ? 'ease-linear' : 'ease-out duration-300'
|
||||
)}
|
||||
style={{
|
||||
transform: slide ? `translateX(-${scrollPx}px)` : 'translateX(0)',
|
||||
transitionDuration: slide ? `${slideMs}ms` : undefined,
|
||||
}}
|
||||
>
|
||||
{queryLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectSidebarTaskListRow({
|
||||
task,
|
||||
firstUserMessageId,
|
||||
active,
|
||||
setScrollToQueryId,
|
||||
}: {
|
||||
task: ChatStore['tasks'][string];
|
||||
firstUserMessageId: string | null;
|
||||
active: boolean;
|
||||
setScrollToQueryId: (id: string) => void;
|
||||
}) {
|
||||
const [rowHovered, setRowHovered] = useState(false);
|
||||
const queryLabel = taskUserQueryLabel(task);
|
||||
const shelfTone = getTaskListShelfTone(task);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (firstUserMessageId) {
|
||||
setScrollToQueryId(firstUserMessageId);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => setRowHovered(true)}
|
||||
onMouseLeave={() => setRowHovered(false)}
|
||||
className={cn(
|
||||
'no-drag h-8 rounded-xl min-w-0 gap-3 px-3 relative flex w-full max-w-full shrink-0 cursor-pointer items-center text-left transition-colors',
|
||||
SHELF_TONE_ROW_CLASS[shelfTone]
|
||||
)}
|
||||
aria-current={active ? 'true' : undefined}
|
||||
>
|
||||
<TaskQueryScrollLabel queryLabel={queryLabel} rowHovered={rowHovered} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ProjectPageSidebarProps {
|
||||
chatStore: ChatStore;
|
||||
className?: string;
|
||||
|
|
@ -391,25 +202,6 @@ export default function ProjectPageSidebar({
|
|||
// Do not use `updateCount` alone — it only bumps on task completion, so the list would stay stale while chatting.
|
||||
}, [projectStore, activeProjectId, chatStore]);
|
||||
|
||||
const rowButtonBaseClass =
|
||||
'no-drag h-8 rounded-xl hover:bg-surface-tertiary min-w-0 flex shrink-0 items-center text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-secondary';
|
||||
const rowButtonClass = cn(rowButtonBaseClass, 'gap-3 px-3 w-full');
|
||||
|
||||
const hubIconTabClass = (active: boolean) =>
|
||||
cn(
|
||||
'no-drag h-8 w-full min-w-0 rounded-xl bg-surface-primary',
|
||||
'hover:bg-surface-tertiary flex cursor-pointer items-center justify-center transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-secondary',
|
||||
active && 'bg-surface-tertiary'
|
||||
);
|
||||
|
||||
/** Hub tile shell without whole-area hover (split controls handle their own hover). */
|
||||
const hubIconTabShellClass = (active: boolean) =>
|
||||
cn(
|
||||
'no-drag w-full min-w-0 rounded-xl bg-surface-primary transition-colors',
|
||||
active && 'bg-surface-tertiary'
|
||||
);
|
||||
|
||||
const authToken = useAuthStore((s) => s.token);
|
||||
const email = useAuthStore((s) => s.email);
|
||||
const modelType = useAuthStore((s) => s.modelType);
|
||||
|
|
@ -669,176 +461,58 @@ export default function ProjectPageSidebar({
|
|||
>
|
||||
<div className="min-h-0 min-w-0 flex h-full w-full flex-col overflow-x-hidden">
|
||||
<div className="gap-2 flex w-full shrink-0 flex-col">
|
||||
{!collapsed ? (
|
||||
<div className="min-w-0 flex items-stretch">
|
||||
<div
|
||||
className={cn(
|
||||
hubIconTabShellClass(historySidebarOpen),
|
||||
'h-8 min-h-8 min-w-0 flex flex-1 flex-row overflow-hidden'
|
||||
)}
|
||||
>
|
||||
<TooltipSimple
|
||||
content={t('layout.collapse-sidebar', {
|
||||
defaultValue: 'Collapse sidebar',
|
||||
})}
|
||||
side="bottom"
|
||||
align="start"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'no-drag min-h-0 w-10 flex h-full shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent transition-colors',
|
||||
historySidebarOpen
|
||||
? 'hover:brightness-[0.98]'
|
||||
: 'hover:bg-surface-tertiary',
|
||||
'focus-visible:ring-border-secondary focus-visible:z-10 focus-visible:ring-2 focus-visible:outline-none'
|
||||
)}
|
||||
onClick={toggleProjectSidebarCollapsed}
|
||||
aria-label={t('layout.collapse-sidebar', {
|
||||
defaultValue: 'Collapse sidebar',
|
||||
})}
|
||||
>
|
||||
<PanelLeftClose
|
||||
className="h-4 w-4 text-icon-primary shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</TooltipSimple>
|
||||
<TooltipSimple
|
||||
content={activeTaskTitle}
|
||||
side="bottom"
|
||||
align="center"
|
||||
>
|
||||
<button
|
||||
id="sidebar-active-task-title-btn"
|
||||
type="button"
|
||||
className={cn(
|
||||
'no-drag min-h-0 min-w-0 border-border-tertiary flex h-full flex-1 cursor-pointer items-center border-x border-t border-b-0 border-solid bg-transparent text-left transition-colors',
|
||||
historySidebarOpen
|
||||
? 'hover:brightness-[0.98]'
|
||||
: 'hover:bg-surface-tertiary',
|
||||
'focus-visible:ring-border-secondary focus-visible:z-10 focus-visible:ring-2 focus-visible:outline-none'
|
||||
)}
|
||||
onClick={toggleHistorySidebar}
|
||||
aria-expanded={historySidebarOpen}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<span className="min-w-0 text-text-body text-body-sm font-bold flex-1 truncate">
|
||||
{activeTaskTitle}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipSimple>
|
||||
<TooltipSimple
|
||||
content={t('layout.new-project')}
|
||||
side="bottom"
|
||||
align="end"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'no-drag w-10 flex h-full shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent transition-colors',
|
||||
historySidebarOpen
|
||||
? 'hover:brightness-[0.98]'
|
||||
: 'hover:bg-surface-tertiary',
|
||||
'focus-visible:ring-border-secondary focus-visible:z-10 focus-visible:ring-2 focus-visible:outline-none'
|
||||
)}
|
||||
onClick={createNewProject}
|
||||
aria-label={t('layout.new-project')}
|
||||
>
|
||||
<Plus
|
||||
className="h-4 w-4 text-icon-primary shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<TooltipSimple
|
||||
content={t('layout.expand-sidebar', {
|
||||
defaultValue: 'Expand sidebar',
|
||||
})}
|
||||
side="right"
|
||||
align="center"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'no-drag h-8 min-h-8 rounded-xl flex w-full shrink-0 cursor-pointer items-center justify-start',
|
||||
'hover:bg-surface-tertiary border-0 bg-transparent',
|
||||
'focus-visible:ring-border-secondary focus-visible:z-10 focus-visible:ring-2 focus-visible:outline-none',
|
||||
'px-3'
|
||||
)}
|
||||
onClick={toggleProjectSidebarCollapsed}
|
||||
aria-label={t('layout.expand-sidebar', {
|
||||
defaultValue: 'Expand sidebar',
|
||||
})}
|
||||
>
|
||||
<PanelLeft
|
||||
className="h-4 w-4 text-icon-primary shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
<HeaderAction
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={toggleProjectSidebarCollapsed}
|
||||
expandAriaLabel={t('layout.expand-sidebar', {
|
||||
defaultValue: 'Expand sidebar',
|
||||
})}
|
||||
expandTooltip={t('layout.expand-sidebar', {
|
||||
defaultValue: 'Expand sidebar',
|
||||
})}
|
||||
collapseAriaLabel={t('layout.collapse-sidebar', {
|
||||
defaultValue: 'Collapse sidebar',
|
||||
})}
|
||||
collapseTooltip={t('layout.collapse-sidebar', {
|
||||
defaultValue: 'Collapse sidebar',
|
||||
})}
|
||||
historySidebarOpen={historySidebarOpen}
|
||||
activeTaskTitle={activeTaskTitle}
|
||||
onCenterClick={toggleHistorySidebar}
|
||||
newProjectAriaLabel={t('layout.new-project')}
|
||||
newProjectTooltip={t('layout.new-project')}
|
||||
onNewProject={createNewProject}
|
||||
/>
|
||||
|
||||
<div className="gap-2 min-w-0 flex w-full flex-col">
|
||||
<TooltipSimple
|
||||
content={t('triggers.workspace')}
|
||||
side="right"
|
||||
align="center"
|
||||
enabled={collapsed}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveWorkspaceTab('workforce')}
|
||||
className={workspaceTabButtonClass(
|
||||
activeWorkspaceTab === 'workforce'
|
||||
)}
|
||||
aria-label={t('triggers.workspace')}
|
||||
aria-current={
|
||||
activeWorkspaceTab === 'workforce' ? 'page' : undefined
|
||||
}
|
||||
>
|
||||
<NavTab
|
||||
active={activeWorkspaceTab === 'workforce'}
|
||||
onClick={() => setActiveWorkspaceTab('workforce')}
|
||||
leading={
|
||||
<LayoutGrid
|
||||
className="h-4 w-4 text-icon-primary shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className={workspaceTabLabelClass}>
|
||||
{t('triggers.workspace')}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipSimple>
|
||||
<TooltipSimple
|
||||
content={
|
||||
collapsed
|
||||
? `${t('layout.folder')} · ${folderSettingTagLabel}`
|
||||
: t('layout.folder')
|
||||
}
|
||||
side="right"
|
||||
align="center"
|
||||
enabled={collapsed}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveWorkspaceTab('inbox')}
|
||||
className={cn(
|
||||
workspaceTabButtonClass(activeWorkspaceTab === 'inbox'),
|
||||
'relative'
|
||||
)}
|
||||
aria-label={`${t('layout.folder')}, ${folderSettingTagLabel}`}
|
||||
aria-current={
|
||||
activeWorkspaceTab === 'inbox' ? 'page' : undefined
|
||||
}
|
||||
>
|
||||
label={t('triggers.workspace')}
|
||||
collapsed={collapsed}
|
||||
tooltip={t('triggers.workspace')}
|
||||
tooltipEnabledWhenCollapsed
|
||||
ariaLabel={t('triggers.workspace')}
|
||||
ariaCurrentPage={activeWorkspaceTab === 'workforce'}
|
||||
/>
|
||||
<NavTab
|
||||
active={activeWorkspaceTab === 'inbox'}
|
||||
onClick={() => setActiveWorkspaceTab('inbox')}
|
||||
leading={
|
||||
<Inbox
|
||||
className="h-4 w-4 text-icon-primary shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className={workspaceTabLabelClass}>
|
||||
{t('layout.folder')}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
}
|
||||
label={t('layout.folder')}
|
||||
trailing={
|
||||
!collapsed ? (
|
||||
<span
|
||||
className="bg-surface-secondary text-text-secondary rounded-md px-1.5 font-medium leading-tight max-w-[5.5rem] shrink-0 truncate py-px text-[10px]"
|
||||
title={
|
||||
|
|
@ -850,131 +524,77 @@ export default function ProjectPageSidebar({
|
|||
>
|
||||
{folderSettingTagLabel}
|
||||
</span>
|
||||
)}
|
||||
{unviewedTabs.has('inbox') && (
|
||||
<span
|
||||
) : undefined
|
||||
}
|
||||
showNotificationDot={unviewedTabs.has('inbox')}
|
||||
notificationDotClassName={
|
||||
collapsed ? 'top-1 right-1 h-2 w-2 absolute' : 'h-2 w-2'
|
||||
}
|
||||
collapsed={collapsed}
|
||||
tooltip={
|
||||
collapsed
|
||||
? `${t('layout.folder')} · ${folderSettingTagLabel}`
|
||||
: t('layout.folder')
|
||||
}
|
||||
tooltipEnabledWhenCollapsed
|
||||
ariaLabel={`${t('layout.folder')}, ${folderSettingTagLabel}`}
|
||||
ariaCurrentPage={activeWorkspaceTab === 'inbox'}
|
||||
className="relative"
|
||||
/>
|
||||
<NavTab
|
||||
layout="split"
|
||||
active={activeWorkspaceTab === 'triggers'}
|
||||
onClick={() => setActiveWorkspaceTab('triggers')}
|
||||
leading={
|
||||
triggersListenerConnected ? (
|
||||
<Zap
|
||||
className={cn(
|
||||
'bg-red-500 shrink-0 rounded-full transition-all duration-300',
|
||||
collapsed ? 'top-1 right-1 h-2 w-2 absolute' : 'h-2 w-2'
|
||||
'h-4 w-4 shrink-0',
|
||||
triggerListenerLeadIconClass(wsConnectionStatus)
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</TooltipSimple>
|
||||
<TooltipSimple
|
||||
content={triggersTabTooltip}
|
||||
side="right"
|
||||
align="center"
|
||||
enabled={collapsed}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
workspaceTabButtonClass(activeWorkspaceTab === 'triggers'),
|
||||
'min-w-0 gap-0 !p-0 relative flex items-stretch overflow-visible'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveWorkspaceTab('triggers')}
|
||||
className={cn(
|
||||
'no-drag min-h-8 min-w-0 gap-3 rounded-xl py-0 pl-3 pr-1 relative flex flex-1 items-center text-left outline-none',
|
||||
'focus-visible:ring-border-secondary hover:bg-transparent focus-visible:z-10 focus-visible:ring-2 focus-visible:outline-none'
|
||||
)}
|
||||
aria-label={triggersTabAriaLabel}
|
||||
aria-current={
|
||||
activeWorkspaceTab === 'triggers' ? 'page' : undefined
|
||||
}
|
||||
>
|
||||
{triggersListenerConnected ? (
|
||||
<Zap
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
triggerListenerLeadIconClass(wsConnectionStatus)
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<ZapOff
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
triggerListenerLeadIconClass(wsConnectionStatus)
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<span className={workspaceTabLabelClass}>
|
||||
{t('layout.triggers')}
|
||||
) : (
|
||||
<ZapOff
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
triggerListenerLeadIconClass(wsConnectionStatus)
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
)
|
||||
}
|
||||
label={t('layout.triggers')}
|
||||
trailing={
|
||||
!collapsed && showTriggersDisconnectedTag ? (
|
||||
<span className="bg-surface-secondary text-text-error rounded-md px-1.5 font-medium leading-tight max-w-[5.5rem] shrink-0 truncate py-px text-[10px]">
|
||||
{t('layout.triggers-disconnected')}
|
||||
</span>
|
||||
{!collapsed && showTriggersDisconnectedTag && (
|
||||
<span className="bg-surface-secondary text-text-error rounded-md px-1.5 font-medium leading-tight max-w-[5.5rem] shrink-0 truncate py-px text-[10px]">
|
||||
{t('layout.triggers-disconnected')}
|
||||
</span>
|
||||
)}
|
||||
{unviewedTabs.has('triggers') && (
|
||||
<span
|
||||
className={cn(
|
||||
'bg-text-error shrink-0 rounded-full transition-all duration-300',
|
||||
collapsed
|
||||
? 'top-1 right-1 h-2 w-2 absolute'
|
||||
: 'h-2 w-2'
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{wsConnectionStatus !== 'connected' && !collapsed && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'no-drag text-icon-secondary hover:bg-surface-tertiary h-8 w-8 rounded-xl flex shrink-0 items-center justify-center transition-colors outline-none',
|
||||
'focus-visible:ring-border-secondary focus-visible:z-10 focus-visible:ring-2 focus-visible:outline-none'
|
||||
)}
|
||||
aria-label={t('layout.triggers-reconnect-hint')}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
'h-3.5 w-3.5',
|
||||
wsConnectionStatus === 'connecting' &&
|
||||
'animate-spin'
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-64 p-4"
|
||||
side="right"
|
||||
align="start"
|
||||
>
|
||||
<div className="gap-3 flex flex-col">
|
||||
<p className="text-body-sm text-text-body">
|
||||
{t('layout.triggers-reconnect-hint')}
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="w-full items-center justify-center"
|
||||
onClick={triggerReconnect}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
wsConnectionStatus === 'connecting' &&
|
||||
'animate-spin'
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
{t('layout.triggers-listener-reconnect')}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
</TooltipSimple>
|
||||
) : undefined
|
||||
}
|
||||
showNotificationDot={unviewedTabs.has('triggers')}
|
||||
notificationDotTone="attention"
|
||||
notificationDotClassName={
|
||||
collapsed ? 'top-1 right-1 h-2 w-2 absolute' : 'h-2 w-2'
|
||||
}
|
||||
suffix={
|
||||
wsConnectionStatus !== 'connected' && !collapsed ? (
|
||||
<NavTabReconnectSuffix
|
||||
wsConnectionStatus={wsConnectionStatus}
|
||||
reconnectHint={t('layout.triggers-reconnect-hint')}
|
||||
reconnectButtonLabel={t(
|
||||
'layout.triggers-listener-reconnect'
|
||||
)}
|
||||
onReconnect={triggerReconnect}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
collapsed={collapsed}
|
||||
tooltip={triggersTabTooltip}
|
||||
tooltipEnabledWhenCollapsed
|
||||
ariaLabel={triggersTabAriaLabel}
|
||||
ariaCurrentPage={activeWorkspaceTab === 'triggers'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -990,173 +610,43 @@ export default function ProjectPageSidebar({
|
|||
transition={SIDEBAR_LAYOUT_TWEEN}
|
||||
/>
|
||||
|
||||
{/* Task List */}
|
||||
<div
|
||||
className={cn(
|
||||
'min-h-0 min-w-0 flex w-full flex-col overflow-hidden',
|
||||
collapsed
|
||||
? 'max-h-0 pointer-events-none flex-none'
|
||||
: 'min-h-0 flex-1'
|
||||
)}
|
||||
style={{ minHeight: 0 }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'gap-2 pl-3 pr-1.5 pb-1.5 pt-0 flex w-full shrink-0 items-center justify-between',
|
||||
collapsed && 'hidden'
|
||||
)}
|
||||
>
|
||||
<span className="text-text-label min-w-0 text-xs font-semibold truncate">
|
||||
{t('layout.task-list-title', { defaultValue: 'Tasks' })}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
buttonContent="icon-only"
|
||||
className="text-icon-primary shrink-0"
|
||||
aria-label={t('layout.task-list-add-hint', {
|
||||
defaultValue: 'Open workspace and focus chat',
|
||||
})}
|
||||
onClick={() => requestWorkspaceChatFocus()}
|
||||
>
|
||||
<SquarePen className="size-3.5" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 w-full flex-1 overflow-x-hidden overflow-y-auto">
|
||||
{allTaskEntries.length === 0 ? (
|
||||
<p className="text-text-label px-3 text-xs w-full">
|
||||
{t('layout.no-tasks', { defaultValue: 'No tasks' })}
|
||||
</p>
|
||||
) : (
|
||||
<div className="gap-2 min-w-0 flex w-full flex-col">
|
||||
{allTaskEntries.map(
|
||||
({ chatId, taskId, task, firstUserMessageId }) => (
|
||||
<ProjectSidebarTaskListRow
|
||||
key={`${chatId}-${taskId}`}
|
||||
task={task}
|
||||
firstUserMessageId={firstUserMessageId}
|
||||
active={chatStore.activeTaskId === taskId}
|
||||
setScrollToQueryId={setScrollToQueryId}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TaskList
|
||||
collapsed={collapsed}
|
||||
entries={allTaskEntries}
|
||||
activeTaskId={chatStore.activeTaskId}
|
||||
setScrollToQueryId={setScrollToQueryId}
|
||||
title={t('layout.task-list-title', { defaultValue: 'Tasks' })}
|
||||
emptyLabel={t('layout.no-tasks', { defaultValue: 'No tasks' })}
|
||||
addButtonAriaLabel={t('layout.task-list-add-hint', {
|
||||
defaultValue: 'Open workspace and focus chat',
|
||||
})}
|
||||
onAddClick={() => requestWorkspaceChatFocus()}
|
||||
/>
|
||||
|
||||
{/* Bottom section - Profile and Help */}
|
||||
<div className="border-border-secondary pt-2 mt-auto w-full shrink-0 border-t">
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 gap-1 grid w-full overflow-hidden',
|
||||
collapsed
|
||||
? 'grid-cols-1'
|
||||
: 'grid-cols-[minmax(0,3fr)_minmax(0,1fr)]'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'min-h-0 min-w-0 overflow-hidden',
|
||||
collapsed &&
|
||||
'max-h-0 pointer-events-none overflow-hidden opacity-0'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/history?tab=agents§ion=models')}
|
||||
title={`${modelModeLine}\n${modelDetailLine}`}
|
||||
className={cn(
|
||||
rowButtonClass,
|
||||
'bg-surface-primary w-full',
|
||||
'focus-visible:ring-border-secondary focus-visible:ring-2 focus-visible:outline-none'
|
||||
)}
|
||||
aria-label={t('setting.models')}
|
||||
>
|
||||
<span
|
||||
className="h-7 w-7 flex shrink-0 items-center justify-center"
|
||||
aria-hidden
|
||||
>
|
||||
<img
|
||||
src={folderIcon}
|
||||
alt=""
|
||||
className="h-7 w-7 mt-1 shrink-0 object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0 flex flex-1 flex-col justify-center leading-none">
|
||||
<div className="bg-surface-information rounded-md px-1 w-fit">
|
||||
<span className="text-text-information text-label-xs font-semibold leading-tight truncate text-nowrap">
|
||||
{modelModeLine}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-text-secondary leading-tight px-1 truncate text-[10px]">
|
||||
{modelDetailLine}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 flex w-full">
|
||||
<DropdownMenu
|
||||
open={helpMenuOpen}
|
||||
onOpenChange={setHelpMenuOpen}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
hubIconTabClass(helpMenuOpen),
|
||||
collapsed && 'px-3 justify-start'
|
||||
)}
|
||||
aria-label={t('layout.help-and-support', {
|
||||
defaultValue: 'Help and support',
|
||||
})}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<CircleHelp
|
||||
className="h-4 w-4 text-icon-primary shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="right"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
alignOffset={8}
|
||||
className={PROJECT_HUB_DROPDOWN_CONTENT_CLASS}
|
||||
style={PROJECT_HUB_DROPDOWN_CONTENT_STYLE}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className={PROJECT_HUB_DROPDOWN_ITEM_CLASS}
|
||||
onSelect={() => setSupportDialogOpen(true)}
|
||||
>
|
||||
{t('layout.contact-support')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={PROJECT_HUB_DROPDOWN_ITEM_CLASS}
|
||||
onSelect={() => {
|
||||
void reportBugOpenGithub();
|
||||
}}
|
||||
>
|
||||
{t('layout.report-bug')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={PROJECT_HUB_DROPDOWN_ITEM_CLASS}
|
||||
onSelect={() => {
|
||||
void downloadLogs();
|
||||
}}
|
||||
>
|
||||
{t('layout.download-logs', {
|
||||
defaultValue: 'Download logs',
|
||||
})}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BottomAction
|
||||
collapsed={collapsed}
|
||||
onOpenModels={() => navigate('/history?tab=agents§ion=models')}
|
||||
modelsAriaLabel={t('setting.models')}
|
||||
modelModeLine={modelModeLine}
|
||||
modelDetailLine={modelDetailLine}
|
||||
helpMenuOpen={helpMenuOpen}
|
||||
onHelpMenuOpenChange={setHelpMenuOpen}
|
||||
helpAriaLabel={t('layout.help-and-support', {
|
||||
defaultValue: 'Help and support',
|
||||
})}
|
||||
onContactSupport={() => setSupportDialogOpen(true)}
|
||||
onReportBug={() => {
|
||||
void reportBugOpenGithub();
|
||||
}}
|
||||
onDownloadLogs={() => {
|
||||
void downloadLogs();
|
||||
}}
|
||||
contactSupportLabel={t('layout.contact-support')}
|
||||
reportBugLabel={t('layout.report-bug')}
|
||||
downloadLogsLabel={t('layout.download-logs', {
|
||||
defaultValue: 'Download logs',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</motion.aside>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||
|
||||
const legacyIcon = sizeProp === 'icon';
|
||||
const resolvedSize: ButtonSize = legacyIcon
|
||||
? 'sm'
|
||||
? 'xs'
|
||||
: (sizeProp as ButtonSize);
|
||||
const resolvedLayout =
|
||||
buttonContent === 'icon-only'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue