From 674fb9faa7db868e7d8d2b7a07072d4cd7c931e5 Mon Sep 17 00:00:00 2001 From: looluo Date: Tue, 18 Aug 2026 18:24:44 +0800 Subject: [PATCH 1/2] feat: maximize file panel --- app/globals.css | 25 +++ .../AppShell.file-panel-maximize.test.mjs | 51 +++++ components/AppShell.tsx | 182 +++++++++++++----- lib/i18n/messages/en.ts | 2 + lib/i18n/messages/zh-CN.ts | 2 + 5 files changed, 217 insertions(+), 45 deletions(-) create mode 100644 components/AppShell.file-panel-maximize.test.mjs diff --git a/app/globals.css b/app/globals.css index bf2dc3da4..a1178a743 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1245,6 +1245,12 @@ span.token.table { .right-panel-overlay-backdrop { display: none; } +.conversation-panel { + display: flex; +} +.conversation-panel.file-panel-maximized { + display: none; +} /* Desktop sidebar: animate width */ @media (min-width: 641px) { @@ -1301,6 +1307,15 @@ span.token.table { .right-panel-container.right-panel-resizing > * { transition: none !important; } + .right-panel-container.right-panel-maximized { + width: auto; + min-width: 0; + flex: 1 1 auto; + } + .right-panel-container.right-panel-maximized > * { + width: 100%; + min-width: 0; + } } /* Compact desktop file panel: overlay the chat instead of collapsing it. */ @@ -1345,6 +1360,16 @@ span.token.table { width: 100%; min-width: 0; } + .right-panel-container.right-panel-maximized { + position: relative; + inset: auto; + z-index: auto; + width: auto; + min-width: 0; + flex: 1 1 auto; + box-shadow: none; + transform: none; + } } /* Mobile sidebar: slide in/out as overlay */ diff --git a/components/AppShell.file-panel-maximize.test.mjs b/components/AppShell.file-panel-maximize.test.mjs new file mode 100644 index 000000000..3052f1934 --- /dev/null +++ b/components/AppShell.file-panel-maximize.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const source = await readFile(new URL("./AppShell.tsx", import.meta.url), "utf8"); +const css = await readFile(new URL("../app/globals.css", import.meta.url), "utf8"); +const english = await readFile(new URL("../lib/i18n/messages/en.ts", import.meta.url), "utf8"); +const chinese = await readFile(new URL("../lib/i18n/messages/zh-CN.ts", import.meta.url), "utf8"); + +test("offers maximize and restore controls around the file panel toggle", () => { + assert.match(source, /data-file-panel-layout-action=\{mode\}/); + assert.match(source, /files\.maximizePanel/); + assert.match(source, /files\.restorePanel/); + assert.match(source, /rightPanelOpen && !rightPanelMaximized && renderFilePanelLayoutButton\("maximize"\)/); + assert.match(source, /rightPanelMaximized && !isMobile && renderFilePanelLayoutButton\("restore"\)/); + assert.match(english, /"files\.maximizePanel": "Maximize file panel"/); + assert.match(english, /"files\.restorePanel": "Restore file panel"/); + assert.match(chinese, /"files\.maximizePanel": "最大化文件面板"/); + assert.match(chinese, /"files\.restorePanel": "恢复文件面板"/); +}); + +test("maximized files replace the mounted conversation and expose the sidebar toggle", () => { + assert.match(source, /className={`conversation-panel\$\{rightPanelMaximized \? " file-panel-maximized" : ""\}`}/); + assert.match(source, /aria-hidden=\{rightPanelMaximized\}/); + assert.match(source, /rightPanelMaximized && !isMobile && renderSidebarToggle\("maximized-file"\)/); + assert.match(source, /right-panel-maximized/); + assert.match(css, /\.conversation-panel\.file-panel-maximized\s*\{[\s\S]*?display: none;/); + assert.match(css, /@media \(min-width: 960px\)[\s\S]*?\.right-panel-container\.right-panel-maximized[\s\S]*?flex: 1 1 auto;/); + assert.match(css, /@media \(min-width: 641px\) and \(max-width: 959px\)[\s\S]*?\.right-panel-container\.right-panel-maximized[\s\S]*?position: relative;/); +}); + +test("maximized layout exits at destructive and responsive boundaries", () => { + assert.match(source, /const handleRightPanelClose = useCallback\(\(\) => \{\s*setRightPanelMaximized\(false\);\s*setRightPanelOpen\(false\);/); + assert.match(source, /if \(remaining\.length === 0\) handleRightPanelClose\(\);/); + assert.match(source, /if \(isMobile\) \{[\s\S]*?setRightPanelMaximized\(false\);[\s\S]*?\}/); + assert.match(source, /const handleAttentionNeeded = useCallback[\s\S]*?setRightPanelMaximized\(false\);/); + assert.match(source, /setFileTabs\(\[\]\);\s*setActiveFileTabId\(null\);\s*handleRightPanelClose\(\);/); +}); + +test("does not show split-layout affordances while maximized", () => { + assert.match(source, /rightPanelOpen && !rightPanelMaximized && \(/); + assert.match(source, /rightPanelOpen && !rightPanelMaximized \? " is-open" : ""/); + assert.match(source, /right-panel-container\$\{rightPanelOpen \? " right-panel-open" : " right-panel-closed"\}\$\{rightPanelMaximized \? " right-panel-maximized" : ""\}/); +}); + +test("keyboard activation transfers focus between maximize and restore", () => { + assert.match(source, /pendingFilePanelLayoutFocusRef\.current = "restore"/); + assert.match(source, /pendingFilePanelLayoutFocusRef\.current = "maximize"/); + assert.match(source, /filePanelRestoreButtonRef\.current\?\.focus\(\)/); + assert.match(source, /filePanelMaximizeButtonRef\.current\?\.focus\(\)/); +}); diff --git a/components/AppShell.tsx b/components/AppShell.tsx index 4f1822a70..26306c40b 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -108,10 +108,14 @@ export function AppShell() { const [projectTrustError, setProjectTrustError] = useState(null); const [sidebarOpen, setSidebarOpen] = useState(true); const [rightPanelOpen, setRightPanelOpen] = useState(false); + const [rightPanelMaximized, setRightPanelMaximized] = useState(false); const [mobileToolbarMoreOpen, setMobileToolbarMoreOpen] = useState(false); const [mobileSidebarReady, setMobileSidebarReady] = useState(false); const sidebarWidthRef = useRef(SIDEBAR_DEFAULT_WIDTH); const rightPanelWidthRef = useRef(RIGHT_PANEL_FALLBACK_WIDTH); + const filePanelMaximizeButtonRef = useRef(null); + const filePanelRestoreButtonRef = useRef(null); + const pendingFilePanelLayoutFocusRef = useRef<"maximize" | "restore" | null>(null); const getResponsiveRightPanelWidth = useCallback( () => typeof window === "undefined" ? RIGHT_PANEL_FALLBACK_WIDTH @@ -166,11 +170,25 @@ export function AppShell() { // On mobile the sidebar is an overlay drawer; hide it by default so the chat // is visible on load. Runs once the breakpoint resolves after hydration. useEffect(() => { - if (isMobile) setSidebarOpen(false); + if (isMobile) { + setSidebarOpen(false); + setRightPanelMaximized(false); + } }, [isMobile]); useEffect(() => { setMobileSidebarReady(true); }, []); + useEffect(() => { + const pendingFocus = pendingFilePanelLayoutFocusRef.current; + if (!pendingFocus) return; + + const frame = window.requestAnimationFrame(() => { + if (pendingFocus === "restore") filePanelRestoreButtonRef.current?.focus(); + else filePanelMaximizeButtonRef.current?.focus(); + pendingFilePanelLayoutFocusRef.current = null; + }); + return () => window.cancelAnimationFrame(frame); + }, [rightPanelMaximized]); useEffect(() => { if (!rightPanelOpen) return; reclampSidebarWidth(); @@ -296,14 +314,31 @@ export function AppShell() { setMobileToolbarMoreOpen((open) => !open); }, []); + const handleRightPanelClose = useCallback(() => { + setRightPanelMaximized(false); + setRightPanelOpen(false); + }, []); + const handleRightPanelToggle = useCallback(() => { if (isMobile) { setSidebarOpen(false); setActiveTopPanel(null); setMobileToolbarMoreOpen(false); } + if (rightPanelOpen) setRightPanelMaximized(false); setRightPanelOpen((open) => !open); - }, [isMobile]); + }, [isMobile, rightPanelOpen]); + + const handleFilePanelMaximize = useCallback((event: React.MouseEvent) => { + if (event.detail === 0) pendingFilePanelLayoutFocusRef.current = "restore"; + setActiveTopPanel(null); + setRightPanelMaximized(true); + }, []); + + const handleFilePanelRestore = useCallback((event: React.MouseEvent) => { + if (event.detail === 0) pendingFilePanelLayoutFocusRef.current = "maximize"; + setRightPanelMaximized(false); + }, []); useEffect(() => { if (!mobileToolbarMoreOpen) return; @@ -545,13 +580,13 @@ export function AppShell() { // project must not linger. Same-project worktree switches keep them. setFileTabs([]); setActiveFileTabId(null); - setRightPanelOpen(false); + handleRightPanelClose(); // Restore the workspace we switched to: its last open session, or keep // the default welcome page when none is remembered. restoreWorkspaceContext(newProject); } router.replace("/", { scroll: false }); - }, [activeCwd, invalidateWorkspaceRestore, newSessionCwd, router, selectedSession, restoreWorkspaceContext]); + }, [activeCwd, handleRightPanelClose, invalidateWorkspaceRestore, newSessionCwd, router, selectedSession, restoreWorkspaceContext]); const handleSelectSession = useCallback((session: SessionInfo, isRestore = false) => { invalidateWorkspaceRestore(); @@ -691,6 +726,9 @@ export function AppShell() { }, [deliverSessionNotification, hydrateSelectedSession, selectedSession, translate]); const handleAttentionNeeded = useCallback((request: BlockingExtensionUiRequest) => { + // Blocking extension UI is rendered in the conversation. Reveal it so the + // user can respond instead of leaving the run stalled behind the file view. + setRightPanelMaximized(false); if (!shouldShowBrowserNotification()) return; if (!claimExtensionAttentionNotification(request, notifiedAttentionRequestIdsRef.current)) return; @@ -811,17 +849,14 @@ export function AppShell() { }, [handleOpenFile, selectedSession?.id]); const handleCloseFileTab = useCallback((tabId: string) => { - setFileTabs((prev) => { - const next = prev.filter((t) => t.id !== tabId); - if (next.length === 0) setRightPanelOpen(false); - return next; - }); - setActiveFileTabId((cur) => { - if (cur !== tabId) return cur; - const remaining = fileTabs.filter((t) => t.id !== tabId); + const remaining = fileTabs.filter((tab) => tab.id !== tabId); + setFileTabs(remaining); + if (remaining.length === 0) handleRightPanelClose(); + setActiveFileTabId((current) => { + if (current !== tabId) return current; return remaining.length > 0 ? remaining[remaining.length - 1].id : null; }); - }, [fileTabs]); + }, [fileTabs, handleRightPanelClose]); const handleViewFullHistory = useCallback(() => { if (!selectedSession) return; @@ -991,6 +1026,36 @@ export function AppShell() { ); + const renderSidebarToggle = (location: "conversation" | "maximized-file") => ( + + ); + const renderThemeButton = (mobile: boolean) => ( + ); + }; + const renderMainFileToggle = (mobile: boolean) => { const covered = mobile && mobileToolbarMoreOpen; return ( @@ -1535,7 +1639,6 @@ export function AppShell() { aria-label={rightPanelOpen ? translate("files.hidePanel") : translate("files.showPanel")} data-mobile-toolbar-file={mobile ? "true" : undefined} style={{ - marginLeft: !mobile && !sessionStats && !contextUsage ? "auto" : 0, display: "flex", alignItems: "center", justifyContent: "center", width: TOP_BAR_ICON_BUTTON_SIZE, height: TOP_BAR_ICON_BUTTON_SIZE, padding: 0, visibility: covered ? "hidden" : "visible", @@ -1694,34 +1797,16 @@ export function AppShell() { /> )} - {/* Center: chat */} -
+ {/* Center: conversation stays mounted while the file panel replaces it. */} +
{/* Top bar with sidebar toggle */}
- + {renderSidebarToggle("conversation")} {isMobile && (
)} - {!isMobile && renderMainFileToggle(false)} + {!isMobile && ( +
+ {rightPanelOpen && !rightPanelMaximized && renderFilePanelLayoutButton("maximize")} + {renderMainFileToggle(false)} +
+ )} {isMobile && (