From 84adf040641f4dd2f609213053f1ee71df0788f7 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sun, 2 Aug 2026 10:29:24 -0400 Subject: [PATCH 01/11] fix(amicode#105): status popover loses the shift={-168} magic offset Anchors bottom-end with the standard gutter and the library's collision handling, via a new statusPopoverLayout() in the model (the policy is now unit-testable, and the AC popover_magic_shift == 0 is locked by tests). Both mounts (legacy StatusPopover + V2 StatusPopoverView) ride it. --- .../components/status-popover-model.test.ts | 22 ++++++++++++++++++- .../src/components/status-popover-model.ts | 11 ++++++++++ .../app/src/components/status-popover.tsx | 9 +++----- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/app/src/components/status-popover-model.test.ts b/packages/app/src/components/status-popover-model.test.ts index 4103ee1275..188d5333e1 100644 --- a/packages/app/src/components/status-popover-model.test.ts +++ b/packages/app/src/components/status-popover-model.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test" -import { GLOBAL_STATUS_DEFAULT_TAB, GLOBAL_STATUS_TABS, statusTriggerVisibility } from "./status-popover-model" +import { + GLOBAL_STATUS_DEFAULT_TAB, + GLOBAL_STATUS_TABS, + statusPopoverLayout, + statusTriggerVisibility, +} from "./status-popover-model" describe("statusTriggerVisibility", () => { // amicode#174 AC2: the status trigger is the only per-session entry to the @@ -38,3 +43,18 @@ describe("global status surface (home chrome entry)", () => { expect(GLOBAL_STATUS_TABS).toContain(GLOBAL_STATUS_DEFAULT_TAB) }) }) + +describe("status popover layout (amicode#105)", () => { + // AC popover_magic_shift == 0: the popover anchors bottom-end with standard + // collision handling — the shift={-168} magic offset is deleted and must + // never come back (it positioned the panel by guesswork and clipped off-anchor). + test("anchors bottom-end with the standard gutter", () => { + const layout = statusPopoverLayout() + expect(layout.placement).toBe("bottom-end") + expect(layout.gutter).toBe(4) + }) + + test("carries NO hardcoded shift", () => { + expect("shift" in statusPopoverLayout()).toBe(false) + }) +}) diff --git a/packages/app/src/components/status-popover-model.ts b/packages/app/src/components/status-popover-model.ts index 2fe6a4c531..8f1a409520 100644 --- a/packages/app/src/components/status-popover-model.ts +++ b/packages/app/src/components/status-popover-model.ts @@ -38,3 +38,14 @@ export type GlobalStatusTab = (typeof GLOBAL_STATUS_TABS)[number] /** The home entry is labeled "Connections", so that tab opens pre-selected. */ export const GLOBAL_STATUS_DEFAULT_TAB: GlobalStatusTab = "connections" + +/** + * Popover anchoring (amicode#105): bottom-end with the standard gutter and the + * library's default collision handling — NEVER a hardcoded `shift`. The + * shift={-168} magic offset positioned the panel by guesswork: it clipped + * off-anchor at narrow widths and could not adapt to the viewport. Deleting it + * is the fix; the AC is that it stays deleted (popover_magic_shift == 0). + */ +export function statusPopoverLayout(): { placement: "bottom-end"; gutter: number } { + return { placement: "bottom-end", gutter: 4 } +} diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 01c758e1f2..4fe0572128 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -5,6 +5,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Popover } from "@opencode-ai/ui/popover" import { Suspense, batch, createEffect, createMemo, createSignal, lazy, Show, type ComponentProps, type JSX } from "solid-js" import { announceChromeDropdown, chromeDropdownOpenId, clearChromeDropdown } from "@/utils/chrome-dropdown" +import { statusPopoverLayout } from "./status-popover-model" import { useLanguage } from "@/context/language" import { ServerConnection, useServer } from "@/context/server" import { useServerSDK } from "@/context/server-sdk" @@ -71,9 +72,7 @@ export function StatusPopover(props: { healthDot?: boolean }) { } class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-lg" - gutter={4} - placement="bottom-end" - shift={-168} + {...statusPopoverLayout()} > Date: Sun, 2 Aug 2026 10:34:26 -0400 Subject: [PATCH 02/11] fix(amicode#105): retire the session side-panel vault tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global drawer is the vault's only host (ADR docs/adr/0001). Deletes the tab trigger + content, the vaultOpen memo, and BOTH store-mirroring createEffects — the reviewPanel store has a single writer again, so the sidebar-right pressed state cannot desync. Leaves a do-not-resurrect note for the next upstream merger. ACs store_mirror_effects == 0 and vault_sidepanel_hosts == 0 locked by a structural test on this merge-hot file. --- .../session-side-panel-structure.test.ts | 24 ++++++ .../src/pages/session/session-side-panel.tsx | 82 ++----------------- 2 files changed, 29 insertions(+), 77 deletions(-) create mode 100644 packages/app/src/pages/session/session-side-panel-structure.test.ts diff --git a/packages/app/src/pages/session/session-side-panel-structure.test.ts b/packages/app/src/pages/session/session-side-panel-structure.test.ts new file mode 100644 index 0000000000..1001704806 --- /dev/null +++ b/packages/app/src/pages/session/session-side-panel-structure.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" + +// amicode#105 structural ACs, locked against resurrection on the next upstream +// merge (this file is merge-hot — the ADR names these deletions deliberately): +// store_mirror_effects == 0 — no createEffect mirrors between the global +// vaultPanel store and the per-session reviewPanel +// vault_sidepanel_hosts == 0 — the vault's only host is the global drawer; +// the side-panel tab is retired +// Behavioral consequence: the sidebar-right toggle's store has a single writer, +// so its pressed state cannot disagree with the screen. +const source = readFileSync(join(import.meta.dir, "session-side-panel.tsx"), "utf8") + +describe("work column is vault-free (amicode#105)", () => { + test("no vaultPanel store references (no mirror effects, no vault tab logic)", () => { + expect(source).not.toContain("vaultPanel") + }) + + test("no vault tab trigger or content", () => { + expect(source).not.toContain('value="vault"') + expect(source).not.toContain("vaultOpen") + }) +}) diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index d15906662a..bc13f5369e 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -29,8 +29,6 @@ import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { useDialog } from "@opencode-ai/ui/context/dialog" import FileTree from "@/components/file-tree" -import { VaultBrowser } from "@/components/vault-browser" -import { vaultPanel } from "@/context/vault-panel" import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" import { SessionContextUsage } from "@/components/session-context-usage" @@ -183,48 +181,10 @@ export function SessionSidePanel(props: { setActive: tabs().setActive, }) - // vault tab <-> the global vaultPanel store: the titlebar button, palette - // 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()) - // 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( - 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, pathFromTab: file.pathFromTab, normalizeTab, - vaultOpen, review: reviewTab, hasReview: props.canReview, fileBrowser: () => !!props.fileBrowserState, @@ -425,34 +385,11 @@ export function SessionSidePanel(props: { - {/* amicode: the vault browser tab (the fork's vault - panel — titlebar button / palette / deep-links - sync it via the vaultPanel store) */} - - { - 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() - if (openedTabs().length === 0 && !contextOpen()) view().reviewPanel.close() - }} - > -
{language.t("amicode.vault.title")}
-
-
+ {/* amicode#105: the vault tab is retired — the + global drawer is the vault's only host (ADR + docs/adr/0001). Do not re-add a tab here: + two hosts mirrored through two stores was the + desync this column's toggle got blamed for. */} - {/* amicode: vault browser content */} - - - - - - - -
From 35b25d0de43613fa546b4f81fee71732f6f36114 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sun, 2 Aug 2026 10:41:44 -0400 Subject: [PATCH 03/11] fix(amicode#105): the vault drawer goes global with honest states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drawer renders on EVERY route (the !params.id guard is deleted — it stood the host down inside sessions and made the button look dead). - pickVaultServer: focused server, else first healthy, else first — a home route with several servers still opens populated. - Named states replace the one bare 'empty' line: loading / no-server / error+retry / empty+attach-CTA (vaultMountsState). Nothing fails silently. - The CTA's announce now OPENS the connections popover (the chrome-dropdown seam is bidirectional), not just coordinates closes. - 5 new i18n keys, English fallbacks in all 17 locales (parity green). --- .../app/src/components/status-popover.tsx | 3 + .../components/vault-browser-model.test.ts | 62 +++++++++ .../app/src/components/vault-browser-model.ts | 38 ++++++ packages/app/src/components/vault-browser.tsx | 128 ++++++++++++++---- packages/app/src/components/vault-panel.tsx | 14 +- packages/app/src/i18n/ar.ts | 5 + packages/app/src/i18n/br.ts | 5 + packages/app/src/i18n/bs.ts | 5 + packages/app/src/i18n/da.ts | 5 + packages/app/src/i18n/de.ts | 5 + packages/app/src/i18n/en.ts | 5 + packages/app/src/i18n/es.ts | 5 + packages/app/src/i18n/fr.ts | 5 + packages/app/src/i18n/ja.ts | 5 + packages/app/src/i18n/ko.ts | 5 + packages/app/src/i18n/no.ts | 5 + packages/app/src/i18n/pl.ts | 5 + packages/app/src/i18n/ru.ts | 5 + packages/app/src/i18n/th.ts | 5 + packages/app/src/i18n/tr.ts | 5 + packages/app/src/i18n/uk.ts | 5 + packages/app/src/i18n/zh.ts | 5 + packages/app/src/i18n/zht.ts | 5 + .../src/components/message-part-groups.ts | 16 ++- 24 files changed, 312 insertions(+), 39 deletions(-) create mode 100644 packages/app/src/components/vault-browser-model.test.ts create mode 100644 packages/app/src/components/vault-browser-model.ts diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 4fe0572128..690f8d7cb9 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -236,6 +236,9 @@ export function GlobalConnectionsPopover(props: { onManageVaults: () => void }) } createEffect(() => { if (chromeDropdownOpenId() !== "connections" && shown()) setShownRaw(false) + // amicode#105: the announce is bidirectional — deep links (the vault + // drawer's attach CTA) open this popover by naming it, not just close it. + if (chromeDropdownOpenId() === "connections" && !shown()) setShownRaw(true) }) return ( diff --git a/packages/app/src/components/vault-browser-model.test.ts b/packages/app/src/components/vault-browser-model.test.ts new file mode 100644 index 0000000000..2eae6a3013 --- /dev/null +++ b/packages/app/src/components/vault-browser-model.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" +import { pickVaultServer, vaultMountsState } from "./vault-browser-model" + +// amicode#105: the drawer is the vault's ONLY host, so it must work on every +// route — and say why when it can't. Pre-fix, a failed mounts fetch and an +// empty vault both rendered the same bare "empty" line (nothing fails +// silently), and the fetch keyed only on the focused server. +describe("pickVaultServer", () => { + const a = { name: "a" } as never + const b = { name: "b" } as never + + test("prefers the focused server when set", () => { + expect(pickVaultServer({ current: a, list: [a, b], healthy: () => false })).toBe(a) + }) + + test("falls back to the first healthy server when none is focused", () => { + expect(pickVaultServer({ current: undefined, list: [a, b], healthy: (s) => s === b })).toBe(b) + }) + + test("falls back to the first server when none is healthy", () => { + expect(pickVaultServer({ current: undefined, list: [a, b], healthy: () => false })).toBe(a) + }) + + test("undefined when there are no servers at all", () => { + expect(pickVaultServer({ current: undefined, list: [], healthy: () => false })).toBeUndefined() + }) +}) + +describe("vaultMountsState", () => { + test("loading while the fetch is in flight", () => { + expect(vaultMountsState({ raw: undefined, loading: true, noServer: false }).kind).toBe("loading") + }) + + test("error when the fetch failed (a named state, never the empty copy)", () => { + expect(vaultMountsState({ raw: undefined, loading: false, noServer: false }).kind).toBe("error") + }) + + test("no-server when there is no server to ask", () => { + expect(vaultMountsState({ raw: undefined, loading: false, noServer: true }).kind).toBe("no-server") + }) + + test("empty when the vault serves zero mounts", () => { + expect(vaultMountsState({ raw: { mounts: [] }, loading: false, noServer: false }).kind).toBe("empty") + }) + + test("ready with mounts", () => { + const state = vaultMountsState({ raw: { mounts: [{ id: "m1" }] }, loading: false, noServer: false }) + expect(state.kind).toBe("ready") + if (state.kind === "ready") expect(state.mounts).toHaveLength(1) + }) +}) + +// The drawer renders on EVERY route — the pre-fix `!params.id` guard stood it +// down inside sessions and made the titlebar button look dead there. +describe("the vault drawer is global (amicode#105)", () => { + test("vault-panel.tsx carries no route-param guard", () => { + const source = readFileSync(join(import.meta.dir, "vault-panel.tsx"), "utf8") + expect(source).not.toContain("params.id") + }) +}) diff --git a/packages/app/src/components/vault-browser-model.ts b/packages/app/src/components/vault-browser-model.ts new file mode 100644 index 0000000000..d3f2247f8a --- /dev/null +++ b/packages/app/src/components/vault-browser-model.ts @@ -0,0 +1,38 @@ +// amicode#105: pure decisions behind the vault drawer's data states. The +// drawer is the vault's ONLY host (ADR docs/adr/0001), so its failure states +// are first-class and named — pre-fix, a failed mounts fetch and an empty +// vault both rendered the same bare "empty" line (nothing fails silently). + +/** Which server the drawer asks: the focused one, else the first healthy, + * else the first at all (a home route with several servers must still open + * populated). Undefined only when there is no server to ask. */ +export function pickVaultServer(input: { + current: Conn | undefined + list: Conn[] + healthy: (conn: Conn) => boolean +}): Conn | undefined { + if (input.current) return input.current + return input.list.find(input.healthy) ?? input.list[0] +} + +export type VaultMountsState = + | { kind: "loading" } + /** the fetch was attempted and failed — named state with retry */ + | { kind: "error" } + /** there is no server to ask */ + | { kind: "no-server" } + /** the vault serves zero mounts — the attach-a-vault CTA */ + | { kind: "empty" } + | { kind: "ready"; mounts: Mount[] } + +export function vaultMountsState(input: { + raw: { mounts?: Mount[] } | undefined + loading: boolean + noServer: boolean +}): VaultMountsState { + if (input.loading) return { kind: "loading" } + if (input.noServer) return { kind: "no-server" } + if (input.raw === undefined) return { kind: "error" } + const mounts = Array.isArray(input.raw.mounts) ? input.raw.mounts : [] + return mounts.length === 0 ? { kind: "empty" } : { kind: "ready", mounts } +} diff --git a/packages/app/src/components/vault-browser.tsx b/packages/app/src/components/vault-browser.tsx index 4490a0a4f4..cc5d9608f9 100644 --- a/packages/app/src/components/vault-browser.tsx +++ b/packages/app/src/components/vault-browser.tsx @@ -6,13 +6,16 @@ // the palette command, and context-tree deep-links land in whichever host is // mounted. Data: the read-only /amicode/vault-files + /amicode/vault-file // routes (loopback-gated server-side). -import { For, Show, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js" +import { For, Match, Show, Switch, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Markdown } from "@opencode-ai/session-ui/markdown" import { useLanguage } from "@/context/language" -import { useServer } from "@/context/server" +import { ServerConnection, useServer } from "@/context/server" +import { useGlobal } from "@/context/global" import { amicodeGet } from "@/utils/amicode-fetch" +import { announceChromeDropdown } from "@/utils/chrome-dropdown" +import { pickVaultServer, vaultMountsState } from "@/components/vault-browser-model" import { vaultPanel } from "@/context/vault-panel" type Mount = { id: string; kind: string; writable: boolean } @@ -45,14 +48,34 @@ export function VaultBrowser(props: { }) { const language = useLanguage() const server = useServer() + const global = useGlobal() - const [mountsRaw] = createResource( - () => (vaultPanel.opened() ? server.current : undefined), + // amicode#105: the drawer is the only host, so it opens POPULATED on every + // route — the fetch rides the focused server, else the first healthy, else + // the first at all (pickVaultServer); and its failure states are named + // (loading / no-server / error+retry / empty+CTA), never one bare "empty". + const picked = createMemo(() => + pickVaultServer({ + current: server.current, + list: server.list, + healthy: (conn) => global.servers.health[ServerConnection.key(conn)]?.healthy === true, + }), + ) + + const [mountsRaw, { refetch: refetchMounts }] = createResource( + () => (vaultPanel.opened() ? picked() : undefined), (conn) => amicodeGet(conn, "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/amicode/vaults").catch(() => undefined), ) + const mountsState = createMemo(() => + vaultMountsState({ + raw: mountsRaw() as { mounts?: Mount[] } | undefined, + loading: mountsRaw.loading, + noServer: !picked(), + }), + ) const mounts = createMemo(() => { - const raw = mountsRaw() as { mounts?: Mount[] } | undefined - return Array.isArray(raw?.mounts) ? raw.mounts.filter((m) => typeof m?.id === "string") : [] + const state = mountsState() + return state.kind === "ready" ? (state.mounts as Mount[]).filter((m) => typeof m?.id === "string") : [] }) const [chosenMount, setChosenMount] = createSignal(undefined) @@ -62,8 +85,8 @@ export function VaultBrowser(props: { return mounts()[0]?.id }) - const [listingRaw] = createResource( - () => (vaultPanel.opened() && mount() && server.current ? { conn: server.current, mount: mount()! } : undefined), + const [listingRaw, { refetch: refetchListing }] = createResource( + () => (vaultPanel.opened() && mount() && picked() ? { conn: picked()!, mount: mount()! } : undefined), (key) => amicodeGet(key.conn, `/amicode/vault-files?mount=${encodeURIComponent(key.mount)}`).catch(() => undefined), ) const listing = createMemo(() => { @@ -227,34 +250,81 @@ export function VaultBrowser(props: {
- +
- {mountsRaw.loading || listingRaw.loading - ? `${language.t("common.loading")}${language.t("common.loading.ellipsis")}` - : language.t("amicode.vault.empty")} + {language.t("common.loading")} + {language.t("common.loading.ellipsis")}
- } - > - {(state) => ( +
+ +
{language.t("amicode.vault.noServer")}
+
+ +
+ {language.t("amicode.vault.fetchError")} + +
+
+ +
+ {language.t("amicode.vault.attachHint")} + +
+
+ {language.t("amicode.vault.error")}
} + when={listing()} + fallback={ +
+ {language.t("common.loading")} + {language.t("common.loading.ellipsis")} +
+ } > - {(ok) => ( - <> - {renderDir(ok().tree, 0)} - -
- {language.t("amicode.vault.truncated")} + {(state) => ( + + {language.t("amicode.vault.error")} +
-
- + } + > + {(ok) => ( + <> + {renderDir(ok().tree, 0)} + +
+ {language.t("amicode.vault.truncated")} +
+
+ + )} + )} - )} - + + diff --git a/packages/app/src/components/vault-panel.tsx b/packages/app/src/components/vault-panel.tsx index ed851de834..ecfc48082d 100644 --- a/packages/app/src/components/vault-panel.tsx +++ b/packages/app/src/components/vault-panel.tsx @@ -1,19 +1,17 @@ -// amicode: the standalone Vault drawer — vault access OUTSIDE sessions -// (Landing/home), where no session side panel exists. Inside a session the -// side panel's Vault tab is the host (it replaced the git review; Kate -// 2026-07-27), so the drawer stands down there — both hosts ride the same -// vaultPanel store and render the same VaultBrowser body. +// amicode: the standalone Vault drawer — the vault's ONLY host, on EVERY +// route (home, new-session, session; amicode#105, ADR docs/adr/0001). The +// side-panel tab it used to yield to inside sessions is retired: two hosts +// mirrored through two stores was the desync the titlebar button got blamed +// for. Renders the same VaultBrowser body everywhere. import { Show } from "solid-js" -import { useParams } from "@solidjs/router" import { useLanguage } from "@/context/language" import { vaultPanel } from "@/context/vault-panel" import { VaultBrowser } from "@/components/vault-browser" export function VaultPanel() { const language = useLanguage() - const params = useParams() return ( - +