diff --git a/backend/internal/preview/poller.go b/backend/internal/preview/poller.go
index 6825840e3..b06c82a09 100644
--- a/backend/internal/preview/poller.go
+++ b/backend/internal/preview/poller.go
@@ -44,6 +44,10 @@ type entryState struct {
path string
modUnix int64
size int64
+ // cleared is set when the poller itself cleared the preview URL because the
+ // workspace entry was missing. When the file reappears, shouldRefresh uses
+ // this to re-discover even though the revision was bumped by the clear.
+ cleared bool
}
// NewPoller constructs a preview poller over the supplied session source and setter.
@@ -112,6 +116,13 @@ func (p *Poller) Poll(ctx context.Context) error {
}
entry, ok := DiscoverEntry(sess.Metadata.WorkspacePath)
if !ok {
+ if isWorkspacePreviewURL(sess.Metadata.PreviewURL, sess.ID) {
+ if _, err := p.setter.SetPreview(ctx, sess.ID, ""); err != nil {
+ p.logger.Error("preview poller: failed to clear stale preview",
+ "session", sess.ID, "err", err)
+ }
+ p.seen[sess.ID] = entryState{cleared: true}
+ }
continue
}
state := stateFor(entry)
@@ -140,7 +151,11 @@ func (p *Poller) Poll(ctx context.Context) error {
func (p *Poller) shouldRefresh(sess domain.SessionRecord, target string, seenBefore bool) bool {
current := strings.TrimSpace(sess.Metadata.PreviewURL)
if current == "" {
- return !seenBefore && sess.Metadata.PreviewRevision == 0
+ if !seenBefore {
+ return sess.Metadata.PreviewRevision == 0
+ }
+ previous := p.seen[sess.ID]
+ return previous.cleared
}
if current == target || isWorkspacePreviewURL(current, sess.ID) {
return true
diff --git a/backend/internal/preview/poller_test.go b/backend/internal/preview/poller_test.go
index c9427eaf5..3090383b6 100644
--- a/backend/internal/preview/poller_test.go
+++ b/backend/internal/preview/poller_test.go
@@ -118,6 +118,47 @@ func TestPollerRefreshesOnlyWhenEntrypointChanges(t *testing.T) {
}
}
+func TestPollerRediscoverEntryAfterDeleteAndRecreate(t *testing.T) {
+ workspace := t.TempDir()
+ entry := filepath.Join(workspace, "index.html")
+ writeFile(t, entry, "v1")
+ svc := &fakePreviewSessions{sessions: []domain.SessionRecord{workerSession("ao-1", workspace, "")}}
+ poller := NewPoller(svc, svc, "http://127.0.0.1:3001", PollerConfig{Logger: discardLogger()})
+
+ // First poll discovers the entry and sets the preview.
+ if err := poller.Poll(context.Background()); err != nil {
+ t.Fatalf("first Poll: %v", err)
+ }
+ wantURL := "http://127.0.0.1:3001/api/v1/sessions/ao-1/preview/files/index.html"
+ assertSets(t, svc.sets, previewSet{id: "ao-1", url: wantURL})
+
+ // Delete the entry — poller must clear the preview and mark the session cleared.
+ if err := os.Remove(entry); err != nil {
+ t.Fatalf("remove index.html: %v", err)
+ }
+ if err := poller.Poll(context.Background()); err != nil {
+ t.Fatalf("second Poll (delete): %v", err)
+ }
+ if len(svc.sets) != 2 {
+ t.Fatalf("sets after delete = %#v, want clear + set", svc.sets)
+ }
+ if svc.sets[1].url != "" {
+ t.Fatalf("second set.url = %q, want empty (clear)", svc.sets[1].url)
+ }
+
+ // Recreate the entry — poller must re-discover.
+ writeFile(t, entry, "v2")
+ if err := poller.Poll(context.Background()); err != nil {
+ t.Fatalf("third Poll (recreate): %v", err)
+ }
+ if len(svc.sets) != 3 {
+ t.Fatalf("sets after recreate = %#v, want 3 sets (discover + clear + rediscover)", svc.sets)
+ }
+ if svc.sets[2].url != wantURL {
+ t.Fatalf("third set.url = %q, want %q", svc.sets[2].url, wantURL)
+ }
+}
+
func TestPollerDoesNotRestoreClearedPreviewAfterRestart(t *testing.T) {
workspace := t.TempDir()
writeFile(t, filepath.Join(workspace, "index.html"), "hello")
diff --git a/frontend/src/main/browser-view-host.test.ts b/frontend/src/main/browser-view-host.test.ts
index 8fd9d8cfc..a242c23ab 100644
--- a/frontend/src/main/browser-view-host.test.ts
+++ b/frontend/src/main/browser-view-host.test.ts
@@ -14,6 +14,7 @@ function setupHost() {
const webContents = {
canGoBack: () => false,
canGoForward: () => false,
+ clearHistory: () => undefined,
getTitle: () => "",
getURL: () => currentURL,
goBack: () => undefined,
@@ -120,6 +121,7 @@ describe("dispose after the window is destroyed", () => {
webContents: {
canGoBack: () => false,
canGoForward: () => false,
+ clearHistory: () => undefined,
getTitle: () => "",
getURL: () => "",
goBack: () => undefined,
diff --git a/frontend/src/main/browser-view-host.ts b/frontend/src/main/browser-view-host.ts
index d4397d0b8..193ddf47b 100644
--- a/frontend/src/main/browser-view-host.ts
+++ b/frontend/src/main/browser-view-host.ts
@@ -27,6 +27,7 @@ type BrowserWebContents = Pick<
WebContents,
| "canGoBack"
| "canGoForward"
+ | "clearHistory"
| "getTitle"
| "getURL"
| "goBack"
@@ -190,7 +191,10 @@ export function createBrowserViewHost(options: BrowserViewHostOptions): BrowserV
// empty state.
const clear = async (viewId: string): Promise => {
const entry = ensure(viewId);
+ entry.view.setVisible?.(false);
+ entry.view.setBounds(OFFSCREEN_BOUNDS);
await entry.view.webContents.loadURL("about:blank");
+ entry.view.webContents.clearHistory();
return pushNavState(options, entry);
};
diff --git a/frontend/src/renderer/components/SessionView.tsx b/frontend/src/renderer/components/SessionView.tsx
index 7dbddfbea..69b320299 100644
--- a/frontend/src/renderer/components/SessionView.tsx
+++ b/frontend/src/renderer/components/SessionView.tsx
@@ -60,6 +60,7 @@ export function SessionView({ sessionId }: SessionViewProps) {
sessionId,
active: Boolean(session && hasInspector && (browserPoppedOut || isInspectorOpen)),
poppedOut: browserPoppedOut,
+ terminated: session?.status === "terminated",
previewUrl,
previewRevision,
});
diff --git a/frontend/src/renderer/hooks/useBrowserView.test.tsx b/frontend/src/renderer/hooks/useBrowserView.test.tsx
index 206b26f95..b83568eee 100644
--- a/frontend/src/renderer/hooks/useBrowserView.test.tsx
+++ b/frontend/src/renderer/hooks/useBrowserView.test.tsx
@@ -75,6 +75,18 @@ describe("useBrowserView", () => {
const { result } = renderHook(() => useBrowserView({ sessionId: "sess-1", active: true, poppedOut: false }));
await waitFor(() => expect(bridge.ensure).toHaveBeenCalledWith("sess-1"));
+ // Simulate the real IPC flow: after ensure, a navigate call sends a nav
+ // state with a URL so the positioning effect considers the view visible.
+ act(() =>
+ bridge.emit({
+ viewId: "42:sess-1",
+ url: "http://localhost:3000/",
+ title: "",
+ canGoBack: false,
+ canGoForward: false,
+ isLoading: false,
+ }),
+ );
act(() => result.current.slotRef(slot));
await waitFor(() =>
@@ -111,6 +123,16 @@ describe("useBrowserView", () => {
const { result } = renderHook(() => useBrowserView({ sessionId: "sess-1", active: true, poppedOut: false }));
await waitFor(() => expect(bridge.ensure).toHaveBeenCalledWith("sess-1"));
+ act(() =>
+ bridge.emit({
+ viewId: "42:sess-1",
+ url: "http://localhost:3000/",
+ title: "",
+ canGoBack: false,
+ canGoForward: false,
+ isLoading: false,
+ }),
+ );
act(() => result.current.slotRef(slot));
await waitFor(() =>
@@ -140,6 +162,17 @@ describe("useBrowserView", () => {
await act(async () => {
await Promise.resolve();
});
+ // Simulate a real nav state with URL so the positioning effect shows the view.
+ act(() =>
+ bridge.emit({
+ viewId: "42:sess-1",
+ url: "http://localhost:3000/",
+ title: "",
+ canGoBack: false,
+ canGoForward: false,
+ isLoading: false,
+ }),
+ );
act(() => result.current.slotRef(slot));
// Flush the mount measure (immediate frame + settle timer).
await act(async () => {
@@ -316,4 +349,27 @@ describe("useBrowserView", () => {
expect(bridge.navigate).not.toHaveBeenCalled();
expect(bridge.clear).not.toHaveBeenCalled();
});
+
+ it("clears the view when the session is terminated, even with an active preview URL", async () => {
+ const bridge = setupBridge();
+ const { rerender } = renderHook(
+ ({ terminated }) =>
+ useBrowserView({
+ sessionId: "sess-1",
+ active: true,
+ poppedOut: false,
+ terminated,
+ previewUrl: "http://localhost:5173/",
+ previewRevision: 1,
+ }),
+ { initialProps: { terminated: false } },
+ );
+ // The preview drives a navigate on mount.
+ await waitFor(() => expect(bridge.navigate).toHaveBeenCalledTimes(1));
+
+ // Terminate the session – the view must be cleared and no re-navigate.
+ rerender({ terminated: true });
+ await waitFor(() => expect(bridge.clear).toHaveBeenCalledWith("42:sess-1"));
+ expect(bridge.navigate).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/frontend/src/renderer/hooks/useBrowserView.ts b/frontend/src/renderer/hooks/useBrowserView.ts
index 066ce7505..e5ba98ef4 100644
--- a/frontend/src/renderer/hooks/useBrowserView.ts
+++ b/frontend/src/renderer/hooks/useBrowserView.ts
@@ -7,6 +7,12 @@ type UseBrowserViewOptions = {
sessionId: string;
active: boolean;
poppedOut: boolean;
+ /**
+ * When true, the view is cleared and the daemon-driven preview is suppressed.
+ * Use when the session is terminated: the old preview content should not
+ * remain visible even if the DB still carries a preview_url.
+ */
+ terminated?: boolean;
/**
* Preview target driven by the daemon (via `ao preview`, streamed over CDC).
* When set, the view navigates here automatically; an empty value clears it.
@@ -68,6 +74,7 @@ export function useBrowserView({
sessionId,
active,
poppedOut,
+ terminated,
previewUrl,
previewRevision,
}: UseBrowserViewOptions): BrowserViewModel {
@@ -80,11 +87,16 @@ export function useBrowserView({
const settleTimerRef = useRef(null);
const observerRef = useRef(null);
const previewTriggerRef = useRef<{ revision: number | null; target: string } | null>(null);
+ const hasUrlRef = useRef(false);
useEffect(() => {
activeRef.current = active;
}, [active]);
+ useEffect(() => {
+ hasUrlRef.current = Boolean(navState.url);
+ }, [navState.url]);
+
const sendHiddenBounds = useCallback((id = viewIdRef.current) => {
if (!id) return;
window.ao?.browser.setBounds({ viewId: id, rect: HIDDEN_RECT, visible: false });
@@ -95,7 +107,7 @@ export function useBrowserView({
const id = viewIdRef.current;
const node = slotNodeRef.current;
if (!id) return;
- if (!activeRef.current || !node || !node.isConnected) {
+ if (!activeRef.current || !node || !node.isConnected || !hasUrlRef.current) {
sendHiddenBounds(id);
return;
}
@@ -189,12 +201,12 @@ export function useBrowserView({
}, []);
useEffect(() => {
- if (active) {
+ if (navState.url && active) {
scheduleSettleMeasure();
} else {
sendHiddenBounds();
}
- }, [active, poppedOut, scheduleSettleMeasure, sendHiddenBounds]);
+ }, [active, navState.url, poppedOut, scheduleSettleMeasure, sendHiddenBounds]);
useEffect(() => {
const handle = () => scheduleMeasure();
@@ -223,11 +235,18 @@ export function useBrowserView({
const clear = useCallback(() => withView((id) => window.ao!.browser.clear(id)), [withView]);
+ // When the session is terminated, clear the view and stop reacting to
+ // daemon-driven preview changes so stale content does not remain visible.
+ useEffect(() => {
+ if (!terminated) return;
+ void clear();
+ }, [clear, terminated]);
+
// Drive the view from the daemon-set preview target. Current daemons key
// this on previewRevision (bumped on every `ao preview` call); older daemons
// did not send it, so fall back to URL changes for compatibility.
useEffect(() => {
- if (!viewId) return;
+ if (!viewId || terminated) return;
const target = previewUrl?.trim() ?? "";
const revision = typeof previewRevision === "number" ? previewRevision : null;
const previous = previewTriggerRef.current;