From 37b046283665a2a48c0413d294f441309037833f Mon Sep 17 00:00:00 2001 From: Rick Ye Date: Tue, 8 Sep 2026 01:28:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8F=B3=E4=BE=A7=E6=94=B9=E9=80=A0?= =?UTF-8?q?=E6=88=90=20ZCode=20=E5=BC=8F=E9=80=9A=E7=94=A8=E4=BE=A7?= =?UTF-8?q?=E8=BE=B9=E9=9D=A2=E6=9D=BF=20+=20=E6=A0=87=E7=AD=BE=E9=A1=B5?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/renderer/src/App.tsx | 135 +++++++-- .../src/renderer/src/components/SidePanel.tsx | 256 ++++++++++++++++++ .../src/renderer/src/components/TitleBar.tsx | 20 +- .../src/components/browser/BrowserPanel.tsx | 55 +--- 4 files changed, 387 insertions(+), 79 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/SidePanel.tsx diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index bb32928..b858cdc 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import type { JSX } from 'react' import { useAgent } from './hooks/useAgent' import { useBrowser } from './hooks/useBrowser' @@ -10,8 +10,47 @@ import { Composer } from './components/Composer' import { SettingsModal } from './components/SettingsModal' import { Search } from './components/Search' import { BrowserPanel } from './components/browser/BrowserPanel' +import { SidePanel, SIDE_TAB_ORDER, type SideTabType } from './components/SidePanel' import { applyThemeColor } from './lib/theme' +// ---- 侧边面板状态(开合 / 标签 / 宽度),跨启动持久化 ---- + +const SIDE_STORAGE_KEY = 'aso.sidepanel.v1' +const SIDE_DEFAULT_WIDTH = 480 +const SIDE_MIN_WIDTH = 380 +const SIDE_MAX_WIDTH = 760 + +interface SideState { + open: boolean + tabs: SideTabType[] + active: SideTabType | null + width: number +} + +function loadSideState(): SideState { + const fallback: SideState = { open: false, tabs: [], active: null, width: SIDE_DEFAULT_WIDTH } + try { + const raw = localStorage.getItem(SIDE_STORAGE_KEY) + if (!raw) return fallback + const parsed = JSON.parse(raw) as Partial + const tabs = Array.isArray(parsed.tabs) + ? parsed.tabs.filter((t): t is SideTabType => SIDE_TAB_ORDER.includes(t)) + : [] + return { + open: Boolean(parsed.open), + tabs, + active: + parsed.active && tabs.includes(parsed.active) ? parsed.active : (tabs[tabs.length - 1] ?? null), + width: + typeof parsed.width === 'number' + ? Math.min(SIDE_MAX_WIDTH, Math.max(SIDE_MIN_WIDTH, parsed.width)) + : SIDE_DEFAULT_WIDTH + } + } catch { + return fallback + } +} + export default function App(): JSX.Element { const { state, @@ -33,8 +72,54 @@ export default function App(): JSX.Element { const prefillRef = useRef(null) const [settingsOpen, setSettingsOpen] = useState(false) const [searchOpen, setSearchOpen] = useState(false) - const [browserOpen, setBrowserOpen] = useState(false) const browser = useBrowser() + const [side, setSide] = useState(loadSideState) + const { open: sideOpen, tabs: sideTabs, active: activeSideTab, width: sideWidth } = side + + useEffect(() => { + try { + localStorage.setItem(SIDE_STORAGE_KEY, JSON.stringify(side)) + } catch { + // 持久化失败(隐私模式等)不影响功能 + } + }, [side]) + + const openSideTab = useCallback((type: SideTabType): void => { + setSide((s) => ({ + ...s, + open: true, + tabs: s.tabs.includes(type) ? s.tabs : [...s.tabs, type], + active: type + })) + }, []) + + const closeSideTab = useCallback((type: SideTabType): void => { + setSide((s) => { + const tabs = s.tabs.filter((t) => t !== type) + return { ...s, tabs, active: s.active === type ? (tabs[tabs.length - 1] ?? null) : s.active } + }) + }, []) + + const toggleSide = useCallback((): void => { + setSide((s) => ({ ...s, open: !s.open })) + }, []) + + const setSideWidth = useCallback((width: number): void => { + setSide((s) => ({ ...s, width })) + }, []) + + const setSideActive = useCallback((type: SideTabType): void => { + setSide((s) => ({ ...s, active: type })) + }, []) + + // Agent 首次创建受管浏览器页签时,自动打开侧边面板的浏览器标签 + const browserTabCount = browser.state?.tabs.length ?? 0 + const prevBrowserTabCount = useRef(0) + useEffect(() => { + const prev = prevBrowserTabCount.current + prevBrowserTabCount.current = browserTabCount + if (browserTabCount > 0 && prev === 0) openSideTab('browser') + }, [browserTabCount, openSideTab]) // 启动时应用持久化的主题色 useEffect(() => { @@ -100,11 +185,7 @@ export default function App(): JSX.Element { onOpenWorkspace={openWorkspace} />
- setBrowserOpen((open) => !open)} - /> +
- {browserOpen && ( - + } /> )}
diff --git a/apps/desktop/src/renderer/src/components/SidePanel.tsx b/apps/desktop/src/renderer/src/components/SidePanel.tsx new file mode 100644 index 0000000..94273da --- /dev/null +++ b/apps/desktop/src/renderer/src/components/SidePanel.tsx @@ -0,0 +1,256 @@ +import { useEffect, useRef, useState, type ComponentType, type JSX, type ReactNode } from 'react' +import { + LuChevronDown, + LuFileDiff, + LuGlobe, + LuMessagesSquare, + LuPlus, + LuSquareTerminal, + LuX +} from 'react-icons/lu' + +/** + * 右侧通用侧边面板(ZCode 式标签页容器)。 + * 面板本身只负责:标签栏(收起 / 标签 / “+”菜单)、空态「打开标签页」宫格、 + * 左缘宽度拖拽;各标签的内容由外部传入(浏览器)或使用占位面板。 + * 每种标签同时至多一个实例;浏览器标签承载原生 WebContentsView 的对齐容器。 + */ + +export type SideTabType = 'assistant' | 'review' | 'terminal' | 'browser' + +export const SIDE_TAB_ORDER: SideTabType[] = ['assistant', 'review', 'terminal', 'browser'] + +type TabIcon = ComponentType<{ size?: number; className?: string }> + +export const SIDE_TAB_META: Record = { + assistant: { title: '辅助对话', icon: LuMessagesSquare }, + review: { title: '审查', icon: LuFileDiff }, + terminal: { title: '终端', icon: LuSquareTerminal }, + browser: { title: '浏览器', icon: LuGlobe } +} + +const PLACEHOLDER_DESCRIPTION: Record, string> = { + assistant: '在侧边面板中与 Agent 展开一路并行的小对话,不打断主会话(即将支持)。', + review: '查看本会话中 Agent 产生的文件改动与差异,并逐项确认(即将支持)。', + terminal: '在侧边面板中打开与当前工作区绑定的终端(即将支持)。' +} + +const PANEL_MIN_WIDTH = 380 +const PANEL_MAX_WIDTH = 760 + +interface SidePanelProps { + tabs: SideTabType[] + active: SideTabType | null + width: number + onWidthChange: (width: number) => void + onSelectTab: (type: SideTabType) => void + onCloseTab: (type: SideTabType) => void + onOpenTab: (type: SideTabType) => void + /** 收起整个面板(标签栏左端的 ˅ 按钮,与标题栏开关等效) */ + onCollapse: () => void + /** 浏览器标签的内容 */ + browser: ReactNode +} + +export function SidePanel({ + tabs, + active, + width, + onWidthChange, + onSelectTab, + onCloseTab, + onOpenTab, + onCollapse, + browser +}: SidePanelProps): JSX.Element { + const [menuOpen, setMenuOpen] = useState(false) + const dragRef = useRef<{ startX: number; startWidth: number } | null>(null) + + // 面板宽度拖拽(左边缘把手) + useEffect(() => { + const onMove = (e: PointerEvent): void => { + if (!dragRef.current) return + const delta = dragRef.current.startX - e.clientX + onWidthChange(Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, dragRef.current.startWidth + delta))) + } + const onUp = (): void => { + dragRef.current = null + } + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + return () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + } + }, [onWidthChange]) + + const startResize = (e: React.PointerEvent): void => { + dragRef.current = { startX: e.clientX, startWidth: width } + } + + const current = active ?? tabs[tabs.length - 1] ?? null + + return ( + + ) +} + +/** 空态:无任何标签时的「打开标签页」宫格 */ +function EmptyState({ onOpen }: { onOpen: (type: SideTabType) => void }): JSX.Element { + return ( +
+
+

打开标签页

+

选择要在侧边面板中打开的标签。

+
+
+ {SIDE_TAB_ORDER.map((type) => { + const { title, icon: Icon } = SIDE_TAB_META[type] + return ( + + ) + })} +
+
+ ) +} + +/** 尚未落地的标签占位页 */ +function PlaceholderPanel({ + icon: Icon, + title, + description +}: { + icon: TabIcon + title: string + description: string +}): JSX.Element { + return ( +
+ +
{title}
+

{description}

+
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/TitleBar.tsx b/apps/desktop/src/renderer/src/components/TitleBar.tsx index d417d4d..080f495 100644 --- a/apps/desktop/src/renderer/src/components/TitleBar.tsx +++ b/apps/desktop/src/renderer/src/components/TitleBar.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, type JSX } from 'react' -import { LuGlobe } from 'react-icons/lu' +import { LuPanelRight } from 'react-icons/lu' /** * 自绘标题栏(配合主进程 titleBarStyle:'hidden')。 @@ -9,12 +9,12 @@ import { LuGlobe } from 'react-icons/lu' */ export function TitleBar({ title, - browserOpen, - onToggleBrowser + panelOpen, + onTogglePanel }: { title?: string - browserOpen: boolean - onToggleBrowser: () => void + panelOpen: boolean + onTogglePanel: () => void }): JSX.Element { const name = title?.trim() const [maximized, setMaximized] = useState(false) @@ -27,14 +27,14 @@ export function TitleBar({
diff --git a/apps/desktop/src/renderer/src/components/browser/BrowserPanel.tsx b/apps/desktop/src/renderer/src/components/browser/BrowserPanel.tsx index 3d8f39c..36c9a63 100644 --- a/apps/desktop/src/renderer/src/components/browser/BrowserPanel.tsx +++ b/apps/desktop/src/renderer/src/components/browser/BrowserPanel.tsx @@ -14,17 +14,12 @@ import type { BrowserImportResult, BrowserSource, BrowserUiState } from '@shared import { BrowserSlot } from './BrowserSlot' /** - * 内置受管浏览器右侧面板。 - * - * 页面本体是主进程的原生 WebContentsView(盖在 DOM 之上),面板 DOM 只负责 - * 标签栏/地址栏/工具条的「chrome」部分;BrowserSlot 量出页面区域矩形后由主进程 - * 对齐原生视图。任何全屏浮层(设置/搜索/导入)出现时必须卸载 Slot 隐藏原生视图。 + * 内置受管浏览器的「chrome」部分(页签栏/地址栏/工具条/导入浮层)。 + * 作为侧边面板的一个标签内容渲染;页面本体是主进程的原生 WebContentsView + * (盖在 DOM 之上),BrowserSlot 量出页面区域矩形后由主进程对齐原生视图。 + * 任何全屏浮层(设置/搜索/导入)出现时必须卸载 Slot 隐藏原生视图。 */ -const PANEL_DEFAULT_WIDTH = 480 -const PANEL_MIN_WIDTH = 380 -const PANEL_MAX_WIDTH = 760 - interface BrowserPanelProps { state: BrowserUiState | null /** 全屏浮层(设置/搜索)打开时暂停原生视图,避免其盖住浮层 */ @@ -43,10 +38,8 @@ interface BrowserPanelProps { export function BrowserPanel(props: BrowserPanelProps): JSX.Element { const { state, suspended } = props - const [width, setWidth] = useState(PANEL_DEFAULT_WIDTH) const [address, setAddress] = useState('') const [importOpen, setImportOpen] = useState(false) - const dragState = useRef<{ startX: number; startWidth: number } | null>(null) const activeTab = useMemo( () => state?.tabs.find((t) => t.tabId === state.activeTabId) ?? null, @@ -61,28 +54,6 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element { } }, [activeTab?.url]) - // 面板宽度拖拽(左边缘把手) - useEffect(() => { - const onMove = (e: PointerEvent): void => { - if (!dragState.current) return - const delta = dragState.current.startX - e.clientX - setWidth(Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, dragState.current.startWidth + delta))) - } - const onUp = (): void => { - dragState.current = null - } - window.addEventListener('pointermove', onMove) - window.addEventListener('pointerup', onUp) - return () => { - window.removeEventListener('pointermove', onMove) - window.removeEventListener('pointerup', onUp) - } - }, []) - - const startResize = (e: React.PointerEvent): void => { - dragState.current = { startX: e.clientX, startWidth: width } - } - const submitAddress = (): void => { const value = address.trim() if (value) props.onNavigate(value) @@ -93,20 +64,8 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element { const showSlot = Boolean(activeTab) && !suspended && !importOpen return ( - +
) }