From ecb05faaa018aeb17fc5026db4d213400f93f07f Mon Sep 17 00:00:00 2001 From: kate bonner Date: Mon, 27 Jul 2026 18:35:52 -0400 Subject: [PATCH 1/2] fix(amicode): side panel owns its width; vault fills it by default; store/column sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kate's live findings on the vault-in-side-panel move: - The tabs column inherited the review panel's flex-fill sizing and swallowed the window. It now owns a persisted width (panelColumn, default 400px) with a start-edge resize handle down to 240px; the chat takes the remainder (the old chat-edge handle is gone). - An open column with nothing to show sat on 'Select a file to open'. It now fills with the vault by default, and closing the vault tab with nothing else open closes the column too. - Closing the column desynced the vaultPanel store, so the titlebar button's next press toggled an invisible state — 'the button does nothing'. Column close now closes the store (reproduced and verified via Playwright probe). - Pending rail chips (Pulse/Device) read as broken dead buttons — they now carry title/aria explaining what creates them. Co-Authored-By: Claude Fable 5 --- packages/app/src/context/layout.tsx | 12 +++++ packages/app/src/pages/session.tsx | 19 +------- .../src/pages/session/session-side-panel.tsx | 46 +++++++++++++++++-- packages/ui/src/amicode/entity-rail.tsx | 21 +++++++++ 4 files changed, 75 insertions(+), 23 deletions(-) diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 6d93337d7..328a58fd0 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -24,6 +24,7 @@ export type { ProjectAvatarVariant } const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const const DEFAULT_SIDEBAR_WIDTH = 344 const DEFAULT_FILE_TREE_WIDTH = 200 +const DEFAULT_PANEL_COLUMN_WIDTH = 400 const DEFAULT_SESSION_WIDTH = 600 const DEFAULT_TERMINAL_HEIGHT = 280 export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number] @@ -269,6 +270,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" as "changes" | "all", }, + panelColumn: { + width: DEFAULT_PANEL_COLUMN_WIDTH, + }, session: { width: DEFAULT_SESSION_WIDTH, }, @@ -642,6 +646,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("review", "diffStyle", diffStyle) }, }, + // the session side panel's tabs column (vault + file tabs) — its OWN + // width, so the panel never flex-fills the window (Kate 2026-07-27) + panelColumn: { + width: createMemo(() => store.panelColumn?.width ?? DEFAULT_PANEL_COLUMN_WIDTH), + resize(width: number) { + setStore("panelColumn", { width }) + }, + }, fileTree: { opened: createMemo(() => store.fileTree?.opened ?? true), width: createMemo(() => store.fileTree?.width ?? DEFAULT_FILE_TREE_WIDTH), diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 9eae92fbe..c7da6d240 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -284,7 +284,7 @@ export default function Page() { const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen()) const sessionPanelWidth = createMemo(() => { if (!desktopSidePanelOpen()) return "100%" - if (desktopReviewOpen()) return `${layout.session.width()}px` + if (desktopReviewOpen()) return `calc(100% - ${layout.panelColumn.width()}px)` return `calc(100% - ${layout.fileTree.width()}px)` }) const centered = createMemo(() => isDesktop() && !desktopReviewOpen()) @@ -1838,23 +1838,6 @@ export default function Page() { {composerRegion("dock")} - -
size.start()}> - { - size.touch() - layout.session.resize(width) - }} - /> -
-
diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 39d522840..18c00d8ff 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -1,4 +1,4 @@ -import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js" +import { For, Match, Show, Switch, createEffect, createMemo, on, onCleanup, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { createMediaQuery } from "@solid-primitives/media" import { Tabs } from "@opencode-ai/ui/tabs" @@ -63,9 +63,11 @@ export function SessionSidePanel(props: { size: Sizing }) { }), ) const open = createMemo(() => tabsOpen() || fileOpen()) + // the tabs column owns its width (never flex-fills the window) and can be + // dragged much narrower — Kate 2026-07-27 const panelWidth = createMemo(() => { if (!open()) return "0px" - if (tabsOpen()) return "auto" + if (tabsOpen()) return `${layout.panelColumn.width()}px` return `${layout.fileTree.width()}px` }) const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px")) @@ -117,6 +119,19 @@ export function SessionSidePanel(props: { size: Sizing }) { tabs().close("vault") } }) + // column closed (panel toggle) → the store must follow, or the titlebar + // button's next press toggles an invisible state and "does nothing" + createEffect( + on( + tabsOpen, + (openNow, wasOpen) => { + if (wasOpen && !openNow && vaultPanel.opened()) vaultPanel.close() + // a column with nothing to show fills with the vault by default + if (!wasOpen && openNow && tabState.activeTab() === "empty") vaultPanel.open() + }, + { defer: true }, + ), + ) const tabState = createSessionTabs({ tabs, @@ -187,10 +202,24 @@ export function SessionSidePanel(props: { size: Sizing }) { "transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none": !props.size.active(), "rounded-[10px] shadow-[var(--v2-elevation-raised)] overflow-hidden": settings.general.newLayoutDesigns(), - "flex-1": tabsOpen(), }} style={{ width: panelWidth() }} > + +
props.size.start()}> + { + props.size.touch() + layout.panelColumn.resize(width) + }} + /> +
+
vaultPanel.close()} + onClick={() => { + vaultPanel.close() + // nothing else to show → the column goes too + if (openedTabs().length === 0 && !contextOpen()) view().reviewPanel.close() + }} aria-label={language.t("amicode.vault.close")} /> } hideCloseButton - onMiddleClick={() => vaultPanel.close()} + onMiddleClick={() => { + vaultPanel.close() + if (openedTabs().length === 0 && !contextOpen()) view().reviewPanel.close() + }} >
{language.t("amicode.vault.title")}
diff --git a/packages/ui/src/amicode/entity-rail.tsx b/packages/ui/src/amicode/entity-rail.tsx index e859e30da..61e5b151e 100644 --- a/packages/ui/src/amicode/entity-rail.tsx +++ b/packages/ui/src/amicode/entity-rail.tsx @@ -67,6 +67,25 @@ interface RailPart { const RUN_POLL_MS = 2500 +// Pending chips are placeholders, not buttons — say WHY they aren't clickable +// (Kate 2026-07-27: an unexplained dead chip reads as a bug). +function pendingHint(kind: string): string { + switch (kind) { + case "pulse": + return "No pulse banked yet — a completed run produces one" + case "device_session": + return "No device session yet — appears when a pulse targets hardware" + case "run": + return "No run yet — solve the formulation to create one" + case "formulation": + return "No formulation yet — amico writes one from the problem" + case "system": + return "No system picked yet" + default: + return "Not created yet" + } +} + // One line-icon per entity kind (glyphs live in the shared family — icon.tsx). function chipIcon(kind: string) { switch (kind) { @@ -235,6 +254,8 @@ export function AmicodeEntityRail(props: { data-slot="amicode-rail-chip" data-stage={chip.kind} data-pending="true" + title={pendingHint(chip.kind)} + aria-label={`${chip.label} — ${pendingHint(chip.kind)}`} > {chip.label} From 7610867bc7c71d3537db954bb900ad422937a0eb Mon Sep 17 00:00:00 2001 From: kate bonner Date: Mon, 27 Jul 2026 18:40:25 -0400 Subject: [PATCH 2/2] fix(amicode): scope the vault store->tab sync to the store signal The sync effect tracked the tab store it writes, so tabs().open() re-triggered it until the stack blew (RangeError caught via Playwright pageerror during vault toggling). on() scopes tracking to vaultPanel.opened() alone, with idempotence guards on the tab ops. Co-Authored-By: Claude Fable 5 --- .../src/pages/session/session-side-panel.tsx | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 18c00d8ff..35d9f8cc4 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -109,16 +109,25 @@ export function SessionSidePanel(props: { size: Sizing }) { // command, and context-tree deep-links open the store; inside a session THIS // is the host, so mirror store state into a "vault" tab (and back on close) const vaultOpen = createMemo(() => vaultPanel.opened()) - createEffect(() => { - if (!isDesktop()) return - if (vaultPanel.opened()) { - if (!view().reviewPanel.opened()) view().reviewPanel.open() - tabs().open("vault") - tabs().setActive("vault") - } else { - tabs().close("vault") - } - }) + // on() scopes tracking to the STORE signal alone — the tab reads/writes in + // the callback are untracked. Tracking them looped: tabs().open() writes + // the same store the effect would re-read, and Solid spins the effect + // until the stack blows (found via Playwright pageerror stack). + createEffect( + on( + () => vaultPanel.opened(), + (openNow) => { + if (!isDesktop()) return + if (openNow) { + if (!view().reviewPanel.opened()) view().reviewPanel.open() + if (!tabs().all().includes("vault")) tabs().open("vault") + if (tabs().active() !== "vault") tabs().setActive("vault") + } else if (tabs().all().includes("vault")) { + tabs().close("vault") + } + }, + ), + ) // column closed (panel toggle) → the store must follow, or the titlebar // button's next press toggles an invisible state and "does nothing" createEffect(