diff --git a/src/components/BottomBar/index.tsx b/src/components/BottomBar/index.tsx index eaa56642..59fba6a2 100644 --- a/src/components/BottomBar/index.tsx +++ b/src/components/BottomBar/index.tsx @@ -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; diff --git a/src/components/ChatBox/index.tsx b/src/components/ChatBox/index.tsx index 3d43dc75..6d6b1d82 100644 --- a/src/components/ChatBox/index.tsx +++ b/src/components/ChatBox/index.tsx @@ -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(null); const bottomBoxOverlayRef = useRef(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(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'); diff --git a/src/components/ProjectPageSidebar/BottomAction.tsx b/src/components/ProjectPageSidebar/BottomAction.tsx new file mode 100644 index 00000000..1ad10b08 --- /dev/null +++ b/src/components/ProjectPageSidebar/BottomAction.tsx @@ -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 ( +
+
+
+ +
+
+ + + + + + + {contactSupportLabel} + + + {reportBugLabel} + + + {downloadLogsLabel} + + + +
+
+
+ ); +} diff --git a/src/components/ProjectPageSidebar/HeaderAction.tsx b/src/components/ProjectPageSidebar/HeaderAction.tsx new file mode 100644 index 00000000..c34b79cc --- /dev/null +++ b/src/components/ProjectPageSidebar/HeaderAction.tsx @@ -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 ( + + + + ); + } + + return ( +
+
+ + + + + + + + + +
+
+ ); +} diff --git a/src/components/ProjectPageSidebar/NavTab.tsx b/src/components/ProjectPageSidebar/NavTab.tsx new file mode 100644 index 00000000..ff98daf6 --- /dev/null +++ b/src/components/ProjectPageSidebar/NavTab.tsx @@ -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 ( + + + + + +
+

{reconnectHint}

+ +
+
+
+ ); +} + +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} + {label} + {trailing} + {showNotificationDot && ( + + )} + + ); +} + +/** + * 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 ( + +
+ + {suffix} +
+
+ ); + } + + return ( + + + + ); +} diff --git a/src/components/ProjectPageSidebar/TaskList.tsx b/src/components/ProjectPageSidebar/TaskList.tsx new file mode 100644 index 00000000..11552e54 --- /dev/null +++ b/src/components/ProjectPageSidebar/TaskList.tsx @@ -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 = { + 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(null); + const innerRef = useRef(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 ( +
+ + {queryLabel} + +
+ ); +} + +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 ( + + ); +} + +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 ( +
+
+ + {title} + + +
+
+ {entries.length === 0 ? ( +

{emptyLabel}

+ ) : ( +
+ {entries.map(({ chatId, taskId, task, firstUserMessageId }) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/ProjectPageSidebar/index.tsx b/src/components/ProjectPageSidebar/index.tsx index 4cfc8e34..d3688a6e 100644 --- a/src/components/ProjectPageSidebar/index.tsx +++ b/src/components/ProjectPageSidebar/index.tsx @@ -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 = { @@ -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 = { - 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(null); - const innerRef = useRef(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 ( -
- - {queryLabel} - -
- ); -} - -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 ( - - ); -} - 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({ >
- {!collapsed ? ( -
-
- - - - - - - - - -
-
- ) : ( - - - - )} +
- - - - - - - -
- - {wsConnectionStatus !== 'connected' && !collapsed && ( - - - - - -
-

- {t('layout.triggers-reconnect-hint')} -

- -
-
-
- )} -
-
+ ) : 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 ? ( + + ) : undefined + } + collapsed={collapsed} + tooltip={triggersTabTooltip} + tooltipEnabledWhenCollapsed + ariaLabel={triggersTabAriaLabel} + ariaCurrentPage={activeWorkspaceTab === 'triggers'} + />
@@ -990,173 +610,43 @@ export default function ProjectPageSidebar({ transition={SIDEBAR_LAYOUT_TWEEN} /> - {/* Task List */} -
-
- - {t('layout.task-list-title', { defaultValue: 'Tasks' })} - - -
-
- {allTaskEntries.length === 0 ? ( -

- {t('layout.no-tasks', { defaultValue: 'No tasks' })} -

- ) : ( -
- {allTaskEntries.map( - ({ chatId, taskId, task, firstUserMessageId }) => ( - - ) - )} -
- )} -
-
+ requestWorkspaceChatFocus()} + /> - {/* Bottom section - Profile and Help */} -
-
-
- -
-
- - - - - - setSupportDialogOpen(true)} - > - {t('layout.contact-support')} - - { - void reportBugOpenGithub(); - }} - > - {t('layout.report-bug')} - - { - void downloadLogs(); - }} - > - {t('layout.download-logs', { - defaultValue: 'Download logs', - })} - - - -
-
-
+ 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', + })} + />
diff --git a/src/components/WorkSpaceMenu/index.tsx b/src/components/WorkspaceMenu/index.tsx similarity index 100% rename from src/components/WorkSpaceMenu/index.tsx rename to src/components/WorkspaceMenu/index.tsx diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 2143bde4..f2851e6a 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -180,7 +180,7 @@ const Button = React.forwardRef( const legacyIcon = sizeProp === 'icon'; const resolvedSize: ButtonSize = legacyIcon - ? 'sm' + ? 'xs' : (sizeProp as ButtonSize); const resolvedLayout = buttonContent === 'icon-only'