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
106 changes: 106 additions & 0 deletions packages/app/e2e/regression/chrome-surfaces.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
22 changes: 21 additions & 1 deletion packages/app/src/components/status-popover-model.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
})
})
11 changes: 11 additions & 0 deletions packages/app/src/components/status-popover-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
12 changes: 6 additions & 6 deletions packages/app/src/components/status-popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -71,9 +72,7 @@ export function StatusPopover(props: { healthDot?: boolean }) {
</div>
}
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()}
>
<Show when={shown()}>
<Suspense
Expand Down Expand Up @@ -185,9 +184,7 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
const popoverProps = {
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" as const,
shift: -168,
...statusPopoverLayout(),
}

return (
Expand Down Expand Up @@ -239,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 (
Expand Down
5 changes: 3 additions & 2 deletions packages/app/src/components/titlebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -406,13 +406,14 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
</TooltipV2>
{/* 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. */}
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
icon={<IconV2 name="sidebar-right" />}
icon={<IconV2 name="sidebar-left" />}
state={layout.sidebar.opened() ? "pressed" : undefined}
onClick={() => layout.sidebar.toggle()}
aria-label={language.t("command.sidebar.toggle")}
Expand Down
62 changes: 62 additions & 0 deletions packages/app/src/components/vault-browser-model.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
38 changes: 38 additions & 0 deletions packages/app/src/components/vault-browser-model.ts
Original file line number Diff line number Diff line change
@@ -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<Conn>(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<Mount> =
| { 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<Mount>(input: {
raw: { mounts?: Mount[] } | undefined
loading: boolean
noServer: boolean
}): VaultMountsState<Mount> {
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 }
}
Loading
Loading