From 8c7c532b13c0ca1568f2726b98725857a420e722 Mon Sep 17 00:00:00 2001 From: GCWing Date: Wed, 25 Mar 2026 23:32:51 +0800 Subject: [PATCH] feat(web-ui): nav search dialog, agents visibility, theme and workspace helpers - Add NavSearchDialog and wire MainNav/GlobalSearch - Agents scene: visibility helper and list hook updates - Light theme tokens, WelcomePanel polish, i18n strings - projectSessionWorkspace utility for project/session/workspace context --- .../src/app/components/NavPanel/MainNav.tsx | 104 +++++-- .../src/app/components/NavPanel/NavPanel.scss | 92 ++++++- .../components/NavPanel/NavSearchDialog.scss | 243 +++++++++++++++++ .../components/NavPanel/NavSearchDialog.tsx | 254 ++++++++++++++++++ .../sections/sessions/SessionsSection.scss | 14 +- .../sections/workspaces/WorkspaceItem.tsx | 13 +- .../workspaces/WorkspaceListSection.scss | 10 +- .../src/app/components/SceneBar/SceneBar.tsx | 25 +- .../app/components/TitleBar/GlobalSearch.tsx | 149 ++++++---- src/web-ui/src/app/scenes/SceneViewport.scss | 15 +- .../src/app/scenes/agents/AgentsScene.tsx | 7 +- .../src/app/scenes/agents/agentVisibility.ts | 10 + .../app/scenes/agents/hooks/useAgentsList.ts | 20 +- .../profile/views/AssistantConfigPage.tsx | 13 +- .../src/app/utils/projectSessionWorkspace.ts | 111 ++++++++ .../src/flow_chat/components/WelcomePanel.css | 26 +- .../src/flow_chat/components/WelcomePanel.tsx | 8 +- .../infrastructure/theme/core/ThemeService.ts | 4 +- .../theme/presets/light-theme.ts | 14 +- src/web-ui/src/locales/en-US/common.json | 15 +- src/web-ui/src/locales/zh-CN/common.json | 15 +- 21 files changed, 1021 insertions(+), 141 deletions(-) create mode 100644 src/web-ui/src/app/components/NavPanel/NavSearchDialog.scss create mode 100644 src/web-ui/src/app/components/NavPanel/NavSearchDialog.tsx create mode 100644 src/web-ui/src/app/scenes/agents/agentVisibility.ts create mode 100644 src/web-ui/src/app/utils/projectSessionWorkspace.ts diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.tsx index 4446047f..82db06e6 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.tsx @@ -2,9 +2,10 @@ * MainNav — default workspace navigation sidebar. * * Layout (top to bottom): - * 1. Top: New sessions | Assistant | Extensions (expand → Agents | Skills) - * 2. Assistant sessions, Workspace - * 3. Bottom: MiniApp + * 1. Workspace file search + * 2. Top: New sessions | Assistant | Extensions (expand → Agents | Skills) + * 3. Assistant sessions, Workspace + * 4. Bottom: MiniApp * * When a scene-nav transition is active (`isDeparting=true`), items receive * positional CSS classes for the split-open animation effect. @@ -12,7 +13,7 @@ import React, { useCallback, useState, useMemo, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; -import { Plus, FolderOpen, FolderPlus, History, Check, BotMessageSquare, Users, Puzzle, Blocks, ChevronDown } from 'lucide-react'; +import { Plus, FolderOpen, FolderPlus, History, Check, BotMessageSquare, Users, Puzzle, Blocks, ChevronDown, Search } from 'lucide-react'; import { Tooltip } from '@/component-library'; import { useApp } from '../../hooks/useApp'; import { useSceneManager } from '../../hooks/useSceneManager'; @@ -30,9 +31,16 @@ import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { createLogger } from '@/shared/utils/logger'; +import { notificationService } from '@/shared/notification-system'; import { WorkspaceKind, isRemoteWorkspace } from '@/shared/types'; +import { + findReusableEmptySessionId, + flowChatSessionConfigForWorkspace, + pickWorkspaceForProjectChatSession, +} from '@/app/utils/projectSessionWorkspace'; import { useSSHRemoteContext, SSHConnectionDialog, RemoteFileBrowser } from '@/features/ssh-remote'; import { useSessionModeStore } from '../../stores/sessionModeStore'; +import NavSearchDialog from './NavSearchDialog'; import './NavPanel.scss'; @@ -68,6 +76,7 @@ const MainNav: React.FC = ({ recentWorkspaces, openedWorkspacesList, assistantWorkspacesList, + normalWorkspacesList, switchWorkspace, setActiveWorkspace, } = useWorkspaceContext(); @@ -88,6 +97,7 @@ const MainNav: React.FC = ({ const [workspaceMenuClosing, setWorkspaceMenuClosing] = useState(false); const [workspaceMenuPos, setWorkspaceMenuPos] = useState({ top: 0, left: 0 }); const [isExtensionsOpen, setIsExtensionsOpen] = useState(false); + const [searchOpen, setSearchOpen] = useState(false); const toggleSection = useCallback((id: string) => { setExpandedSections(prev => { @@ -137,28 +147,59 @@ const MainNav: React.FC = ({ }); }, [openedWorkspacesList]); - const handleCreateSession = useCallback(async (mode?: 'agentic' | 'Cowork' | 'Claw') => { - openScene('session'); - switchLeftPanelTab('sessions'); - try { - await flowChatManager.createChatSession( - {}, - mode ?? (isAssistantWorkspaceActive ? 'Claw' : 'agentic') - ); - } catch (err) { - log.error('Failed to create session', err); - } - }, [openScene, switchLeftPanelTab, isAssistantWorkspaceActive]); + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault(); + setSearchOpen(v => !v); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, []); + + const handleCreateProjectSession = useCallback( + async (mode: 'agentic' | 'Cowork') => { + const target = pickWorkspaceForProjectChatSession(currentWorkspace, normalWorkspacesList); + if (!target) { + notificationService.warning(t('nav.sessions.needProjectWorkspaceForSession'), { duration: 4500 }); + return; + } + openScene('session'); + switchLeftPanelTab('sessions'); + try { + if (target.id !== currentWorkspace?.id) { + await setActiveWorkspace(target.id); + } + const reusableId = findReusableEmptySessionId(target, mode); + if (reusableId) { + await flowChatManager.switchChatSession(reusableId); + return; + } + await flowChatManager.createChatSession(flowChatSessionConfigForWorkspace(target), mode); + } catch (err) { + log.error('Failed to create session', err); + } + }, + [ + currentWorkspace, + normalWorkspacesList, + openScene, + setActiveWorkspace, + switchLeftPanelTab, + t, + ] + ); const handleCreateCodeSession = useCallback(() => { setSessionMode('code'); - void handleCreateSession('agentic'); - }, [handleCreateSession, setSessionMode]); + void handleCreateProjectSession('agentic'); + }, [handleCreateProjectSession, setSessionMode]); const handleCreateCoworkSession = useCallback(() => { setSessionMode('cowork'); - void handleCreateSession('Cowork'); - }, [handleCreateSession, setSessionMode]); + void handleCreateProjectSession('Cowork'); + }, [handleCreateProjectSession, setSessionMode]); const handleOpenProject = useCallback(async () => { try { @@ -338,6 +379,27 @@ const MainNav: React.FC = ({ const extensionsLabel = t('nav.sections.extensions'); return ( <> + {/* ── Workspace search ───────────────────────── */} +
+
+ + + + setSearchOpen(false)} /> +
+
+ {/* ── Top action strip ────────────────────────── */}
@@ -462,7 +524,7 @@ const MainNav: React.FC = ({ />
-
+
{assistantWorkspacesList.map(workspace => { const assistantDisplayName = workspace.workspaceKind === WorkspaceKind.Assistant diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index 044af6fd..a9c47d56 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -520,7 +520,9 @@ $_section-header-height: 24px; flex: 1 1 auto; overflow-y: auto; overflow-x: hidden; - padding: $size-gap-5 0 $size-gap-3 0; + // No padding-top: separation from top-actions is only .bitfun-nav-panel__top-actions padding-bottom. + // Top padding here stacked with that and read as “extra” gap above the first section header. + padding: 0 0 $size-gap-3 0; &::-webkit-scrollbar { width: 3px; } &::-webkit-scrollbar-track { background: transparent; } @@ -994,6 +996,11 @@ $_section-header-height: 24px; flex-direction: column; padding: 2px $size-gap-2; gap: 2px; + + // Multiple SessionsSection siblings: match vertical rhythm of __workspace-list (gap 0 + 2px row padding). + &--session-blocks { + gap: 0; + } } button.bitfun-nav-panel__inline-item { @@ -1020,10 +1027,14 @@ $_section-header-height: 24px; // Item slots depart with the split-open animation used by scene navs. &__section { - margin-bottom: $size-gap-5; + margin-bottom: $size-gap-5 * 0.75; transition: transform $_depart-duration $_depart-easing, opacity $_depart-duration $_depart-easing; + &:last-child { + margin-bottom: $size-gap-5; + } + &.is-departing-up { transform: translateY(-$_depart-distance); opacity: 0; @@ -1703,6 +1714,51 @@ $_section-header-height: 24px; filter: none; } } + + .bitfun-nav-panel__top-action-icon-circle { + transition: none; + } + + .bitfun-nav-panel__top-action-btn:hover .bitfun-nav-panel__top-action-icon-circle { + transform: scale(1); + } + + .bitfun-nav-panel__top-action-btn:not(.bitfun-nav-panel__top-action-btn--sub) .bitfun-nav-panel__top-action-icon-slot { + transition: none; + } + + .bitfun-nav-panel__top-action-btn:not(.bitfun-nav-panel__top-action-btn--sub):hover .bitfun-nav-panel__top-action-icon-slot { + transform: scale(1); + } +} + +// ── Nav workspace search ───────────────────────── + +.bitfun-nav-panel__brand-header { + display: flex; + flex-direction: column; + padding: $size-gap-2 $size-gap-2 $size-gap-1; + flex-shrink: 0; +} + +.bitfun-nav-panel__brand-search { + width: 100%; + min-width: 0; + + // Legacy GlobalSearch overrides kept for reference but no longer rendered. + // Trigger button styles live in NavSearchDialog.scss. + + .bitfun-global-search--nav-panel .bitfun-global-search__option { + width: 22px; + height: 22px; + min-width: 22px; + padding: 0; + + svg { + width: 11px; + height: 11px; + } + } } // ── Top action strip ───────────────────────────── @@ -1711,7 +1767,7 @@ $_section-header-height: 24px; display: flex; flex-direction: column; gap: $size-gap-1; - padding: $size-gap-2 $size-gap-2 $size-gap-4; + padding: $size-gap-2 $size-gap-2 $size-gap-4 * 0.75; flex-shrink: 0; } @@ -1719,6 +1775,10 @@ $_section-header-height: 24px; display: flex; flex-direction: column; gap: 1px; + // Divider below 扩展 (main row + optional Agents / Skills sublist) → scrollable sections + padding-bottom: $size-gap-2; + margin-bottom: $size-gap-1; + border-bottom: 1px solid var(--border-subtle); } .bitfun-nav-panel__top-action-sublist { @@ -1777,6 +1837,17 @@ $_section-header-height: 24px; justify-content: center; } + // 助理行:前导 icon 悬停微微放大(与 __top-action-icon-circle 一致;排除 --sub 的 Agents / Skills) + &:not(.bitfun-nav-panel__top-action-btn--sub) .bitfun-nav-panel__top-action-icon-slot { + transform: scale(1); + transform-origin: center; + transition: transform $motion-fast $easing-standard; + } + + &:not(.bitfun-nav-panel__top-action-btn--sub):hover .bitfun-nav-panel__top-action-icon-slot { + transform: scale(1.07); + } + &:hover { background: var(--element-bg-soft); color: var(--color-text-primary); @@ -1795,7 +1866,7 @@ $_section-header-height: 24px; } &--sub { - padding-left: 28px; + padding-left: 18px; } } @@ -1851,9 +1922,18 @@ $_section-header-height: 24px; justify-content: center; border-radius: 50%; background: var(--element-bg-medium); - transition: background $motion-fast $easing-standard; + color: var(--color-text-muted); + transform: scale(1); + transform-origin: center; + transition: background $motion-fast $easing-standard, + color $motion-fast $easing-standard, + box-shadow $motion-fast $easing-standard, + transform $motion-fast $easing-standard; .bitfun-nav-panel__top-action-btn:hover & { - background: color-mix(in srgb, var(--color-primary) 18%, var(--element-bg-medium)); + background: var(--element-bg-strong); + color: var(--color-primary); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 28%, transparent); + transform: scale(1.07); } } diff --git a/src/web-ui/src/app/components/NavPanel/NavSearchDialog.scss b/src/web-ui/src/app/components/NavPanel/NavSearchDialog.scss new file mode 100644 index 00000000..c2a03d64 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/NavSearchDialog.scss @@ -0,0 +1,243 @@ +@use '../../../component-library/styles/tokens.scss' as *; + +// ── Overlay ────────────────────────────────────────────────────────────────── + +.bitfun-nav-search-dialog__overlay { + position: fixed; + inset: 0; + z-index: 600; + background: rgba(0, 0, 0, 0.35); + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 80px; + animation: bitfun-nav-search-overlay-in 0.12s ease; +} + +@keyframes bitfun-nav-search-overlay-in { + from { opacity: 0; } + to { opacity: 1; } +} + +// ── Card ───────────────────────────────────────────────────────────────────── + +.bitfun-nav-search-dialog__card { + width: 520px; + max-width: calc(100vw - 32px); + background: var(--color-bg-elevated); + border-radius: $size-radius-lg; + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.25), + 0 2px 8px rgba(0, 0, 0, 0.15), + inset 0 0 0 1px var(--border-subtle); + overflow: hidden; + display: flex; + flex-direction: column; + max-height: calc(100vh - 120px); + animation: bitfun-nav-search-card-in 0.14s ease; +} + +@keyframes bitfun-nav-search-card-in { + from { opacity: 0; transform: translateY(-8px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +// ── Input row ──────────────────────────────────────────────────────────────── + +.bitfun-nav-search-dialog__input-row { + padding: $size-gap-3; + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; +} + +// Search component — capsule style matching the nav trigger +.bitfun-nav-search-dialog__search { + width: 100%; + + .search__wrapper { + height: 32px; + border-radius: $size-radius-full; + background: var(--element-bg-soft); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border-subtle) 65%, transparent); + + &:hover { + background: var(--element-bg-medium); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border-medium) 50%, transparent); + } + + &:focus-within { + background: var(--element-bg-medium); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border-medium) 65%, transparent); + } + } + + .search__input { + font-size: $font-size-sm; + } +} + +// ── Results ────────────────────────────────────────────────────────────────── + +.bitfun-nav-search-dialog__results { + overflow-y: auto; + flex: 1; + padding: $size-gap-1 0; + + &::-webkit-scrollbar { + width: 4px; + } + &::-webkit-scrollbar-thumb { + background: var(--border-medium); + border-radius: 2px; + } +} + +.bitfun-nav-search-dialog__empty { + padding: $size-gap-4; + text-align: center; + color: var(--color-text-muted); + font-size: $font-size-sm; +} + +// ── Groups ─────────────────────────────────────────────────────────────────── + +.bitfun-nav-search-dialog__group { + & + & { + margin-top: $size-gap-1; + border-top: 1px solid var(--border-subtle); + padding-top: $size-gap-1; + } +} + +.bitfun-nav-search-dialog__group-label { + padding: $size-gap-1 $size-gap-3; + font-size: $font-size-xs; + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + user-select: none; +} + +// ── Result item ────────────────────────────────────────────────────────────── + +.bitfun-nav-search-dialog__item { + display: flex; + align-items: center; + gap: $size-gap-2; + width: 100%; + padding: 5px $size-gap-3; + border: none; + background: transparent; + cursor: pointer; + text-align: left; + color: var(--color-text-primary); + transition: background 0.08s ease; + + &:hover { + background: var(--element-bg-soft); + } + + &--active { + background: var(--element-bg-medium); + } +} + +.bitfun-nav-search-dialog__item-icon { + color: var(--color-text-muted); + display: flex; + align-items: center; + flex-shrink: 0; +} + +.bitfun-nav-search-dialog__item-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} + +.bitfun-nav-search-dialog__item-label { + font-size: $font-size-sm; + color: var(--color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.bitfun-nav-search-dialog__item-sublabel { + font-size: $font-size-xs; + color: var(--color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +// ── Trigger button (in NavPanel brand-header) ──────────────────────────────── + +.bitfun-nav-panel__search-trigger { + display: flex; + align-items: center; + gap: $size-gap-2; + width: 100%; + min-height: 32px; + padding: 0 $size-gap-2; + border: none; + // Capsule shape + border-radius: $size-radius-full; + background: var(--element-bg-soft); + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--border-subtle) 65%, transparent); + cursor: text; + color: var(--color-text-muted); + font-size: $font-size-xs; + text-align: left; + transition: + background 0.15s ease, + box-shadow 0.15s ease, + transform 0.12s ease, + color 0.15s ease; + + &:hover { + background: var(--element-bg-medium); + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--border-medium) 55%, transparent), + 0 2px 8px rgba(0, 0, 0, 0.12), + 0 1px 3px rgba(0, 0, 0, 0.08); + transform: translateY(-1px); + color: var(--color-text-secondary); + cursor: text; + } + + &:active { + transform: translateY(0); + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--border-medium) 45%, transparent), + 0 1px 3px rgba(0, 0, 0, 0.08); + } +} + +.bitfun-nav-panel__search-trigger__label { + flex: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.bitfun-nav-panel__search-trigger__kbd { + flex-shrink: 0; + font-size: 10px; + color: var(--color-text-muted); + background: var(--element-bg-subtle); + border: 1px solid var(--border-subtle); + border-radius: $size-radius-sm; + padding: 1px 5px; + line-height: 1.4; + opacity: 0.6; + transition: opacity 0.15s ease; + + .bitfun-nav-panel__search-trigger:hover & { + opacity: 0.85; + } +} diff --git a/src/web-ui/src/app/components/NavPanel/NavSearchDialog.tsx b/src/web-ui/src/app/components/NavPanel/NavSearchDialog.tsx new file mode 100644 index 00000000..75d7966f --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/NavSearchDialog.tsx @@ -0,0 +1,254 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { FolderOpen, Bot, MessageSquare } from 'lucide-react'; +import { Search } from '@/component-library'; +import { useI18n } from '@/infrastructure/i18n'; +import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; +import { useSceneManager } from '@/app/hooks/useSceneManager'; +import { useApp } from '@/app/hooks/useApp'; +import { useMyAgentStore } from '@/app/scenes/my-agent/myAgentStore'; +import { useNurseryStore } from '@/app/scenes/profile/nurseryStore'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import { openMainSession } from '@/flow_chat/services/openBtwSession'; +import type { FlowChatState, Session } from '@/flow_chat/types/flow-chat'; +import type { WorkspaceInfo } from '@/shared/types'; +import { WorkspaceKind } from '@/shared/types'; +import './NavSearchDialog.scss'; + +interface NavSearchDialogProps { + open: boolean; + onClose: () => void; +} + +type ResultKind = 'workspace' | 'assistant' | 'session'; + +interface SearchResultItem { + kind: ResultKind; + id: string; + label: string; + sublabel?: string; + workspaceId?: string; +} + +const MAX_PER_GROUP = 20; + +const getTitle = (session: Session): string => + session.title?.trim() || `Session ${session.sessionId.slice(0, 6)}`; + +const matchesQuery = (query: string, ...fields: (string | undefined | null)[]): boolean => { + const q = query.toLowerCase(); + return fields.some(f => f && f.toLowerCase().includes(q)); +}; + +const NavSearchDialog: React.FC = ({ open, onClose }) => { + const { t } = useI18n('common'); + const { openedWorkspacesList, assistantWorkspacesList, setActiveWorkspace } = useWorkspaceContext(); + const { openScene } = useSceneManager(); + const { switchLeftPanelTab } = useApp(); + const setSelectedAssistantWorkspaceId = useMyAgentStore(s => s.setSelectedAssistantWorkspaceId); + const openNurseryAssistant = useNurseryStore(s => s.openAssistant); + const [query, setQuery] = useState(''); + const [activeIndex, setActiveIndex] = useState(0); + const [flowChatState, setFlowChatState] = useState(() => flowChatStore.getState()); + const inputRef = useRef(null); + const listRef = useRef(null); + const cardRef = useRef(null); + + useEffect(() => { + if (!open) return; + const unsub = flowChatStore.subscribe(s => setFlowChatState(s)); + return () => unsub(); + }, [open]); + + useEffect(() => { + if (open) { + setQuery(''); + setActiveIndex(0); + } + }, [open]); + + const projectWorkspaces = useMemo( + () => openedWorkspacesList.filter(w => w.workspaceKind !== WorkspaceKind.Assistant), + [openedWorkspacesList] + ); + + const allSessions = useMemo((): Array<{ session: Session; workspace: WorkspaceInfo | undefined }> => { + const result: Array<{ session: Session; workspace: WorkspaceInfo | undefined }> = []; + const allWorkspaces = [...openedWorkspacesList]; + for (const session of flowChatState.sessions.values()) { + const workspace = allWorkspaces.find(w => w.rootPath === session.workspacePath); + result.push({ session, workspace }); + } + result.sort((a, b) => { + const aTime = a.session.updatedAt ?? a.session.createdAt ?? 0; + const bTime = b.session.updatedAt ?? b.session.createdAt ?? 0; + return bTime - aTime; + }); + return result; + }, [flowChatState.sessions, openedWorkspacesList]); + + const results = useMemo((): SearchResultItem[] => { + if (!query.trim()) return []; + + const items: SearchResultItem[] = []; + + const filteredWorkspaces = projectWorkspaces + .filter(w => matchesQuery(query, w.name, w.rootPath)) + .slice(0, MAX_PER_GROUP); + for (const w of filteredWorkspaces) { + items.push({ kind: 'workspace', id: w.id, label: w.name, sublabel: w.rootPath }); + } + + const filteredAssistants = assistantWorkspacesList + .filter(w => matchesQuery(query, w.name, w.identity?.name, w.description)) + .slice(0, MAX_PER_GROUP); + for (const w of filteredAssistants) { + const displayName = w.identity?.name?.trim() || w.name; + items.push({ kind: 'assistant', id: w.id, label: displayName, sublabel: w.description }); + } + + const filteredSessions = allSessions + .filter(({ session }) => !session.parentSessionId && matchesQuery(query, getTitle(session))) + .slice(0, MAX_PER_GROUP); + for (const { session, workspace } of filteredSessions) { + items.push({ + kind: 'session', + id: session.sessionId, + label: getTitle(session), + sublabel: workspace ? t('nav.search.sessionWorkspaceHint', { workspace: workspace.name }) : undefined, + workspaceId: workspace?.id, + }); + } + + return items; + }, [query, projectWorkspaces, assistantWorkspacesList, allSessions, t]); + + useEffect(() => { + setActiveIndex(0); + }, [results.length]); + + const handleSelect = useCallback(async (item: SearchResultItem) => { + onClose(); + if (item.kind === 'workspace') { + await setActiveWorkspace(item.id); + } else if (item.kind === 'assistant') { + setSelectedAssistantWorkspaceId(item.id); + openNurseryAssistant(item.id); + await setActiveWorkspace(item.id).catch(() => {}); + switchLeftPanelTab('profile'); + openScene('assistant'); + } else if (item.kind === 'session') { + await openMainSession(item.id, { + workspaceId: item.workspaceId, + activateWorkspace: item.workspaceId ? setActiveWorkspace : undefined, + }); + } + }, [onClose, setActiveWorkspace, setSelectedAssistantWorkspaceId, openNurseryAssistant, switchLeftPanelTab, openScene]); + + // Passed to Search component's onKeyDown — called before its built-in handling. + // Use e.preventDefault() to suppress Search's own Enter/Escape logic when needed. + const handleInputKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + return; + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIndex(i => Math.min(i + 1, results.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex(i => Math.max(i - 1, 0)); + } else if (e.key === 'Enter') { + e.preventDefault(); + const item = results[activeIndex]; + if (item) void handleSelect(item); + } + }, [activeIndex, handleSelect, onClose, results]); + + // Scroll active item into view + useEffect(() => { + const list = listRef.current; + if (!list) return; + const active = list.querySelector('.bitfun-nav-search-dialog__item--active'); + active?.scrollIntoView({ block: 'nearest' }); + }, [activeIndex]); + + if (!open) return null; + + const workspaceItems = results.filter(r => r.kind === 'workspace'); + const assistantItems = results.filter(r => r.kind === 'assistant'); + const sessionItems = results.filter(r => r.kind === 'session'); + + let globalIndex = 0; + const renderGroup = ( + groupLabel: string, + items: SearchResultItem[], + icon: (item: SearchResultItem) => React.ReactNode + ) => { + if (items.length === 0) return null; + const startIndex = globalIndex; + globalIndex += items.length; + return ( +
+
{groupLabel}
+ {items.map((item, i) => { + const idx = startIndex + i; + return ( + + ); + })} +
+ ); + }; + + const dialog = ( +
{ if (e.target === e.currentTarget) onClose(); }}> +
+
+ setQuery('')} + onKeyDown={handleInputKeyDown} + clearable + size="medium" + autoFocus + /> +
+
+ {results.length === 0 ? ( +
{t('nav.search.empty')}
+ ) : ( + <> + {renderGroup(t('nav.search.groupWorkspaces'), workspaceItems, () => )} + {renderGroup(t('nav.search.groupAssistants'), assistantItems, () => )} + {renderGroup(t('nav.search.groupSessions'), sessionItems, () => )} + + )} +
+
+
+ ); + + return createPortal(dialog, document.body); +}; + +export default NavSearchDialog; diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss index 0850441b..9d90526c 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss @@ -11,9 +11,11 @@ &__inline-list { display: flex; flex-direction: column; - padding: 2px $size-gap-2 $size-gap-1; + padding: 2px $size-gap-2 2px; gap: 1px; - margin: 1px $size-gap-2 2px calc(#{$size-gap-2} + 14px); + // No vertical margin: spacing between assistant blocks comes from 2px top/bottom padding only + // (see .bitfun-nav-panel__items--session-blocks gap: 0), aligned with __workspace-item padding. + margin: 0 $size-gap-2 0 calc(#{$size-gap-2} + 4px); } &__inline-action { @@ -94,13 +96,13 @@ &.is-child { min-height: 24px; font-size: 12px; - padding-left: calc(#{$size-gap-1} + 18px); + padding-left: calc(#{$size-gap-1} + 8px); position: relative; &::before { content: ''; position: absolute; - left: 10px; + left: 6px; top: 0; width: 1px; height: 50%; @@ -111,9 +113,9 @@ &::after { content: ''; position: absolute; - left: 10px; + left: 6px; top: 50%; - width: 12px; + width: 6px; height: 1px; background: color-mix(in srgb, var(--border-subtle) 92%, transparent); opacity: 0.95; diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index b8c9a8aa..ba7cbd89 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -14,6 +14,8 @@ import { useGitBasicInfo } from '@/tools/git/hooks/useGitState'; import { workspaceAPI } from '@/infrastructure/api'; import { notificationService } from '@/shared/notification-system'; import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; +import { openMainSession } from '@/flow_chat/services/openBtwSession'; +import { findReusableEmptySessionId } from '@/app/utils/projectSessionWorkspace'; import { BranchSelectModal, type BranchSelectResult } from '../../../panels/BranchSelectModal'; import SessionsSection from '../sessions/SessionsSection'; import { @@ -243,7 +245,16 @@ const WorkspaceItem: React.FC = ({ const handleCreateSession = useCallback(async (mode?: 'agentic' | 'Cowork' | 'Claw') => { setMenuOpen(false); + const resolvedMode = mode ?? (workspace.workspaceKind === WorkspaceKind.Assistant ? 'Claw' : undefined); try { + const reusableId = findReusableEmptySessionId(workspace, resolvedMode); + if (reusableId) { + await openMainSession(reusableId, { + workspaceId: workspace.id, + activateWorkspace: setActiveWorkspace, + }); + return; + } await flowChatManager.createChatSession( { workspacePath: workspace.rootPath, @@ -251,7 +262,7 @@ const WorkspaceItem: React.FC = ({ ? { remoteConnectionId: workspace.connectionId } : {}), }, - mode ?? (workspace.workspaceKind === WorkspaceKind.Assistant ? 'Claw' : undefined) + resolvedMode ); await setActiveWorkspace(workspace.id); } catch (error) { diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss index 4bf9aa38..a6e18210 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss @@ -4,8 +4,8 @@ &__workspace-list { display: flex; flex-direction: column; - gap: $size-gap-2; - padding: 4px 0 0; + gap: 0; + padding: 2px 0 0; &.is-dragging { .bitfun-nav-panel__workspace-item { @@ -75,7 +75,7 @@ display: flex; flex-direction: column; gap: 2px; - padding: $size-gap-1; + padding: 2px $size-gap-1; border-radius: $size-radius-base; background: transparent; border: 1px solid transparent; @@ -533,7 +533,7 @@ } .bitfun-nav-panel__inline-list { - margin-left: 18px; + margin-left: 12px; margin-right: 0; padding-top: 0; } @@ -799,7 +799,7 @@ } .bitfun-nav-panel__inline-list { - margin-left: 18px; + margin-left: 12px; margin-right: 0; padding-top: 0; } diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.tsx b/src/web-ui/src/app/components/SceneBar/SceneBar.tsx index 174f3210..a5200a07 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.tsx +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.tsx @@ -13,7 +13,14 @@ import { useCurrentSessionTitle } from '../../hooks/useCurrentSessionTitle'; import { useCurrentSettingsTabTitle } from '../../hooks/useCurrentSettingsTabTitle'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; +import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; +import { notificationService } from '@/shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; +import { + findReusableEmptySessionId, + flowChatSessionConfigForWorkspace, + pickWorkspaceForProjectChatSession, +} from '@/app/utils/projectSessionWorkspace'; import './SceneBar.scss'; const log = createLogger('SceneBar'); @@ -37,6 +44,7 @@ const SceneBar: React.FC = ({ isMaximized = false, }) => { const { openTabs, activeTabId, tabDefs, activateScene, closeScene } = useSceneManager(); + const { currentWorkspace, normalWorkspacesList, setActiveWorkspace } = useWorkspaceContext(); const sessionTitle = useCurrentSessionTitle(); const settingsTabTitle = useCurrentSettingsTabTitle(); const { t } = useI18n('common'); @@ -81,13 +89,26 @@ const SceneBar: React.FC = ({ }, [isSingleTab, onMaximize]); const handleCreateSession = useCallback(async () => { + const target = pickWorkspaceForProjectChatSession(currentWorkspace, normalWorkspacesList); + if (!target) { + notificationService.warning(t('nav.sessions.needProjectWorkspaceForSession'), { duration: 4500 }); + return; + } activateScene('session'); try { - await flowChatManager.createChatSession({}); + if (target.id !== currentWorkspace?.id) { + await setActiveWorkspace(target.id); + } + const reusableId = findReusableEmptySessionId(target, 'agentic'); + if (reusableId) { + await flowChatManager.switchChatSession(reusableId); + return; + } + await flowChatManager.createChatSession(flowChatSessionConfigForWorkspace(target), 'agentic'); } catch (err) { log.error('Failed to create session', err); } - }, [activateScene]); + }, [activateScene, currentWorkspace, normalWorkspacesList, setActiveWorkspace, t]); return (
void; + /** + * Render the results dropdown in document.body with fixed positioning. + * Use when the parent uses overflow:hidden (e.g. sidebar) so results are not clipped. + */ + attachResultsToBody?: boolean; } // Result item component (memoized) @@ -92,12 +98,15 @@ SearchPhaseIndicator.displayName = 'SearchPhaseIndicator'; export const GlobalSearch: React.FC = ({ className = '', - onSearchResultClick + onSearchResultClick, + attachResultsToBody = false, }) => { const { t } = useI18n('tools'); const { workspacePath } = useWorkspaceContext(); const searchContainerRef = useRef(null); + const resultsDropdownRef = useRef(null); const resultsListRef = useRef(null); + const [dropdownLayout, setDropdownLayout] = useState({ top: 0, left: 0, width: 0 }); const [showResults, setShowResults] = useState(false); const [displayCount, setDisplayCount] = useState(INITIAL_DISPLAY_COUNT); @@ -149,12 +158,30 @@ export const GlobalSearch: React.FC = ({ setShowResults(false); }, [clearSearch]); + const updateDropdownPosition = useCallback(() => { + if (!attachResultsToBody || !searchContainerRef.current) return; + const r = searchContainerRef.current.getBoundingClientRect(); + setDropdownLayout({ top: r.bottom + 6, left: r.left, width: r.width }); + }, [attachResultsToBody]); + + useLayoutEffect(() => { + if (!attachResultsToBody || !showResults) return; + updateDropdownPosition(); + window.addEventListener('resize', updateDropdownPosition); + window.addEventListener('scroll', updateDropdownPosition, true); + return () => { + window.removeEventListener('resize', updateDropdownPosition); + window.removeEventListener('scroll', updateDropdownPosition, true); + }; + }, [attachResultsToBody, showResults, updateDropdownPosition, query, allResults.length]); + // Close results when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (searchContainerRef.current && !searchContainerRef.current.contains(event.target as Node)) { - setShowResults(false); - } + const target = event.target as Node; + if (searchContainerRef.current?.contains(target)) return; + if (resultsDropdownRef.current?.contains(target)) return; + setShowResults(false); }; document.addEventListener('mousedown', handleClickOutside); @@ -207,6 +234,67 @@ export const GlobalSearch: React.FC = ({ }); }, []); + const showResultsPanel = showResults && (query.trim() || allResults.length > 0); + + const resultsDropdown = showResultsPanel ? ( +
+
+ + {allResults.length > 0 ? ( + <> + {t('search.global.resultsFound', { count: allResults.length })} + {hasMoreResults && ( + + {t('search.global.resultsShowing', { count: displayCount })} + + )} + + ) : ( + isSearching ? '' : t('search.noResults') + )} + + +
+ + {displayResults.length > 0 && ( +
+ {displayResults.map((result, index) => ( + handleResultClick(result)} + /> + ))} + + {hasMoreResults && ( + + )} +
+ )} +
+ ) : null; + return (
= ({ } /> - {/* Search results dropdown */} - {showResults && (query.trim() || allResults.length > 0) && ( -
-
- - {allResults.length > 0 ? ( - <> - {t('search.global.resultsFound', { count: allResults.length })} - {hasMoreResults && ( - - {t('search.global.resultsShowing', { count: displayCount })} - - )} - - ) : ( - isSearching ? '' : t('search.noResults') - )} - - -
- - {displayResults.length > 0 && ( -
- {displayResults.map((result, index) => ( - handleResultClick(result)} - /> - ))} - - {/* Load more button */} - {hasMoreResults && ( - - )} -
- )} -
- )} + {!attachResultsToBody && resultsDropdown} + {attachResultsToBody && resultsDropdown ? createPortal(resultsDropdown, document.body) : null}
); }; diff --git a/src/web-ui/src/app/scenes/SceneViewport.scss b/src/web-ui/src/app/scenes/SceneViewport.scss index 6be09c0a..65e6aa6f 100644 --- a/src/web-ui/src/app/scenes/SceneViewport.scss +++ b/src/web-ui/src/app/scenes/SceneViewport.scss @@ -10,8 +10,21 @@ overflow: hidden; min-height: 0; border-radius: $size-radius-base; - border: none; + border: 1px solid var(--border-subtle); background: var(--color-bg-scene); + // Soft lift off workbench — layered shadow, kept subtle + box-shadow: + 0 1px 2px rgba(15, 23, 42, 0.04), + 0 8px 24px -6px rgba(15, 23, 42, 0.08); + + // Subtle token reads too faint on white scene surface — use stronger edge on light themes + :root[data-theme-type='light'] & { + border-color: var(--border-medium); + } + + :root[data-theme-type='dark'] & { + box-shadow: 0 10px 32px -8px rgba(0, 0, 0, 0.55); + } &__clip { position: absolute; diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx index c88377f7..be403a2b 100644 --- a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx @@ -33,10 +33,7 @@ import { getAgentBadge } from './utils'; import './AgentsView.scss'; import './AgentsScene.scss'; import { useGallerySceneAutoRefresh } from '@/app/hooks/useGallerySceneAutoRefresh'; - -const HIDDEN_AGENT_IDS = new Set(['Claw']); - -const CORE_AGENT_IDS = new Set(['agentic', 'Cowork']); +import { CORE_AGENT_IDS, isAgentInOverviewZone } from './agentVisibility'; const AgentsHomeView: React.FC = () => { const { t } = useTranslation('scenes/agents'); @@ -99,7 +96,7 @@ const AgentsHomeView: React.FC = () => { const coreAgents = useMemo(() => allAgents.filter((agent) => CORE_AGENT_IDS.has(agent.id)), [allAgents]); const visibleAgents = useMemo( - () => filteredAgents.filter((agent) => !HIDDEN_AGENT_IDS.has(agent.id) && !CORE_AGENT_IDS.has(agent.id)), + () => filteredAgents.filter(isAgentInOverviewZone), [filteredAgents], ); diff --git a/src/web-ui/src/app/scenes/agents/agentVisibility.ts b/src/web-ui/src/app/scenes/agents/agentVisibility.ts new file mode 100644 index 00000000..a2ec054f --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/agentVisibility.ts @@ -0,0 +1,10 @@ +/** Agent IDs hidden from the Agents overview UI (not listed, not counted). */ +export const HIDDEN_AGENT_IDS = new Set(['Claw']); + +/** Core mode agents shown in the top zone only; excluded from overview zone list and counts. */ +export const CORE_AGENT_IDS = new Set(['agentic', 'Cowork']); + +/** Agents that appear in the bottom “Agent 总览” grid (same pool as filter chip counts). */ +export function isAgentInOverviewZone(agent: { id: string }): boolean { + return !HIDDEN_AGENT_IDS.has(agent.id) && !CORE_AGENT_IDS.has(agent.id); +} diff --git a/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts index e8b0c0db..489e0677 100644 --- a/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts +++ b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts @@ -7,6 +7,7 @@ import type { ModeConfigItem, SkillInfo } from '@/infrastructure/config/types'; import { useNotification } from '@/shared/notification-system'; import type { AgentWithCapabilities } from '../agentsStore'; import { enrichCapabilities } from '../utils'; +import { isAgentInOverviewZone } from '../agentVisibility'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; export type FilterLevel = 'all' | 'builtin' | 'user' | 'project'; @@ -217,14 +218,19 @@ export function useAgentsList({ return true; }), [allAgents, filterLevel, filterType, searchQuery]); + const overviewAgents = useMemo( + () => allAgents.filter(isAgentInOverviewZone), + [allAgents], + ); + const counts = useMemo(() => ({ - all: allAgents.length, - builtin: allAgents.filter((agent) => (agent.agentKind === 'mode' ? 'builtin' : (agent.subagentSource ?? 'builtin')) === 'builtin').length, - user: allAgents.filter((agent) => agent.subagentSource === 'user').length, - project: allAgents.filter((agent) => agent.subagentSource === 'project').length, - mode: allAgents.filter((agent) => agent.agentKind === 'mode').length, - subagent: allAgents.filter((agent) => agent.agentKind === 'subagent').length, - }), [allAgents]); + all: overviewAgents.length, + builtin: overviewAgents.filter((agent) => (agent.agentKind === 'mode' ? 'builtin' : (agent.subagentSource ?? 'builtin')) === 'builtin').length, + user: overviewAgents.filter((agent) => agent.subagentSource === 'user').length, + project: overviewAgents.filter((agent) => agent.subagentSource === 'project').length, + mode: overviewAgents.filter((agent) => agent.agentKind === 'mode').length, + subagent: overviewAgents.filter((agent) => agent.agentKind === 'subagent').length, + }), [overviewAgents]); return { allAgents, diff --git a/src/web-ui/src/app/scenes/profile/views/AssistantConfigPage.tsx b/src/web-ui/src/app/scenes/profile/views/AssistantConfigPage.tsx index 288a1ce4..5aa88bea 100644 --- a/src/web-ui/src/app/scenes/profile/views/AssistantConfigPage.tsx +++ b/src/web-ui/src/app/scenes/profile/views/AssistantConfigPage.tsx @@ -67,11 +67,14 @@ const AssistantConfigPage: React.FC = () => { const inList = (id: string | null | undefined) => id && assistantWorkspacesList.some((w) => w.id === id) ? id : null; - if (currentWorkspace?.workspaceKind === WorkspaceKind.Assistant) { - const id = inList(currentWorkspace.id); - if (id) return id; - } - return inList(activeWorkspaceId) ?? inList(selectedAssistantWorkspaceId) ?? null; + // Explicit selection from nursery gallery takes highest priority, + // followed by the selected assistant store, then the active workspace. + return ( + inList(activeWorkspaceId) ?? + inList(selectedAssistantWorkspaceId) ?? + (currentWorkspace?.workspaceKind === WorkspaceKind.Assistant ? inList(currentWorkspace.id) : null) ?? + null + ); }, [ activeWorkspaceId, assistantWorkspacesList, diff --git a/src/web-ui/src/app/utils/projectSessionWorkspace.ts b/src/web-ui/src/app/utils/projectSessionWorkspace.ts new file mode 100644 index 00000000..9aa6a953 --- /dev/null +++ b/src/web-ui/src/app/utils/projectSessionWorkspace.ts @@ -0,0 +1,111 @@ +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import type { Session } from '@/flow_chat/types/flow-chat'; +import { WorkspaceKind, isRemoteWorkspace, type WorkspaceInfo } from '@/shared/types'; + +type SessionDisplayBucket = 'code' | 'cowork' | 'claw'; + +function normalizeAgentModeForWorkspace(mode: string | undefined, workspace: WorkspaceInfo): string { + if (workspace.workspaceKind === WorkspaceKind.Assistant) { + return 'Claw'; + } + return mode || 'agentic'; +} + +function sessionDisplayBucket(sessionMode: string | undefined, workspace: WorkspaceInfo): SessionDisplayBucket { + if (workspace.workspaceKind === WorkspaceKind.Assistant) { + return 'claw'; + } + if (!sessionMode) { + return 'code'; + } + const normalized = sessionMode.toLowerCase(); + if (normalized === 'cowork') { + return 'cowork'; + } + if (normalized === 'claw') { + return 'claw'; + } + return 'code'; +} + +function targetDisplayBucket(requestedMode: string | undefined, workspace: WorkspaceInfo): SessionDisplayBucket { + const agentMode = normalizeAgentModeForWorkspace(requestedMode, workspace); + return sessionDisplayBucket(agentMode, workspace); +} + +function sessionBelongsToWorkspace(session: Session, workspace: WorkspaceInfo): boolean { + const path = session.workspacePath?.trim(); + const root = workspace.rootPath?.trim(); + if (!path || !root || path !== root) { + return false; + } + if (isRemoteWorkspace(workspace)) { + const wc = workspace.connectionId?.trim() ?? ''; + const sc = session.remoteConnectionId?.trim() ?? ''; + if (wc.length > 0 || sc.length > 0) { + return wc === sc; + } + } + return true; +} + +function isEmptyReusableSession(session: Session, workspace: WorkspaceInfo, bucket: SessionDisplayBucket): boolean { + if (session.sessionKind !== 'normal') { + return false; + } + if (session.isHistorical) { + return false; + } + if (session.dialogTurns.length > 0) { + return false; + } + if (!sessionBelongsToWorkspace(session, workspace)) { + return false; + } + return sessionDisplayBucket(session.mode, workspace) === bucket; +} + +/** + * If the workspace already has a main session with no dialog turns for the same UI mode + * (Code / Cowork / Claw), return its id so callers can switch instead of creating another. + */ +export function findReusableEmptySessionId( + workspace: WorkspaceInfo, + requestedMode?: string +): string | null { + const bucket = targetDisplayBucket(requestedMode, workspace); + const sessions = flowChatStore.getState().sessions; + let best: { id: string; lastActiveAt: number } | null = null; + for (const session of sessions.values()) { + if (!isEmptyReusableSession(session, workspace, bucket)) { + continue; + } + if (!best || session.lastActiveAt > best.lastActiveAt) { + best = { id: session.sessionId, lastActiveAt: session.lastActiveAt }; + } + } + return best?.id ?? null; +} + +/** + * Code / Cowork sessions belong to project (non-assistant) workspaces only. + * Assistant “instances” use Claw sessions under their own storage. + */ +export function pickWorkspaceForProjectChatSession( + currentWorkspace: WorkspaceInfo | null | undefined, + normalWorkspacesList: WorkspaceInfo[] +): WorkspaceInfo | null { + if (currentWorkspace && currentWorkspace.workspaceKind !== WorkspaceKind.Assistant) { + return currentWorkspace; + } + return normalWorkspacesList[0] ?? null; +} + +export function flowChatSessionConfigForWorkspace(workspace: WorkspaceInfo) { + return { + workspacePath: workspace.rootPath, + ...(isRemoteWorkspace(workspace) && workspace.connectionId + ? { remoteConnectionId: workspace.connectionId } + : {}), + }; +} diff --git a/src/web-ui/src/flow_chat/components/WelcomePanel.css b/src/web-ui/src/flow_chat/components/WelcomePanel.css index 0566ed60..6f75b859 100644 --- a/src/web-ui/src/flow_chat/components/WelcomePanel.css +++ b/src/web-ui/src/flow_chat/components/WelcomePanel.css @@ -116,17 +116,17 @@ .welcome-panel__narrative-text { margin: 0; font-size: 15px; - line-height: 1.8; + line-height: 1.55; color: var(--color-text-muted); } -/* One flex row: lead text, workspace/branch controls, trail text share the same vertical center */ +/* Lead text, chips, and trail text share one typographic line (baseline-aligned) */ .welcome-panel__narrative-sentence { display: inline-flex; - align-items: center; + align-items: baseline; flex-wrap: wrap; column-gap: 0.35em; - row-gap: 0.2em; + row-gap: 0.25em; } .welcome-panel__narrative-sentence__text { @@ -137,7 +137,7 @@ display: inline-flex; align-items: center; gap: 3px; - padding: 1px 5px; + padding: 0 5px; margin: 0 1px; background: none; border: none; @@ -146,10 +146,9 @@ font-size: inherit; font-family: inherit; font-weight: 600; - /* Do not inherit narrative line-height (1.8); it inflates the flex line box vs fixed-size SVGs */ - line-height: 1.2; + line-height: inherit; cursor: pointer; - vertical-align: middle; + vertical-align: baseline; transition: background 0.15s; } @@ -190,13 +189,14 @@ .welcome-panel__context-row { display: inline-flex; - align-items: center; + align-items: baseline; flex-wrap: nowrap; margin: 0 1px; } .welcome-panel__context-sep { padding: 0 2px; + line-height: inherit; color: var(--color-text-muted); opacity: 0.4; font-weight: 400; @@ -208,7 +208,13 @@ .welcome-panel__workspace-anchor { position: relative; display: inline-flex; - align-items: center; + align-items: baseline; +} + +/* Git status line: tight follow-up under workspace sentence (avoid
full line-gap) */ +.welcome-panel__narrative-git { + display: block; + margin-top: 0.28em; } /* ── Dropdown ── */ diff --git a/src/web-ui/src/flow_chat/components/WelcomePanel.tsx b/src/web-ui/src/flow_chat/components/WelcomePanel.tsx index 8ebc7703..df1cd0ea 100644 --- a/src/web-ui/src/flow_chat/components/WelcomePanel.tsx +++ b/src/web-ui/src/flow_chat/components/WelcomePanel.tsx @@ -272,13 +272,11 @@ export const WelcomePanel: React.FC = ({ {!isCoworkSession && gitState ? ( - <> -
+ {isGitClean ? {t('welcome.gitClean')} - : buildGitNarrative() - } - + : buildGitNarrative()} + ) : null} )} diff --git a/src/web-ui/src/infrastructure/theme/core/ThemeService.ts b/src/web-ui/src/infrastructure/theme/core/ThemeService.ts index 0d012b54..b5fe1184 100644 --- a/src/web-ui/src/infrastructure/theme/core/ThemeService.ts +++ b/src/web-ui/src/infrastructure/theme/core/ThemeService.ts @@ -638,8 +638,8 @@ export class ThemeService { root.style.setProperty('--card-bg-default', 'rgba(0, 0, 0, 0.06)'); root.style.setProperty('--card-bg-elevated', 'rgba(0, 0, 0, 0.08)'); root.style.setProperty('--card-bg-subtle', 'rgba(0, 0, 0, 0.04)'); - root.style.setProperty('--card-bg-hover', 'rgba(0, 0, 0, 0.09)'); - root.style.setProperty('--card-bg-active', 'rgba(0, 0, 0, 0.12)'); + root.style.setProperty('--card-bg-hover', 'rgba(0, 0, 0, 0.065)'); + root.style.setProperty('--card-bg-active', 'rgba(0, 0, 0, 0.09)'); root.style.setProperty('--card-bg-accent', 'rgba(59, 130, 246, 0.12)'); root.style.setProperty('--card-bg-accent-hover', 'rgba(59, 130, 246, 0.18)'); root.style.setProperty('--card-bg-purple', 'rgba(124, 58, 237, 0.12)'); diff --git a/src/web-ui/src/infrastructure/theme/presets/light-theme.ts b/src/web-ui/src/infrastructure/theme/presets/light-theme.ts index ae3833b1..14efe3cd 100644 --- a/src/web-ui/src/infrastructure/theme/presets/light-theme.ts +++ b/src/web-ui/src/infrastructure/theme/presets/light-theme.ts @@ -14,7 +14,7 @@ export const bitfunLightTheme: ThemeConfig = { colors: { background: { - primary: '#e8ecf2', + primary: '#f3f3f5', secondary: '#ffffff', tertiary: '#dde2eb', quaternary: '#d4dae5', @@ -91,12 +91,12 @@ export const bitfunLightTheme: ThemeConfig = { element: { - subtle: 'rgba(71, 102, 143, 0.07)', - soft: 'rgba(71, 102, 143, 0.11)', - base: 'rgba(71, 102, 143, 0.15)', - medium: 'rgba(71, 102, 143, 0.20)', - strong: 'rgba(71, 102, 143, 0.27)', - elevated: 'rgba(255, 255, 255, 0.92)', + subtle: 'rgba(71, 102, 143, 0.06)', + soft: 'rgba(71, 102, 143, 0.08)', + base: 'rgba(71, 102, 143, 0.12)', + medium: 'rgba(71, 102, 143, 0.16)', + strong: 'rgba(71, 102, 143, 0.22)', + elevated: 'rgba(255, 255, 255, 0.92)', }, diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index dcbffc72..f5d12ee4 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -94,10 +94,20 @@ }, "sections": { "workspace": "Workspace", - "extensions": "Extensions", + "extensions": "Customize", "shell": "Shell", "assistantSessions": "Assistant Sessions" }, + "search": { + "triggerPlaceholder": "Search workspaces, assistants, sessions...", + "triggerTooltip": "Search (Ctrl+K)", + "inputPlaceholder": "Search workspaces, assistants, sessions...", + "groupWorkspaces": "Open Workspaces", + "groupAssistants": "Assistants", + "groupSessions": "All Sessions", + "empty": "No results found", + "sessionWorkspaceHint": "in {{workspace}}" + }, "displayModes": { "pro": "Expert Mode", "assistant": "Assistant Mode", @@ -162,7 +172,8 @@ "showMore": "{{count}} more sessions", "showAll": "{{count}} more (show all)", "showLess": "Show less", - "assistantOwner": "Assistant: {{name}}" + "assistantOwner": "Assistant: {{name}}", + "needProjectWorkspaceForSession": "Open or add a project workspace first. Code and Cowork sessions cannot be created in the assistant area." }, "scheduledJobs": { "title": "Scheduled Jobs", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index d4dfb265..f9615dd4 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -94,10 +94,20 @@ }, "sections": { "workspace": "工作区", - "extensions": "扩展", + "extensions": "定制", "shell": "Shell", "assistantSessions": "助理会话" }, + "search": { + "triggerPlaceholder": "搜索工作区、助理、会话...", + "triggerTooltip": "搜索 (Ctrl+K)", + "inputPlaceholder": "搜索工作区、助理、会话...", + "groupWorkspaces": "已打开工作区", + "groupAssistants": "助理", + "groupSessions": "所有会话", + "empty": "未找到相关结果", + "sessionWorkspaceHint": "位于 {{workspace}}" + }, "displayModes": { "pro": "专业模式", "assistant": "助理模式", @@ -162,7 +172,8 @@ "showMore": "还有 {{count}} 个会话", "showAll": "还有 {{count}} 个(展开全部)", "showLess": "收起", - "assistantOwner": "所属助理:{{name}}" + "assistantOwner": "所属助理:{{name}}", + "needProjectWorkspaceForSession": "请先打开或添加项目工作区。编码与工作(Cowork)会话不能在助理区域创建。" }, "scheduledJobs": { "title": "定时任务",