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
2 changes: 2 additions & 0 deletions packages/app/src/pages/session/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) => {
Expand Down
44 changes: 44 additions & 0 deletions packages/app/src/pages/session/use-amicode-commands.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
7 changes: 5 additions & 2 deletions packages/app/src/pages/session/use-amicode-commands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down
34 changes: 34 additions & 0 deletions packages/ui/src/amicode/entity-rail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}) {
Expand Down Expand Up @@ -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 (
<Show when={amicodeParts().any > 0}>
Expand Down Expand Up @@ -264,6 +274,30 @@ export function AmicodeEntityRail(props: {
</Show>
)}
</For>
<Show when={props.onInspectRun && hasRun()}>
<button
type="button"
data-slot="amicode-rail-inspect"
style={{
display: "inline-flex",
"align-items": "center",
"flex-shrink": "0",
gap: "4px",
border: "1px solid var(--v2-border-border-base)",
"border-radius": "5px",
background: "none",
color: "var(--v2-text-text-accent)",
padding: "1px 8px",
font: "inherit",
"font-weight": "600",
cursor: "pointer",
}}
title="Open the Run Inspector panel"
onClick={() => props.onInspectRun?.()}
>
Inspect Run
</button>
</Show>
</Show>
</div>
</Show>
Expand Down
Loading