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: { )} + + +