diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index 8039ee64..98246572 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -30,6 +30,7 @@ import { useI18n } from '@/infrastructure/i18n'; import { WorkspaceKind } from '@/shared/types'; import { SSHContext } from '@/features/ssh-remote/SSHRemoteContext'; import { shortcutManager } from '@/infrastructure/services/ShortcutManager'; +import { useSessionModeStore } from '../stores/sessionModeStore'; import './AppLayout.scss'; const log = createLogger('AppLayout'); @@ -359,18 +360,28 @@ const AppLayout: React.FC = ({ className = '' }) => { return () => window.removeEventListener('toolbar-cancel-task', handleToolbarCancelTask); }, []); - // Create FlowChat session - const handleCreateFlowChatSession = React.useCallback(async () => { + // Create FlowChat session (toolbar / floating UI). detail.mode: 'cowork' → Cowork, else code (agentic). + const handleCreateFlowChatSession = React.useCallback(async (mode?: 'code' | 'cowork') => { try { const flowChatManager = FlowChatManager.getInstance(); - await flowChatManager.createChatSession({}); + const setMode = useSessionModeStore.getState().setMode; + if (mode === 'cowork') { + setMode('cowork'); + await flowChatManager.createChatSession({}, 'Cowork'); + } else { + setMode('code'); + await flowChatManager.createChatSession({}, 'agentic'); + } } catch (error) { log.error('Failed to create FlowChat session', error); } }, []); React.useEffect(() => { - const handler = () => handleCreateFlowChatSession(); + const handler = (e: Event) => { + const mode = (e as CustomEvent<{ mode?: 'code' | 'cowork' }>).detail?.mode; + void handleCreateFlowChatSession(mode === 'cowork' ? 'cowork' : 'code'); + }; window.addEventListener('toolbar-create-session', handler); return () => window.removeEventListener('toolbar-create-session', handler); }, [handleCreateFlowChatSession]); diff --git a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.scss b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.scss index 78f5af2a..40c068cd 100644 --- a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.scss +++ b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.scss @@ -5,7 +5,6 @@ $transition-duration: 0.25s; $transition-timing: cubic-bezier(0.4, 0, 0.2, 1); -$transition-timing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); // Full-window container; draggable. .bitfun-toolbar-mode { @@ -65,8 +64,9 @@ $transition-timing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); .bitfun-toolbar-mode__header { flex: 0 0 auto; height: 32px; + position: relative; display: flex; - align-items: center; + align-items: stretch; padding: 0 12px; gap: 8px; border-bottom: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.06)); @@ -75,6 +75,50 @@ $transition-timing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); animation: title-fade-in 0.2s $transition-timing; } +.bitfun-toolbar-mode__header-left { + flex: 0 0 auto; + display: flex; + align-items: center; + z-index: 2; + pointer-events: none; +} + +.bitfun-toolbar-mode__session-menu-root { + position: relative; + pointer-events: auto; +} + +.bitfun-toolbar-mode__session-menu-trigger--open { + background: var(--element-bg-base, rgba(255, 255, 255, 0.08)); + color: var(--color-accent-500, #60a5fa); +} + +.bitfun-toolbar-mode__header-right { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + min-width: 0; + z-index: 2; + pointer-events: none; +} + +.bitfun-toolbar-mode__header-overflow { + position: relative; + flex-shrink: 0; + display: flex; + align-items: center; + pointer-events: auto; +} + +.bitfun-toolbar-mode__header-collapsed-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + .bitfun-toolbar-mode__create-btn { flex-shrink: 0; width: 22px; @@ -89,70 +133,104 @@ $transition-timing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); cursor: pointer; transition: all 0.2s $transition-timing; - &:hover { + &:hover:not(:disabled) { background: var(--color-accent-200, rgba(96, 165, 250, 0.15)); color: var(--color-accent-500, #60a5fa); transform: scale(1.1); } - &:active { + &:active:not(:disabled) { transform: scale(0.95); } + + &:disabled { + cursor: not-allowed; + opacity: var(--opacity-disabled, 0.45); + transform: none; + } } .bitfun-toolbar-mode__title-wrapper { - flex: 1; + // Horizontally centered in the header bar; width follows title up to max-width. + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: max-content; + max-width: min(260px, 42vw); min-width: 0; - position: relative; + z-index: 1; + pointer-events: auto; +} + +.bitfun-toolbar-mode__header-drag-area { + flex: 1 1 auto; + min-width: 12px; + min-height: 100%; + align-self: stretch; + pointer-events: none; } -.bitfun-toolbar-mode__title-btn { +.bitfun-toolbar-mode__title-display { width: 100%; min-width: 0; + max-width: 100%; display: flex; align-items: center; - gap: 4px; + justify-content: center; padding: 4px 8px; - border: none; - background: transparent; + box-sizing: border-box; color: var(--color-text-primary, #e8e8e8); font-size: 12px; font-weight: var(--font-weight-medium, 500); - cursor: pointer; border-radius: var(--radius-sm, 6px); - transition: background 0.15s ease; - max-width: 100%; - - &:hover { - background: var(--element-bg-base, rgba(255, 255, 255, 0.08)); - } - - svg { - flex-shrink: 0; - color: var(--color-text-muted, #707070); - } } .bitfun-toolbar-mode__title-text { - flex: 1; + min-width: 0; + flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: center; } -.bitfun-toolbar-mode__title-chevron { - flex-shrink: 0; - opacity: 0; - transition: transform 0.2s ease, opacity 0.15s ease; - - .bitfun-toolbar-mode__title-btn:hover & { - opacity: 1; +.bitfun-toolbar-mode__session-menu { + display: flex; + flex-direction: column; + min-height: 0; + max-height: inherit; +} + +.bitfun-toolbar-mode__session-menu-actions { + flex: 0 0 auto; +} + +.bitfun-toolbar-mode__session-menu-scroll { + flex: 1 1 auto; + min-height: 0; + /* 仅列表区域限高,滚轮 / 触控板滚动 */ + max-height: min(240px, 38vh); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--color-text-muted, #707070) 45%, transparent) transparent; + -ms-overflow-style: auto; + + &::-webkit-scrollbar { + width: 6px; } - - &--open { - opacity: 1; - transform: rotate(180deg); + + &::-webkit-scrollbar-thumb { + border-radius: 999px; + background: color-mix(in srgb, var(--color-text-muted, #707070) 40%, transparent); + } + + &::-webkit-scrollbar-track { + background: transparent; } } @@ -160,40 +238,31 @@ $transition-timing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); position: absolute; top: 100%; left: 0; - right: 0; margin-top: 4px; - max-height: 240px; - overflow-y: auto; + width: max(100%, 220px); + max-width: min(360px, calc(100vw - 24px)); + max-height: min(360px, calc(100vh - 80px)); + display: flex; + flex-direction: column; + overflow: hidden; background: var(--color-bg-primary, #121214); border: 1px solid var(--border-medium, rgba(255, 255, 255, 0.12)); border-radius: var(--radius-base, 8px); box-shadow: var(--shadow-xl, 0 8px 24px rgba(0, 0, 0, 0.4)); z-index: 100; animation: dropdown-appear 0.2s $transition-timing; - transform-origin: top center; - - // Hide scrollbar while keeping scroll behavior. - scrollbar-width: none; - -ms-overflow-style: none; - - &::-webkit-scrollbar { - display: none; + transform-origin: top left; + + .bitfun-toolbar-mode__session-menu { + max-height: inherit; } - - .bitfun-toolbar-mode__session-item { - border-radius: 0; - - &:first-child { - border-radius: 7px 7px 0 0; - } - - &:last-child { - border-radius: 0 0 7px 7px; - } - - &:only-child { - border-radius: 7px; - } + + .bitfun-toolbar-mode__session-menu-actions .bitfun-toolbar-mode__session-item--new:first-of-type { + border-radius: 7px 7px 0 0; + } + + .bitfun-toolbar-mode__session-menu-scroll .bitfun-toolbar-mode__session-item:last-child { + border-radius: 0 0 7px 7px; } } @@ -201,17 +270,23 @@ $transition-timing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); flex: 1; width: 100%; min-height: 0; - overflow-y: auto; + display: flex; + flex-direction: column; + overflow: hidden; overflow-x: hidden; border-bottom: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.06)); animation: session-picker-expand 0.25s $transition-timing; - - // Hide scrollbar while keeping scroll behavior. - scrollbar-width: none; // Firefox - -ms-overflow-style: none; // IE/Edge - - &::-webkit-scrollbar { - display: none; // Chrome/Safari/Opera + + .bitfun-toolbar-mode__session-menu { + flex: 1; + min-height: 0; + } + + /* 紧凑窗体:列表占剩余高度,内部滚动 */ + .bitfun-toolbar-mode__session-menu-scroll { + flex: 1 1 auto; + min-height: 0; + max-height: none; } } @@ -241,6 +316,92 @@ $transition-timing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); background: var(--color-accent-200, rgba(96, 165, 250, 0.15)); color: var(--color-accent-500, #60a5fa); } + + &--new { + display: flex; + align-items: center; + gap: 8px; + font-weight: var(--font-weight-medium, 500); + color: var(--color-text-primary, #e8e8e8); + + &:hover .bitfun-toolbar-mode__session-item-icon { + background: var(--color-accent-300, rgba(96, 165, 250, 0.25)); + color: var(--color-accent-500, #60a5fa); + } + } +} + +/* “+ 编程会话”:圆底加号,对齐侧栏顶栏 */ +.bitfun-toolbar-mode__session-item-icon { + flex-shrink: 0; + width: 20px; + height: 20px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--color-accent-200, rgba(96, 165, 250, 0.15)); + color: var(--color-accent-500, #60a5fa); + transition: background 0.1s ease, color 0.1s ease; +} + +.bitfun-toolbar-mode__session-item-label { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bitfun-toolbar-mode__session-list-divider { + height: 1px; + margin: 4px 8px; + background: var(--border-subtle, rgba(255, 255, 255, 0.06)); + flex-shrink: 0; +} + +.bitfun-toolbar-mode__overflow-menu { + position: absolute; + top: calc(100% + 4px); + right: 0; + min-width: 180px; + padding: 4px; + background: var(--color-bg-primary, #121214); + border: 1px solid var(--border-medium, rgba(255, 255, 255, 0.12)); + border-radius: var(--radius-base, 8px); + box-shadow: var(--shadow-xl, 0 8px 24px rgba(0, 0, 0, 0.4)); + z-index: 120; + animation: dropdown-appear 0.2s $transition-timing; +} + +.bitfun-toolbar-mode__overflow-menu-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + border: none; + border-radius: var(--radius-sm, 6px); + background: transparent; + color: var(--color-text-primary, #e8e8e8); + font-size: 13px; + text-align: left; + cursor: pointer; + transition: background 0.1s ease; + + svg { + flex-shrink: 0; + color: var(--color-text-muted, #707070); + } + + span { + min-width: 0; + flex: 1; + } + + &:hover { + background: var(--element-bg-soft, rgba(255, 255, 255, 0.06)); + } } @@ -491,6 +652,17 @@ $transition-timing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); color: var(--color-accent-500, #60a5fa); } } + + &--overflow { + width: 24px; + height: 24px; + background: transparent; + color: var(--color-text-muted, #707070); + &:hover { + background: var(--element-bg-base, rgba(255, 255, 255, 0.08)); + color: var(--color-text-primary, #e8e8e8); + } + } &--send { background: var(--color-accent-600, #3b82f6); @@ -603,25 +775,6 @@ $transition-timing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); } } -// Rotate the toggle icon in expanded mode. -.toolbar-btn--toggle { - width: 24px; - height: 24px; - background: transparent; - color: var(--color-text-muted, #707070); - transition: transform $transition-duration $transition-timing, - background 0.15s ease, - color 0.15s ease; - - &:hover { - background: var(--color-purple-200, rgba(139, 92, 246, 0.15)); - color: var(--color-purple-500, #8b5cf6); - } - - .bitfun-toolbar-mode--expanded & { - transform: rotate(180deg); - } -} .bitfun-toolbar-mode__expanded-input { flex-shrink: 0; diff --git a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx index c5b57bea..34d61d37 100644 --- a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx +++ b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx @@ -3,14 +3,13 @@ * Single-window morph UI for compact toolbar view. * * Layout: two rows - * - Row 1: status icons + session title (click to switch) + * - Row 1: + / session list only when expanded; collapsed: no left control. Right: ⋮ when expanded, expand when collapsed. * - Row 2: streaming content/input + controls */ import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { getCurrentWindow } from '@tauri-apps/api/window'; -import { PhysicalSize, PhysicalPosition } from '@tauri-apps/api/dpi'; import { MessageSquare, Square, @@ -18,7 +17,7 @@ import { X, Send, Maximize2, - ChevronDown, + MoreVertical, PanelTopOpen, PanelTopClose, Plus @@ -35,11 +34,6 @@ import { ModernFlowChatContainer } from '../modern/ModernFlowChatContainer'; import { Tooltip } from '@/component-library'; import './ToolbarMode.scss'; -// Window size config (physical pixels, accounts for Windows DPI scaling). -const TOOLBAR_WIDTH = 600; -const TOOLBAR_HEIGHT_NORMAL = 120; // Two-row height (32px + ~88px). -const TOOLBAR_HEIGHT_EXPANDED = 320; // Height when session list is expanded. - export const ToolbarMode: React.FC = () => { const { t } = useTranslation('flow-chat'); const { @@ -53,10 +47,12 @@ export const ToolbarMode: React.FC = () => { const [showInput, setShowInput] = useState(false); const [inputValue, setInputValue] = useState(''); const [showSessionPicker, setShowSessionPicker] = useState(false); + const [showHeaderOverflowMenu, setShowHeaderOverflowMenu] = useState(false); const [flowChatState, setFlowChatState] = useState(() => flowChatStore.getState() ); const sessionPickerRef = useRef(null); + const headerOverflowRef = useRef(null); useEffect(() => { const unsubscribe = flowChatStore.subscribe((state) => { @@ -156,46 +152,12 @@ export const ToolbarMode: React.FC = () => { return { isStreaming, toolName, content }; }, [flowChatState, t]); - // Window position is initialized in ToolbarModeContext.tsx to avoid conflicts. - - // Track the previous picker state to avoid redundant resize calls. - const prevShowSessionPickerRef = useRef(showSessionPicker); - useEffect(() => { - if (prevShowSessionPickerRef.current === showSessionPicker) { - return; + if (!isExpanded) { + setShowSessionPicker(false); + setShowHeaderOverflowMenu(false); } - prevShowSessionPickerRef.current = showSessionPicker; - - const adjustWindowSize = async () => { - if (isExpanded) return; - if (!isToolbarMode) return; - - try { - const win = getCurrentWindow(); - const currentPosition = await win.outerPosition(); - const currentSize = await win.outerSize(); - - if (showSessionPicker) { - const heightDiff = TOOLBAR_HEIGHT_EXPANDED - currentSize.height; - const newY = currentPosition.y - heightDiff; - - await win.setSize(new PhysicalSize(TOOLBAR_WIDTH, TOOLBAR_HEIGHT_EXPANDED)); - await win.setPosition(new PhysicalPosition(currentPosition.x, Math.max(0, newY))); - } else { - const heightDiff = currentSize.height - TOOLBAR_HEIGHT_NORMAL; - const newY = currentPosition.y + heightDiff; - - await win.setSize(new PhysicalSize(TOOLBAR_WIDTH, TOOLBAR_HEIGHT_NORMAL)); - await win.setPosition(new PhysicalPosition(currentPosition.x, newY)); - } - } catch (error) { - log.error('Failed to adjust window size', { isToolbarMode, isExpanded, error }); - } - }; - - adjustWindowSize(); - }, [isToolbarMode, isExpanded, showSessionPicker]); + }, [isExpanded]); useEffect(() => { const handleClickOutside = (e: MouseEvent) => { @@ -203,7 +165,7 @@ export const ToolbarMode: React.FC = () => { if (sessionPickerRef.current?.contains(target)) { return; } - if (target.closest?.('.bitfun-toolbar-mode__title-btn')) { + if (target.closest?.('.bitfun-toolbar-mode__session-menu-trigger')) { return; } setShowSessionPicker(false); @@ -219,11 +181,36 @@ export const ToolbarMode: React.FC = () => { }; } }, [showSessionPicker]); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement; + if (headerOverflowRef.current?.contains(target)) { + return; + } + if (target.closest?.('.bitfun-toolbar-mode__overflow-trigger')) { + return; + } + setShowHeaderOverflowMenu(false); + }; + + if (showHeaderOverflowMenu) { + const timer = setTimeout(() => { + document.addEventListener('mousedown', handleClickOutside); + }, 0); + return () => { + clearTimeout(timer); + document.removeEventListener('mousedown', handleClickOutside); + }; + } + }, [showHeaderOverflowMenu]); const handleStartDrag = useCallback(async (e: React.MouseEvent) => { const target = e.target as HTMLElement; // Avoid dragging when interacting with UI controls. - if (target.closest?.('button, input, .bitfun-toolbar-mode__session-picker, .bitfun-toolbar-mode__stream-content, .bitfun-toolbar-mode__session-item, .bitfun-toolbar-mode__flowchat-container')) { + if (target.closest?.( + 'button, input, .bitfun-toolbar-mode__session-picker, .bitfun-toolbar-mode__session-dropdown, .bitfun-toolbar-mode__overflow-menu, .bitfun-toolbar-mode__stream-content, .bitfun-toolbar-mode__session-item, .bitfun-toolbar-mode__flowchat-container' + )) { return; } try { @@ -266,9 +253,21 @@ export const ToolbarMode: React.FC = () => { } }, [toolbarState.pendingToolId]); - const handleCreateSession = useCallback(() => { - window.dispatchEvent(new CustomEvent('toolbar-create-session')); + const dispatchToolbarCreateSession = useCallback((mode: 'code' | 'cowork') => { + window.dispatchEvent(new CustomEvent('toolbar-create-session', { detail: { mode } })); + setShowSessionPicker(false); }, []); + + const toggleSessionMenu = useCallback(() => { + setShowHeaderOverflowMenu(false); + setShowSessionPicker(v => !v); + }, []); + + const toggleHeaderOverflowMenu = useCallback(() => { + if (!isExpanded) return; + setShowSessionPicker(false); + setShowHeaderOverflowMenu(v => !v); + }, [isExpanded]); const handleSendMessage = useCallback(() => { const message = inputValue.trim(); @@ -290,13 +289,76 @@ export const ToolbarMode: React.FC = () => { e.preventDefault(); if (showInput) { setShowInput(false); + } else if (showHeaderOverflowMenu) { + setShowHeaderOverflowMenu(false); } else if (showSessionPicker) { setShowSessionPicker(false); } else { handleExpand(); } } - }, [handleSendMessage, showInput, showSessionPicker, handleExpand]); + }, [handleSendMessage, showInput, showSessionPicker, showHeaderOverflowMenu, handleExpand]); + + const sessionMenuContent = useMemo( + () => ( +
+
+ + +
+
+
+ {sessions.map((session) => ( + + ))} +
+
+ ), + [sessions, flowChatState.activeSessionId, dispatchToolbarCreateSession, handleSwitchSession, t] + ); if (!isToolbarMode) { return null; @@ -312,84 +374,120 @@ export const ToolbarMode: React.FC = () => { return (
- {showSessionPicker && !isExpanded && ( -
e.stopPropagation()} - > - {sessions.map((session) => ( - - ))} -
- )} -
- - - - -
- - - - - {showSessionPicker && isExpanded && ( -
e.stopPropagation()} - > - {sessions.map((session) => ( +
+ {isExpanded ? ( +
+ - ))} + + {showSessionPicker && ( +
e.stopPropagation()} + > + {sessionMenuContent} +
+ )}
- )} + ) : null} +
+ +
+
+ {sessionTitle} +
+
+ +
+ - - - - - -
{isExpanded ? ( diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index be164e10..7b44bb67 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -809,7 +809,11 @@ "aiProcessing": "AI is processing...", "inputMessage": "Enter message...", "processing": "Processing...", - "startNewChat": "Start new chat..." + "startNewChat": "Start new chat...", + "openSessionMenu": "Sessions & new", + "moreMenu": "More", + "newCodeSessionItem": "Code session", + "newCoworkSessionItem": "Cowork session" }, "chatInput": { "inputHint": "Enter content..." diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index aed9c522..3f4e0b15 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -801,7 +801,11 @@ "aiProcessing": "AI 正在处理中...", "inputMessage": "输入消息...", "processing": "处理中...", - "startNewChat": "开始新对话..." + "startNewChat": "开始新对话...", + "openSessionMenu": "会话与新建", + "moreMenu": "更多", + "newCodeSessionItem": "编程会话", + "newCoworkSessionItem": "办公会话" }, "chatInput": { "inputHint": "输入内容..."