diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index c6329b39c5..5385dd6c7a 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -73,6 +73,7 @@ import { useServer } from "@/context/server"
import { usePrompt } from "@/context/prompt"
import { startPrompt, draftPrompt } from "@/utils/start-prompt"
import { amicodeGet } from "@/utils/amicode-fetch"
+import { inAmicode, postAmicode } from "@/pages/session/use-amicode-commands"
import { useSync } from "@/context/sync"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { messageAgentColor } from "@/utils/agent"
@@ -1697,6 +1698,7 @@ export function MessageTimeline(props: {
}
onOpenEntity={openEntityView}
onOpenSwitcher={openSwitcher}
+ onInspectRun={inAmicode() ? () => postAmicode("amicode.openInspector") : undefined}
retryLabel={language.t("amicode.retry")}
unavailableLabel={language.t("amicode.unavailable")}
onAsk={(text) => {
diff --git a/packages/app/src/pages/session/use-amicode-commands.test.ts b/packages/app/src/pages/session/use-amicode-commands.test.ts
new file mode 100644
index 0000000000..602ba8d10c
--- /dev/null
+++ b/packages/app/src/pages/session/use-amicode-commands.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, spyOn, test } from "bun:test"
+import { inAmicode, postAmicode } from "./use-amicode-commands"
+
+// The "Inspect Run" button (entity rail) and the Amico command palette both
+// reach the VS Code extension through postAmicode(). chat_panel.ts relays the
+// envelope to the host and executes ONLY commands on its BRIDGE_ALLOWED_COMMANDS
+// allowlist — so the exact envelope shape and command string are a contract.
+describe("postAmicode bridge envelope", () => {
+ test('posts {source:"amicode", kind:"command", command} to window.parent with "*"', () => {
+ const spy = spyOn(window.parent, "postMessage").mockImplementation(() => {})
+ try {
+ postAmicode("amicode.openInspector")
+ expect(spy).toHaveBeenCalledTimes(1)
+ // Cast past the DOM postMessage overloads (which type arg 2 as
+ // WindowPostMessageOptions) — postAmicode passes a legacy string origin.
+ const [message, targetOrigin] = spy.mock.calls[0] as unknown as [unknown, unknown]
+ expect(message).toEqual({ source: "amicode", kind: "command", command: "amicode.openInspector" })
+ // Post to any origin — chat_panel.ts pins the origin on the receiving side.
+ expect(targetOrigin).toBe("*")
+ } finally {
+ spy.mockRestore()
+ }
+ })
+
+ test("never throws even if the parent frame rejects the post", () => {
+ const spy = spyOn(window.parent, "postMessage").mockImplementation(() => {
+ throw new Error("no parent")
+ })
+ try {
+ expect(() => postAmicode("amicode.openInspector")).not.toThrow()
+ } finally {
+ spy.mockRestore()
+ }
+ })
+})
+
+// inAmicode() gates the button (and the palette ops) so they never render in the
+// public web / share build, where there is no extension host to relay to. In a
+// non-framed context self === top, so it must report false.
+describe("inAmicode gate", () => {
+ test("false when not framed (self === top)", () => {
+ expect(inAmicode()).toBe(false)
+ })
+})
diff --git a/packages/app/src/pages/session/use-amicode-commands.tsx b/packages/app/src/pages/session/use-amicode-commands.tsx
index ebeee0f065..b9a072be9d 100644
--- a/packages/app/src/pages/session/use-amicode-commands.tsx
+++ b/packages/app/src/pages/session/use-amicode-commands.tsx
@@ -10,9 +10,12 @@ import { useCommand, type CommandOption } from "@/context/command"
// Each command posts {source:"amicode",kind:"command",command} to window.parent;
// chat_panel.ts relays it to an ALLOWLISTED vscode command.
-const inAmicode = () => typeof window !== "undefined" && window.self !== window.top
+// Exported so non-palette surfaces (e.g. the "Inspect Run" button on the entity
+// rail) can fire the same host-bridged commands. inAmicode() gates them out of
+// the public web/share build, where there is no extension host to relay to.
+export const inAmicode = () => typeof window !== "undefined" && window.self !== window.top
-const postAmicode = (command: string) => {
+export const postAmicode = (command: string) => {
try {
window.parent?.postMessage({ source: "amicode", kind: "command", command }, "*")
} catch {}
diff --git a/packages/ui/src/amicode/entity-rail.tsx b/packages/ui/src/amicode/entity-rail.tsx
index c3c4e2b86a..3d82be9ad1 100644
--- a/packages/ui/src/amicode/entity-rail.tsx
+++ b/packages/ui/src/amicode/entity-rail.tsx
@@ -49,6 +49,10 @@ export function AmicodeEntityRail(props: {
onOpenEntity: (kind: string, seq?: number) => void
onOpenSwitcher: () => void
onAsk?: (text: string) => void
+ // Bridge-agnostic: fired when the user clicks "Inspect Run". The app wires it
+ // to the host (postAmicode → amicode.openInspector) and passes it only when
+ // framed in Amicode, so the button stays hidden everywhere else.
+ onInspectRun?: () => void
retryLabel: string
unavailableLabel: string
}) {
@@ -134,6 +138,12 @@ export function AmicodeEntityRail(props: {
if (snapshot.kind !== "ready") return undefined
return snapshot.view.name ?? snapshot.view.slug
})
+ // Whether there is a run to inspect — gates the "Inspect Run" button so it
+ // appears alongside the live run chip, not before any solve has started.
+ const hasRun = createMemo(() => {
+ const snapshot = state()
+ return snapshot.kind === "ready" && snapshot.view.runs.length > 0
+ })
return (
0}>
@@ -264,6 +274,30 @@ export function AmicodeEntityRail(props: {
)}
+
+
+