From c0912fcd41e9f701c3b2e4d2bf1c01f5d4b3337d Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Sun, 1 Nov 2026 16:20:27 -0500 Subject: [PATCH 01/11] Serve a share link's revisions to the canvas Agent-Session: 64182dea-534e-45f1-8e28-c1e27a5d6bab Agent-Session: 2b04f44e-91c6-4568-8c57-c4a01b2828ad Agent-Session: 61c94475-694a-4e46-8971-e0d703d3b344 Agent-Session: 196d7f0b-63a2-4186-84b0-5068df27bf9a Agent-Session: b47f56d8-24aa-4d4f-95b3-4217b7ec9e00 Agent-Session: f2f8f3d8-5a50-4c15-8ad8-9c9308d4a110 Agent-Session: 226d831c-5d71-4f4d-8cdb-7b3c4f30284d --- packages/review/src/sharing/host.ts | 107 +++++++++++------ packages/review/src/sharing/publish.test.ts | 121 ++++++++++++++++++++ 2 files changed, 191 insertions(+), 37 deletions(-) diff --git a/packages/review/src/sharing/host.ts b/packages/review/src/sharing/host.ts index 48eec35377..27364fafc2 100644 --- a/packages/review/src/sharing/host.ts +++ b/packages/review/src/sharing/host.ts @@ -401,6 +401,40 @@ export function remoteShare( : undefined; } +const appUrl = (href: string) => { + const url = new URL(href); + + if ( + process.env.DEV_FAST_REVIEW_APP_URL_PROTOCOL === "dev-fast-review-preview" + ) + url.searchParams.set("app", "preview"); + + return url.href; +}; + +const shareRequest = async ( + request: () => Promise, + message: string, +): Promise => { + try { + return await request(); + } catch (error) { + if (error instanceof ReviewInputError) throw error; + + if (error instanceof ShareAuthError) { + // A CI token lives in the environment; only the saved login can go stale. + if (process.env.DEV_REVIEW_SHARE_TOKEN === undefined) + await clearStoreAuth(); + throw new ReviewInputError( + "Your sign-in has expired. Sign in again to share.", + 401, + ); + } + + throw new ReviewInputError(message, 409); + } +}; + /** Publishing needs only an authored store, never Desktop or recipient workspaces. */ export function mountSharingPublisher( app: Hono, @@ -498,49 +532,48 @@ export function mountSharingPublisher( const { bundle, version } = await prepare(input); - try { - const result = await new ShareClient( - account.origin, - account.token, - options.fetch, - ).create(bundle, input.requestId ?? randomUUID()); - - const url = new URL(result.url); - - if ( - process.env.DEV_FAST_REVIEW_APP_URL_PROTOCOL === - "dev-fast-review-preview" - ) - url.searchParams.set("app", "preview"); - - options.onPublished?.({ - reviewId: input.reviewId, - version, - }); + const result = await shareRequest( + () => + new ShareClient(account.origin, account.token, options.fetch).create( + bundle, + input.requestId ?? randomUUID(), + ), + "Sharing failed. Check your connection and login, then retry.", + ); - return context.json({ - ...result, - url: url.href, - version, - }); - } catch (error) { - if (error instanceof ReviewInputError) throw error; + options.onPublished?.({ + reviewId: input.reviewId, + version, + }); - if (error instanceof ShareAuthError) { - // A CI token lives in the environment; only the saved login can go stale. - if (process.env.DEV_REVIEW_SHARE_TOKEN === undefined) - await clearStoreAuth(); - throw new ReviewInputError( - "Your sign-in has expired. Sign in again to share.", - 401, - ); - } + return context.json({ + ...result, + url: appUrl(result.url), + version, + }); + }); + app.get("/sharing/history/:shareId", async (context) => { + const shareId = shareIdSchema.parse(context.req.param("shareId")); + const account = await readSharingAuth(); + if (!account) throw new ReviewInputError( - "Sharing failed. Check your connection and login, then retry.", + "Set DEV_REVIEW_SHARE_TOKEN for CI, or run whiteboard login first.", 409, ); - } + + const history = await shareRequest( + () => + new ShareClient(account.origin, account.token, options.fetch).revisions( + shareId, + ), + "Could not load this link's history. Check your connection, then retry.", + ); + + return context.json({ + ...history, + items: history.items.map((item) => ({ ...item, url: appUrl(item.url) })), + }); }); app.post("/sharing/revoke", async (context) => { const { shareId } = z diff --git a/packages/review/src/sharing/publish.test.ts b/packages/review/src/sharing/publish.test.ts index 4b755b3bcb..3de80a947b 100644 --- a/packages/review/src/sharing/publish.test.ts +++ b/packages/review/src/sharing/publish.test.ts @@ -125,3 +125,124 @@ it("signs the user out when the share service rejects the stored token", async ( await rm(root, { recursive: true, force: true }); } }); + +const historyHost = async ( + root: string, + fetch: typeof globalThis.fetch, + token = "fixture", +) => { + const fixture = await createShareFixture(root); + await writeStoreAuth({ + origin: "https://app.dev.fast", + token, + login: "fixture", + savedAt: new Date().toISOString(), + }); + const api = new Hono(); + api.onError((error, context) => + context.json( + { error: error.message }, + error instanceof ReviewInputError ? error.status : 500, + ), + ); + mountSharingHost(api, fixture.store, fixture.data, { fetch }); + + return { api, fixture }; +}; + +it.each(["dev-fast-review", "dev-fast-review-preview"])( + "lists a link's revisions newest first (%s)", + async (protocol) => { + const root = await mkdtemp(path.join(tmpdir(), "sharing-history-")); + vi.stubEnv("DEV_REVIEW_HOME", root); + vi.stubEnv("DEV_FAST_REVIEW_APP_URL_PROTOCOL", protocol); + const preview = protocol === "dev-fast-review-preview"; + const [shareId, R1, R2] = [randomUUID(), randomUUID(), randomUUID()]; + const link = `https://app.dev.fast/s/${"x".repeat(43)}`; + const requests: string[] = []; + + const { api, fixture } = await historyHost(root, async (input, init) => { + const url = new URL(String(input)); + + if (url.pathname === "/api/auth/token") + return new Response(null, { status: 404 }); + requests.push(`${init?.method ?? "GET"} ${url.pathname}`); + + return Response.json({ + latest: R2, + items: [ + { + id: R2, + version: 5, + size: 2048, + createdAt: 2, + url: `${link}?rev=${R2}`, + }, + { + id: R1, + version: 4, + size: 1024, + createdAt: 1, + url: `${link}?rev=${R1}`, + }, + ], + }); + }); + + try { + expect( + await (await api.request(`/sharing/history/${shareId}`)).json(), + ).toEqual({ + latest: R2, + items: [ + { + id: R2, + version: 5, + size: 2048, + createdAt: 2, + url: `${link}?rev=${R2}${preview ? "&app=preview" : ""}`, + }, + { + id: R1, + version: 4, + size: 1024, + createdAt: 1, + url: `${link}?rev=${R1}${preview ? "&app=preview" : ""}`, + }, + ], + }); + expect(requests).toEqual([`GET /api/v2/shares/${shareId}/revisions`]); + } finally { + vi.unstubAllEnvs(); + await fixture.data.close(); + await fixture.store.close(); + await rm(root, { recursive: true, force: true }); + } + }, +); + +it("signs the user out when loading a link's history is unauthorized", async () => { + const root = await mkdtemp(path.join(tmpdir(), "sharing-history-")); + vi.stubEnv("DEV_REVIEW_HOME", root); + + const { api, fixture } = await historyHost( + root, + async () => new Response("unauthorized", { status: 401 }), + "expired", + ); + + try { + const response = await api.request(`/sharing/history/${randomUUID()}`); + + expect(await response.json()).toEqual({ + error: "Your sign-in has expired. Sign in again to share.", + }); + expect(response.status).toBe(401); + expect(await readStoreAuth()).toBeNull(); + } finally { + vi.unstubAllEnvs(); + await fixture.data.close(); + await fixture.store.close(); + await rm(root, { recursive: true, force: true }); + } +}); From f49c9832a087a0426b07c8d0a034b77c52463f9a Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Sun, 1 Nov 2026 16:21:08 -0500 Subject: [PATCH 02/11] Open a share revision in the browser through the host Agent-Session: 64182dea-534e-45f1-8e28-c1e27a5d6bab Agent-Session: 2b04f44e-91c6-4568-8c57-c4a01b2828ad Agent-Session: 61c94475-694a-4e46-8971-e0d703d3b344 Agent-Session: 196d7f0b-63a2-4186-84b0-5068df27bf9a Agent-Session: b47f56d8-24aa-4d4f-95b3-4217b7ec9e00 Agent-Session: f2f8f3d8-5a50-4c15-8ad8-9c9308d4a110 Agent-Session: 226d831c-5d71-4f4d-8cdb-7b3c4f30284d --- packages/review/src/sharing/host.test.ts | 41 ++++++++++++++++++++++++ packages/review/src/sharing/host.ts | 24 +++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/review/src/sharing/host.test.ts b/packages/review/src/sharing/host.test.ts index a4a20ad3c5..d962a1936d 100644 --- a/packages/review/src/sharing/host.test.ts +++ b/packages/review/src/sharing/host.test.ts @@ -9,6 +9,7 @@ import { type runStoreLogin, writeStoreAuth, } from "@dev.fast/trace-core"; +import { ReviewInputError } from "@dev.fast/whiteboard-document"; import type { SharedProvenance } from "@review/review-api/store.js"; import { Hono } from "hono"; import { afterEach, expect, it, vi } from "vitest"; @@ -481,3 +482,43 @@ it("reports the saved sign-in and forgets it on sign-out", async () => { await rm(root, { recursive: true, force: true }); } }); + +it("opens a share revision in the browser and refuses any other URL", async () => { + const root = await mkdtemp(path.join(tmpdir(), "share-open-")); + vi.stubEnv("DEV_REVIEW_HOME", root); + const fixture = await createShareFixture(root); + const api = new Hono(); + const openUrl = vi.fn(async () => {}); + api.onError((error, context) => + context.json( + { error: error.message }, + error instanceof ReviewInputError ? error.status : 500, + ), + ); + mountSharingHost(api, fixture.store, fixture.data, { openUrl }); + + const post = (path: string, body: { url: string }) => + api.request(path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + + try { + expect( + (await post("/sharing/open", { url: `${url}?rev=${R}` })).status, + ).toBe(200); + expect(openUrl).toHaveBeenCalledWith(`${url}?rev=${R}`); + + for (const url of [ + "https://evil.test/s/" + capability, + "file:///etc/passwd", + ]) + expect((await post("/sharing/open", { url })).status).toBe(400); + expect(openUrl).toHaveBeenCalledTimes(1); + } finally { + await fixture.data.close(); + fixture.store.close(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/review/src/sharing/host.ts b/packages/review/src/sharing/host.ts index 27364fafc2..29dc26ad5d 100644 --- a/packages/review/src/sharing/host.ts +++ b/packages/review/src/sharing/host.ts @@ -12,7 +12,7 @@ import { import { DEFAULT_STORE_ORIGIN, clearStoreAuth, - type openUrlInBrowser, + openUrlInBrowser, readStoreAuth, runStoreLogin, } from "@dev.fast/trace-core"; @@ -181,6 +181,28 @@ export function mountSharingHost( return context.json(login); }); + app.post("/sharing/open", async (context) => { + const { url } = z + .strictObject({ url: z.string().max(2048) }) + .parse(await readBoundedRequestJson(context.req.raw)); + + const origin = (() => { + try { + return parseShareLink(url).origin; + } catch { + return undefined; + } + })(); + + if ( + origin !== DEFAULT_STORE_ORIGIN && + (!origin || origin !== (await readStoreAuth())?.origin) + ) + throw new ReviewInputError("Only Whiteboard share links open here.", 400); + await (options.openUrl ?? openUrlInBrowser)(url); + + return context.json({ opened: true }); + }); mountSharingPublisher(app, store, data, options); const imports = new Map(); From ae15f2f6b81ae39a1532e0df50d0b9936011a9e3 Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Sun, 1 Nov 2026 16:21:26 -0500 Subject: [PATCH 03/11] Let the canvas ask the host to confirm Agent-Session: 64182dea-534e-45f1-8e28-c1e27a5d6bab Agent-Session: 2b04f44e-91c6-4568-8c57-c4a01b2828ad Agent-Session: 61c94475-694a-4e46-8971-e0d703d3b344 Agent-Session: 196d7f0b-63a2-4186-84b0-5068df27bf9a Agent-Session: b47f56d8-24aa-4d4f-95b3-4217b7ec9e00 Agent-Session: f2f8f3d8-5a50-4c15-8ad8-9c9308d4a110 Agent-Session: 226d831c-5d71-4f4d-8cdb-7b3c4f30284d --- .../src/vs/review/browser/parts/canvas/reviewCanvasPart.ts | 2 ++ packages/review-protocol/src/contracts.ts | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts b/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts index 3f014a568b..e9b793e459 100644 --- a/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts +++ b/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts @@ -1450,6 +1450,7 @@ export class ReviewCanvasEditorPane extends EditorPane { | "onDidChangeDiffLayout" | "setupTooltip" | "notify" + | "confirm" | "ready" | "reportDiagnostic" | "reportSidebar" @@ -1472,6 +1473,7 @@ export class ReviewCanvasEditorPane extends EditorPane { setDiffLayout: (scope, layout) => this.diffViews.layout(scope).set(layout), onDidChangeDiffLayout: (scope, listener) => this.diffViews.layout(scope).onDidChange(listener), notify: (notification) => showReviewNotification(this.notificationService, notification), + confirm: async (request) => (await this.dialogService.confirm({ type: 'warning', ...request })).confirmed, setupTooltip: this.setupTooltip, ready: () => { if (generation !== this.loadGeneration || !this.targetDocument) return; diff --git a/packages/review-protocol/src/contracts.ts b/packages/review-protocol/src/contracts.ts index 2c75e63a5b..00488b5b97 100644 --- a/packages/review-protocol/src/contracts.ts +++ b/packages/review-protocol/src/contracts.ts @@ -404,6 +404,11 @@ export interface ReviewCanvasBridge { listener: (show: boolean) => void, ): ReviewDisposable; notify?(notification: ReviewNotification): ReviewDisposable; + confirm?(request: { + message: string; + detail?: string; + primaryButton: string; + }): Promise; setupTooltip?( target: HTMLElement, text: string, From 3ee209782a162b467cbaa2f4d7f5f4e48de09784 Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Sun, 1 Nov 2026 16:22:45 -0500 Subject: [PATCH 04/11] Format byte sizes Agent-Session: 64182dea-534e-45f1-8e28-c1e27a5d6bab Agent-Session: 2b04f44e-91c6-4568-8c57-c4a01b2828ad Agent-Session: 61c94475-694a-4e46-8971-e0d703d3b344 Agent-Session: 196d7f0b-63a2-4186-84b0-5068df27bf9a Agent-Session: b47f56d8-24aa-4d4f-95b3-4217b7ec9e00 Agent-Session: f2f8f3d8-5a50-4c15-8ad8-9c9308d4a110 Agent-Session: 226d831c-5d71-4f4d-8cdb-7b3c4f30284d --- packages/review/app/src/format.test.ts | 8 +++++++- packages/review/app/src/format.ts | 10 ++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/review/app/src/format.test.ts b/packages/review/app/src/format.test.ts index ed42d88280..0e2fd52237 100644 --- a/packages/review/app/src/format.test.ts +++ b/packages/review/app/src/format.test.ts @@ -1,6 +1,6 @@ import { expect, it } from "vitest"; -import { formatRelativeTime } from "./format"; +import { formatBytes, formatRelativeTime } from "./format"; const now = Date.parse("2026-10-15T12:00:00Z"); @@ -27,3 +27,9 @@ it("writes hours and days in short for a narrow row", () => { "2d ago", ); }); + +it("writes a size in B, KB or MB", () => { + expect(formatBytes(512)).toBe("512 B"); + expect(formatBytes(2048)).toBe("2 KB"); + expect(formatBytes(1_572_864)).toBe("1.5 MB"); +}); diff --git a/packages/review/app/src/format.ts b/packages/review/app/src/format.ts index 4046d1432c..a63e4636cd 100644 --- a/packages/review/app/src/format.ts +++ b/packages/review/app/src/format.ts @@ -65,3 +65,13 @@ export function formatDuration(durationMs: number): string { return rest ? `${hours}h ${rest}m` : `${hours}h`; } + +/** A byte count to one decimal: "512 B", "2 KB", "1.5 MB". */ +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const kilobytes = bytes / 1024; + + if (kilobytes < 1024) return `${Number(kilobytes.toFixed(1))} KB`; + + return `${Number((kilobytes / 1024).toFixed(1))} MB`; +} From 0bcb36ff3bc02156a06f5a5c88384cfda87fcca4 Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Sun, 1 Nov 2026 16:28:41 -0500 Subject: [PATCH 05/11] List a share link's revisions in the share popover Agent-Session: 2b04f44e-91c6-4568-8c57-c4a01b2828ad Agent-Session: 61c94475-694a-4e46-8971-e0d703d3b344 Agent-Session: 196d7f0b-63a2-4186-84b0-5068df27bf9a Agent-Session: b47f56d8-24aa-4d4f-95b3-4217b7ec9e00 Agent-Session: f2f8f3d8-5a50-4c15-8ad8-9c9308d4a110 Agent-Session: 226d831c-5d71-4f4d-8cdb-7b3c4f30284d --- packages/review/app/src/canvas-query.tsx | 1 + .../review/app/src/share-control.test.tsx | 54 ++++++++++++++- packages/review/app/src/share-control.tsx | 69 +++++++++++++++++-- 3 files changed, 119 insertions(+), 5 deletions(-) diff --git a/packages/review/app/src/canvas-query.tsx b/packages/review/app/src/canvas-query.tsx index 88b6aeda1d..172964a353 100644 --- a/packages/review/app/src/canvas-query.tsx +++ b/packages/review/app/src/canvas-query.tsx @@ -35,6 +35,7 @@ export function createCanvasQueryClient(): QueryClient { export const canvasQueryKeys = { sharingAccount: () => ["sharing", "account"] as const, shareLink: (board: string) => ["sharing", "link", board] as const, + shareHistory: (shareId: string) => ["sharing", "history", shareId] as const, traceList: ( board: string, version: number | undefined, diff --git a/packages/review/app/src/share-control.test.tsx b/packages/review/app/src/share-control.test.tsx index b285cc6c3a..120ade31c6 100644 --- a/packages/review/app/src/share-control.test.tsx +++ b/packages/review/app/src/share-control.test.tsx @@ -11,6 +11,16 @@ import { ShareControl, SharingContext } from "./share-control"; let dispose: (() => void) | undefined; +const shareId = "6f1c2b0e-8e0a-4b8e-9a51-0d6f1e2c3b4a"; + +const revision5 = "0b8d6c3e-3a9f-4c1e-8f2a-5d7e9b1c4a60"; + +const revision4 = "1c9e7d4f-4b0a-4d2f-9a3b-6e8f0c2d5b71"; + +const u5 = `https://app.dev.fast/s/capability?rev=${revision5}`; + +const u4 = `https://app.dev.fast/s/capability?rev=${revision4}`; + afterEach(async () => { await act(async () => dispose?.()); document.body.replaceChildren(); @@ -75,6 +85,29 @@ function mount(options: { }); } + if (path.includes("/history/")) + return Response.json({ + latest: revision5, + items: [ + { + id: revision5, + version: 5, + size: 2048, + createdAt: Date.now(), + url: u5, + }, + { + id: revision4, + version: 4, + size: 1024, + createdAt: Date.now() - 2 * 3_600_000, + url: u4, + }, + ], + }); + + if (path.endsWith("/open")) return Response.json({ opened: true }); + if (state.holdPublish) await state.holdPublish; if ( @@ -101,7 +134,7 @@ function mount(options: { ); return Response.json({ - shareId: "6f1c2b0e-8e0a-4b8e-9a51-0d6f1e2c3b4a", + shareId, url: "https://app.dev.fast/s/capability", version: JSON.parse(String(init!.body)).version, }); @@ -410,6 +443,25 @@ it("shows a newer version's link when the popover opens on an older version", as expect(container.querySelector("input")?.value).toContain("/s/capability"); }); +it("lists the link's revisions newest first, marks the latest and opens one in the browser", async () => { + const harness = mount({ signedIn: true }); + const { container } = harness; + + await harness.render(5); + await harness.click("Share review"); + await harness.settle(); + expect( + [...container.querySelectorAll("[role=dialog] [data-share-revision]")].map( + (row) => row.textContent, + ), + ).toEqual(["just now · v5 · 2 KB · Latest", "2 hours ago · v4 · 1 KB"]); + await harness.click("2 hours ago · v4 · 1 KB"); + expect(harness.posts.at(-1)).toEqual({ + path: "/reviews-api/sharing/open", + body: { url: u4 }, + }); +}); + it("polls only while sign-in is pending and stops when the control unmounts", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval"], diff --git a/packages/review/app/src/share-control.tsx b/packages/review/app/src/share-control.tsx index dbf6b150e8..d0fbf626fa 100644 --- a/packages/review/app/src/share-control.tsx +++ b/packages/review/app/src/share-control.tsx @@ -1,12 +1,15 @@ import { layer, space } from "@canvas/scale.stylex"; -import { Button, IconButton } from "@canvas/ui/button"; +import { Button, IconButton, buttonStyles } from "@canvas/ui/button"; import { useCopy } from "@canvas/ui/copy"; +import { focusStyles } from "@canvas/ui/focus"; import { iconStyles } from "@canvas/ui/icon-styles"; import { ShareIcon } from "@canvas/ui/icons"; +import { listRowStyles } from "@canvas/ui/list-row"; import { Popover } from "@canvas/ui/popover"; import { textStyles } from "@canvas/ui/text"; import { TextField } from "@canvas/ui/text-field"; import { useTooltip } from "@canvas/ui/tooltip"; +import type { ShareRevisions } from "@dev.fast/review-share-protocol"; import { type ReviewApiClient, ReviewApiError, @@ -29,6 +32,7 @@ import { } from "react"; import { canvasQueryKeys } from "./canvas-query"; +import { formatBytes, formatRelativeTime } from "./format"; import { useOptionalReviewSession } from "./host/review-session"; import { topbarActionsMarker } from "./markers.stylex"; import { useSharingAccount, useSignIn } from "./sharing-account"; @@ -119,10 +123,14 @@ export function ShareControl({ requestId, }), // An older version's upload can land after a newer one's. - onSuccess: (shared) => + onSuccess: (shared) => { queryClient.setQueryData(linkKey, (cached) => cached && cached.version > shared.version ? cached : shared, - ), + ); + void queryClient.invalidateQueries({ + queryKey: canvasQueryKeys.shareHistory(shared.shareId), + }); + }, onError: (error) => { // The host forgot a stale login; show sign-in and upload again after it. if (error instanceof ReviewApiError && error.status === 401) @@ -130,6 +138,11 @@ export function ShareControl({ }, }); + const openRevision = useMutation({ + mutationFn: (url: string) => + context!.client.post<{ opened: true }>("/sharing/open", { url }), + }); + const upload = (next: ShareTarget) => { login.reset(); publish.mutate(next); @@ -146,6 +159,7 @@ export function ShareControl({ }); publish.reset(); login.reset(); + openRevision.reset(); setCopyError(undefined); } @@ -164,6 +178,20 @@ export function ShareControl({ const current = link && target && link.version >= target.version ? link : undefined; + const historyQuery = useQuery({ + queryKey: canvasQueryKeys.shareHistory(current?.shareId ?? ""), + queryFn: + open && current + ? ({ signal }) => + context!.client.read( + `/sharing/history/${current.shareId}`, + signal, + ) + : skipToken, + }); + + const history = historyQuery.data; + // Upload once per opening; a failure waits for Retry or a new sign-in. useEffect(() => { if (open && signedIn && target && publish.isIdle && !current) @@ -172,7 +200,11 @@ export function ShareControl({ if (!context) return null; - const error = copyError ?? actionError(publish.error ?? login.error); + const error = + copyError ?? + actionError( + publish.error ?? login.error ?? openRevision.error ?? historyQuery.error, + ); const accountError = accountQuery.isError ? "Could not read sign-in status." @@ -233,6 +265,30 @@ export function ShareControl({ + {history && history.items.length > 0 && ( +
    + {history.items.map((revision) => ( +
  • + +
  • + ))} +
+ )} ) : error ? ( + )} ) : error ? ( + ) : current ? ( <> )} + {historyQuery.isError && ( +

+ Could not load share history. +

+ )} {confirm && (