fix: clear stale preview URL from SQLite and prevent stale white screen on preview clear (#2358)

* fix: remove auto-switch-to-summary effect, keep browser tab on clear

* fix: clear browser preview when session is terminated
This commit is contained in:
Dushyant Singh Hada 2026-07-03 15:38:43 +05:30 committed by GitHub
parent fadbdd26cd
commit 7e409d438b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 143 additions and 5 deletions

View File

@ -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

View File

@ -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, "<main>v1</main>")
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, "<main>v2</main>")
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"), "<main>hello</main>")

View File

@ -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,

View File

@ -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<BrowserNavState> => {
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);
};

View File

@ -60,6 +60,7 @@ export function SessionView({ sessionId }: SessionViewProps) {
sessionId,
active: Boolean(session && hasInspector && (browserPoppedOut || isInspectorOpen)),
poppedOut: browserPoppedOut,
terminated: session?.status === "terminated",
previewUrl,
previewRevision,
});

View File

@ -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);
});
});

View File

@ -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<number | null>(null);
const observerRef = useRef<ResizeObserver | null>(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;