Skip to content
Draft
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
20 changes: 13 additions & 7 deletions packages/app-bundle/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
},
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<string, unknown> = {}) => ({
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)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand All @@ -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.
Expand Down
Loading
Loading