Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1676,6 +1676,48 @@ span.token.table {
}
}

/* Full-width mode keeps the mounted viewer and saved split width intact. */
@media (min-width: 641px) {
.right-panel-container.right-panel-open.right-panel-full-width {
position: fixed;
inset: 0 env(safe-area-inset-right) 0 env(safe-area-inset-left);
z-index: 250;
width: auto;
min-width: 0;
height: var(--app-viewport-height, 100dvh);
border-left: none !important;
box-shadow: none;
transform: none;
transition: none;
}
.right-panel-container.right-panel-full-width > * {
width: 100%;
min-width: 0;
}
}
.file-panel-expand-button {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 36px;
height: 36px;
padding: 0;
background: var(--bg-selected);
border: none;
border-left: 1px solid var(--border);
color: var(--text);
cursor: pointer;
}
.file-panel-expand-button:hover,
.file-panel-expand-button[aria-pressed="true"] {
color: var(--accent);
}
.file-panel-expand-button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -3px;
}

/* Mobile sidebar: slide in/out as overlay */
@media (max-width: 640px) {
.chat-stats-center {
Expand Down Expand Up @@ -1722,6 +1764,7 @@ span.token.table {
width: 100%;
min-width: 0;
}
.file-panel-expand-button,
.panel-resize-handle,
.right-panel-overlay-backdrop {
display: none;
Expand Down
9 changes: 9 additions & 0 deletions components/AppShell.mobile-toolbar.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,12 @@ test("places trust warnings below the mobile toolbar and the file toggle in tool
assert.doesNotMatch(source, /File panel toggle — always visible at top-right/);
assert.doesNotMatch(source, /position: "fixed", top: "env\(safe-area-inset-top\)"/);
});

test("closes top-bar dropdowns when the file panel expands to full width", () => {
assert.match(
source,
/const handleRightPanelExpandToggle = useCallback\(\(\) => \{\s*setActiveTopPanel\(null\);\s*setRightPanelExpanded\(\(expanded\) => !expanded\);/,
);
assert.match(source, /onClick=\{handleRightPanelExpandToggle\}/);
assert.match(source, /if \(rightPanelFullWidth\) setActiveTopPanel\(null\);/);
});
36 changes: 34 additions & 2 deletions components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,11 @@ export function AppShell() {
const [projectTrustError, setProjectTrustError] = useState<string | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [rightPanelOpen, setRightPanelOpen] = useState(false);
const [rightPanelExpanded, setRightPanelExpanded] = useState(false);
const rightPanelFullWidth = rightPanelOpen && rightPanelExpanded && !isMobile;
useEffect(() => {
if (!rightPanelOpen || isMobile) setRightPanelExpanded(false);
}, [rightPanelOpen, isMobile]);
const [mobileToolbarMoreOpen, setMobileToolbarMoreOpen] = useState(false);
const [mobileSidebarReady, setMobileSidebarReady] = useState(false);
const sidebarWidthRef = useRef(SIDEBAR_DEFAULT_WIDTH);
Expand Down Expand Up @@ -334,6 +339,10 @@ export function AppShell() {
}
}, [hasSubagentSessions]);

useEffect(() => {
if (rightPanelFullWidth) setActiveTopPanel(null);
}, [rightPanelFullWidth]);

const toggleTopPanel = useCallback((
panel: "agents" | "branches" | "system" | "tools" | "session" | "language" | "theme",
keepMobileToolbarOpen = false,
Expand Down Expand Up @@ -393,6 +402,11 @@ export function AppShell() {
setRightPanelOpen((open) => !open);
}, [isMobile]);

const handleRightPanelExpandToggle = useCallback(() => {
setActiveTopPanel(null);
setRightPanelExpanded((expanded) => !expanded);
}, []);

useEffect(() => {
if (!mobileToolbarMoreOpen) return;

Expand Down Expand Up @@ -1982,6 +1996,7 @@ export function AppShell() {
<div
ref={sidebarResizer.panelRef}
id="session-sidebar"
inert={rightPanelFullWidth}
className={`sidebar-container${sidebarOpen ? " sidebar-open" : " sidebar-closed"}${mobileSidebarReady ? "" : " sidebar-mobile-pending"}${sidebarResizer.isResizing ? " sidebar-resizing" : ""}`}
style={{
"--sidebar-width": `${sidebarResizer.width}px`,
Expand All @@ -2000,6 +2015,7 @@ export function AppShell() {
{sidebarOpen && (
<div
{...sidebarResizer.separatorProps}
inert={rightPanelFullWidth}
aria-controls="session-sidebar"
className={`panel-resize-handle sidebar-resize-handle${sidebarResizer.isResizing ? " is-resizing" : ""}`}
data-resize-handle="sidebar"
Expand All @@ -2008,7 +2024,7 @@ export function AppShell() {
)}

{/* Center: chat */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", minWidth: 0 }}>
<div inert={rightPanelFullWidth} style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", minWidth: 0 }}>
{/* Top bar with sidebar toggle */}
<div ref={topBarRef} style={{ flexShrink: 0, background: "var(--bg-panel)" }}>
<div style={{ display: "flex", alignItems: "center", position: "relative", borderBottom: "1px solid var(--border)", height: "calc(36px + env(safe-area-inset-top))", paddingTop: "env(safe-area-inset-top)" }}>
Expand Down Expand Up @@ -2558,6 +2574,7 @@ export function AppShell() {
{rightPanelOpen && (
<div
{...rightPanelResizer.separatorProps}
inert={rightPanelFullWidth}
aria-controls="file-panel"
className={`panel-resize-handle right-panel-resize-handle${rightPanelResizer.isResizing ? " is-resizing" : ""}`}
data-resize-handle="right-panel"
Expand All @@ -2569,7 +2586,7 @@ export function AppShell() {
<div
ref={rightPanelResizer.panelRef}
id="file-panel"
className={`right-panel-container${rightPanelOpen ? " right-panel-open" : " right-panel-closed"}${rightPanelResizer.isResizing ? " right-panel-resizing" : ""}`}
className={`right-panel-container${rightPanelOpen ? " right-panel-open" : " right-panel-closed"}${rightPanelFullWidth ? " right-panel-full-width" : ""}${rightPanelResizer.isResizing ? " right-panel-resizing" : ""}`}
style={{
"--right-panel-width": `${rightPanelResizer.width}px`,
display: "flex",
Expand All @@ -2596,6 +2613,21 @@ export function AppShell() {
onCloseTab={handleCloseFileTab}
/>
</div>
<button
type="button"
className="file-panel-expand-button"
onClick={handleRightPanelExpandToggle}
aria-controls="file-panel"
aria-pressed={rightPanelFullWidth}
title={translate(rightPanelFullWidth ? "files.restorePanelWidth" : "files.expandPanel")}
aria-label={translate(rightPanelFullWidth ? "files.restorePanelWidth" : "files.expandPanel")}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d={rightPanelFullWidth
? "M9 3v6H3m12-6v6h6M9 21v-6H3m12 6v-6h6M3 3l6 6m12-6-6 6M3 21l6-6m12 6-6-6"
: "M8 3H3v5m13-5h5v5M3 16v5h5m13-5v5h-5M3 3l6 6m12-6-6 6M3 21l6-6m12 6-6-6"} />
</svg>
</button>
<button
type="button"
onClick={() => setRightPanelOpen(false)}
Expand Down
66 changes: 66 additions & 0 deletions e2e/file-panel.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import assert from "node:assert/strict";

export const filePanelFixture = `<!doctype html><html><body style="margin:20px;min-height:2400px">
<label>Notes <input id="notes"></label>
<label>Filter <select id="filter"><option>All</option><option>Pending</option></select></label>
<p>HTML preview state must survive layout changes.</p>
<script>window.previewInstance = Math.random();</script></body></html>`;

export async function checkFilePanel(page, filePath) {
const showSidebar = page.getByRole("button", { name: "Show sidebar", exact: true });
if (await showSidebar.isVisible()) await showSidebar.click();
await page.locator(`[title="${filePath}"]`).click();
const panel = page.locator("#file-panel");
const iframe = panel.locator("iframe");
await iframe.waitFor();
const frame = await (await iframe.elementHandle()).contentFrame();
await frame.locator("#notes").fill("Keep this note");
await frame.locator("#filter").selectOption({ label: "Pending" });
await frame.evaluate(() => scrollTo(0, 120));
const instance = await frame.evaluate(() => window.previewInstance);
const width = () => panel.evaluate(async el => {
await new Promise(requestAnimationFrame);
await Promise.all(el.getAnimations().map(animation => animation.finished.catch(() => {})));
return el.getBoundingClientRect().width;
});
const storedWidths = () => page.evaluate(() => [
localStorage.getItem("pi-sidebar-width"), localStorage.getItem("pi-right-panel-width"),
]);
const toggle = panel.getByRole("button", { name: "Expand file panel", exact: true });
if (page.viewportSize().width <= 640) {
assert.equal(await toggle.isVisible(), false, "Mobile already uses full width");
} else {
// Include a manually resized split in the round trip.
const separator = page.locator('[data-resize-handle="right-panel"]');
if (await separator.isVisible()) await separator.press("ArrowLeft");
const originalWidth = await width();
const originalStored = await storedWidths();
const sessionInfo = page.getByRole("button", { name: "Session info", exact: true });
const sessionPopover = page.locator(".session-info-popover");
for (let i = 0; i < 2; i++) {
await sessionInfo.click();
await sessionPopover.waitFor();
await toggle.click();
assert.equal(await sessionPopover.count(), 0, "Full-width mode dismisses inert top-bar menus");
assert.equal(await panel.getByRole("button", { name: "Restore file panel width", exact: true }).getAttribute("aria-pressed"), "true");
assert.equal(Math.round(await width()), page.viewportSize().width);
assert.equal(await page.locator("#session-sidebar").evaluate(el => el.inert), true);
assert.equal(await frame.evaluate(() => window.previewInstance), instance);
assert.equal(await frame.evaluate(() => scrollY), 120);
assert.equal(await frame.locator("#notes").inputValue(), "Keep this note");
assert.equal(await frame.locator("#filter").inputValue(), "Pending");
await panel.getByRole("button", { name: "Restore file panel width", exact: true }).press("Enter");
assert.equal(await width(), originalWidth);
assert.deepEqual(await storedWidths(), originalStored);
assert.equal(await page.locator("#session-sidebar").evaluate(el => el.inert), false);
}
await toggle.click();
await panel.getByRole("button", { name: "Hide file panel", exact: true }).click();
await page.getByRole("button", { name: "Show file panel", exact: true }).click();
assert.equal(await toggle.getAttribute("aria-pressed"), "false", "Reopening returns to the split layout");
assert.equal(await width(), originalWidth);
assert.equal(await frame.evaluate(() => window.previewInstance), instance);
}
await panel.getByRole("button", { name: "Hide file panel", exact: true }).click();
console.log(`PASS: file panel width and preview state at ${page.viewportSize().width}px`);
}
6 changes: 6 additions & 0 deletions e2e/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { dirname, join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
import { checkFilePanel, filePanelFixture } from "./file-panel.mjs";
import { checkExtensionDialogs, extensionSource } from "./extension-dialog.mjs";
import { checkChatAppearance } from "./chat-appearance.mjs";

Expand All @@ -22,6 +23,8 @@ const agentDir = mkdtempSync(join(tmpdir(), "pi-web-e2e-"));
const project = join(agentDir, "project");
const sessionDir = join(agentDir, "sessions", "e2e");
mkdirSync(project);
const previewFile = join(project, "preview.html");
writeFileSync(previewFile, filePanelFixture);
mkdirSync(sessionDir, { recursive: true });
const timestamp = "2026-08-23T00:00:00.000Z";
const LONG = "e2e-long-session";
Expand Down Expand Up @@ -351,6 +354,9 @@ try {
await page.goto(`${base}/?session=${COMPACTED}`, { waitUntil: "domcontentloaded" });
await heading.waitFor({ state: "visible" });
}
await page.goto(`${base}/?session=${RICH}`, { waitUntil: "domcontentloaded" });
await page.locator(".markdown-code-block pre").waitFor();
await checkFilePanel(page, previewFile);
await checkExtensionDialogs(page, artifacts, viewport.width);
if (viewport.width > 600) {
await page.goto(`${base}/?session=${RICH}`, { waitUntil: "domcontentloaded" });
Expand Down
2 changes: 2 additions & 0 deletions lib/i18n/messages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ export const enLocale: LocalePlugin = {
"workspace.stepProject": "Select a project directory from the sidebar",
"workspace.stepModels": "Open Settings at the bottom, then choose Models",
"files.hidePanel": "Hide file panel",
"files.expandPanel": "Expand file panel",
"files.restorePanelWidth": "Restore file panel width",
"files.showPanel": "Show file panel",
"files.noneOpen": "No file open",
"layout.resizeSidebar": "Resize sidebar",
Expand Down
2 changes: 2 additions & 0 deletions lib/i18n/messages/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ export const zhCNLocale: LocalePlugin = {
"workspace.stepProject": "从侧边栏选择项目目录",
"workspace.stepModels": "打开底部的“设置”,然后选择“模型”",
"files.hidePanel": "隐藏文件面板",
"files.expandPanel": "全宽展示文件面板",
"files.restorePanelWidth": "还原文件面板宽度",
"files.showPanel": "显示文件面板",
"files.noneOpen": "没有打开的文件",
"layout.resizeSidebar": "调整侧边栏宽度",
Expand Down
2 changes: 2 additions & 0 deletions lib/i18n/messages/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ export const zhTWLocale: LocalePlugin = {
"workspace.stepProject": "從側邊欄選擇專案目錄",
"workspace.stepModels": "開啟底部的「設定」,然後選擇「模型」",
"files.hidePanel": "隱藏檔案面板",
"files.expandPanel": "全寬顯示檔案面板",
"files.restorePanelWidth": "還原檔案面板寬度",
"files.showPanel": "顯示檔案面板",
"files.noneOpen": "沒有開啟的檔案",
"layout.resizeSidebar": "調整側邊欄寬度",
Expand Down
Loading