diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 723500b31..6ae1ea22c 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -591,7 +591,7 @@ "packages/ui/src/amicode/brain-ref.ts": "0f8e866933ec3d161973f5e62acdfd51b0320f3ad5be4c487685c469467901c4", "packages/ui/src/amicode/bridge.ts": "e7b93a15060a0cb1ab0be2b367f73e926d74b990a8af917a9966b451ee303b5b", "packages/ui/src/amicode/card.stories.tsx": "b1591abb4af426b0eb08588ea1585336a392d755b5d308cb31f17cb65acf5a3e", - "packages/ui/src/amicode/card.tsx": "1f462017d9113082bb2e802e3d812b25cf5ee9b1bd848559e0533de4d382517f", + "packages/ui/src/amicode/card.tsx": "c025b03d79630acf8a3754cc1b566dc3348f33c305bd62443a416ecb7c4a1a66", "packages/ui/src/amicode/connection-icon.tsx": "f0ffda2ebae57a2945a60cd278b5aa3986273b0cabe97ed46f417c037d167879", "packages/ui/src/amicode/connection-picker.tsx": "73293d1e159dab94a6d1600844fa1e47810ce0558bda6ba4e4209f7e368e3aff", "packages/ui/src/amicode/connections-paths.test.ts": "07c86e8e04700cdbd55bff8db342a8400d85114d76bc73fee8205e9162953f2a", @@ -620,6 +620,9 @@ "packages/ui/src/amicode/institution-lookup.ts": "3edf90b0778ea50b048975cc317a18a1aa82290cd4f5241cf9df74fd17866fe9", "packages/ui/src/amicode/onboarding-wizard.test.ts": "268df538ea99280c5297f9b1dc06a236da2fb82830c2d49a107feca3f610279f", "packages/ui/src/amicode/onboarding-wizard.tsx": "75db623f8f50fd875f1bde8942841fbb5167c77a34fe3c6dbfa80732841b53e8", + "packages/ui/src/amicode/preview-open-card.tsx": "f7acb9e25ef9d55c100fc977ad5435ba8f0afd07675ae9abd12d057e7712becc", + "packages/ui/src/amicode/preview-open.test.ts": "4f02dc2ceecdfb015a6ba6cfedccd8d0ce899cc417dadf6a94286303c22d9f0d", + "packages/ui/src/amicode/preview-open.ts": "69934ca6ed1af0e90e556a70abd9cf6c502a3b6825b7901fe08d7fb85bdfa07e", "packages/ui/src/amicode/problem.test.ts": "3b7d212cc5a11855c0606189c007f2bbc3957c6b2ee4e1642fc95242f3dd94bd", "packages/ui/src/amicode/problem.ts": "186c1615f387ce930f61b2a8bcc41a11c7d6979c3a398e04be9a3939a6931d5b", "packages/ui/src/amicode/project-type-icons.test.ts": "1db755e2818a5e97d018e7133ae329b9ce1ff63fc347a06bcac7b9f4d0784969", @@ -800,11 +803,11 @@ "packages/app/src/components/session/preview-draft-store.test.ts": "3985665fbbe2255dcc1ea683a4353c5d2e4c24e77e93e5981bb466698fe23c05", "packages/app/src/components/session/preview-draft-store.ts": "28355fc9be787f81ddc0f052f96d51c283ab9a917b309ae05a29c1c98ffba7a3", "packages/app/src/components/session/preview-draft-wiring.test.ts": "8d54de2788ab52e7ba02a90e606072602aa1de3411d2371148258e2a9d33b44a", - "packages/app/src/components/session/preview-file-helpers.test.ts": "e0c1ccb40b48990c0c5b87ea62a35de60b860cd760067c7f1327cbb1d36584b1", - "packages/app/src/components/session/preview-file-helpers.ts": "2244aaef25f7412df7b53a45a7d2f72380d45eb90caefa5e96c48d01e988eb4e", + "packages/app/src/components/session/preview-file-helpers.test.ts": "a1c180771a0279f82eead051266c1f3f2bcb1af7ff7b1914ae39862a0bebd601", + "packages/app/src/components/session/preview-file-helpers.ts": "8fcce56f9abce1be2a5868eba369bd3c3eb02c5cf49f548927cea8b82d0baed8", "packages/app/src/components/session/preview-file-target.test.ts": "d9a341349d8922b0369f3fdf911cce35d96344962ad442267e9078106d266db3", "packages/app/src/components/session/preview-file-target.ts": "11738d77fc1e86a4a968b65f3fb342f51792b6a2c2d57a08114197ff2e82a51b", - "packages/app/src/components/session/preview-file-view.tsx": "27153914e75d6e596fe56ebe6556653bc589c7f6c31fcdb24e37618814133268", + "packages/app/src/components/session/preview-file-view.tsx": "31779ea862550a5400aa9d1477e38de443f3c801326596cdfde7a01b01cfb020", "packages/app/src/components/session/preview-find-in-sidebar.test.ts": "68cdc2d9743b3c3a6a6c1fab6cbd362d27b4f7179cba973cba67d10f01b35970", "packages/app/src/components/session/preview-frontmatter-card.tsx": "00f439e3bf482673dfb06c0ce3464c0f51985093ed57b321cdebd38d452f2e5d", "packages/app/src/components/session/preview-frontmatter.test.ts": "93ec9f7f4c604d8ed8ee6009779c425bffecfca331a122a7a700b588a8ca3c0c", @@ -1102,7 +1105,7 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-11-07T23:24:13.583Z", + "extracted_at": "2026-11-08T14:53:42.145Z", "per_package": { "packages/app": { "A": 312, @@ -1145,13 +1148,13 @@ "D": 0 }, "packages/ui": { - "A": 151, + "A": 154, "M": 66, "D": 0 } }, "counts": { - "overlay_total": 1080, + "overlay_total": 1083, "deletions": 7, "server_coupled": 0 }, @@ -1768,6 +1771,9 @@ "packages/ui/src/amicode/institution-lookup.ts": "A", "packages/ui/src/amicode/onboarding-wizard.test.ts": "A", "packages/ui/src/amicode/onboarding-wizard.tsx": "A", + "packages/ui/src/amicode/preview-open-card.tsx": "A", + "packages/ui/src/amicode/preview-open.test.ts": "A", + "packages/ui/src/amicode/preview-open.ts": "A", "packages/ui/src/amicode/problem.test.ts": "A", "packages/ui/src/amicode/problem.ts": "A", "packages/ui/src/amicode/project-type-icons.test.ts": "A", diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-helpers.test.ts b/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-helpers.test.ts index 5912aeab7..0f9b4f98d 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-helpers.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-helpers.test.ts @@ -1,5 +1,12 @@ import { test, expect, describe } from "bun:test" -import { toAbsolutePath, shouldApplyWatcherRead } from "./preview-file-helpers" +import { + toAbsolutePath, + shouldApplyWatcherRead, + previewSizeLimit, + isPreviewRefreshFor, + MAX_HTML_PREVIEW_SIZE, + MAX_TEXT_PREVIEW_SIZE, +} from "./preview-file-helpers" // #1414 (my review finding): saving a .tex opened by a workspace-RELATIVE path // posted `run-latex` with that relative path; the compile bridge's containment @@ -66,3 +73,43 @@ describe("shouldApplyWatcherRead", () => { expect(shouldApplyWatcherRead({ ...base, hasUnsavedEdits: true })).toBe(false) }) }) + +// #2130: the HTML size cap and the agent tool's refresh request. +describe("previewSizeLimit", () => { + test("HTML pages get the larger cap; everything else read as text keeps 1 MB", () => { + expect(previewSizeLimit("/r/transport-dashboard.html")).toBe(MAX_HTML_PREVIEW_SIZE) + expect(previewSizeLimit("page.HTM")).toBe(MAX_HTML_PREVIEW_SIZE) + expect(previewSizeLimit("/r/notes.md")).toBe(MAX_TEXT_PREVIEW_SIZE) + expect(previewSizeLimit("/r/data.json")).toBe(MAX_TEXT_PREVIEW_SIZE) + // a 1.73 MB dashboard with embedded plots now fits + expect(1_732_245).toBeLessThan(previewSizeLimit("/r/transport-dashboard.html")) + }) +}) + +describe("isPreviewRefreshFor", () => { + const msg = (extra: Record = {}) => ({ + source: "amicode", + kind: "preview-file", + path: "/v/dash/index.html", + refresh: true, + sessionID: "ses_a", + ...extra, + }) + test("a refresh for this file and this session", () => { + expect(isPreviewRefreshFor(msg(), "/v/dash/index.html", "ses_a")).toBe(true) + }) + test("absolute message path matches a workspace-relative previewed path", () => { + expect(isPreviewRefreshFor(msg({ path: "/w/repo/docs/a.md" }), "docs/a.md", "ses_a")).toBe(true) + }) + test("an unstamped message refreshes every view of the file", () => { + expect(isPreviewRefreshFor(msg({ sessionID: undefined }), "/v/dash/index.html", "ses_b")).toBe(true) + }) + test("another session, another file, no refresh flag, or not a preview-file → no", () => { + expect(isPreviewRefreshFor(msg(), "/v/dash/index.html", "ses_b")).toBe(false) + expect(isPreviewRefreshFor(msg(), "/v/dash/other.html", "ses_a")).toBe(false) + expect(isPreviewRefreshFor(msg({ path: "/x/index.html" }), "/v/dash/index.html", "ses_a")).toBe(false) + expect(isPreviewRefreshFor(msg({ refresh: undefined }), "/v/dash/index.html", "ses_a")).toBe(false) + expect(isPreviewRefreshFor(msg({ kind: "open-file" }), "/v/dash/index.html", "ses_a")).toBe(false) + expect(isPreviewRefreshFor("preview-file", "/v/dash/index.html", "ses_a")).toBe(false) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-helpers.ts b/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-helpers.ts index b1732dcf0..9e4b91de0 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-helpers.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-helpers.ts @@ -45,3 +45,41 @@ export function shouldApplyWatcherRead(args: { !args.hasUnsavedEdits ) } + +/** The Preview's read cap for text-like files: bigger files show a placeholder. */ +export const MAX_TEXT_PREVIEW_SIZE = 1_000_000 + +/** #2130: an HTML page's cap is larger — a campaign dashboard often carries its + * plots as base64 data URIs, and the HTML renderer's own inlining budget + * (preview-html.ts MAX_INLINE_CHARS) is already far above 1 MB. Mirrored by + * the agent tool's size warning (extension opencode-plugin/preview_open.ts). */ +export const MAX_HTML_PREVIEW_SIZE = 16_000_000 + +/** The size above which a text read shows "File too large to preview". */ +export function previewSizeLimit(path: string): number { + return /\.html?$/i.test(path) ? MAX_HTML_PREVIEW_SIZE : MAX_TEXT_PREVIEW_SIZE +} + +/** Does a watcher/bridge path name the previewed file? The watcher reports + * absolute paths; the previewed path may be workspace-relative. */ +export function previewPathMatches(other: string, filePath: string): boolean { + if (other === filePath) return true + return other.endsWith(`/${filePath}`) || filePath.endsWith(`/${other}`) +} + +/** + * #2130: is this window message a request to RE-READ the file this view shows? + * The agent's open-in-Preview tool posts the ordinary `preview-file` open with + * `refresh: true`; when the file is already open the open itself is a no-op, + * so the view re-reads instead (files outside the project never reach the + * engine's watcher). Session targeting follows preview-file-target.ts: a + * stamped message is for its session only; an unstamped one is for everyone. + */ +export function isPreviewRefreshFor(data: unknown, filePath: string, sessionID: string | undefined): boolean { + if (!data || typeof data !== "object") return false + const msg = data as { source?: unknown; kind?: unknown; path?: unknown; sessionID?: unknown; refresh?: unknown } + if (msg.source !== "amicode" || msg.kind !== "preview-file" || msg.refresh !== true) return false + if (typeof msg.path !== "string" || msg.path === "") return false + if (msg.sessionID !== undefined && msg.sessionID !== sessionID) return false + return previewPathMatches(msg.path, filePath) +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx index 654c16ad1..7290b1bb8 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx @@ -35,7 +35,12 @@ import type { PreviewViewState } from "@opencode-ai/session-ui/v2/preview-view-s import { PdfCanvasView } from "./pdf-canvas-view" import { createPdfFindController } from "./pdf-find-controller" import { PdfFindPill } from "./pdf-find-pill" -import { toAbsolutePath, shouldApplyWatcherRead } from "./preview-file-helpers" +import { + toAbsolutePath, + shouldApplyWatcherRead, + previewSizeLimit, + isPreviewRefreshFor, +} from "./preview-file-helpers" import { validateHumanWritePath } from "./preview-human-write-gate" import { previewDrafts } from "./preview-draft-store" import { @@ -86,8 +91,6 @@ type PdfPageNavigation = { navigate: (page: number) => boolean } -const MAX_FILE_SIZE = 1_000_000 - // Wrapper padding: p-4 = 16px each side = 32px total const IMAGE_WRAPPER_PADDING = 32 @@ -224,10 +227,13 @@ export function PreviewFileView(props: { const category = () => getFileCategory(props.filePath) + // #2130: bumped to force a full re-load of the same path (a refresh request + // while the view shows a placeholder — too large, or a failed read). + const [reloadTick, setReloadTick] = createSignal(0) createEffect( on( - () => props.filePath, - (path) => { + () => [props.filePath, reloadTick()] as const, + ([path]) => { setLoading(true) setFileType(null) setBinaryData(null) @@ -257,7 +263,7 @@ export function PreviewFileView(props: { return } const size = (content as any).length ?? content.content.length - if (size > MAX_FILE_SIZE) { + if (size > previewSizeLimit(path)) { setFileType("too-large") setFileContent("") return @@ -286,6 +292,50 @@ export function PreviewFileView(props: { // #1414: reads are async — guard their completion so a read finishing after a // file switch, a newer read, or a fresh edit can't clobber the view. let watcherReadGen = 0 + // Re-read the previewed file from disk (watcher change, or an explicit refresh + // request, #2130), tagging the read so a stale completion is discarded. + const rereadFromDisk = (filePath: string) => { + const capturedGeneration = ++watcherReadGen + sdk() + .client.file.read({ path: filePath }) + .then((result) => { + if ( + !shouldApplyWatcherRead({ + capturedPath: filePath, + currentPath: props.filePath, + capturedGeneration, + latestGeneration: watcherReadGen, + hasUnsavedEdits: unsavedContent() !== null, + }) + ) + return + const content = result.data + if (!content) return + if (content.type !== "text") { + // Binary file (PDF, image) — update binary data + if ((content as any).encoding === "base64") { + setBinaryData({ + base64: content.content, + mime: (content as any).mimeType ?? "application/octet-stream", + }) + } + } else { + // Text file — update content + setFileContent(content.content) + // #1423: If a .tex/.ltx file was updated on disk (e.g. by the + // agent), trigger a LaTeX recompile so the PDF auto-refreshes. + if (/\.(tex|ltx)$/i.test(filePath)) { + try { + window.parent?.postMessage( + { source: "amicode", kind: "run-latex", file: toAbsolute(filePath) }, + "*", + ) + } catch {} + } + } + }) + .catch(() => {}) + } createEffect(() => { const filePath = props.filePath const unsub = sdk().event.listen((e) => { @@ -299,51 +349,28 @@ export function PreviewFileView(props: { if (!changedPath.endsWith(filePath) && !filePath.endsWith(changedPath)) return // Skip if the user has unsaved edits — don't clobber their work if (unsavedContent() !== null) return - // Re-read the file, tagging this read so a stale completion is discarded. - const capturedGeneration = ++watcherReadGen - sdk() - .client.file.read({ path: filePath }) - .then((result) => { - if ( - !shouldApplyWatcherRead({ - capturedPath: filePath, - currentPath: props.filePath, - capturedGeneration, - latestGeneration: watcherReadGen, - hasUnsavedEdits: unsavedContent() !== null, - }) - ) - return - const content = result.data - if (!content) return - if (content.type !== "text") { - // Binary file (PDF, image) — update binary data - if ((content as any).encoding === "base64") { - setBinaryData({ - base64: content.content, - mime: (content as any).mimeType ?? "application/octet-stream", - }) - } - } else { - // Text file — update content - setFileContent(content.content) - // #1423: If a .tex/.ltx file was updated on disk (e.g. by the - // agent), trigger a LaTeX recompile so the PDF auto-refreshes. - if (/\.(tex|ltx)$/i.test(filePath)) { - try { - window.parent?.postMessage( - { source: "amicode", kind: "run-latex", file: toAbsolute(filePath) }, - "*", - ) - } catch {} - } - } - }) - .catch(() => {}) + rereadFromDisk(filePath) }) onCleanup(unsub) }) + // #2130: the agent's open-in-Preview tool posts `preview-file` with + // `refresh: true`. Opening an already-open file changes nothing, so the view + // re-reads it here — the only way a file outside the project (a vault + // dashboard, a results folder) refreshes on demand. A placeholder view (too + // large, failed read) gets a full re-load instead; unsaved edits are kept. + onMount(() => { + const onRefresh = (event: MessageEvent) => { + if (!isPreviewRefreshFor(event.data, props.filePath, props.draftSession)) return + if (unsavedContent() !== null) return + const type = fileType() + if (type === "too-large" || type === "error") setReloadTick((n) => n + 1) + else rereadFromDisk(props.filePath) + } + window.addEventListener("message", onRefresh) + onCleanup(() => window.removeEventListener("message", onRefresh)) + }) + // ─── PDF refresh after compile (#1254) ───────────────────────────────── // Re-read the file from disk and swap the base64 payload when the companion // PDF's producer (latexmk) reports "done" via run-latex-status. diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/card.tsx b/packages/app-bundle/overlay/packages/ui/src/amicode/card.tsx index 2755cf036..402fbdf62 100644 --- a/packages/app-bundle/overlay/packages/ui/src/amicode/card.tsx +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/card.tsx @@ -1,4 +1,4 @@ -import { For, Match, Show, Switch, createMemo } from "solid-js" +import { For, Match, Show, Switch, createEffect, createMemo, on } from "solid-js" import { openFileInEditor } from "./bridge" import { amicodeStage } from "./stage" import { parseAskInput } from "./ask" @@ -11,6 +11,13 @@ import { compositeChip, chipText } from "./problem" import { RunWindow } from "./run-window" import { parseWidgetSentinel } from "./widget-preview" import { WidgetPreviewCard } from "./widget-preview-card" +import { parsePreviewSentinel, shouldAutoOpen } from "./preview-open" +import { PreviewOpenCard, openInPreview } from "./preview-open-card" + +// #2130: tool parts whose live completion already opened the Preview — so a +// remount of the same card (timeline virtualization, a re-render) never opens +// it a second time. Keyed by message + path: the card is not handed a part id. +const autoOpenedPreviews = new Set() // spec C: when an amicode_solve recorded a run_dir, its sentinel carries the // path (well under the 120-char truncation cap) — extract lab/run_id from the @@ -321,6 +328,39 @@ export function AmicodeToolCard(props: { props.tool === "amicode_author_widget" ? parseWidgetSentinel(props.output) : undefined, ) + // #2130: amicode_open_preview — open the file in this session's Preview when + // the call completes LIVE. The watcher lives here, not in PreviewOpenCard: + // this card is mounted from the part's first (pending) render, so it sees + // the transition; a replayed session mounts already-completed and never + // re-opens anything. A missed open is one click on the chip. + const previewRequest = createMemo(() => + props.tool === "amicode_open_preview" && props.status === "completed" + ? parsePreviewSentinel(props.output) + : undefined, + ) + // `sawLive` latches when the part is seen pending/running; the open fires on + // the first completed render that also carries the sentinel (status and + // output may land in separate updates, so both are tracked). + let sawLive = false + createEffect( + on( + () => [props.status, props.output] as const, + ([current]) => { + if (props.tool !== "amicode_open_preview") return + if (current === "pending" || current === "running") { + sawLive = true + return + } + const request = previewRequest() + if (!request) return + const key = `${props.messageID ?? ""}:${request.path}` + if (!shouldAutoOpen({ sawLive, current, alreadyOpened: autoOpenedPreviews.has(key) })) return + autoOpenedPreviews.add(key) + openInPreview(request.path, props.sessionID) + }, + ), + ) + return ( }> @@ -329,6 +369,9 @@ export function AmicodeToolCard(props: { {(req) => } {(ref) => } {(preview) => } + + {(request) => } + ) } diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/preview-open-card.tsx b/packages/app-bundle/overlay/packages/ui/src/amicode/preview-open-card.tsx new file mode 100644 index 000000000..85e461db2 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/preview-open-card.tsx @@ -0,0 +1,54 @@ +import { previewOpenMessage, type PreviewOpenRequest } from "./preview-open" + +// AMICODE (#2130): the face of an amicode_open_preview tool part — a chip that +// reads "Preview " and re-opens the file in this session's Preview when +// clicked. The AUTO-open on a live call is not here: this card only mounts once +// the sentinel exists (i.e. already completed), so the running → completed +// transition is watched by AmicodeToolCard (card.tsx), which is mounted from +// the part's first (pending) render. Same shell as AmicoSkillChip: a real +// + ) +} diff --git a/packages/app-bundle/overlay/packages/ui/src/amicode/preview-open.test.ts b/packages/app-bundle/overlay/packages/ui/src/amicode/preview-open.test.ts new file mode 100644 index 000000000..46bca7e52 --- /dev/null +++ b/packages/app-bundle/overlay/packages/ui/src/amicode/preview-open.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" +import { parsePreviewSentinel, previewOpenMessage, shouldAutoOpen } from "./preview-open" + +// The LAST line exactly as packages/extension/opencode-plugin/preview_open.ts writes it. +const LINE = 'AMICODE_PREVIEW {"path":"/Users/j/.amico/dashboards/s/index.html","name":"index.html","bytes":21996}' +const OUTPUT = `Sent index.html to the user's Preview (side panel): it opens there now.\n${LINE}` + +describe("parsePreviewSentinel", () => { + test("parses the LAST line when it is a sentinel", () => { + expect(parsePreviewSentinel(OUTPUT)).toEqual({ + path: "/Users/j/.amico/dashboards/s/index.html", + name: "index.html", + bytes: 21996, + }) + }) + test("trailing whitespace is fine; a missing name falls back to the basename", () => { + expect(parsePreviewSentinel(`x\nAMICODE_PREVIEW {"path":"/a/b/plot.png"}\n\n`)).toEqual({ + path: "/a/b/plot.png", + name: "plot.png", + bytes: undefined, + }) + }) + test("refusals, non-strings, non-last sentinels, relative paths, bad JSON → undefined", () => { + expect(parsePreviewSentinel(undefined)).toBeUndefined() + expect(parsePreviewSentinel("Not opened: /x does not exist on this machine.")).toBeUndefined() + expect(parsePreviewSentinel(`${LINE}\ntrailing prose`)).toBeUndefined() + expect(parsePreviewSentinel('AMICODE_PREVIEW {"path":"rel/x.html"}')).toBeUndefined() + expect(parsePreviewSentinel("AMICODE_PREVIEW {nope")).toBeUndefined() + expect(parsePreviewSentinel('AMICODE_PREVIEW "/a.html"')).toBeUndefined() + }) +}) + +describe("previewOpenMessage", () => { + test("is the session-targeted preview-file open, with refresh", () => { + expect(previewOpenMessage("/a.html", "ses_1")).toEqual({ + source: "amicode", + kind: "preview-file", + path: "/a.html", + refresh: true, + sessionID: "ses_1", + }) + }) + test("omits sessionID when the card has none (legacy broadcast)", () => { + expect("sessionID" in previewOpenMessage("/a.html", undefined)).toBe(false) + }) +}) + +describe("shouldAutoOpen", () => { + test("opens on a live completion, once", () => { + expect(shouldAutoOpen({ sawLive: true, current: "completed", alreadyOpened: false })).toBe(true) + expect(shouldAutoOpen({ sawLive: true, current: "completed", alreadyOpened: true })).toBe(false) + }) + test("a replayed (already-completed) part never opens", () => { + expect(shouldAutoOpen({ sawLive: false, current: "completed", alreadyOpened: false })).toBe(false) + }) + test("errors and in-flight states never open", () => { + for (const current of ["error", "running", "pending", undefined]) + expect(shouldAutoOpen({ sawLive: true, current, alreadyOpened: false })).toBe(false) + }) +}) + +// Wiring pins (JSX cannot render under bun here): the auto-open watcher lives in +// AmicodeToolCard (mounted from the pending render, so it sees the live +// transition) and goes through the pure gate above, at most once per part. +const card = readFileSync(join(import.meta.dir, "card.tsx"), "utf8") + +describe("AmicodeToolCard wiring for amicode_open_preview", () => { + test("the watcher latches on pending/running and opens through shouldAutoOpen, once", () => { + expect(card).toContain('if (current === "pending" || current === "running") {') + expect(card).toContain("shouldAutoOpen({ sawLive, current, alreadyOpened: autoOpenedPreviews.has(key) })") + expect(card).toContain("autoOpenedPreviews.add(key)") + expect(card).toContain("openInPreview(request.path, props.sessionID)") + }) + test("a completed call renders the re-open chip", () => { + expect(card).toMatch(/\s*\{\(request\) => = 0 ? raw.bytes : undefined, + } +} + +/** The app-window message that opens (or re-reads) a file in this session's + * Preview: the same `preview-file` kind the chat bridge and sidebar send + * (app preview-file-target.ts), stamped with the session so no other session + * view acts on it (#1648), plus `refresh` so an already-open file re-reads. */ +export function previewOpenMessage(path: string, sessionID: string | undefined) { + return { + source: "amicode" as const, + kind: "preview-file" as const, + path, + refresh: true, + ...(sessionID ? { sessionID } : {}), + } +} + +/** Decide whether a card should auto-open: only when the card saw the part + * live (pending/running) before it completed — so replaying an old session, + * which mounts parts already completed, never re-opens anything — and only + * once per tool part. */ +export function shouldAutoOpen(state: { + sawLive: boolean + current: string | undefined + alreadyOpened: boolean +}): boolean { + return state.sawLive && state.current === "completed" && !state.alreadyOpened +} diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index f7be22688..b47f2b221 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -87,6 +87,17 @@ user's model budget — fan out deliberately (max 4 per call). `mode: "fork"` seeds a child from this session's history; the spawn-depth cap (2) is soft and overridable with `force: true`. +**Showing results: `amicode_open_preview`.** To put a figure, PDF, markdown +note or HTML report in front of the user, open it in the side-panel Preview +with `amicode_open_preview` (absolute path) — never launch an external +browser or viewer (`open -a "Google Chrome" …`, `open plot.png`). For a live +dashboard, write one HTML page with its plots as image files beside it +(``), open it once, and keep rewriting the files: the +Preview re-reads a visible page every few seconds. It runs no scripts and +strips ``, so don't rely on either. Calling the tool +again on an open file brings it to the front and re-reads it. The user can pop +the page out to their browser from the Preview's own controls. + **The `amico` CLI** is the five-verb surface — AUTHOR · EXECUTE · VERIFY · REMEMBER · METER — over the launch package (`packages/amico-run`). `amico run` is EXECUTE, the gate + launch verb: it validates specs, scans imports, checks diff --git a/packages/extension/opencode-plugin/amicode_tools.ts b/packages/extension/opencode-plugin/amicode_tools.ts index b07b961a8..470ee339c 100644 --- a/packages/extension/opencode-plugin/amicode_tools.ts +++ b/packages/extension/opencode-plugin/amicode_tools.ts @@ -166,6 +166,9 @@ import { // test/warrant_bounds_parity.test.ts. The core table validates against the // schema itself; the twin follows it by construction + pin. import { validateWarrantBounds, boundsRefusal } from "./warrant_bounds"; +// #2130: the open-in-Preview tool — one dependency-free definition shared with +// the core table (src/amicode_tools_core.ts), so the two cannot drift. +import { PREVIEW_OPEN_ARGS, PREVIEW_OPEN_DESCRIPTION, executePreviewOpen } from "./preview_open"; // Issue #799/#995 — the widget-authoring twin executes against the SAME // service helper the core table calls (src/amicode_service/widgets.ts). @@ -609,6 +612,15 @@ returns an error, fix \`js\`/the fields and call it again. }, }, + // #2130: put a file in the side-panel Preview (twin of the core entry). + amicode_open_preview: { + description: PREVIEW_OPEN_DESCRIPTION, + args: { ...PREVIEW_OPEN_ARGS }, + async execute(a: { path?: unknown }) { + return executePreviewOpen(a); + }, + }, + amicode_problem: { description: "Open or create the Problem workspace the design state belongs to (spec A). " + diff --git a/packages/extension/opencode-plugin/preview_open.ts b/packages/extension/opencode-plugin/preview_open.ts new file mode 100644 index 000000000..fd8d46766 --- /dev/null +++ b/packages/extension/opencode-plugin/preview_open.ts @@ -0,0 +1,111 @@ +// amicode_open_preview (#2130) — let an agent put a file in the side-panel +// Preview, the in-studio replacement for `open -a "Google Chrome" report.html`. +// +// The tool itself only VALIDATES and ANNOUNCES: it resolves the path, checks the +// file exists on this machine (the engine's machine — the same one the app's +// file reads go to), and returns an `AMICODE_PREVIEW {json}` sentinel as its +// LAST line. The app's tool card (ui/src/amicode/preview-open-card.tsx) parses +// that sentinel and, when the call completes live, posts the existing +// session-targeted `preview-file` open (with `refresh: true`, so a file that is +// already open re-reads). Re-opening an old session never re-fires it: the card +// only acts on a running → completed transition it witnessed. +// +// Dependency-free on purpose: imported by BOTH transports (the opencode plugin +// runs in the engine's Bun runtime; the harness-neutral core feeds the MCP +// server), so the description/args/behavior are one definition and the parity +// test can never see them drift. + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +export const PREVIEW_SENTINEL_PREFIX = "AMICODE_PREVIEW "; + +/** The Preview's text-file cap (app preview-file-view.tsx MAX_FILE_SIZE). */ +export const PREVIEW_TEXT_LIMIT = 1_000_000; +/** The Preview's HTML-page cap (app preview-file-view.tsx MAX_HTML_FILE_SIZE). */ +export const PREVIEW_HTML_LIMIT = 16_000_000; + +export const PREVIEW_OPEN_DESCRIPTION = + "Open a file in the user's side-panel Preview, beside this conversation — use this INSTEAD of " + + "launching an external browser or viewer (`open -a \"Google Chrome\" …`, `open report.pdf`). " + + "The Preview renders HTML pages (scripts off; local images inlined; the page re-reads itself " + + "every few seconds while visible, so a dashboard a script keeps rewriting stays live), " + + "markdown, images (PNG/JPG/SVG/…), PDFs, and shows other files as text. Calling it again on a " + + "file that is already open brings it to the front and re-reads it. Pass an ABSOLUTE path (or " + + "~/…) to a file on this machine. For a live HTML dashboard, write plots as files next to the " + + "page () rather than base64-embedding them, and do not rely on " + + " or scripts — the Preview strips both and refreshes on its own."; + +export const PREVIEW_OPEN_ARGS = { + path: { + type: "string", + description: "Absolute path (or ~/…) of the file to show in the Preview.", + }, +} as const; + +export type PreviewSentinel = { path: string; name: string; bytes: number }; + +function expandHome(p: string): string { + if (p === "~") return os.homedir(); + if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2)); + return p; +} + +function limitFor(file: string): { limit: number; kind: string } | undefined { + const ext = path.extname(file).toLowerCase(); + if (ext === ".html" || ext === ".htm") return { limit: PREVIEW_HTML_LIMIT, kind: "HTML page" }; + // Binary renderers (images, PDFs) have no size cap; everything else is read as text. + if (/^\.(png|jpe?g|gif|webp|bmp|ico|avif|pdf)$/.test(ext)) return undefined; + return { limit: PREVIEW_TEXT_LIMIT, kind: "text file" }; +} + +function mb(bytes: number): string { + return `${(bytes / 1_000_000).toFixed(bytes < 10_000_000 ? 2 : 1)} MB`; +} + +/** Validate + announce. Returns the agent-directed text whose LAST line (on + * success) is the sentinel the app's card acts on. Never throws. */ +export function executePreviewOpen(a: { path?: unknown }): string { + const raw = typeof a?.path === "string" ? a.path.trim() : ""; + if (raw === "") return "Not opened: `path` is required (an absolute path or ~/… to a file on this machine)."; + let target = expandHome(raw); + if (/^file:\/\//i.test(target)) { + try { + target = decodeURIComponent(new URL(target).pathname); + } catch { + return `Not opened: ${raw} is not a valid file:// URL.`; + } + } + if (!path.isAbsolute(target)) { + return `Not opened: ${raw} is a relative path — pass an absolute path (or ~/…), e.g. ${path.resolve(target)} if that is the file you mean.`; + } + if (target.length > 4096) return "Not opened: the path is longer than 4096 characters."; + target = path.normalize(target); + let stat: fs.Stats; + try { + stat = fs.statSync(target); + } catch { + return `Not opened: ${target} does not exist on this machine.`; + } + if (stat.isDirectory()) return `Not opened: ${target} is a directory — pass a file inside it.`; + if (!stat.isFile()) return `Not opened: ${target} is not a regular file.`; + + const cap = limitFor(target); + const name = path.basename(target); + const sentinel: PreviewSentinel = { path: target, name, bytes: stat.size }; + let note = ""; + if (cap && stat.size > cap.limit) { + note = + ` Warning: it is ${mb(stat.size)}, over the Preview's ${mb(cap.limit)} limit for a ${cap.kind}, ` + + `so the user will see "File too large to preview" — ` + + (cap.kind === "HTML page" + ? "write the plots as separate image files instead of embedding them, then call this again." + : "write a smaller summary file and open that instead."); + } + return ( + `Sent ${name} to the user's Preview (side panel): it opens there now, or comes to the front and ` + + `re-reads if it was already open.${note}\n` + + `${PREVIEW_SENTINEL_PREFIX}${JSON.stringify(sentinel)}` + ); +} diff --git a/packages/extension/skills/research/SKILL.md b/packages/extension/skills/research/SKILL.md index 70dd78670..92bc4326c 100644 --- a/packages/extension/skills/research/SKILL.md +++ b/packages/extension/skills/research/SKILL.md @@ -189,13 +189,16 @@ churn does not belong in a synced repo). `` is the ledger's file na **Static only.** Preview renders the page in a sandbox with no scripts: no scripts, no CDN libraries (Plotly, MathJax), no meta refresh, no external stylesheets. Use inline CSS, -tables, inline SVG, and PNG/SVG files in `plots/`. Keep the page under 1 MB. Preview reloads -it when the file changes, so a rewrite is the refresh. - -**Link it once.** After the first write, post the page as a `file://` link in chat -(`[campaign dashboard](file:///abs/path/index.html)`); one click opens it in Preview, where -it stays live, and its "Open in browser" control pops it out for a second monitor. Do not -re-post the link every loop. +tables, inline SVG, and PNG/SVG files in `plots/`. Keep the page under 1 MB of its own HTML +(plots as files, never base64-embedded). While the page is visible, Preview re-reads it and +its plots every few seconds, so a rewrite is the refresh. + +**Open it once.** After the first write, call `amicode_open_preview` with the page's +absolute path: it opens in the Preview beside the chat and stays live there, and its "Open +in browser" control pops it out for a second monitor. Never launch an external browser +(`open -a "Google Chrome" …`) instead. Also post it once as a `file://` link in chat +(`[campaign dashboard](file:///abs/path/index.html)`) so the researcher can reopen it with +one click. Do not re-open or re-post it every loop. ## Roles at a glance diff --git a/packages/extension/src/amicode_tools_core.ts b/packages/extension/src/amicode_tools_core.ts index d2aa806e3..364f12283 100644 --- a/packages/extension/src/amicode_tools_core.ts +++ b/packages/extension/src/amicode_tools_core.ts @@ -142,6 +142,11 @@ import { // the same directory, so a tool call lands in the inventory end-to-end // without an HTTP hop and without touching the harness server. import { authorWidget } from "./amicode_service/widgets"; +import { + PREVIEW_OPEN_ARGS, + PREVIEW_OPEN_DESCRIPTION, + executePreviewOpen, +} from "../opencode-plugin/preview_open"; /** The shipped role cards' directory, for the S3 routing resolver (#860): * the suggestion source + the hand-set `model:` tier. The bundled core @@ -626,6 +631,17 @@ export const AMICODE_TOOLS: Record = { }, }, + // #2130: put a file in the side-panel Preview (the in-studio replacement + // for launching Chrome). One definition shared with the plugin twin + // (opencode-plugin/preview_open.ts); the app's card does the opening. + amicode_open_preview: { + description: PREVIEW_OPEN_DESCRIPTION, + args: { ...PREVIEW_OPEN_ARGS }, + async execute(a: { path?: unknown }) { + return executePreviewOpen(a); + }, + }, + amicode_problem: { description: "Open or create the Problem workspace the design state belongs to (spec A). " + diff --git a/packages/extension/test/amicode_tools_core.test.ts b/packages/extension/test/amicode_tools_core.test.ts index 030511aca..ab87ef3d0 100644 --- a/packages/extension/test/amicode_tools_core.test.ts +++ b/packages/extension/test/amicode_tools_core.test.ts @@ -28,6 +28,7 @@ const EXPECTED_TOOLS = [ "amicode_request_approval", "amicode_ask", "amicode_author_widget", + "amicode_open_preview", "amicode_problem", "amicode_pick_system", "amicode_set_model", diff --git a/packages/extension/test/preview_open.test.ts b/packages/extension/test/preview_open.test.ts new file mode 100644 index 000000000..cae9894e4 --- /dev/null +++ b/packages/extension/test/preview_open.test.ts @@ -0,0 +1,103 @@ +// #2130 — amicode_open_preview: the agent-side half of "open a file in the +// side-panel Preview". The tool validates and returns an AMICODE_PREVIEW +// sentinel; the app's card does the opening (ui/src/amicode/preview-open.ts +// parses the same line). Both transports execute the same function. +import { describe, it, expect } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join, relative } from "node:path"; + +process.env.AMICODE_PROBLEMS_DIR = mkdtempSync(join(tmpdir(), "amicode-preview-open-")); + +import { + PREVIEW_HTML_LIMIT, + PREVIEW_SENTINEL_PREFIX, + PREVIEW_TEXT_LIMIT, + executePreviewOpen, +} from "../opencode-plugin/preview_open"; + +const CORE = await import("../src/amicode_tools_core"); +const PLUGIN = await import("../opencode-plugin/amicode_tools"); + +const dir = mkdtempSync(join(tmpdir(), "preview-open-")); +const page = join(dir, "dash.html"); +writeFileSync(page, "

hi

"); + +function sentinelOf(out: string) { + const last = out.trimEnd().split("\n").pop() ?? ""; + if (!last.startsWith(PREVIEW_SENTINEL_PREFIX)) return undefined; + return JSON.parse(last.slice(PREVIEW_SENTINEL_PREFIX.length)); +} + +describe("executePreviewOpen", () => { + it("an existing absolute file returns the sentinel as the LAST line", () => { + const out = executePreviewOpen({ path: page }); + expect(sentinelOf(out)).toEqual({ path: page, name: "dash.html", bytes: 11 }); + expect(out).not.toMatch(/Warning/); + }); + + it("accepts file:// URLs and normalizes the path", () => { + expect(sentinelOf(executePreviewOpen({ path: `file://${dir}/./dash.html` }))?.path).toBe(page); + }); + + it("expands ~/", () => { + const home = mkdtempSync(join(homedir(), ".amicode-preview-open-test-")); + const f = join(home, "a.md"); + writeFileSync(f, "# a"); + const rel = "~/" + relative(homedir(), f); + try { + expect(sentinelOf(executePreviewOpen({ path: rel }))?.path).toBe(f); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + it("refuses missing, relative, directory and empty paths with no sentinel", () => { + for (const p of [join(dir, "nope.html"), "dash.html", dir, "", undefined]) { + const out = executePreviewOpen({ path: p }); + expect(out, String(p)).toMatch(/^Not opened:/); + expect(sentinelOf(out)).toBeUndefined(); + } + }); + + it("warns (but still opens) when a page is over the Preview's size limit", () => { + const big = join(dir, "big.html"); + writeFileSync(big, "x".repeat(PREVIEW_HTML_LIMIT + 1)); + const out = executePreviewOpen({ path: big }); + expect(out).toMatch(/over the Preview's 16\.0 MB limit for a HTML page/); + expect(sentinelOf(out)?.path).toBe(big); + }); + + it("an HTML page between the text and HTML limits gets no warning", () => { + const mid = join(dir, "mid.html"); + writeFileSync(mid, "x".repeat(PREVIEW_TEXT_LIMIT + 1)); + expect(executePreviewOpen({ path: mid })).not.toMatch(/Warning/); + const txt = join(dir, "mid.txt"); + writeFileSync(txt, "x".repeat(PREVIEW_TEXT_LIMIT + 1)); + expect(executePreviewOpen({ path: txt })).toMatch(/limit for a text file/); + }); + + it("images and PDFs are never size-warned", () => { + const sub = join(dir, "plots"); + mkdirSync(sub); + const png = join(sub, "p.png"); + writeFileSync(png, Buffer.alloc(PREVIEW_TEXT_LIMIT + 10)); + expect(executePreviewOpen({ path: png })).not.toMatch(/Warning/); + }); +}); + +describe("amicode_open_preview in both transports", () => { + it("the core table and the plugin twin execute the same thing", async () => { + const pack = (await PLUGIN.AmicodeTools({})) as { + tool: Record Promise }>; + }; + const viaPlugin = await pack.tool["amicode_open_preview"].execute({ path: page }); + const viaCore = await CORE.AMICODE_TOOLS["amicode_open_preview"].execute({ path: page }, {}); + expect(viaPlugin).toBe(viaCore); + expect(sentinelOf(viaCore)?.path).toBe(page); + }); + + it("tells the agent to use it instead of an external browser", () => { + expect(CORE.AMICODE_TOOLS["amicode_open_preview"].description).toMatch(/INSTEAD of/); + }); +}); diff --git a/packages/extension/test/workflow_skills_public.test.ts b/packages/extension/test/workflow_skills_public.test.ts index 52826dc10..513e5984c 100644 --- a/packages/extension/test/workflow_skills_public.test.ts +++ b/packages/extension/test/workflow_skills_public.test.ts @@ -284,6 +284,11 @@ describe("the research skill carries the live campaign dashboard contract (#2144 expect(research).toMatch(/file:\/\//); }); + it("opens the page in the Preview with the agent tool, never an external browser (#2130)", () => { + expect(research).toContain("amicode_open_preview"); + expect(research).toMatch(/Never launch an external browser/); + }); + it("the mode card's record sentence points at the dashboard page, not a widget", () => { expect(card).toContain("the campaign dashboard page rewrite"); expect(card).not.toContain("campaign dashboard widget refresh");