Desktop Compact Right Panel Implementation Plan#
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the current expanding right sidebar with a permanent 49 DIP icon rail and a separate rounded, resizable content panel on its left while preserving and completing the existing bookmark-management workflow.
Architecture: Keep RightSidebar as the single flex item mounted by AppShell, but render it as a horizontal shell containing an optional layout-occupying panel and a permanent icon-only rail. Keep useSessionStore.rightDock as the open/view source of truth, retain the existing data-view view host, and use the browser-native storage event alongside the existing same-window bookmark event for cross-window updates. No new UI dependency, drag-and-drop dependency, persistence layer, or test dependency is introduced.
Tech Stack: TypeScript, React 18, Zustand 5, Lucide icons, CSS, Electron 37 WebContentsView, Node assert checks.
Global Constraints#
- The right rail is always visible, icon-only, and exactly 49 DIP wide.
- The content panel is immediately to the rail's left, defaults to 320 DIP, clamps to 280–560 DIP, has 8 DIP outer gaps, a 16 DIP radius, the existing popover border/shadow tokens, and a 40 DIP header.
- The panel is non-modal and always occupies flex layout space, including below 900px, so a native
WebContentsViewcannot cover it. - History, Bookmarks, and Files remain 32 DIP controls with tooltips and the existing neutral selected background. Do not add a persistent accent line or copy the Codex screenshot's dotted execution timeline into non-timeline views.
- Clicking the selected rail item or pressing
Escapecloses the panel and returns focus to that rail item; clicking a different rail item opens or switches the panel. - History, Bookmarks, and Files are native
buttonelements. The resize handle is a keyboard-operable vertical separator with current/minimum/maximum ARIA values. - Preserve bookmark add/remove from the web toolbar, new-tab shortcuts, title/URL search, inline rename, delete, split-open, full-tab-open, empty/no-result states, immediate UI updates, and profile-local persistence.
- Bookmark view selection must survive reload, and bookmark mutations must update every OpenProgram window without reload.
- Do not add bookmark folders, cloud synchronization, favicon fetching, import/export, a backend bookmark schema, or a new dependency.
- Perform live testing against the development application on port 18200; leave the stable 18100 application unchanged.
Task 1: Persist the Bookmarks view and synchronize bookmark consumers across windows#
Files:
- Modify:
web/lib/bookmarks.ts:6-55 - Modify:
web/lib/session-store/index.ts:267-305 - Modify:
web/components/center-tabs/web-tab-pane.tsx:37-77 - Modify:
web/components/center-tabs/new-tab-page.tsx:21-46 - Modify:
web/components/right-sidebar/bookmarks-panel.tsx:6-30 - Modify:
web/scripts/check-bookmarks.mjs:5-253
Interfaces:
-
Consumes:
BOOKMARKS_STORAGE_KEY,BOOKMARKS_CHANGE_EVENT, and browserstorageevents. -
Produces:
subscribeBookmarks(listener: () => void): () => voidand a persisted right-dock view id ofbookmarks. -
Step 1: Add failing persistence and subscription checks
Add the session-store source to check-bookmarks.mjs and require bookmarks in the persisted view whitelist:
const sessionStorePath = new URL("../lib/session-store/index.ts", import.meta.url);
const sessionStore = readFileSync(sessionStorePath, "utf8");
assert.match(
sessionStore,
/const VALID_VIEWS = new Set\(\[[^\]]*"bookmarks"[^\]]*\]\);/s,
"Bookmarks must survive right-dock restore",
);
After the existing bookmark storage assertions, exercise same-window delivery, cross-window-style storage delivery, irrelevant keys, and cleanup:
let refreshes = 0;
const unsubscribe = bookmarks.subscribeBookmarks(() => refreshes++);
windowEvents.dispatchEvent(new Event(bookmarks.BOOKMARKS_CHANGE_EVENT));
assert.equal(refreshes, 1, "same-window bookmark event must refresh subscribers");
const relevantStorage = new Event("storage");
Object.defineProperty(relevantStorage, "key", {
configurable: true,
value: bookmarks.BOOKMARKS_STORAGE_KEY,
});
windowEvents.dispatchEvent(relevantStorage);
assert.equal(refreshes, 2, "shared-profile storage changes must refresh subscribers");
const unrelatedStorage = new Event("storage");
Object.defineProperty(unrelatedStorage, "key", {
configurable: true,
value: "unrelated.key",
});
windowEvents.dispatchEvent(unrelatedStorage);
assert.equal(refreshes, 2, "unrelated storage writes must be ignored");
unsubscribe();
windowEvents.dispatchEvent(new Event(bookmarks.BOOKMARKS_CHANGE_EVENT));
windowEvents.dispatchEvent(relevantStorage);
assert.equal(refreshes, 2, "unsubscribe must remove both listeners");
Replace the existing component-source assertions for direct custom-event listeners with assertions that all three consumers call the shared subscription:
for (const [name, text] of [
["web-tab-pane.tsx", webTab],
["new-tab-page.tsx", newTab],
["bookmarks-panel.tsx", manager],
]) {
assert.match(text, /subscribeBookmarks\(refresh\)/, `${name} must use shared bookmark subscription`);
}
- Step 2: Run the focused check and verify RED
Run:
cd web
npm run check:bookmarks
Expected: FAIL because subscribeBookmarks is not exported and VALID_VIEWS does not contain bookmarks.
- Step 3: Implement the shared subscription and persistence fix
Add this function to web/lib/bookmarks.ts immediately after the event constants:
export function subscribeBookmarks(listener: () => void): () => void {
if (typeof window === "undefined") return () => {};
const onStorage = (event: StorageEvent) => {
if (event.key === BOOKMARKS_STORAGE_KEY) listener();
};
window.addEventListener(BOOKMARKS_CHANGE_EVENT, listener);
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener(BOOKMARKS_CHANGE_EVENT, listener);
window.removeEventListener("storage", onStorage);
};
}
Add bookmarks to the right-dock persistence whitelist in web/lib/session-store/index.ts:
const VALID_VIEWS = new Set(["history", "context", "detail", "files", "bookmarks"]);
In web-tab-pane.tsx, new-tab-page.tsx, and bookmarks-panel.tsx, import subscribeBookmarks from @/lib/bookmarks and replace each direct event-listener effect with:
useEffect(() => {
const refresh = () => setBookmarks(readBookmarks());
refresh();
return subscribeBookmarks(refresh);
}, []);
For BookmarkButton, retain the URL dependency and its boolean update:
useEffect(() => {
const refresh = () => setBookmarked(isBookmarked(url));
refresh();
return subscribeBookmarks(refresh);
}, [url]);
Remove the now-unused BOOKMARKS_CHANGE_EVENT imports from those three components. Do not change bookmark serialization, ordering, rename behavior, navigation behavior, or storage keys.
- Step 4: Run focused and aggregate checks
Run:
cd web
npm run check:bookmarks
npm run check
Expected: both commands exit 0; the focused command prints bookmark storage checks passed.
- Step 5: Commit
git add web/lib/bookmarks.ts web/lib/session-store/index.ts \
web/components/center-tabs/web-tab-pane.tsx \
web/components/center-tabs/new-tab-page.tsx \
web/components/right-sidebar/bookmarks-panel.tsx \
web/scripts/check-bookmarks.mjs
git commit -m "fix(bookmarks): sync manager across windows"
Task 2: Build the permanent rail and accessible panel interactions#
Files:
- Create:
web/lib/right-panel-behavior.ts - Modify:
web/components/right-sidebar/right-sidebar.tsx:29-387 - Modify:
web/scripts/check-bookmarks.mjs:5-170 - Modify:
web/package.json:5-14
Interfaces:
-
Consumes:
useSessionStore.rightDock,setRightDockOpen(open: boolean),setRightDockView(view: string), existingrightDocklegacy shims,sidebarNavItemClass, and existing view components. -
Produces:
resolveRightPanelAction,panelWidthAfterKey,clampPanelWidth, and the sharedhandlePanelResizeKeyevent handler as directly executable interaction rules; the semantic.right-sidebar-rail/.right-sidebar-panelshell; native rail buttons; close/focus behavior; and a keyboard-operable.right-panel-resizeseparator. -
Step 1: Add failing executable interaction checks
Change check:bookmarks so Node can import the small TypeScript behavior module directly:
"check:bookmarks": "node --no-warnings --experimental-strip-types scripts/check-bookmarks.mjs"
Import the not-yet-created module at the top of check-bookmarks.mjs:
import {
RIGHT_PANEL_DEFAULT,
RIGHT_PANEL_GAP,
RIGHT_PANEL_MAX,
RIGHT_PANEL_MIN,
RIGHT_RAIL_WIDTH,
clampPanelWidth,
handlePanelResizeKey,
panelWidthAfterKey,
resolveRightPanelAction,
} from "../lib/right-panel-behavior.ts";
Add these direct behavior assertions. They execute the same pure rules the React handlers will call; they do not infer behavior from source text:
assert.equal(RIGHT_PANEL_DEFAULT, 320);
assert.equal(RIGHT_PANEL_MIN, 280);
assert.equal(RIGHT_PANEL_MAX, 560);
assert.equal(RIGHT_PANEL_GAP, 8);
assert.equal(RIGHT_RAIL_WIDTH, 49);
assert.equal(clampPanelWidth(120), 280);
assert.equal(clampPanelWidth(420), 420);
assert.equal(clampPanelWidth(900), 560);
assert.equal(panelWidthAfterKey(320, "ArrowLeft"), 336);
assert.equal(panelWidthAfterKey(320, "ArrowRight"), 304);
assert.equal(panelWidthAfterKey(280, "ArrowRight"), 280);
assert.equal(panelWidthAfterKey(560, "ArrowLeft"), 560);
assert.equal(panelWidthAfterKey(400, "Home"), 280);
assert.equal(panelWidthAfterKey(400, "End"), 560);
assert.equal(panelWidthAfterKey(400, "Enter"), null);
assert.deepEqual(
resolveRightPanelAction(
{ open: true, view: "bookmarks" },
{ type: "select", view: "bookmarks" },
),
{ open: false, view: "bookmarks", focusView: "bookmarks" },
"re-clicking the selected rail item must close and target that item for focus",
);
assert.deepEqual(
resolveRightPanelAction(
{ open: true, view: "bookmarks" },
{ type: "escape" },
),
{ open: false, view: "bookmarks", focusView: "bookmarks" },
"Escape must close and target the selected rail item for focus",
);
assert.deepEqual(
resolveRightPanelAction(
{ open: true, view: "bookmarks" },
{ type: "select", view: "files" },
),
{ open: true, view: "files", focusView: null },
"selecting another rail item must switch the open panel",
);
assert.deepEqual(
resolveRightPanelAction(
{ open: false, view: "bookmarks" },
{ type: "escape" },
),
{ open: false, view: "bookmarks", focusView: null },
"Escape on a closed panel must not move focus",
);
Add a dependency-free EventTarget/attribute harness that binds and executes the same handlePanelResizeKey function used by the separator's React onKeyDown handler:
class SeparatorHarness extends EventTarget {
#attributes = new Map();
setAttribute(name, value) {
this.#attributes.set(name, String(value));
}
getAttribute(name) {
return this.#attributes.get(name) ?? null;
}
}
const separator = new SeparatorHarness();
separator.setAttribute("role", "separator");
separator.setAttribute("aria-valuemin", RIGHT_PANEL_MIN);
separator.setAttribute("aria-valuemax", RIGHT_PANEL_MAX);
let renderedPanelWidth = RIGHT_PANEL_DEFAULT;
function renderSeparator(nextWidth) {
renderedPanelWidth = nextWidth;
separator.setAttribute("aria-valuenow", nextWidth);
}
function dispatchSeparatorKey(key) {
const event = new Event("keydown", { cancelable: true });
Object.defineProperty(event, "key", { value: key });
separator.dispatchEvent(event);
return event;
}
separator.addEventListener("keydown", (event) => {
handlePanelResizeKey(event, renderedPanelWidth, renderSeparator);
});
renderSeparator(RIGHT_PANEL_DEFAULT);
assert.equal(separator.getAttribute("aria-valuemin"), "280");
assert.equal(separator.getAttribute("aria-valuemax"), "560");
assert.equal(separator.getAttribute("aria-valuenow"), "320");
assert.equal(dispatchSeparatorKey("ArrowLeft").defaultPrevented, true);
assert.equal(separator.getAttribute("aria-valuenow"), "336");
dispatchSeparatorKey("Home");
assert.equal(separator.getAttribute("aria-valuenow"), "280");
dispatchSeparatorKey("ArrowRight");
assert.equal(separator.getAttribute("aria-valuenow"), "280");
dispatchSeparatorKey("End");
assert.equal(separator.getAttribute("aria-valuenow"), "560");
dispatchSeparatorKey("ArrowLeft");
assert.equal(separator.getAttribute("aria-valuenow"), "560");
assert.equal(dispatchSeparatorKey("Enter").defaultPrevented, false);
assert.equal(separator.getAttribute("aria-valuenow"), "560");
After the existing parseTsx and attr helpers, add one AST wiring check that locates the actual separator and verifies its onKeyDown arrow invokes the exported handler exercised above:
const rightSidebarFile = parseTsx(rightSidebar, "right-sidebar.tsx");
let resizeSeparator;
function findResizeSeparator(node) {
if (
ts.isJsxSelfClosingElement(node) &&
attr(node, "role")?.initializer?.text === "separator"
) {
resizeSeparator = node;
return;
}
ts.forEachChild(node, findResizeSeparator);
}
findResizeSeparator(rightSidebarFile);
assert.ok(resizeSeparator, "keyboard resize separator missing");
const resizeKeyDown = attr(resizeSeparator, "onKeyDown");
assert.ok(resizeKeyDown, "separator onKeyDown missing");
assert.ok(ts.isJsxExpression(resizeKeyDown.initializer));
const resizeKeyExpression = resizeKeyDown.initializer.expression;
assert.ok(resizeKeyExpression && ts.isArrowFunction(resizeKeyExpression));
assert.ok(ts.isCallExpression(resizeKeyExpression.body));
assert.equal(
resizeKeyExpression.body.expression.getText(rightSidebarFile),
"handlePanelResizeKey",
"separator must invoke the handler covered by the EventTarget harness",
);
Retain only small semantic/AST checks for the three native rail buttons, the separator ARIA attributes, no nested buttons, localized icon labels, bookmark focus-visible styles, and the bookmark navigation matrix. Do not use source regex to claim resize, selected-item close, Escape, or focus-target behavior; those behaviors are covered by the direct assertions above and later by live document.activeElement checks.
- Step 2: Run the focused check and verify RED
Run:
cd web
npm run check:bookmarks
Expected: FAIL with ERR_MODULE_NOT_FOUND because right-panel-behavior.ts does not exist yet.
- Step 3: Implement the shared behavior module and call its handler from React
Create web/lib/right-panel-behavior.ts:
export const RIGHT_RAIL_WIDTH = 49;
export const RIGHT_PANEL_DEFAULT = 320;
export const RIGHT_PANEL_MIN = 280;
export const RIGHT_PANEL_MAX = 560;
export const RIGHT_PANEL_GAP = 8;
export const RIGHT_PANEL_KEY_STEP = 16;
export type RightPanelState = { open: boolean; view: string };
export type RightPanelAction =
| { type: "select"; view: string }
| { type: "escape" };
export function clampPanelWidth(width: number): number {
return Math.max(RIGHT_PANEL_MIN, Math.min(RIGHT_PANEL_MAX, width));
}
export function panelWidthAfterKey(width: number, key: string): number | null {
if (key === "ArrowLeft") return clampPanelWidth(width + RIGHT_PANEL_KEY_STEP);
if (key === "ArrowRight") return clampPanelWidth(width - RIGHT_PANEL_KEY_STEP);
if (key === "Home") return RIGHT_PANEL_MIN;
if (key === "End") return RIGHT_PANEL_MAX;
return null;
}
type ResizeKeyEvent = {
key: string;
preventDefault: () => void;
};
export function handlePanelResizeKey(
event: ResizeKeyEvent,
width: number,
commit: (nextWidth: number) => void,
): boolean {
const next = panelWidthAfterKey(width, event.key);
if (next === null) return false;
event.preventDefault();
commit(next);
return true;
}
export function resolveRightPanelAction(
state: RightPanelState,
action: RightPanelAction,
): RightPanelState & { focusView: string | null } {
if (action.type === "escape") {
return state.open
? { ...state, open: false, focusView: state.view }
: { ...state, focusView: null };
}
if (state.open && state.view === action.view) {
return { ...state, open: false, focusView: action.view };
}
return { open: true, view: action.view, focusView: null };
}
In right-sidebar.tsx, change the Lucide import and replace the existing sidebar width constants/state/handlers with:
import { Bookmark, X } from "lucide-react";
import {
RIGHT_PANEL_DEFAULT,
RIGHT_PANEL_GAP,
RIGHT_PANEL_MAX,
RIGHT_PANEL_MIN,
RIGHT_RAIL_WIDTH,
clampPanelWidth,
handlePanelResizeKey,
resolveRightPanelAction,
} from "@/lib/right-panel-behavior";
// Inside RightSidebar:
const [panelWidth, setPanelWidth] = useState(RIGHT_PANEL_DEFAULT);
const [resizing, setResizing] = useState(false);
const dragRef = useRef<{ startX: number; startW: number } | null>(null);
const toggleButtonRef = useRef<HTMLButtonElement>(null);
const railButtonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
function restoreRailFocus(targetView = view) {
const target = railButtonRefs.current[targetView] ?? toggleButtonRef.current;
requestAnimationFrame(() => target?.focus());
}
function closePanelAndRestoreFocus(targetView = view) {
setRightDockOpen(false);
restoreRailFocus(targetView);
}
function onToggleRail() {
if (open) closePanelAndRestoreFocus();
else setRightDockOpen(true);
}
function onNavClick(v: string, button: HTMLButtonElement) {
const next = resolveRightPanelAction(
{ open, view },
{ type: "select", view: v },
);
if (next.view !== view) setRightDockView(next.view);
setRightDockOpen(next.open);
if (next.focusView) requestAnimationFrame(() => button.focus());
}
function onSidebarKeyDown(event: React.KeyboardEvent<HTMLElement>) {
if (event.key !== "Escape" || !open) return;
event.preventDefault();
event.stopPropagation();
const next = resolveRightPanelAction({ open, view }, { type: "escape" });
setRightDockOpen(next.open);
if (next.focusView) restoreRailFocus(next.focusView);
}
function onResizePointerDown(event: React.PointerEvent<HTMLDivElement>) {
if (event.button !== 0) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = { startX: event.clientX, startW: panelWidth };
setResizing(true);
}
function onResizePointerMove(event: React.PointerEvent<HTMLDivElement>) {
if (!dragRef.current || !event.currentTarget.hasPointerCapture(event.pointerId)) return;
setPanelWidth(clampPanelWidth(dragRef.current.startW + dragRef.current.startX - event.clientX));
}
function finishResize(event: React.PointerEvent<HTMLDivElement>) {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
dragRef.current = null;
setResizing(false);
}
Delete onResizeMouseDown and its document-level listeners. Keep the existing window.rightDock compatibility effect unchanged.
- Step 4: Replace the single-column sidebar shell
Compute the header text before return:
const panelTitle =
view === VIEW_HISTORY ? t("right.history")
: view === VIEW_BOOKMARKS ? text("Bookmarks", "书签")
: view === VIEW_FILES ? text("Files", "文件")
: view === VIEW_CONTEXT ? t("right.context")
: text("Execution detail", "执行详情");
const shellWidth = open
? panelWidth + RIGHT_RAIL_WIDTH + RIGHT_PANEL_GAP * 2
: RIGHT_RAIL_WIDTH;
Replace the current <aside> return block with this structure. The existing HistoryGraphPanel, DetailPanel, ContextCommitTimeline, and FileTree children remain the same components and IDs:
<aside
id="rightSidebar"
className={`sidebar right-sidebar${open ? "" : " collapsed"}`}
style={{ width: `${shellWidth}px`, minWidth: `${shellWidth}px` }}
data-view={view}
data-resizing={resizing ? "true" : "false"}
onKeyDown={onSidebarKeyDown}
>
<section
className="right-sidebar-panel"
style={{ width: `${panelWidth}px` }}
role="region"
aria-labelledby="right-panel-title"
hidden={!open}
>
<div
className="right-panel-resize"
role="separator"
aria-label={t("right.resize_panel")}
aria-orientation="vertical"
aria-valuemin={RIGHT_PANEL_MIN}
aria-valuemax={RIGHT_PANEL_MAX}
aria-valuenow={panelWidth}
tabIndex={0}
onPointerDown={onResizePointerDown}
onPointerMove={onResizePointerMove}
onPointerUp={finishResize}
onPointerCancel={finishResize}
onKeyDown={(event) =>
handlePanelResizeKey(event, panelWidth, setPanelWidth)
}
/>
<header className="right-sidebar-panel-header">
<span id="right-panel-title">{panelTitle}</span>
<button
type="button"
className={sidebarToggleClass}
onClick={() => closePanelAndRestoreFocus()}
title={text("Close panel", "关闭面板")}
aria-label={text("Close panel", "关闭面板")}
>
<X size={18} aria-hidden="true" />
</button>
</header>
<div className="right-view-host">
<div className="right-view" data-view={VIEW_BOOKMARKS}>
<BookmarksPanel />
</div>
<div className="right-view" data-view={VIEW_FILES}>
{treeProjectId ? (
<FileTree projectId={treeProjectId} />
) : (
<div style={{ padding: 16, fontSize: 13, color: "var(--text-dim)" }}>
{text("Bind a project to browse files", "绑定项目后可浏览文件")}
</div>
)}
</div>
<div id="historyPanel" className="right-view" data-view={VIEW_HISTORY}>
<HistoryGraphPanel />
</div>
<div id="detailPanel" className="right-view" data-view={VIEW_DETAIL}>
<DetailPanel />
</div>
<div id="commitsPanel" className="right-view" data-view={VIEW_CONTEXT}>
<ContextCommitTimeline />
</div>
</div>
</section>
<nav className="right-sidebar-rail" aria-label={text("Workspace tools", "工作区工具")}>
<div className="right-sidebar-rail-header">
<button
ref={toggleButtonRef}
className={sidebarToggleClass}
onClick={onToggleRail}
onMouseEnter={() => toggleIconRef.current?.startAnimation?.()}
onMouseLeave={() => toggleIconRef.current?.stopAnimation?.()}
title={t("right.toggle_panel")}
aria-label={t("right.toggle_panel")}
aria-expanded={open}
type="button"
>
{open ? (
<PanelLeftCloseIcon ref={toggleIconRef} size={20} style={{ transform: "scaleX(-1)" }} />
) : (
<PanelLeftOpenIcon ref={toggleIconRef} size={20} style={{ transform: "scaleX(-1)" }} />
)}
</button>
</div>
<div className="right-sidebar-rail-items">
<button
ref={(node) => { railButtonRefs.current[VIEW_HISTORY] = node; }}
type="button"
className={sidebarNavItemClass + " right-nav-item" +
(view === VIEW_HISTORY ? " " + sidebarNavItemActiveClass : "")}
data-view={VIEW_HISTORY}
onClick={(event) => onNavClick(VIEW_HISTORY, event.currentTarget)}
onMouseEnter={() => historyIconRef.current?.startAnimation?.()}
onMouseLeave={() => historyIconRef.current?.stopAnimation?.()}
title={t("right.history")}
aria-label={t("right.history")}
aria-pressed={open && view === VIEW_HISTORY}
>
<span className={sidebarNavIconClass}>
<GitGraphIcon ref={historyIconRef} size={20} />
</span>
</button>
<button
ref={(node) => { railButtonRefs.current[VIEW_BOOKMARKS] = node; }}
type="button"
className={sidebarNavItemClass + " right-nav-item" +
(view === VIEW_BOOKMARKS ? " " + sidebarNavItemActiveClass : "")}
data-view={VIEW_BOOKMARKS}
onClick={(event) => onNavClick(VIEW_BOOKMARKS, event.currentTarget)}
title={text("Bookmarks", "书签")}
aria-label={text("Bookmarks", "书签")}
aria-pressed={open && view === VIEW_BOOKMARKS}
>
<span className={sidebarNavIconClass}>
<Bookmark size={20} aria-hidden="true" />
</span>
</button>
<button
ref={(node) => { railButtonRefs.current[VIEW_FILES] = node; }}
type="button"
className={sidebarNavItemClass + " right-nav-item" +
(view === VIEW_FILES ? " " + sidebarNavItemActiveClass : "")}
data-view={VIEW_FILES}
onClick={(event) => onNavClick(VIEW_FILES, event.currentTarget)}
onMouseEnter={() => filesIconRef.current?.startAnimation?.()}
onMouseLeave={() => filesIconRef.current?.stopAnimation?.()}
title={text("Project files", "项目文件")}
aria-label={text("Project files", "项目文件")}
aria-pressed={open && view === VIEW_FILES}
>
<span className={sidebarNavIconClass}>
<FolderOpenIcon ref={filesIconRef} size={20} />
</span>
</button>
</div>
</nav>
</aside>
Remove the unused sidebarNavLabelClass import. Do not expose the legacy Detail or Context views as permanent rail buttons; their existing window.rightDock.show("detail" | "context") entry points continue to select the content host.
- Step 5: Run focused and type checks
Run:
cd web
npm run check:bookmarks
npx tsc --noEmit
Expected: both commands exit 0; the focused check prints bookmark storage checks passed and TypeScript reports no errors.
- Step 6: Commit
git add web/lib/right-panel-behavior.ts \
web/components/right-sidebar/right-sidebar.tsx \
web/scripts/check-bookmarks.mjs web/package.json
git commit -m "feat(desktop): add compact right panel controls"
Task 3: Apply compact geometry and keep the panel in layout at narrow widths#
Files:
- Modify:
web/app/styles/right-dock.css:1-169 - Modify:
web/scripts/check-bookmarks.mjs:5-170
Interfaces:
-
Consumes:
.right-sidebar,.right-sidebar-panel,.right-sidebar-rail,.right-sidebar-panel-header,.right-panel-resize, anddata-resizingfrom Task 2. -
Produces: exact 49/320/280/560 panel geometry, 8 DIP gaps, a 16 DIP rounded surface, a 40 DIP header, and a non-fixed narrow layout.
-
Step 1: Add failing geometry and responsive checks
Extend check-bookmarks.mjs with:
for (const [selector, declaration] of [
["right-sidebar-rail", /width:\s*49px/],
["right-sidebar-panel", /margin:\s*8px/],
["right-sidebar-panel", /border-radius:\s*16px/],
["right-sidebar-panel", /box-shadow:\s*var\(--shadow-popover\)/],
["right-sidebar-panel-header", /height:\s*40px/],
]) {
const rule = new RegExp(`\\.${selector}\\s*\\{[^}]*${declaration.source}`, "s");
assert.match(rightDockCss, rule, `${selector} geometry missing`);
}
assert.match(rightDockCss, /\.right-sidebar-panel\[hidden\]\s*\{\s*display:\s*none;/);
assert.match(
rightDockCss,
/@media\s*\(max-width:\s*900px\)[\s\S]*\.right-sidebar[\s\S]*position:\s*relative;/,
"narrow right panel must remain a layout participant",
);
- Step 2: Run the focused check and verify RED
Run:
cd web
npm run check:bookmarks
Expected: FAIL because the new compact-panel selectors and responsive override are absent.
- Step 3: Add compact-panel CSS and remove narrow overlay behavior
Replace the shell/view-host preamble in right-dock.css and retain the existing bookmark and other view-specific rules below it:
.right-sidebar {
position: relative;
display: flex;
flex: 0 0 auto;
flex-direction: row;
align-items: stretch;
min-height: 0;
overflow: hidden;
background: var(--bg-secondary);
transition: width 150ms cubic-bezier(0.165, 0.84, 0.44, 1),
min-width 150ms cubic-bezier(0.165, 0.84, 0.44, 1);
}
.right-sidebar[data-resizing="true"] { transition: none; }
.right-sidebar-panel {
position: relative;
display: flex;
min-width: 0;
min-height: 0;
flex: 0 0 auto;
flex-direction: column;
box-sizing: border-box;
margin: 8px;
overflow: hidden;
border: 1px solid var(--border-popover);
border-radius: 16px;
background: var(--bg-secondary);
box-shadow: var(--shadow-popover);
}
.right-sidebar-panel[hidden] { display: none; }
.right-sidebar-panel-header {
display: flex;
height: 40px;
flex: 0 0 40px;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
padding: 4px 8px 4px 14px;
border-bottom: 1px solid var(--border);
color: var(--text-primary);
font-size: 13px;
font-weight: 550;
}
.right-sidebar-rail {
display: flex;
width: 49px;
min-width: 49px;
flex: 0 0 49px;
flex-direction: column;
box-sizing: border-box;
border-left: 1px solid var(--border);
background: var(--bg-secondary);
}
.right-sidebar-rail-header {
display: flex;
height: 40px;
flex: 0 0 40px;
align-items: center;
justify-content: center;
}
.right-sidebar-rail-items {
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
padding: 8px;
}
.right-sidebar-rail .right-nav-item {
width: 32px;
min-width: 32px;
padding: 6px;
justify-content: center;
}
.right-panel-resize {
position: absolute;
z-index: 10;
top: 16px;
bottom: 16px;
left: 0;
width: 8px;
cursor: ew-resize;
touch-action: none;
}
.right-panel-resize:focus-visible {
outline: 2px solid var(--accent-blue);
outline-offset: -2px;
}
.right-view-host {
position: relative;
min-height: 0;
flex: 1;
overflow: hidden;
}
.right-view {
position: absolute;
inset: 0;
display: none;
flex-direction: column;
}
.right-sidebar[data-view="history"] .right-view[data-view="history"],
.right-sidebar[data-view="detail"] .right-view[data-view="detail"],
.right-sidebar[data-view="context"] .right-view[data-view="context"],
.right-sidebar[data-view="files"] .right-view[data-view="files"],
.right-sidebar[data-view="bookmarks"] .right-view[data-view="bookmarks"] {
display: flex;
}
@media (max-width: 900px) {
.right-sidebar,
.right-sidebar:not(.collapsed) {
position: relative;
inset: auto;
z-index: auto;
box-shadow: none;
}
}
Delete the obsolete .right-sidebar.collapsed .right-view-host rule because the panel's hidden attribute now controls its closed state. Keep all bookmark rows and History/Detail/Context-specific styles unchanged.
- Step 4: Run focused, aggregate, type, and production checks
Run:
cd web
npm run check:bookmarks
npm run check
npx tsc --noEmit
npm run build
cd ../desktop
npm run check:webtabs
Expected: every command exits 0; the focused check prints bookmark storage checks passed, TypeScript reports no errors, and Next reports a successful production build.
- Step 5: Commit
git add web/app/styles/right-dock.css web/scripts/check-bookmarks.mjs
git commit -m "style(desktop): add compact right panel geometry"
Task 4: Verify geometry, accessibility, native-view bounds, and the full bookmark workflow#
Files:
- Modify only if a live failure first receives a focused executable regression assertion in
web/scripts/check-bookmarks.mjsorweb/lib/right-panel-behavior.ts, followed by the smallest covering fix in an already-listed implementation file.
Interfaces:
-
Verifies the completed compact-panel and bookmark interfaces without adding production APIs.
-
Step 1: Confirm or start the development worker on 18209/18200
From the repository root, first inspect only the dev profile and probe both required services without changing any process:
OPENPROGRAM_PROFILE=dev \
OPENPROGRAM_BACKEND_PORT=18209 \
OPENPROGRAM_WEB_PORT=18200 \
openprogram worker status
curl -fsS http://127.0.0.1:18209/healthz
curl -fsS http://127.0.0.1:18200/chat >/dev/null
Also use the repository's identity probes from openprogram/_ports.py; they distinguish OpenProgram from an unrelated process occupying either port:
python - <<'PY'
from openprogram._ports import backend_is_ours, frontend_is_ours
assert backend_is_ours(18209) is True, "OpenProgram dev backend is not ready on 18209"
assert frontend_is_ours(18200) is True, "OpenProgram dev frontend is not ready on 18200"
PY
If status or either identity probe fails, start only the dev worker with the repository's supported worker start command:
OPENPROGRAM_PROFILE=dev \
OPENPROGRAM_BACKEND_PORT=18209 \
OPENPROGRAM_WEB_PORT=18200 \
openprogram worker start
Repeat both HTTP probes and the identity-probe block until all commands exit 0:
curl -fsS http://127.0.0.1:18209/healthz
curl -fsS http://127.0.0.1:18200/chat >/dev/null
Expected: the OpenProgram backend reports its /healthz JSON on 18209, the development frontend responds on 18200, and both backend_is_ours / frontend_is_ours return True. This is the dev profile pairing documented in docs/install/profiles.md. Do not run a default-profile start, stop, or restart command, and do not change stable ports 18109 or 18100.
- Step 2: Launch Electron with an actual isolated user-data directory argument
Create a disposable directory and pass Electron's supported command-line switch through npm run:
RIGHT_PANEL_PROFILE_DIR="$(mktemp -d /tmp/openprogram-right-panel.XXXXXX)"
cd desktop
OPENPROGRAM_WEB_PORT=18200 \
OPENPROGRAM_DESKTOP_URL=http://127.0.0.1:18200/chat \
npm run dev -- --user-data-dir="$RIGHT_PANEL_PROFILE_DIR"
Expected: Electron receives an argument of the form --user-data-dir=/tmp/openprogram-right-panel.…, opens http://127.0.0.1:18200/chat, and http://127.0.0.1:9223/json/version responds. Do not use an environment variable such as ELECTRON_EXTRA_LAUNCH_ARGS; desktop/main.js does not read one.
- Step 3: Measure the collapsed and expanded geometry through CDP
In the renderer target, evaluate:
(() => {
const sidebar = document.querySelector("#rightSidebar");
const rail = document.querySelector(".right-sidebar-rail");
const panel = document.querySelector(".right-sidebar-panel");
const header = document.querySelector(".right-sidebar-panel-header");
const style = panel ? getComputedStyle(panel) : null;
return {
sidebarWidth: sidebar?.getBoundingClientRect().width,
railWidth: rail?.getBoundingClientRect().width,
panelWidth: panel?.getBoundingClientRect().width,
headerHeight: header?.getBoundingClientRect().height,
panelMargin: style?.margin,
panelRadius: style?.borderRadius,
panelPosition: style?.position,
};
})()
Expected when closed: sidebarWidth === 49 and railWidth === 49. Expected after opening Bookmarks at the default width: railWidth === 49, panelWidth === 320, headerHeight === 40, panelMargin === "8px", panelRadius === "16px", and the panel is a normal child of the layout-occupying sidebar shell.
- Step 4: Verify rail toggle, Escape, focus restoration, and keyboard resize
Use pointer and keyboard input, then inspect document.activeElement and the separator attributes:
- Open Bookmarks, click Bookmarks again, and confirm the panel closes while the Bookmarks button remains focused.
- Reopen Bookmarks, focus the search field, press
Escape, and confirm the panel closes and focus moves to the Bookmarks rail button. - Focus
.right-panel-resize; pressArrowLeftonce and confirmaria-valuenowchanges from320to336; pressArrowRightonce and confirm it returns to320. - Press
Homeand confirmaria-valuenow === "280"; press anotherArrowRightand confirm it remains280. - Press
Endand confirmaria-valuenow === "560"; press anotherArrowLeftand confirm it remains560.
Expected: every control remains keyboard reachable, no focus is lost to body, and resize changes do not reload or close any center tab.
- Step 5: Verify narrow-window layout and native page bounds
Open a real web tab, then open the right panel and resize the Electron window below 900 DIP. Through CDP, compare .center-body, .right-sidebar-panel, and .right-sidebar-rail rectangles. Through the existing web-tab bridge/CDP target, inspect the active native view bounds.
Expected: the right shell remains a flex layout participant rather than position: fixed; the center rectangle ends before the panel begins; the native WebContentsView right edge does not enter the panel rectangle; closing the panel increases the center width by the panel width plus its two 8 DIP gaps without reloading the page.
- Step 6: Accept the web-toolbar star add/remove entry independently
- Open a real page with a unique URL, for example
https://example.com/?bookmark-toolbar-acceptance=1. - Click the toolbar star and confirm its accessible label changes from
BookmarktoRemove bookmarkand its icon becomes filled. - Open Bookmarks and a new-tab page in turn; confirm the new item is visible in both surfaces without reload.
- Return to the same web page, click the filled star, and confirm the accessible label returns to
Bookmark. - Confirm the item disappears from Bookmarks and the new-tab page without reload.
Expected: the toolbar star independently performs both add and remove, and every consumer updates immediately.
- Step 7: Accept the new-tab-page bookmark shortcut independently
- Open
https://example.org/?bookmark-ntp-acceptance=1and add it with the toolbar star. - Open a new-tab page and confirm its bookmark shortcut shows the page title and carries the exact URL in its title attribute.
- Click the shortcut title and confirm it opens the bookmarked URL in a normal web tab.
- Return to a new-tab page, click that shortcut's
Delete bookmarkcontrol, and confirm the shortcut disappears immediately. - Return to the web page and confirm the toolbar star is no longer filled.
Expected: the new-tab-page open/delete entry works independently of the manager. This is a separate acceptance case from the toolbar-star add/remove case.
- Step 8: Accept manager search, rename, split-open, full-open, delete, and reload persistence
- Add two real pages with distinct titles and URLs through the toolbar star, including
https://example.com/?bookmark-manager-acceptance=1. - Open Bookmarks and search for the first item by a unique title substring; confirm only the title match remains.
- Clear the query, search by a unique URL substring, and confirm only the URL match remains.
- Rename the first item, press
Enter, and confirm the renamed title appears immediately in the manager and new-tab page while the URL is unchanged. - With a session active and desktop split available, click the renamed title; confirm the page opens beside chat and the panel closes.
- Reopen Bookmarks and click the explicit
Open in full tabaction for the second item; confirm it activates as a normal full web tab rather than replacing the split target. - Reopen Bookmarks, reload the renderer while
bookmarksis selected, and confirm the right panel restores to Bookmarks rather than Files; both items and the renamed title persist. - Delete the first item through the manager, reload again, and confirm it remains absent from the manager, toolbar state, and new-tab page.
Expected: title/URL search, rename, split-open, full-tab-open, manager delete, view restore, and profile-local persistence all work with real renderer state.
- Step 9: Verify real cross-window storage updates
Using the multi-window implementation and the same isolated --user-data-dir, open windows A and B. Keep Bookmarks open in B. In A, add a third unique bookmark from the toolbar, rename it in A's manager, then delete it.
Expected: B shows the added item, its renamed title, and its removal after each operation without reload. Confirm B receives the native browser storage event for BOOKMARKS_STORAGE_KEY; the same-window custom event alone is not sufficient for this acceptance. Do not replace this step with two tabs in one renderer.
- Step 10: Run the final regression commands
Run:
cd web
npm run check
npx tsc --noEmit
npm run build
cd ../desktop
npm run check:webtabs
cd ..
python -m pytest tests/unit/test_webtab_control.py -q
git diff --check
Expected: every command exits 0; no check relies on the stable 18100 application.
- Step 11: Commit only live-discovered fixes
If live verification exposed no issue, make no commit. If it exposed an issue, first add a failing executable assertion to web/scripts/check-bookmarks.mjs or web/lib/right-panel-behavior.ts, implement the smallest covering fix, rerun Steps 4–10, then commit only those files:
git add web/scripts/check-bookmarks.mjs \
web/lib/right-panel-behavior.ts \
web/components/right-sidebar/right-sidebar.tsx \
web/app/styles/right-dock.css \
web/lib/bookmarks.ts web/lib/session-store/index.ts
git commit -m "fix(desktop): correct compact right panel behavior"