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")}
+ void refetchMounts()}
+ >
+ {language.t("amicode.vault.retry")}
+
+
+
+
+
+ {language.t("amicode.vault.attachHint")}
+ announceChromeDropdown("connections")}
+ >
+ {language.t("amicode.vault.attach")}
+
+
+
+
{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")}
+ void refetchListing()}
+ >
+ {language.t("amicode.vault.retry")}
+
-
- >
+ }
+ >
+ {(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 (
-
+
{props.children}
+ {/* amicode#105: the vault's global host lives in the layout that ACTUALLY
+ renders the v2 tree — the workbench/wave merges grafted their chrome
+ into layout.tsx's LegacyLayout branch, which newLayoutDesigns never
+ reaches (VaultPanel AND the ConnectionBanner were invisible here). */}
+
+
{/* DebugBar removed with the fork's debug-bar deletion (kept during the
upstream merge) — the debugTools toggle state stays for the titlebar's
channel indicator. */}
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index f6e750cccc..62778c0139 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -78,14 +78,17 @@ import { useSessionLayout } from "@/pages/session/session-layout"
import { restorePromptModel, syncPromptModel, syncSessionModel } from "@/pages/session/session-model-helpers"
import {
clampSessionPanelWidth,
+ clampWorkColumnWidth,
SESSION_PANEL_WIDTH_MIN,
+ sessionChatTakesRemainder,
sessionPanelWidthMax,
+ WORK_COLUMN_WIDTH_MIN,
+ workColumnWidthMax,
} from "@/pages/session/session-panel-width"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { sessionPanelLayout } from "@/pages/session/session-panel-layout"
import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2"
import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2"
-import { SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2"
import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2"
import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "@/pages/session/v2/review-diff-kinds"
@@ -498,11 +501,6 @@ export default function Page() {
split: splitReview(),
}),
)
- const sessionPanelWidth = createMemo(() => {
- if (!desktopSidePanelOpen()) return "100%"
- if (desktopSessionResizeOpen()) return `${sessionPanelResizedWidth()}px`
- return `calc(100% - ${layout.fileTree.width()}px)`
- })
const centered = createMemo(() => isDesktop() && (newSessionDesign() || !desktopReviewOpen()))
const desktopV2PanelLayout = createMemo(() =>
sessionPanelLayout({
@@ -511,6 +509,23 @@ export default function Page() {
files: desktopFileTreeOpen(),
}),
)
+ // amicode#105: in v2 the WORK COLUMN owns a bounded width (default 320,
+ // user-resizable) and the CHAT is the flex remainder — the pre-fix layout
+ // gave the review pane whatever the chat left behind, squishing the chat
+ // into the margin on wide monitors. (After desktopV2PanelLayout — createMemo
+ // evaluates eagerly, so order is load-bearing.)
+ const chatTakesRemainder = createMemo(() =>
+ sessionChatTakesRemainder({ newDesign: newSessionDesign(), columnVisible: isDesktop() && desktopV2PanelLayout().visible }),
+ )
+ const workColumnWidth = createMemo(() =>
+ clampWorkColumnWidth({ width: layout.panelColumn.width(), available: sessionPanelAvailable() }),
+ )
+ const sessionPanelWidth = createMemo(() => {
+ if (chatTakesRemainder()) return undefined
+ if (!desktopSidePanelOpen()) return "100%"
+ if (desktopSessionResizeOpen()) return `${sessionPanelResizedWidth()}px`
+ return `calc(100% - ${layout.fileTree.width()}px)`
+ })
function normalizeTab(tab: string) {
if (!tab.startsWith("file://")) return tab
@@ -2277,7 +2292,9 @@ export default function Page() {
)}
-
+ {/* the chat-side handle only survives in the classic layout — in v2
+ the work column owns its width and the handle rides its edge */}
+
size.start()}>
-
+
+ {/* amicode#105: the work column's own resize handle (its left
+ edge), sizing layout.panelColumn within the policy bounds —
+ the chat flexes around it, never below it. */}
+
+ size.start()}>
+ {
+ size.touch()
+ layout.panelColumn.resize(width)
+ }}
+ />
+
+
+ {/* amicode#105: no reviewSidebarToggle — the Work Column is
+ single-pane (reviewSidebarOpened is policy-false), so the
+ old kanban-icon affordance would be a dead button. */}
hasReview() || reviewV2State.sidebarOpened()}
reviewCount={reviewCount}
reviewPanel={reviewPanelV2}
- reviewSidebarToggle={(disabled) => (
-
- )}
fileBrowserState={reviewV2State}
activeDiff={activeReviewFile()}
focusReviewDiff={focusReviewDiff}
diff --git a/packages/app/src/pages/session/session-panel-width.test.ts b/packages/app/src/pages/session/session-panel-width.test.ts
index 3e2623775f..dca5a86c28 100644
--- a/packages/app/src/pages/session/session-panel-width.test.ts
+++ b/packages/app/src/pages/session/session-panel-width.test.ts
@@ -1,10 +1,14 @@
import { describe, expect, test } from "bun:test"
import {
clampSessionPanelWidth,
+ clampWorkColumnWidth,
REVIEW_PANE_WIDTH_MIN,
REVIEW_PANE_WIDTH_MIN_SPLIT,
SESSION_PANEL_WIDTH_MIN,
+ sessionChatTakesRemainder,
sessionPanelWidthMax,
+ WORK_COLUMN_WIDTH_MIN,
+ workColumnWidthMax,
} from "./session-panel-width"
describe("sessionPanelWidthMax", () => {
@@ -50,3 +54,28 @@ describe("clampSessionPanelWidth", () => {
expect(clampSessionPanelWidth({ width: 1600, available: undefined, split: false })).toBe(1600)
})
})
+
+describe("work column width (amicode#105 — the column is bounded, the CHAT is the remainder)", () => {
+ // The pre-fix philosophy (this file's header): the review pane took whatever
+ // the chat left behind — a wide monitor squished the chat into the margin.
+ // The work column now owns a bounded width (default 320, user-resizable).
+ test("workColumnWidthMax caps the column at 60% of the row", () => {
+ expect(workColumnWidthMax(1000)).toBe(600)
+ })
+
+ test("workColumnWidthMax never drops below the floor", () => {
+ expect(workColumnWidthMax(300)).toBe(WORK_COLUMN_WIDTH_MIN)
+ })
+
+ test("clampWorkColumnWidth caps and floors, passes through pre-measure", () => {
+ expect(clampWorkColumnWidth({ width: 1500, available: 1000 })).toBe(600)
+ expect(clampWorkColumnWidth({ width: 100, available: 1000 })).toBe(WORK_COLUMN_WIDTH_MIN)
+ expect(clampWorkColumnWidth({ width: 1500, available: undefined })).toBe(1500)
+ })
+
+ test("the chat is the flex remainder only in v2 with the column visible", () => {
+ expect(sessionChatTakesRemainder({ newDesign: true, columnVisible: true })).toBe(true)
+ expect(sessionChatTakesRemainder({ newDesign: true, columnVisible: false })).toBe(false)
+ expect(sessionChatTakesRemainder({ newDesign: false, columnVisible: true })).toBe(false)
+ })
+})
diff --git a/packages/app/src/pages/session/session-panel-width.ts b/packages/app/src/pages/session/session-panel-width.ts
index d04646b483..9a01921399 100644
--- a/packages/app/src/pages/session/session-panel-width.ts
+++ b/packages/app/src/pages/session/session-panel-width.ts
@@ -17,3 +17,28 @@ export function clampSessionPanelWidth(input: { width: number; available: number
if (input.available === undefined) return input.width
return Math.min(input.width, sessionPanelWidthMax({ available: input.available, split: input.split }))
}
+
+// amicode#105: the Work Column owns a bounded width of its own. The pre-fix
+// philosophy (above) let the review pane take everything the chat left behind
+// — a wide monitor squished the chat into the left margin. Now the column is
+// fixed-width (default 320 — DEFAULT_PANEL_COLUMN_WIDTH — user-resizable
+// within these bounds) and the CHAT is the flex remainder.
+export const WORK_COLUMN_WIDTH_MIN = 240
+
+/** The column may never take more than 60% of the measured row. */
+export function workColumnWidthMax(available: number) {
+ return Math.max(WORK_COLUMN_WIDTH_MIN, Math.floor(available * 0.6))
+}
+
+/** `available` is undefined until the layout row is first measured; render the
+ * stored width untouched until then (same first-frame rule as the chat). */
+export function clampWorkColumnWidth(input: { width: number; available: number | undefined }) {
+ if (input.available === undefined) return input.width
+ return Math.min(Math.max(input.width, WORK_COLUMN_WIDTH_MIN), workColumnWidthMax(input.available))
+}
+
+/** In v2 with the work column visible, the chat is the flex remainder (no
+ * fixed pixel width). Classic layout keeps the historical fixed chat width. */
+export function sessionChatTakesRemainder(input: { newDesign: boolean; columnVisible: boolean }): boolean {
+ return input.newDesign && input.columnVisible
+}
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 */}
-
-
-
-
-
-
-
-
diff --git a/packages/app/src/pages/session/v2/review-panel-v2-state.test.ts b/packages/app/src/pages/session/v2/review-panel-v2-state.test.ts
new file mode 100644
index 0000000000..d1fa63a877
--- /dev/null
+++ b/packages/app/src/pages/session/v2/review-panel-v2-state.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, test } from "bun:test"
+import { reviewSidebarOpened, reviewSidebarToggled } from "./review-panel-v2-state"
+
+// amicode#105: the Work Column is SINGLE-PANE — the review panel's file-list
+// sidebar never renders (it split the column into two panes and squished the
+// chat). Diffs get the full column width; navigation is the changes dropdown.
+describe("review panel v2 sidebar policy (single-pane column)", () => {
+ test("the sidebar is closed by default", () => {
+ expect(reviewSidebarOpened()).toBe(false)
+ })
+
+ test("the toggle can never open it — no split at any width", () => {
+ expect(reviewSidebarToggled(true)).toBe(false)
+ expect(reviewSidebarToggled(false)).toBe(false)
+ })
+
+ test("a persisted true from the split-pane era is ignored", () => {
+ expect(reviewSidebarOpened(true)).toBe(false)
+ })
+})
diff --git a/packages/app/src/pages/session/v2/review-panel-v2-state.ts b/packages/app/src/pages/session/v2/review-panel-v2-state.ts
index 645055e107..cceac75519 100644
--- a/packages/app/src/pages/session/v2/review-panel-v2-state.ts
+++ b/packages/app/src/pages/session/v2/review-panel-v2-state.ts
@@ -1,19 +1,34 @@
-import {
- SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT,
- SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
- SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
- type SessionReviewExpandMode,
-} from "@opencode-ai/session-ui/v2/session-review-v2"
+import type { SessionReviewExpandMode } from "@opencode-ai/session-ui/v2/session-review-v2"
import { createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
+// Width bounds duplicated from @opencode-ai/session-ui/v2/session-review-v2
+// so this module stays importable in the bun test env (the component chain
+// pulls @pierre/diffs workers, which don't run under happydom).
+const SIDEBAR_WIDTH_DEFAULT = 240
+const SIDEBAR_WIDTH_MIN = 200
+const SIDEBAR_WIDTH_MAX = 480
+
+/** amicode#105 single-pane policy (pure, tested): the Work Column never
+ * splits — the review sidebar (the file list) does not render at any width.
+ * Diffs take the full column width; navigation is the changes dropdown. */
+export function reviewSidebarOpened(_persisted?: boolean): boolean {
+ return false
+}
+
+/** The toggle is inert — it stays for prop compatibility but can never open
+ * the split. A persisted `sidebarOpened: true` from the split-pane era is
+ * ignored everywhere through reviewSidebarOpened. */
+export function reviewSidebarToggled(_opened: boolean): boolean {
+ return false
+}
+
export function createReviewPanelV2State() {
const [store, setStore, , ready] = persisted(
Persist.global("review-panel-v2"),
createStore({
- sidebarOpened: true,
- sidebarWidth: SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT,
+ sidebarWidth: SIDEBAR_WIDTH_DEFAULT,
expandMode: "collapse" as SessionReviewExpandMode,
}),
)
@@ -22,7 +37,7 @@ export function createReviewPanelV2State() {
const [filter, setFilter] = createSignal("")
return {
- sidebarOpened: () => store.sidebarOpened,
+ sidebarOpened: reviewSidebarOpened,
sidebarWidth: () => store.sidebarWidth,
sidebarTransition: ready,
filter,
@@ -30,11 +45,8 @@ export function createReviewPanelV2State() {
expandMode: () => store.expandMode,
setExpandMode: (mode: SessionReviewExpandMode) => setStore("expandMode", mode),
resizeSidebar: (width: number) =>
- setStore(
- "sidebarWidth",
- Math.min(SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX, Math.max(SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, width)),
- ),
- toggleSidebar: () => setStore("sidebarOpened", (opened) => !opened),
+ setStore("sidebarWidth", Math.min(SIDEBAR_WIDTH_MAX, Math.max(SIDEBAR_WIDTH_MIN, width))),
+ toggleSidebar: () => {},
}
}
diff --git a/packages/session-ui/src/components/group-parts.test.ts b/packages/session-ui/src/components/group-parts.test.ts
index 6d71be8e53..2febdcaa60 100644
--- a/packages/session-ui/src/components/group-parts.test.ts
+++ b/packages/session-ui/src/components/group-parts.test.ts
@@ -35,8 +35,40 @@ describe("groupParts — shell grouping (spec B)", () => {
expect(groups.map((g) => g.type)).toEqual(["shell", "context"])
})
- test("edits are never grouped", () => {
- const groups = groupParts([tool("e1", "edit"), tool("e2", "write")])
- expect(groups.map((g) => g.type)).toEqual(["part", "part"])
+ test("≥2 consecutive file mutations collapse into one edit group", () => {
+ const groups = groupParts([tool("a", "edit"), tool("b", "write"), tool("c", "edit")])
+ expect(groups).toHaveLength(1)
+ expect(groups[0]!.type).toBe("edit")
+ expect(groups[0]!.type === "edit" && groups[0]!.refs.map((r) => r.partID)).toEqual(["a", "b", "c"])
+ })
+
+ test("a lone edit stays a full part (its inline diff is worth the card)", () => {
+ const groups = groupParts([text("t"), tool("a", "edit"), text("u")])
+ expect(groups.map((g) => g.type)).toEqual(["part", "part", "part"])
+ })
+
+ test("non-adjacent edits are not grouped", () => {
+ const groups = groupParts([tool("a", "edit"), text("t"), tool("b", "write")])
+ expect(groups.map((g) => g.type)).toEqual(["part", "part", "part"])
+ })
+
+ test("patch and apply_patch join the edit run; shell and edit runs stay distinct", () => {
+ const groups = groupParts([
+ tool("e1", "edit"),
+ tool("e2", "apply_patch"),
+ tool("b1", "bash"),
+ tool("b2", "bash"),
+ tool("e3", "write"),
+ tool("e4", "edit"),
+ ])
+ expect(groups.map((g) => g.type)).toEqual(["edit", "shell", "edit"])
+ expect(groups[0]!.type === "edit" && groups[0]!.refs).toHaveLength(2)
+ expect(groups[1]!.type === "shell" && groups[1]!.refs).toHaveLength(2)
+ expect(groups[2]!.type === "edit" && groups[2]!.refs).toHaveLength(2)
+ })
+
+ test("edit then context: order + boundaries preserved", () => {
+ const groups = groupParts([tool("e1", "write"), tool("e2", "write"), tool("r1", "read")])
+ expect(groups.map((g) => g.type)).toEqual(["edit", "context"])
})
})
diff --git a/packages/session-ui/src/components/message-part-groups.ts b/packages/session-ui/src/components/message-part-groups.ts
index 75d6e26bd9..54a1b9eade 100644
--- a/packages/session-ui/src/components/message-part-groups.ts
+++ b/packages/session-ui/src/components/message-part-groups.ts
@@ -5,8 +5,11 @@
import type { Part as PartType, ToolPart } from "@opencode-ai/sdk/v2"
// Consecutive read/search/list calls collapse into one "Explored" context group;
-// consecutive bash calls (≥2) collapse into one "Worked in shell" group (spec B).
+// consecutive bash calls (≥2) collapse into one "Worked in shell" group (spec B);
+// consecutive file-mutation calls (≥2) collapse into one "Edited files" group
+// (same spec-B shape — the mutation set mirrors toolDefaultOpen's edit family).
const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"])
+const EDIT_GROUP_TOOLS = new Set(["edit", "write", "patch", "apply_patch"])
export function isContextGroupTool(part: PartType): part is ToolPart {
return part.type === "tool" && CONTEXT_GROUP_TOOLS.has(part.tool)
@@ -16,6 +19,10 @@ export function isShellGroupTool(part: PartType): part is ToolPart {
return part.type === "tool" && part.tool === "bash"
}
+export function isEditGroupTool(part: PartType): part is ToolPart {
+ return part.type === "tool" && EDIT_GROUP_TOOLS.has(part.tool)
+}
+
export type PartRef = {
messageID: string
partID: string
@@ -37,6 +44,11 @@ export type PartGroup =
type: "shell"
refs: PartRef[]
}
+ | {
+ key: string
+ type: "edit"
+ refs: PartRef[]
+ }
function sameRef(a: PartRef, b: PartRef) {
return a.messageID === b.messageID && a.partID === b.partID
@@ -50,7 +62,7 @@ function sameGroup(a: PartGroup, b: PartGroup) {
if (b.type !== "part") return false
return sameRef(a.ref, b.ref)
}
- // context | shell — both carry refs
+ // context | shell | edit — all carry refs
if (b.type === "part") return false
if (a.refs.length !== b.refs.length) return false
return a.refs.every((ref, i) => sameRef(ref, b.refs[i]!))
@@ -65,11 +77,13 @@ export function sameGroups(a: readonly PartGroup[] | undefined, b: readonly Part
export function groupParts(parts: { messageID: string; part: PartType }[]) {
const result: PartGroup[] = []
- // At most one run is open at a time: a part is context-group, shell-group, or
- // a standalone part. Context collapses at any length (matches read/grep); shell
- // collapses only at ≥2 consecutive commands, so a lone command stays a full card.
+ // At most one run is open at a time: a part is context-group, shell-group,
+ // edit-group, or a standalone part. Context collapses at any length (matches
+ // read/grep); shell and edit collapse only at ≥2 consecutive calls, so a lone
+ // command (or a lone edit, whose inline diff is worth the card) stays full.
let contextStart = -1
let shellStart = -1
+ let editStart = -1
const pushPart = (item: { messageID: string; part: PartType }) => {
result.push({
@@ -108,23 +122,49 @@ export function groupParts(parts: { messageID: string; part: PartType }[]) {
shellStart = -1
}
+ const flushEdit = (end: number) => {
+ if (editStart < 0) return
+ const slice = parts.slice(editStart, end + 1)
+ const first = slice[0]
+ if (first) {
+ if (slice.length >= 2)
+ result.push({
+ key: `edit:${first.part.id}`,
+ type: "edit",
+ refs: slice.map((item) => ({ messageID: item.messageID, partID: item.part.id })),
+ })
+ else pushPart(first)
+ }
+ editStart = -1
+ }
+
parts.forEach((item, index) => {
if (isContextGroupTool(item.part)) {
flushShell(index - 1)
+ flushEdit(index - 1)
if (contextStart < 0) contextStart = index
return
}
if (isShellGroupTool(item.part)) {
flushContext(index - 1)
+ flushEdit(index - 1)
if (shellStart < 0) shellStart = index
return
}
+ if (isEditGroupTool(item.part)) {
+ flushContext(index - 1)
+ flushShell(index - 1)
+ if (editStart < 0) editStart = index
+ return
+ }
flushContext(index - 1)
flushShell(index - 1)
+ flushEdit(index - 1)
pushPart(item)
})
flushContext(parts.length - 1)
flushShell(parts.length - 1)
+ flushEdit(parts.length - 1)
return result
}
diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx
index 1c23bd1264..390b81f002 100644
--- a/packages/session-ui/src/components/message-part.tsx
+++ b/packages/session-ui/src/components/message-part.tsx
@@ -635,6 +635,7 @@ export {
sameGroups,
isContextGroupTool,
isShellGroupTool,
+ isEditGroupTool,
type PartGroup,
type PartRef,
} from "./message-part-groups"
@@ -643,10 +644,12 @@ import {
sameGroups,
isContextGroupTool,
isShellGroupTool,
+ isEditGroupTool,
type PartGroup,
type PartRef,
} from "./message-part-groups"
import { parseDiffSentinel } from "@opencode-ai/ui/amicode-receipt"
+import { editRowDiff, editRowFilePath, editRowLabel } from "@opencode-ai/ui/amicode-edit-row"
import {
collapseReceiptRuns,
receiptRunKey,
@@ -850,6 +853,28 @@ export function AssistantParts(props: {
)
})()}
+
+ {(() => {
+ const parts = createMemo(
+ () => {
+ const entry = entryAccessor()
+ if (entry.type !== "edit") return emptyTools
+ return entry.refs
+ .map((ref) => part().get(ref.messageID)?.get(ref.partID))
+ .filter((part): part is ToolPart => !!part && isEditGroupTool(part))
+ },
+ emptyTools,
+ { equals: same },
+ )
+ const busy = createMemo(() => props.working && last() === entryAccessor().key)
+
+ return (
+ 0}>
+
+
+ )
+ })()}
+
{(() => {
const message = createMemo(() => {
@@ -1127,6 +1152,27 @@ export function AssistantMessageDisplay(props: {
)
})()}
+
+ {(() => {
+ const parts = createMemo(
+ () => {
+ const entry = entryAccessor()
+ if (entry.type !== "edit") return emptyTools
+ return entry.refs
+ .map((ref) => part().get(ref.partID))
+ .filter((part): part is ToolPart => !!part && isEditGroupTool(part))
+ },
+ emptyTools,
+ { equals: same },
+ )
+
+ return (
+ 0}>
+
+
+ )
+ })()}
+
{(() => {
const item = createMemo(() => {
@@ -1367,6 +1413,119 @@ export function ShellToolGroup(props: { parts: ToolPart[]; busy?: boolean; onSiz
)
}
+// AMICODE (spec B shape): consecutive file mutations (edit/write/patch) collapse
+// into one row so a long authoring run doesn't dominate the timeline. Mirrors
+// ShellToolGroup's markup (reuses its CSS slots) with per-file rows that keep
+// their diff stats. A lone mutation never reaches here — groupParts leaves it
+// as a full card, whose inline diff is worth the space. Row labels come from
+// @opencode-ai/ui/amicode-edit-row (pure, tested): a pending part without a
+// filePath can never fill the row with prose.
+export function EditToolGroup(props: { parts: ToolPart[]; busy?: boolean; onSizeChange?: () => void }) {
+ const [open, setOpen] = createSignal(false)
+ const pending = createMemo(
+ () =>
+ !!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"),
+ )
+ const count = createMemo(() => props.parts.length)
+ // Unique targets: "4 changes in 3 files" reads truer than a bare call count
+ // when the same file is re-edited mid-run.
+ const fileCount = createMemo(() => {
+ const paths = props.parts
+ .map((part) => editRowFilePath(part))
+ .filter((path): path is string => typeof path === "string")
+ return new Set(paths).size
+ })
+ // Aggregate +/- across the run — DiffChanges sums an array itself. Parts that
+ // haven't recorded a filediff yet (still pending) simply don't contribute.
+ const diffs = createMemo(() =>
+ props.parts
+ .map((part) => editRowDiff(part))
+ .filter((diff): diff is { additions: number; deletions: number } => !!diff),
+ )
+ const handleOpenChange = (value: boolean) => {
+ setOpen(value)
+ props.onSizeChange?.()
+ }
+ // amicode: hovering the group chip glances at every member node on the map
+ const glanceAll = () => {
+ for (const p of props.parts.slice(0, 8)) emitAmicoBrainHover(amicoBrainRef(p.tool, p.state.input ?? {}))
+ }
+
+ return (
+ part.id).join(",")}
+ >
+
+
+
+
+
+
+
+ {count()} {count() === 1 ? "change" : "changes"}
+ 0}>
+ {" "}
+ in {fileCount()} {fileCount() === 1 ? "file" : "files"}
+
+
+ 0}>
+
+
+
+
+
+
+
+
+
+ {(partAccessor) => {
+ const label = createMemo(() => editRowLabel(partAccessor()))
+ const diff = createMemo(() => editRowDiff(partAccessor()))
+ const running = createMemo(
+ () => partAccessor().state.status === "pending" || partAccessor().state.status === "running",
+ )
+ const errored = createMemo(() => partAccessor().state.status === "error")
+ return (
+
+
+
+
+
+
+
+
+
+ {(d) => }
+
+
+ failed
+
+
+
+
+
+
+
+
+ )
+ }}
+
+
+
+
+ )
+}
+
function UserMessageComments(props: { comments: UserMessageComment[]; bounded: boolean }) {
const i18n = useI18n()
const [state, setState] = createStore({ expanded: false })
diff --git a/packages/ui/src/amicode/edit-row.test.ts b/packages/ui/src/amicode/edit-row.test.ts
new file mode 100644
index 0000000000..12694e7f8a
--- /dev/null
+++ b/packages/ui/src/amicode/edit-row.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, test } from "bun:test"
+import { editRowDiff, editRowFilePath, editRowLabel, EDIT_ROW_MAX } from "./edit-row"
+
+// The edit row has the shell row's bug class (see shell-row.test.ts): a PENDING
+// edit part has no `input.filePath` yet, so the label chain must not fall
+// through to unclamped prose in the filename slot.
+
+describe("edit row label", () => {
+ test("the file's basename always wins over the title", () => {
+ expect(
+ editRowLabel({
+ state: { title: "Fix the context wiring", input: { filePath: "/repo/src/ui/ChatApp.svelte" } },
+ }),
+ ).toBe("ChatApp.svelte")
+ })
+
+ test("patch-family input.path and the filediff's file are honoured", () => {
+ expect(editRowFilePath({ state: { input: { path: "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/repo/a.ts" } } })).toBe("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/repo/a.ts")
+ expect(
+ editRowFilePath({ state: { metadata: { filediff: { file: "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/repo/b.ts", additions: 1, deletions: 0 } } } }),
+ ).toBe("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/repo/b.ts")
+ })
+
+ test("a pending part falls back to a clamped title, never raw prose", () => {
+ const prose = `"${"Rephrasing the whole module so the wiring reaches the transcript and the view ".repeat(3)}"`
+ const out = editRowLabel({ state: { title: prose } })
+ expect(out.length).toBeLessThanOrEqual(EDIT_ROW_MAX)
+ expect(out.endsWith("…")).toBe(true)
+ })
+
+ test("blank filePath does not beat a usable title", () => {
+ expect(editRowLabel({ state: { title: "Write svelte-shims.d.ts", input: { filePath: " " } } })).toBe(
+ "Write svelte-shims.d.ts",
+ )
+ })
+
+ test("nothing usable → the neutral placeholder, never a throw", () => {
+ expect(editRowLabel({})).toBe("file")
+ expect(editRowLabel({ state: {} })).toBe("file")
+ expect(editRowLabel({ state: { input: {} } })).toBe("file")
+ })
+})
+
+describe("edit row diff", () => {
+ test("reads additions/deletions off the filediff", () => {
+ expect(editRowDiff({ state: { metadata: { filediff: { file: "a.ts", additions: 14, deletions: 1 } } } })).toEqual({
+ additions: 14,
+ deletions: 1,
+ })
+ })
+
+ test("no (or partial) filediff → undefined, never a fabricated count", () => {
+ expect(editRowDiff({})).toBeUndefined()
+ expect(editRowDiff({ state: { metadata: {} } })).toBeUndefined()
+ expect(editRowDiff({ state: { metadata: { filediff: { file: "a.ts" } } } })).toBeUndefined()
+ expect(editRowDiff({ state: { metadata: { filediff: { additions: 3 } } } })).toBeUndefined()
+ })
+})
diff --git a/packages/ui/src/amicode/edit-row.ts b/packages/ui/src/amicode/edit-row.ts
new file mode 100644
index 0000000000..c8d05dc35a
--- /dev/null
+++ b/packages/ui/src/amicode/edit-row.ts
@@ -0,0 +1,60 @@
+// The one-line label + diff stats for an edit/write part's row inside the
+// "Edited files" group (spec B shape — see message-part-groups.ts).
+//
+// Extracted from message-part.tsx so the fallback chain is testable — the same
+// bug class that bit the shell row (see ./shell-row.ts's module docs): while a
+// part is PENDING its `input.filePath` may not be populated yet, so a naive
+// chain falls through to the model's free-text title and renders prose in the
+// slot where users read a filename. Whatever wins the chain is clamped, so a
+// pending part can never fill the row with a sentence.
+
+import { clampShellLabel, SHELL_ROW_MAX } from "./shell-row"
+
+/** Longest label we render before eliding. Matches the shell row's budget. */
+export const EDIT_ROW_MAX = SHELL_ROW_MAX
+
+export interface EditRowPartLike {
+ state?: {
+ input?: Record
+ title?: unknown
+ metadata?: Record
+ }
+}
+
+function recordOf(value: unknown): Record | undefined {
+ if (typeof value !== "object" || value === null) return undefined
+ return value as Record
+}
+
+function nonEmpty(value: unknown): string | undefined {
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined
+}
+
+/** The path this part mutates: input.filePath (edit/write), input.path
+ * (patch-family), or the filediff's recorded file. undefined while pending. */
+export function editRowFilePath(part: EditRowPartLike): string | undefined {
+ const input = part.state?.input ?? {}
+ const candidates = [input.filePath, input.path, recordOf(part.state?.metadata?.filediff)?.file]
+ for (const candidate of candidates) {
+ const path = nonEmpty(candidate)
+ if (path) return path
+ }
+ return undefined
+}
+
+/** Prefer the file's basename; fall back to the (clamped) title, then the
+ * neutral placeholder. A prose title can never fill the row unclamped. */
+export function editRowLabel(part: EditRowPartLike): string {
+ const path = editRowFilePath(part)
+ if (path) return clampShellLabel(path.split("/").pop() ?? path, EDIT_ROW_MAX)
+ return clampShellLabel(nonEmpty(part.state?.title) ?? "file", EDIT_ROW_MAX)
+}
+
+/** The {additions, deletions} this part recorded, when it recorded them. */
+export function editRowDiff(part: EditRowPartLike): { additions: number; deletions: number } | undefined {
+ const filediff = recordOf(part.state?.metadata?.filediff)
+ if (!filediff) return undefined
+ const { additions, deletions } = filediff
+ if (typeof additions !== "number" || typeof deletions !== "number") return undefined
+ return { additions, deletions }
+}
diff --git a/packages/ui/src/components/amicode-edit-row.tsx b/packages/ui/src/components/amicode-edit-row.tsx
new file mode 100644
index 0000000000..ed95a7f0b8
--- /dev/null
+++ b/packages/ui/src/components/amicode-edit-row.tsx
@@ -0,0 +1,3 @@
+// AMICODE: re-export shim (same wildcard-export pattern as amicode-shell-row.tsx).
+// Logic lives in ../amicode/edit-row.ts.
+export { editRowDiff, editRowFilePath, editRowLabel } from "../amicode/edit-row"
diff --git a/packages/ui/src/context/marked-math.test.ts b/packages/ui/src/context/marked-math.test.ts
new file mode 100644
index 0000000000..05338bcb2c
--- /dev/null
+++ b/packages/ui/src/context/marked-math.test.ts
@@ -0,0 +1,71 @@
+import { expect, test } from "bun:test"
+import { Marked } from "marked"
+import { markedCodeSpanBoundary } from "./marked-code-span"
+import { katexExtension, renderMathInText } from "./marked"
+
+const parse = (src: string) => new Marked(markedCodeSpanBoundary, katexExtension).parse(src)
+
+test("renders single-$ inline math", async () => {
+ const html = await parse("in the rotating frame, where $\\Omega_x, \\Omega_y$ are the drives")
+ expect(html).toContain('class="katex"')
+ expect(html).not.toContain("$\\Omega_x")
+})
+
+test("renders tight and loose-inner single-$ math", async () => {
+ expect(await parse("structure is settled (one spin, $N = 1$)")).toContain('class="katex"')
+ expect(await parse("a qubit $x$ here")).toContain('class="katex"')
+ expect(await parse("set $\\delta/2\\pi = 0.1$ MHz")).toContain('class="katex"')
+})
+
+test("keeps currency and env-var dollars literal", async () => {
+ const prices = await parse("costs $5 and $10 total")
+ expect(prices).not.toContain('class="katex"')
+ expect(prices).toContain("$5")
+ expect(prices).toContain("$10")
+
+ const env = await parse("set $HOME before $PATH please")
+ expect(env).not.toContain('class="katex"')
+ expect(env).toContain("$HOME")
+ expect(env).toContain("$PATH")
+})
+
+test("keeps escaped dollars literal", async () => {
+ const html = await parse("use \\$HOME before $E=mc^2$")
+ expect(html).toContain("$HOME")
+ // the real formula after the prose dollar still renders
+ expect(html).toContain('class="katex"')
+ expect(html).not.toContain("$E=mc^2$")
+})
+
+test("never eats one half of $$..$$", async () => {
+ // one-line $$..$$ mid-paragraph is not block math (blockKatex wants fenced
+ // newlines); the single-$ tokenizer must leave it untouched
+ const html = await parse("the drive $$\\Omega_x(t)$$ sits here")
+ expect(html).not.toContain('class="katex"')
+ expect(html).toContain("$$\\Omega_x(t)$$")
+})
+
+test("keeps math inside code spans literal", async () => {
+ const html = await parse("run `$x$` verbatim")
+ expect(html).toContain("$x$")
+ expect(html).not.toContain('class="katex"')
+})
+
+test("display $$ and \\(...\\) keep working", async () => {
+ expect(await parse("$$\n\\hat H/\\hbar\n$$")).toContain("katex-display")
+ expect(await parse("inline \\(\\Omega_x\\) math")).toContain('class="katex"')
+})
+
+test("renderMathInText renders single-$ inline and spares prose dollars", () => {
+ const math = renderMathInText("where $\\Omega_x$ are")
+ expect(math).toContain('class="katex"')
+ expect(math).not.toContain("$\\Omega_x")
+
+ const prices = renderMathInText("costs $5 and $10 total")
+ expect(prices).not.toContain('class="katex"')
+ expect(prices).toContain("$5")
+ expect(prices).toContain("$10")
+
+ expect(renderMathInText("$$\n\\hat H\n$$")).toContain("katex-display")
+ expect(renderMathInText("inline \\(\\Omega_x\\) math")).toContain('class="katex"')
+})
diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx
index 6815fc86c6..e4a00db7da 100644
--- a/packages/ui/src/context/marked.tsx
+++ b/packages/ui/src/context/marked.tsx
@@ -418,7 +418,16 @@ export const OpenCodeTheme = {
registerCustomTheme("OpenCode", () => Promise.resolve(OpenCodeTheme))
-function renderMathInText(text: string): string {
+// Single-$ inline math — restored after #34850 removed it for currency false
+// positives. Pandoc-style tight delimiters (no whitespace just inside either
+// $), no digit-led content (so $5, and $30-and-$50 pairs, stay literal), no
+// $$ adjacency, no escaped \$. One regex family, three shapes: the tokenizer
+// start hint, the anchored tokenizer match, and the global replace below.
+const singleDollarStartRegex = /(? {
+ try {
+ return katex.renderToString(math, {
+ displayMode: false,
+ throwOnError: false,
+ macros: KATEX_MACROS,
+ })
+ } catch {
+ return `$${math}$`
+ }
+ })
+
return result
}
const inlineMathRegex = /^\\\(((?:\\.|[^\\\n])*?)\\\)/
const blockMathRegex = /^\$\$\n([\s\S]+?)\n\$\$(?:\n|$)/
-const katexExtension: MarkedExtension = {
+export const katexExtension: MarkedExtension = {
extensions: [
{
name: "inlineKatex",
@@ -492,6 +514,27 @@ const katexExtension: MarkedExtension = {
},
renderer: renderKatexToken,
},
+ {
+ // Single-$ inline math (guarded, see singleDollar*Regex above). The
+ // close-side (?!\$) keeps this from ever eating one half of $$..$$.
+ name: "singleDollarKatex",
+ level: "inline",
+ start(src) {
+ const match = src.match(singleDollarStartRegex)
+ return match ? match.index : undefined
+ },
+ tokenizer(src) {
+ const match = src.match(singleDollarTokenizerRegex)
+ if (!match) return
+ return {
+ type: "singleDollarKatex",
+ raw: match[0],
+ text: match[1].trim(),
+ displayMode: false,
+ }
+ },
+ renderer: renderKatexToken,
+ },
],
}
diff --git a/packages/ui/src/v2/components/icon.tsx b/packages/ui/src/v2/components/icon.tsx
index fe472e782d..74e06f8636 100644
--- a/packages/ui/src/v2/components/icon.tsx
+++ b/packages/ui/src/v2/components/icon.tsx
@@ -27,7 +27,13 @@ const icons = {
},
"sidebar-right": {
viewBox: "0 0 20 20",
- body: ` `,
+ // mirrored so the narrow pane reads on the RIGHT (the art used to show it
+ // left — the name and the glyph disagreed; amicode#105 follow-up)
+ body: ` `,
+ },
+ "sidebar-left": {
+ viewBox: "0 0 20 20",
+ body: ` `,
},
status: {
viewBox: "0 0 20 20",