diff --git a/packages/app/e2e/regression/chrome-surfaces.spec.ts b/packages/app/e2e/regression/chrome-surfaces.spec.ts new file mode 100644 index 0000000000..f48a338387 --- /dev/null +++ b/packages/app/e2e/regression/chrome-surfaces.spec.ts @@ -0,0 +1,106 @@ +import { test, expect, type Page } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +// ============================================================================ +// amicode#105 chrome surfaces — AC button_flow_e2e_passing >= 3: +// 1. the vault button opens the POPULATED global drawer on home +// 2. sidebar-right toggles a SINGLE-PANE work column (pressed state truthful) +// 3. the status popover opens inside the viewport (no magic shift) +// ============================================================================ + +const sessionRoute = `/${base64Encode(fixture.directory)}/session/${fixture.sourceID}` + +async function bootApp(page: Page) { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + }) + await page.addInitScript((directory) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + }, fixture.directory) +} + +async function mockVaultRoutes(page: Page) { + // Registered AFTER the base mock — last registration wins in Playwright. + await page.route("**/amicode/vaults", (route) => + route.fulfill({ json: { mounts: [{ id: "personal", kind: "personal", writable: true }] } }), + ) + await page.route("**/amicode/vault-files*", (route) => + route.fulfill({ + json: { ok: true, files: [{ path: "notes/todo.md", name: "todo.md", size: 42, readable: true }] }, + }), + ) +} + +test("vault button opens the populated drawer on home (global host)", async ({ page }) => { + await bootApp(page) + await mockVaultRoutes(page) + await page.goto("/") + await expectAppVisible(page.getByText("Open chat").first()) + + await page.getByRole("button", { name: "Open vault" }).click() + + const drawer = page.locator('[data-component="amico-vault-panel"]') + await expect(drawer).toBeVisible() + // populated, not a silent empty shell — the mocked tree renders (dirs + // start collapsed: unfold, then the file is there) + await drawer.getByRole("button", { name: "notes" }).click() + await expect(drawer.getByText("todo.md")).toBeVisible() + // and it closes from the same TITLEBAR button (the toggle is not one-way) + await page.getByRole("banner").getByRole("button", { name: "Close vault panel" }).click() + await expect(drawer).toBeHidden() +}) + +test("sidebar-right toggles a single-pane work column, pressed state truthful", async ({ page }) => { + await bootApp(page) + await page.goto(sessionRoute) + const toggle = page.getByRole("button", { name: "Toggle review" }) + await expect(toggle).toBeVisible() + + await toggle.click() + const column = page.locator("#review-panel") + await expect(column).toBeVisible() + await expect(toggle).toHaveAttribute("aria-expanded", "true") + // single-pane: the review file-list sidebar never renders (the split that + // squished the chat) — the aside must not exist even with the column open + await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0) + // bounded: the column is fixed-width and the chat is the flex REMAINDER — + // the pre-fix review pane took everything the chat left behind + const width = await page.evaluate(() => window.innerWidth) + const columnBox = (await column.boundingBox())! + expect(columnBox.width).toBeLessThanOrEqual(width * 0.6) + expect(columnBox.width).toBeLessThan(width / 2) + + await toggle.click() + await expect(column).toBeHidden() + await expect(toggle).toHaveAttribute("aria-expanded", "false") +}) + +test("status popover opens inside the viewport (no magic shift)", async ({ page }) => { + await bootApp(page) + await page.goto(sessionRoute) + const trigger = page.getByRole("button", { name: "Status" }) + await expect(trigger).toBeVisible() + + await trigger.click() + const body = page.locator('[data-slot="popover-body"]').first() + await expect(body).toBeVisible() + const box = (await body.boundingBox())! + const width = await page.evaluate(() => window.innerWidth) + // the old shift={-168} could land the panel off-anchor/clipped; honest + // anchoring keeps it fully inside the viewport + expect(box.x).toBeGreaterThanOrEqual(0) + expect(box.x + box.width).toBeLessThanOrEqual(width) +}) 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..690f8d7cb9 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()} > 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/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 1221187a19..671cf082b5 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -406,13 +406,14 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl {/* amicode(workbench S1): the sessions-panel toggle, v2 titlebar edition — the legacy grid-branch button never rendered here, - so the panel had no affordance (S1.4 probe). */} + so the panel had no affordance (S1.4 probe). sidebar-LEFT: + this one drives the left sessions panel. */} } + icon={} state={layout.sidebar.opened() ? "pressed" : undefined} onClick={() => layout.sidebar.toggle()} aria-label={language.t("command.sidebar.toggle")} 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 ( - +