From 3068e80b6662c3873c269197ef44be374b6962cf Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sat, 18 Jul 2026 10:05:40 -0700 Subject: [PATCH] feat(mobile): improve PWA performance and reliability --- package.json | 1 + public/manifest.webmanifest | 6 +- public/service-worker.js | 117 +++++++++++-- scripts/finalize-mobile-build.mjs | 13 +- src/main/remote/RemoteAccessServer.test.ts | 79 ++++++++- src/main/remote/pairingPage.ts | 76 ++++++++- src/main/remote/server/httpRouter.ts | 28 ++- src/main/remote/server/snapshots.ts | 27 ++- src/mobile/bridge.ts | 9 +- src/mobile/registerServiceWorker.ts | 66 ++++++- src/mobile/remoteProtocol.perf.test.ts | 103 +++++++++++ src/mobile/remoteSocketCoordinator.test.ts | 32 +++- src/mobile/remoteSocketCoordinator.ts | 13 +- src/mobile/routeComponents.test.tsx | 10 +- src/mobile/routeComponents.tsx | 161 ++++++++++-------- src/mobile/router.tsx | 20 ++- src/mobile/routing.test.ts | 8 +- src/mobile/routing.ts | 5 + .../storeSync.applyThreadSnapshot.test.tsx | 40 +++++ src/mobile/useRemoteDesktop.test.tsx | 26 +++ src/mobile/useRemoteDesktop.ts | 36 +++- src/renderer/locales/de/messages.po | 2 + src/renderer/locales/en/messages.po | 2 + src/renderer/locales/es/messages.po | 2 + src/renderer/locales/fr/messages.po | 2 + src/renderer/locales/ja/messages.po | 2 + src/renderer/locales/ko/messages.po | 2 + src/renderer/locales/pl/messages.po | 2 + src/renderer/locales/pt-BR/messages.po | 2 + src/renderer/locales/ru/messages.po | 2 + src/renderer/locales/tr/messages.po | 2 + src/renderer/locales/uk/messages.po | 2 + src/renderer/locales/vi/messages.po | 2 + src/renderer/locales/zh-CN/messages.po | 2 + .../state/chatRuntimePersister.test.ts | 21 +++ src/renderer/state/chatRuntimePersister.ts | 5 + src/renderer/state/remote/sync.ts | 11 +- src/renderer/styles.css | 4 +- src/server/relay/relayHost.test.ts | 33 ++++ src/server/relay/relayHost.ts | 21 ++- src/shared/remote/client.test.ts | 49 ++++++ src/shared/remote/client.ts | 31 +++- src/shared/remote/protocol.ts | 16 ++ vercel.json | 2 +- vite.config.ts | 2 +- website/next.config.js | 4 + 46 files changed, 971 insertions(+), 130 deletions(-) create mode 100644 src/mobile/remoteProtocol.perf.test.ts diff --git a/package.json b/package.json index d8d9ef8ed..62590d28d 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "fmt:check": "oxfmt --check .", "test": "vitest run --configLoader runner", "test:perf:cli-hook": "vitest run --configLoader runner src/supervisor/runtime/cliHookEventChain.perf.test.ts", + "test:perf:remote": "vitest run --configLoader runner src/mobile/remoteProtocol.perf.test.ts", "test:integration:providers": "vitest run --configLoader runner --config vitest.integration.config.ts", "smoke:integration": "node .agents/skills/interactive-testing/scripts/run-poracode-smoke.mjs", "update-server": "node scripts/update-server.mjs", diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index 96faa809a..6ab790bdb 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -1,10 +1,10 @@ { - "id": "./", + "id": "/pwa/", "name": "Poracode", "short_name": "Poracode", "description": "Follow and steer your Poracode AI agents from your phone — threads, git review, and live browser, paired to your desktop.", - "start_url": "./app", - "scope": "./", + "start_url": "/app/threads", + "scope": "/app/", "display": "standalone", "display_override": ["standalone", "minimal-ui"], "background_color": "#070709", diff --git a/public/service-worker.js b/public/service-worker.js index 5ae227a36..50cecf84c 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -2,19 +2,49 @@ // build ships an equivalent worker generated at runtime (see // src/main/remote/pairingPage.ts); keep the two in sync. // -// Strategy: network-first for same-origin GETs, caching successful responses -// and falling back to the cache (and to the app shell for navigations) when -// offline. Cross-origin requests — notably the paired desktop's /api, /oauth -// and /ws endpoints, which live on a different host — are never intercepted. -const CACHE_NAME = "poracode-pwa-v2"; +// Strategy: cache-first for immutable hashed build assets, network-first for +// other same-origin GETs, and an app-shell fallback for offline navigations. +// Cross-origin requests — notably the paired desktop's /api, /oauth and /ws +// endpoints, which live on a different host — are never intercepted. +const BUILD_VERSION = "__PORACODE_BUILD_VERSION__"; +const CACHE_NAME = `poracode-pwa-${BUILD_VERSION}`; +const NAVIGATION_FALLBACK_DELAY_MS = 500; const APP_BASE_URL = new URL("./", self.location.href); const shellUrl = (path) => new URL(path, APP_BASE_URL).pathname; const SHELL_URLS = ["./", "app", "manifest.webmanifest", "app-icon.svg"].map(shellUrl); +function shellAssetUrls(html) { + const urls = new Set(); + for (const match of html.matchAll(/["']([^"']*\/assets\/[^"']+)["']/g)) { + const url = new URL(match[1], APP_BASE_URL); + if (url.origin === self.location.origin) urls.add(`${url.pathname}${url.search}`); + } + return [...urls]; +} + +async function cacheShell() { + const cache = await caches.open(CACHE_NAME); + await Promise.allSettled(SHELL_URLS.map((url) => cache.add(url))); + const shell = await cache.match(shellUrl("app")); + if (!shell) return; + const assets = shellAssetUrls(await shell.text()); + await Promise.allSettled(assets.map((url) => cache.add(url))); +} + +function validBuildAssetUrls(value) { + if (!Array.isArray(value)) return []; + const assetPrefix = `${APP_BASE_URL.pathname}assets/`; + return value.slice(0, 256).flatMap((candidate) => { + if (typeof candidate !== "string") return []; + const url = new URL(candidate, APP_BASE_URL); + return url.origin === self.location.origin && url.pathname.startsWith(assetPrefix) + ? [`${url.pathname}${url.search}`] + : []; + }); +} + self.addEventListener("install", (event) => { - event.waitUntil( - caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_URLS).catch(() => undefined)), - ); + event.waitUntil(cacheShell()); self.skipWaiting(); }); @@ -23,12 +53,24 @@ self.addEventListener("activate", (event) => { caches .keys() .then((keys) => - Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))), + Promise.all( + keys + .filter((key) => key.startsWith("poracode-pwa-") && key !== CACHE_NAME) + .map((key) => caches.delete(key)), + ), ) .then(() => self.clients.claim()), ); }); +self.addEventListener("message", (event) => { + if (event.data?.type !== "cache-build-assets") return; + const urls = validBuildAssetUrls(event.data.urls); + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => Promise.allSettled(urls.map((url) => cache.add(url)))), + ); +}); + self.addEventListener("fetch", (event) => { const request = event.request; if (request.method !== "GET") return; @@ -36,13 +78,68 @@ self.addEventListener("fetch", (event) => { const url = new URL(request.url); // Only handle same-origin requests; the desktop API lives elsewhere. if (url.origin !== self.location.origin) return; + const isAppRequest = url.pathname === "/app" || url.pathname.startsWith("/app/"); + const buildRoute = APP_BASE_URL.pathname.replace(/\/$/, ""); + const isBuildRequest = + buildRoute === "" || url.pathname === buildRoute || url.pathname.startsWith(`${buildRoute}/`); + if (!isAppRequest && !isBuildRequest) return; + + if (url.pathname.startsWith(`${APP_BASE_URL.pathname}assets/`)) { + event.respondWith( + caches.match(request).then( + (cached) => + cached || + fetch(request).then((response) => { + if (response.ok) { + const clone = response.clone(); + void caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); + } + return response; + }), + ), + ); + return; + } + + if (request.mode === "navigate") { + const networkResponse = fetch(request).then(async (response) => { + if (response.ok) { + const cache = await caches.open(CACHE_NAME); + await cache.put(shellUrl("app"), response.clone()); + } + return response; + }); + const cachedResponse = new Promise((resolve) => { + setTimeout(() => { + void caches.match(shellUrl("app")).then(resolve); + }, NAVIGATION_FALLBACK_DELAY_MS); + }); + event.waitUntil( + networkResponse.then( + () => undefined, + () => undefined, + ), + ); + event.respondWith( + Promise.race([networkResponse, cachedResponse]) + .then((response) => response || networkResponse) + .catch( + async () => + (await caches.match(shellUrl("app"))) || + (await caches.match(shellUrl("./"))) || + Response.error(), + ), + ); + return; + } event.respondWith( fetch(request) .then((response) => { if (response.ok) { const clone = response.clone(); - void caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); + const cacheKey = request.mode === "navigate" ? shellUrl("app") : request; + void caches.open(CACHE_NAME).then((cache) => cache.put(cacheKey, clone)); } return response; }) diff --git a/scripts/finalize-mobile-build.mjs b/scripts/finalize-mobile-build.mjs index 8707f3230..0cab49f77 100644 --- a/scripts/finalize-mobile-build.mjs +++ b/scripts/finalize-mobile-build.mjs @@ -3,7 +3,8 @@ // Capacitor native shells both default to serving `index.html` from the web // root, so mirror the entry to `index.html`. Asset URLs use a relative base // ("./"), so the copy resolves identically at the new filename. -import { copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; const mobileBasePath = readEnv("PORACODE_MOBILE_BASE_PATH"); @@ -14,6 +15,7 @@ const outDir = resolve( ); const source = join(outDir, "mobile.html"); const target = join(outDir, "index.html"); +const serviceWorkerPath = join(outDir, "service-worker.js"); const wellKnownDir = join(outDir, ".well-known"); const sshRuntimeSourceDir = resolve(process.cwd(), "resources/mobile-ssh-runtime"); const sshRuntimeTargetDir = join(outDir, "poracode-ssh-runtime"); @@ -44,6 +46,14 @@ if (requireIosLinks && !appleTeamId) { } copyFileSync(source, target); +const serviceWorker = readFileSync(serviceWorkerPath, "utf8"); +const buildVersion = createHash("sha256").update(readFileSync(source)).digest("hex").slice(0, 12); +const versionToken = "__PORACODE_BUILD_VERSION__"; +if (!serviceWorker.includes(versionToken)) { + console.error(`[finalize-mobile-build] missing build-version token in ${serviceWorkerPath}`); + process.exit(1); +} +writeFileSync(serviceWorkerPath, serviceWorker.replaceAll(versionToken, buildVersion), "utf8"); mkdirSync(sshRuntimeTargetDir, { recursive: true }); copyFileSync( join(sshRuntimeSourceDir, "manifest.json"), @@ -54,6 +64,7 @@ mkdirSync(wellKnownDir, { recursive: true }); writeJson(join(wellKnownDir, "assetlinks.json"), buildAssetLinks()); writeJson(join(wellKnownDir, "apple-app-site-association"), buildAppleAppSiteAssociation()); console.log(`[finalize-mobile-build] wrote ${target}`); +console.log(`[finalize-mobile-build] versioned the service worker as ${buildVersion}`); console.log("[finalize-mobile-build] embedded the SSH runtime"); console.log("[finalize-mobile-build] wrote .well-known association files"); diff --git a/src/main/remote/RemoteAccessServer.test.ts b/src/main/remote/RemoteAccessServer.test.ts index 9bd497f46..5af6bd113 100644 --- a/src/main/remote/RemoteAccessServer.test.ts +++ b/src/main/remote/RemoteAccessServer.test.ts @@ -33,6 +33,7 @@ import { dbGetThreadContextUsage, dbGetLatestThreadRuntimeAnchorItemId, dbGetThreadRuntimeItems, + dbGetThreadRuntimeItemsPage, dbGetThreadRuntimeSummaries, dbGetThreads, dbReplaceThreadRuntimeSnapshot, @@ -70,6 +71,9 @@ vi.mock("../db", () => { dbGetThreadContextUsage: vi.fn<() => null>(() => null), dbGetLatestThreadRuntimeAnchorItemId: vi.fn<() => null>(() => null), dbGetThreadRuntimeItems: vi.fn<() => unknown[]>(() => []), + dbGetThreadRuntimeItemsPage: vi.fn<() => { items: unknown[]; nextCursor: number | null }>( + () => ({ items: [], nextCursor: null }), + ), dbGetThreadRuntimeSummaries: vi.fn<() => Record>(() => ({})), dbGetThread: vi.fn<(threadId: string) => unknown>(() => null), dbGetThreads: vi.fn<() => unknown[]>(() => []), @@ -119,6 +123,9 @@ afterEach(async () => { vi.mocked(dbGetThreadContextUsage).mockReset().mockReturnValue(null); vi.mocked(dbGetLatestThreadRuntimeAnchorItemId).mockReset().mockReturnValue(null); vi.mocked(dbGetThreadRuntimeItems).mockReset().mockReturnValue([]); + vi.mocked(dbGetThreadRuntimeItemsPage) + .mockReset() + .mockReturnValue({ items: [], nextCursor: null }); vi.mocked(dbGetThreadRuntimeSummaries).mockReset().mockReturnValue({}); vi.mocked(dbGetThread).mockReset().mockReturnValue(null); vi.mocked(dbGetThreads).mockReset().mockReturnValue([]); @@ -720,8 +727,14 @@ describe("RemoteAccessServer", () => { const serviceWorkerResponse = await fetch(new URL("/service-worker.js", info.httpBaseUrl)); expect(serviceWorkerResponse.status).toBe(200); const serviceWorker = await serviceWorkerResponse.text(); - expect(serviceWorker).toContain("poracode-remote-local"); + expect(serviceWorker).toContain("poracode-remote-local-1.0.0"); expect(serviceWorker).toContain("caches.delete(LEGACY_CACHE_NAME)"); + expect(serviceWorker).toContain("if (response.ok)"); + expect(serviceWorker).toContain('url.pathname.startsWith("/assets/")'); + expect(serviceWorker).toContain("NAVIGATION_FALLBACK_DELAY_MS = 500"); + expect(serviceWorker).toContain('if (request.mode === "navigate")'); + expect(serviceWorker).toContain("if (!isAppRequest && !isPwaStaticRequest) return"); + expect(serviceWorker).toContain('request.mode === "navigate" ? "/app" : request'); const { ws, ready } = await openPairedSocket(info); expect(ready).toMatchObject({ type: "ready", seq: 0 }); @@ -743,6 +756,70 @@ describe("RemoteAccessServer", () => { ws.close(); }); + it("pages remote thread runtime history while preserving the legacy full response", async () => { + const thread = createTestThread({ id: "thread-paged", presentationMode: "gui" }); + const fullItems = [ + { + id: "old", + type: "assistant_message", + state: "completed" as const, + payload: {}, + streams: {}, + }, + { + id: "tail", + type: "assistant_message", + state: "completed" as const, + payload: {}, + streams: {}, + }, + ]; + const tailPage = { items: [fullItems[1]!], nextCursor: 41 }; + vi.mocked(dbGetThread).mockReturnValue(thread); + vi.mocked(dbGetThreadRuntimeItems).mockReturnValue(fullItems); + vi.mocked(dbGetThreadRuntimeItemsPage).mockReturnValue(tailPage); + + const server = new RemoteAccessServer({ + appVersion: "1.0.0", + identity: { desktopId: "desktop-test", label: "Test Desktop" }, + host: "127.0.0.1", + port: 0, + callSupervisor: async () => null as never, + }); + servers.push(server); + const info = await server.start(); + const token = await issueAccessToken(info, ["session:read"]); + const headers = { authorization: `Bearer ${token}` }; + + const legacyResponse = await fetch( + new URL("/api/threads/thread-paged/history", info.httpBaseUrl), + { headers }, + ); + expect(legacyResponse.status).toBe(200); + await expect(legacyResponse.json()).resolves.toMatchObject({ runtimeItems: fullItems }); + + const tailResponse = await fetch( + new URL("/api/threads/thread-paged/history?runtimePage=1", info.httpBaseUrl), + { headers }, + ); + expect(tailResponse.status).toBe(200); + await expect(tailResponse.json()).resolves.toMatchObject({ + runtimeItems: tailPage.items, + runtimeNextCursor: 41, + }); + + const olderResponse = await fetch( + new URL( + "/api/threads/thread-paged/history/items?beforePosition=41&limit=500&targetTimelineEntryCount=40", + info.httpBaseUrl, + ), + { headers }, + ); + expect(olderResponse.status).toBe(200); + await expect(olderResponse.json()).resolves.toEqual(tailPage); + expect(dbGetThreadRuntimeItemsPage).toHaveBeenLastCalledWith("thread-paged", 41, 500, 40); + }); + it("builds shell snapshots from aggregated runtime summaries", async () => { vi.mocked(dbGetProjects).mockReturnValue([ createTestProject({ diff --git a/src/main/remote/pairingPage.ts b/src/main/remote/pairingPage.ts index 8b80d2266..30a604e6a 100644 --- a/src/main/remote/pairingPage.ts +++ b/src/main/remote/pairingPage.ts @@ -195,8 +195,9 @@ export function buildLocalPairingManifestJson(): string { return LOCAL_PAIRING_MANIFEST_JSON; } -const LOCAL_PAIRING_SERVICE_WORKER_JS = `const CACHE_NAME = "poracode-remote-local-v1"; +const LOCAL_PAIRING_SERVICE_WORKER_JS = `const CACHE_NAME = "poracode-remote-local-__PORACODE_LOCAL_BUILD_VERSION__"; const LEGACY_CACHE_NAME = "lightcode-remote-local-v1"; +const NAVIGATION_FALLBACK_DELAY_MS = 500; const SHELL_URLS = ["/app", "/manifest.webmanifest", "/app-icon.svg"]; self.addEventListener("install", (event) => { @@ -205,7 +206,18 @@ self.addEventListener("install", (event) => { }); self.addEventListener("activate", (event) => { - event.waitUntil(caches.delete(LEGACY_CACHE_NAME).then(() => self.clients.claim())); + event.waitUntil( + Promise.all([ + caches.delete(LEGACY_CACHE_NAME), + caches.keys().then((keys) => + Promise.all( + keys + .filter((key) => key.startsWith("poracode-remote-local-") && key !== CACHE_NAME) + .map((key) => caches.delete(key)), + ), + ), + ]).then(() => self.clients.claim()), + ); }); self.addEventListener("fetch", (event) => { @@ -213,12 +225,63 @@ self.addEventListener("fetch", (event) => { if (request.method !== "GET") return; const url = new URL(request.url); if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/oauth/") || url.pathname === "/ws") return; + const isAppRequest = url.pathname === "/app" || url.pathname.startsWith("/app/"); + const isPwaStaticRequest = + url.pathname.startsWith("/assets/") || + url.pathname.startsWith("/icons/") || + url.pathname.startsWith("/poracode-ssh-runtime/") || + url.pathname === "/manifest.webmanifest" || + url.pathname === "/app-icon.svg" || + url.pathname === "/notification.mp3"; + if (!isAppRequest && !isPwaStaticRequest) return; + + if (url.pathname.startsWith("/assets/")) { + event.respondWith( + caches.match(request).then( + (cached) => + cached || + fetch(request).then((response) => { + if (response.ok) { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); + } + return response; + }), + ), + ); + return; + } + + if (request.mode === "navigate") { + const networkResponse = fetch(request).then(async (response) => { + if (response.ok) { + const cache = await caches.open(CACHE_NAME); + await cache.put("/app", response.clone()); + } + return response; + }); + const cachedResponse = new Promise((resolve) => { + setTimeout(() => { + caches.match("/app").then(resolve); + }, NAVIGATION_FALLBACK_DELAY_MS); + }); + event.waitUntil(networkResponse.then(() => undefined, () => undefined)); + event.respondWith( + Promise.race([networkResponse, cachedResponse]) + .then((response) => response || networkResponse) + .catch(() => caches.match("/app").then((cached) => cached || Response.error())), + ); + return; + } event.respondWith( fetch(request) .then((response) => { - const clone = response.clone(); - caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); + if (response.ok) { + const clone = response.clone(); + const cacheKey = request.mode === "navigate" ? "/app" : request; + caches.open(CACHE_NAME).then((cache) => cache.put(cacheKey, clone)); + } return response; }) .catch(() => caches.match(request).then((cached) => cached || caches.match("/app"))), @@ -226,8 +289,9 @@ self.addEventListener("fetch", (event) => { }); `; -export function buildLocalPairingServiceWorkerJs(): string { - return LOCAL_PAIRING_SERVICE_WORKER_JS; +export function buildLocalPairingServiceWorkerJs(appVersion: string): string { + const buildVersion = appVersion.replace(/[^a-zA-Z0-9._-]/g, "-"); + return LOCAL_PAIRING_SERVICE_WORKER_JS.replace("__PORACODE_LOCAL_BUILD_VERSION__", buildVersion); } // Kept in sync with public/app-icon.svg (the static/standalone icon). diff --git a/src/main/remote/server/httpRouter.ts b/src/main/remote/server/httpRouter.ts index 8b1c53675..b2ac30284 100644 --- a/src/main/remote/server/httpRouter.ts +++ b/src/main/remote/server/httpRouter.ts @@ -10,6 +10,7 @@ import { remoteProjectCommandSchema, remotePushRegistrationSchema, remotePushUnregisterSchema, + remoteRuntimeItemsPageRequestSchema, remoteSettingsPatchSchema, remoteScheduleCommandSchema, remoteTokenExchangePayloadSchema, @@ -70,6 +71,7 @@ import { buildAgentStatuses, buildShellSnapshot, buildThreadSnapshot, + buildThreadRuntimeItemsPage, descriptor, } from "./snapshots"; import { applyRemoteThreadCommand, runGitCall, runProjectCommand } from "./threadCommands"; @@ -294,7 +296,7 @@ export async function handleHttp( writeText( res, 200, - buildLocalPairingServiceWorkerJs(), + buildLocalPairingServiceWorkerJs(ctx.options.appVersion), "application/javascript; charset=utf-8", ); return; @@ -513,10 +515,32 @@ export async function handleHttp( writeJson(res, 200, { ok: true }); return; } + const historyItemsThreadId = threadIdFromPath(url.pathname, "/history/items"); + if (req.method === "GET" && historyItemsThreadId) { + ctx.security.requireBearer(req, ["session:read"]); + const beforePosition = url.searchParams.get("beforePosition"); + const targetTimelineEntryCount = url.searchParams.get("targetTimelineEntryCount"); + const input = remoteRuntimeItemsPageRequestSchema.parse({ + threadId: historyItemsThreadId, + limit: Number(url.searchParams.get("limit")), + ...(beforePosition !== null ? { beforePosition: Number(beforePosition) } : {}), + ...(targetTimelineEntryCount !== null + ? { targetTimelineEntryCount: Number(targetTimelineEntryCount) } + : {}), + }); + writeJson(res, 200, buildThreadRuntimeItemsPage(input)); + return; + } const historyThreadId = threadIdFromPath(url.pathname, "/history"); if (req.method === "GET" && historyThreadId) { ctx.security.requireBearer(req, ["session:read"]); - writeJson(res, 200, await buildThreadSnapshot(ctx, historyThreadId)); + writeJson( + res, + 200, + await buildThreadSnapshot(ctx, historyThreadId, { + runtimePage: url.searchParams.get("runtimePage") === "1", + }), + ); return; } if (req.method === "POST" && url.pathname === "/api/threads/start") { diff --git a/src/main/remote/server/snapshots.ts b/src/main/remote/server/snapshots.ts index e1d12b0ee..a2ddf1fcd 100644 --- a/src/main/remote/server/snapshots.ts +++ b/src/main/remote/server/snapshots.ts @@ -3,10 +3,13 @@ import { REMOTE_STANDARD_SCOPES, remoteAgentStatusesSchema, remoteEnvironmentDescriptorSchema, + remoteRuntimeItemsPageSchema, remoteShellSnapshotSchema, remoteThreadSnapshotSchema, type RemoteAgentStatuses, type RemoteEnvironmentDescriptor, + type RemoteRuntimeItemsPage, + type RemoteRuntimeItemsPageRequest, type RemoteShellSnapshot, type RemoteThreadSnapshot, } from "@/shared/remote"; @@ -17,6 +20,7 @@ import { dbGetThreadCompletedTurns, dbGetThreadContextUsage, dbGetThreadRuntimeItems, + dbGetThreadRuntimeItemsPage, dbGetThreadRuntimeSummaries, dbGetThreads, } from "../../db"; @@ -99,6 +103,7 @@ export async function buildAgentStatuses(ctx: RemoteServerContext): Promise { const thread = dbGetThread(threadId); if (!thread) { @@ -119,10 +124,14 @@ export async function buildThreadSnapshot( terminalSize = undefined; } + const runtimePage = options.runtimePage + ? dbGetThreadRuntimeItemsPage(threadId, undefined, 500, 40) + : null; return remoteThreadSnapshotSchema.parse({ snapshotSeq: ctx.seq, thread, - runtimeItems: dbGetThreadRuntimeItems(threadId), + runtimeItems: runtimePage?.items ?? dbGetThreadRuntimeItems(threadId), + ...(runtimePage ? { runtimeNextCursor: runtimePage.nextCursor } : {}), completedTurns: dbGetThreadCompletedTurns(threadId), contextUsage: dbGetThreadContextUsage(threadId), ...(terminalScrollback ? { terminalScrollback } : {}), @@ -130,3 +139,19 @@ export async function buildThreadSnapshot( updatedAt: new Date().toISOString(), }); } + +export function buildThreadRuntimeItemsPage( + input: RemoteRuntimeItemsPageRequest, +): RemoteRuntimeItemsPage { + if (!dbGetThread(input.threadId)) { + throw new RemoteHttpError("thread_not_found", "Thread not found.", 404); + } + return remoteRuntimeItemsPageSchema.parse( + dbGetThreadRuntimeItemsPage( + input.threadId, + input.beforePosition, + input.limit, + input.targetTimelineEntryCount, + ), + ); +} diff --git a/src/mobile/bridge.ts b/src/mobile/bridge.ts index 9a4ae1bd3..a4ad8fef0 100644 --- a/src/mobile/bridge.ts +++ b/src/mobile/bridge.ts @@ -21,6 +21,7 @@ import { isGitRemoteNoopProcedure, isGitRemoteProcedure, type RemoteBrowserCommand, + type RemoteRuntimeItemsPageRequest, } from "@/shared/remote"; import type { SharedSettingsInput } from "@/shared/settings"; import { useBrowserMirrorStore } from "./browserMirror"; @@ -218,9 +219,13 @@ const remoteBridge = { getAgentHookPluginStatuses: () => Promise.resolve([]), // Runtime history hydration is fed by the remote sync layer instead of the - // local DB; empty results keep `hydrateThreadRuntimeItems` a no-op. + // local DB. The selected thread's tail arrives with its snapshot; older + // pages are fetched lazily when ChatPane reaches the start. dbGetThreadRuntimeItems: () => Promise.resolve([]), - dbGetThreadRuntimeItemsPage: () => Promise.resolve({ items: [], nextCursor: null }), + dbGetThreadRuntimeItemsPage: (payload: RemoteRuntimeItemsPageRequest) => + payload.beforePosition === undefined + ? Promise.resolve({ items: [], nextCursor: null }) + : requireClient().threadRuntimeItemsPage(payload), dbTruncateThreadRuntimeAfter: () => Promise.resolve(), dbGetThreadCompletedTurns: () => Promise.resolve([]), dbGetThreadContextUsage: () => Promise.resolve(null), diff --git a/src/mobile/registerServiceWorker.ts b/src/mobile/registerServiceWorker.ts index b6e5ff4b8..713ee9eb8 100644 --- a/src/mobile/registerServiceWorker.ts +++ b/src/mobile/registerServiceWorker.ts @@ -1,3 +1,52 @@ +import { mobileServiceWorkerScope } from "./routing"; + +function loadedBuildAssetUrls(buildBasePath: string): string[] { + if (typeof performance === "undefined") return []; + const assetPrefix = new URL(`${buildBasePath}assets/`, window.location.href).href; + return performance + .getEntriesByType("resource") + .map((entry) => entry.name) + .filter((url) => url.startsWith(assetPrefix)); +} + +function cacheLoadedBuildAssets( + registration: ServiceWorkerRegistration, + buildBasePath: string, +): void { + const watchedWorkers = new WeakSet(); + const notifyWorker = (worker: ServiceWorker | null | undefined) => { + if (!worker) return; + worker.postMessage({ type: "cache-build-assets", urls: loadedBuildAssetUrls(buildBasePath) }); + }; + const watchInstallingWorker = (worker: ServiceWorker | null | undefined) => { + if (!worker || watchedWorkers.has(worker)) return; + watchedWorkers.add(worker); + notifyWorker(worker); + const handleStateChange = () => { + if (worker.state === "activated") notifyWorker(worker); + if (worker.state === "activated" || worker.state === "redundant") { + worker.removeEventListener("statechange", handleStateChange); + } + }; + worker.addEventListener("statechange", handleStateChange); + }; + + notifyWorker(registration.active); + notifyWorker(registration.waiting); + watchInstallingWorker(registration.installing); + registration.addEventListener("updatefound", () => { + watchInstallingWorker(registration.installing); + }); + navigator.serviceWorker.addEventListener( + "controllerchange", + () => notifyWorker(navigator.serviceWorker.controller), + { once: true }, + ); + void navigator.serviceWorker.ready.then((readyRegistration) => { + notifyWorker(readyRegistration.active); + }); +} + /** * Register the PWA service worker so the app is installable and the shell is * available offline. Best-effort and non-blocking: @@ -13,11 +62,18 @@ export function registerServiceWorker(): void { if (typeof window !== "undefined" && window.isSecureContext === false) return; const register = () => { - const scope = import.meta.env.BASE_URL; - navigator.serviceWorker.register(`${scope}service-worker.js`, { scope }).catch(() => { - // Registration failing (e.g. worker not served, blocked) must never break - // the app — it just means no offline shell / install prompt this session. - }); + const buildBasePath = import.meta.env.BASE_URL; + const scriptUrl = buildBasePath.startsWith("/") + ? `${buildBasePath}service-worker.js` + : "/service-worker.js"; + const scope = mobileServiceWorkerScope(); + navigator.serviceWorker + .register(scriptUrl, { scope }) + .then((registration) => cacheLoadedBuildAssets(registration, buildBasePath)) + .catch(() => { + // Registration failing (e.g. worker not served, blocked) must never break + // the app — it just means no offline shell / install prompt this session. + }); }; if (document.readyState === "complete") { diff --git a/src/mobile/remoteProtocol.perf.test.ts b/src/mobile/remoteProtocol.perf.test.ts new file mode 100644 index 000000000..7fb4996b5 --- /dev/null +++ b/src/mobile/remoteProtocol.perf.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { collectRuntimeEventsFromSupervisoryMessage } from "@/renderer/state/remote/runtimeRequests"; +import { applyRuntimeEventBatchesToState } from "@/renderer/state/slices/runtimeEventReducer"; +import type { AppStoreState } from "@/renderer/state/slices/shared"; +import type { RuntimeEvent } from "@/shared/contracts"; +import { RemoteDesktopClient } from "@/shared/remote/client"; + +const THREAD_COUNT = 6; + +function deltasPerThread(): number { + const scale = Math.max(1, Math.min(10, Number(process.env.PORACODE_PERF_SCALE ?? "1") || 1)); + return 1000 * scale; +} + +function emptyRuntimeState(): AppStoreState { + return { + threads: [], + runtimeItemIdsByThread: {}, + runtimeItemsByIdByThread: {}, + runtimeRequestsByThread: {}, + runtimeContextByThread: {}, + runtimeStructuralVersionByThread: {}, + runtimeCompletedTurnsByThread: {}, + runtimeOpenTurnByThread: {}, + } as unknown as AppStoreState; +} + +describe("remote PWA protocol data flow", () => { + it("keeps WebSocket parsing, runtime validation, and batched store reduction responsive", () => { + const client = new RemoteDesktopClient("https://desktop.example.test"); + const deltaCount = deltasPerThread(); + const frames: string[] = []; + let seq = 0; + for (let threadIndex = 0; threadIndex < THREAD_COUNT; threadIndex += 1) { + const threadId = `perf-thread-${threadIndex}`; + const itemId = `assistant-${threadIndex}`; + frames.push( + JSON.stringify({ + type: "event", + seq: ++seq, + event: { + type: "thread-runtime-event", + threadId, + event: { + type: "item.started", + threadId, + itemId, + itemType: "assistant_message", + payload: { content: [{ kind: "text", text: "" }] }, + }, + }, + }), + ); + for (let deltaIndex = 0; deltaIndex < deltaCount; deltaIndex += 1) { + frames.push( + JSON.stringify({ + type: "event", + seq: ++seq, + event: { + type: "thread-runtime-event", + threadId, + event: { + type: "content.delta", + threadId, + itemId, + stream: "assistant_text", + delta: "token ", + }, + }, + }), + ); + } + } + + const batches = new Map(); + const startedAt = performance.now(); + for (const frame of frames) { + const message = client.parseSocketMessage(frame); + if (message.type !== "event") continue; + for (const batch of collectRuntimeEventsFromSupervisoryMessage(message.event)) { + const events = batches.get(batch.threadId) ?? []; + events.push(...batch.events); + batches.set(batch.threadId, events); + } + } + const patch = applyRuntimeEventBatchesToState( + emptyRuntimeState(), + [...batches].map(([threadId, events]) => ({ threadId, events })), + ); + const wallMs = performance.now() - startedAt; + + for (let index = 0; index < THREAD_COUNT; index += 1) { + expect(patch.runtimeItemIdsByThread?.[`perf-thread-${index}`]).toHaveLength(1); + } + expect(wallMs).toBeLessThan(Math.max(5000, frames.length)); + + if (process.env.PORACODE_PERF_LOG) { + console.log( + `[perf/remote-pwa] threads=${THREAD_COUNT} frames=${frames.length} wallMs=${wallMs.toFixed(1)} avgMs=${(wallMs / frames.length).toFixed(4)}`, + ); + } + }); +}); diff --git a/src/mobile/remoteSocketCoordinator.test.ts b/src/mobile/remoteSocketCoordinator.test.ts index 8c26038fe..e37dcdb88 100644 --- a/src/mobile/remoteSocketCoordinator.test.ts +++ b/src/mobile/remoteSocketCoordinator.test.ts @@ -39,7 +39,7 @@ import { } from "./remoteSocketCoordinator"; interface ClientMock { - readonly websocketTicket: Mock<() => Promise>; + readonly websocketTicket: Mock<(timeoutMs?: number) => Promise>; readonly websocketUrl: Mock<(ticket: string, lastSeenSeq: number | null | undefined) => string>; readonly parseSocketMessage: Mock<(raw: string) => RemoteWebSocketServerMessage>; } @@ -114,7 +114,7 @@ interface Harness { function createClient(): ClientMock { return { - websocketTicket: vi.fn<() => Promise>(async () => "ticket-1"), + websocketTicket: vi.fn<(timeoutMs?: number) => Promise>(async () => "ticket-1"), websocketUrl: vi.fn<(ticket: string, lastSeenSeq: number | null | undefined) => string>( (ticket, lastSeenSeq) => `ws://desktop/ws?ticket=${ticket}&lastSeenSeq=${lastSeenSeq}`, ), @@ -213,7 +213,7 @@ describe("remoteSocketCoordinator", () => { const harness = track(createHarness({ initialLastSeenSeq: 7 })); const socket = await start(harness); - expect(harness.client.websocketTicket).toHaveBeenCalledTimes(1); + expect(harness.client.websocketTicket).toHaveBeenCalledWith(15000); expect(harness.client.websocketUrl).toHaveBeenCalledWith("ticket-1", 7); harness.coordinator.start(); expect(FakeWebSocket.instances).toHaveLength(1); @@ -232,6 +232,31 @@ describe("remoteSocketCoordinator", () => { }); }); + it("advances to an authoritative snapshot sequence and ignores covered replay events", async () => { + const harness = track(createHarness({ initialLastSeenSeq: 2 })); + const socket = await start(harness); + socket.open(); + + harness.coordinator.advanceLastSeenSeq(8); + socket.message({ + type: "event", + seq: 8, + event: { type: "thread-runtime-event", threadId: "covered" }, + }); + socket.message({ + type: "event", + seq: 9, + event: { type: "thread-runtime-event", threadId: "new" }, + }); + + expect(h.dispatchRemoteSupervisorEvent).toHaveBeenCalledTimes(1); + expect(h.dispatchRemoteSupervisorEvent).toHaveBeenCalledWith({ + type: "thread-runtime-event", + threadId: "new", + }); + expect(harness.coordinator.getLastSeenSeq()).toBe(9); + }); + it("does not let an event refresh downgrade a pending recovery refresh", async () => { const harness = track(createHarness()); const socket = await start(harness); @@ -267,6 +292,7 @@ describe("remoteSocketCoordinator", () => { seq: 8, event: { type: "thread-state", threadId: "selected" }, }); + expect(harness.coordinator.getLastSeenSeq()).toBe(8); await vi.advanceTimersByTimeAsync(600); expect(harness.requestRefresh).toHaveBeenCalledWith({ refreshSelectedThread: true, diff --git a/src/mobile/remoteSocketCoordinator.ts b/src/mobile/remoteSocketCoordinator.ts index e694b839e..f0d84dc66 100644 --- a/src/mobile/remoteSocketCoordinator.ts +++ b/src/mobile/remoteSocketCoordinator.ts @@ -44,6 +44,8 @@ export interface RemoteSocketCoordinatorOptions { export interface RemoteSocketCoordinator { start(): void; + getLastSeenSeq(): number; + advanceLastSeenSeq(seq: number): void; dispose(): void; } @@ -138,7 +140,7 @@ export function createRemoteSocketCoordinator( void (async () => { try { const client = options.createClient(); - const ticket = await client.websocketTicket(); + const ticket = await client.websocketTicket(CONNECT_TIMEOUT_MS); if (closed) { connecting = false; return; @@ -186,7 +188,8 @@ export function createRemoteSocketCoordinator( if (handleBrowserServerMessage(parsed)) return; if (handleTerminalServerMessage(parsed)) return; if (parsed.type === "event") { - lastSeenSeq = Math.max(lastSeenSeq, parsed.seq); + if (parsed.seq <= lastSeenSeq) return; + lastSeenSeq = parsed.seq; dispatchRemoteSupervisorEvent(parsed.event); if (shouldRefreshAfterSupervisorEvent(parsed.event)) { const triggerThreadId = @@ -296,6 +299,12 @@ export function createRemoteSocketCoordinator( }, HEALTH_PING_INTERVAL_MS); connect(); }, + getLastSeenSeq() { + return lastSeenSeq; + }, + advanceLastSeenSeq(seq) { + if (Number.isInteger(seq) && seq >= 0) lastSeenSeq = Math.max(lastSeenSeq, seq); + }, dispose() { if (closed) return; closed = true; diff --git a/src/mobile/routeComponents.test.tsx b/src/mobile/routeComponents.test.tsx index 86e6118c8..74dd85c46 100644 --- a/src/mobile/routeComponents.test.tsx +++ b/src/mobile/routeComponents.test.tsx @@ -259,10 +259,10 @@ describe("mobile route components", () => { expect(fixtures.navigate).toHaveBeenCalledWith({ to: "/projects" }); }); - it("renders the home composer only after a connected desktop has projects", () => { + it("renders the home composer only after a connected desktop has projects", async () => { render(); - expect(screen.getByTestId("quick-compose")).toBeTruthy(); + expect(await screen.findByTestId("quick-compose")).toBeTruthy(); }); it("redirects stale Usage settings when no desktop is paired", async () => { @@ -305,14 +305,14 @@ describe("mobile route components", () => { render(); - expect(screen.getByTestId("thread-title")).toHaveTextContent("No thread"); + expect(screen.getByText("No thread selected")).toBeTruthy(); expect(fixtures.remote.openThread).not.toHaveBeenCalled(); }); - it("opens a project terminal with the routed thread as the close target", () => { + it("opens a project terminal with the routed thread as the close target", async () => { render(); - fireEvent.click(screen.getByRole("button", { name: "Open terminal" })); + fireEvent.click(await screen.findByRole("button", { name: "Open terminal" })); expect(fixtures.navigate).toHaveBeenCalledWith({ to: "/terminal/$projectId", diff --git a/src/mobile/routeComponents.tsx b/src/mobile/routeComponents.tsx index 3778c1dfe..84f02a396 100644 --- a/src/mobile/routeComponents.tsx +++ b/src/mobile/routeComponents.tsx @@ -9,6 +9,7 @@ import { } from "react"; import { toast } from "@heroui/react"; import { Trans, useLingui } from "@lingui/react/macro"; +import { MessageCircle } from "lucide-react"; import { getRouteApi, useNavigate } from "@tanstack/react-router"; import { useAppStore } from "@/renderer/state/appStore"; import type { Thread } from "@/shared/contracts"; @@ -31,6 +32,7 @@ import { subscribePairingLaunch, } from "./pairing"; import { MobileSetupEmptyState, type MobileSetupKind } from "./setupEmptyState"; +import { EmptyState } from "./components"; import { isDesktopSettingsSection } from "./settingsSections"; import type { MobileSshPairRequest } from "./views/DesktopsView"; import { useGitSummaryHydration } from "./useGitSummaryHydration"; @@ -38,10 +40,17 @@ import { useMediaQuery, WIDE_SHELL_QUERY } from "./useMediaQuery"; import { DesktopsView } from "./views/DesktopsView"; import { ManageProjectsView } from "./views/ManageProjectsView"; import { MoreView } from "./views/MoreView"; -import { NewThreadFlow } from "./views/NewThreadFlow"; -import { QuickCompose } from "./views/QuickCompose"; import { ThreadsView } from "./views/ThreadsView"; -import { ThreadView } from "./views/ThreadView"; + +const NewThreadFlow = lazy(() => + import("./views/NewThreadFlow").then((module) => ({ default: module.NewThreadFlow })), +); +const QuickCompose = lazy(() => + import("./views/QuickCompose").then((module) => ({ default: module.QuickCompose })), +); +const ThreadView = lazy(() => + import("./views/ThreadView").then((module) => ({ default: module.ThreadView })), +); const BrowserView = lazy(() => import("./views/BrowserView").then((module) => ({ default: module.BrowserView })), @@ -96,8 +105,8 @@ function LazyRoute(props: { readonly children: ReactNode }) { * on a cold chunk that's the fallback, so the fallback itself must be a * fullscreen, `m-screen`-named surface or the slide has nothing to animate * (the old page then just dissolves via the root cross-fade, and the late- - * arriving screen paints with no coherent entry). The idle warmup below makes - * this fallback a rare slow-network sight. + * arriving screen paints with no coherent entry). Connected sessions warm + * these chunks after the first paint, keeping the fallback a rare sight. */ function FullscreenLazyRoute(props: { readonly children: ReactNode }) { return ( @@ -115,21 +124,6 @@ function FullscreenLazyRoute(props: { readonly children: ReactNode }) { ); } -// Warm the fullscreen screens' chunks once the first paint has settled, so the -// first push into workspace/terminal captures real content for its slide -// instead of the cold Suspense fallback. -if (typeof window !== "undefined") { - const warmFullscreenChunks = () => { - void import("./views/WorkspaceView"); - void import("./views/TerminalView"); - }; - if (typeof window.requestIdleCallback === "function") { - window.requestIdleCallback(warmFullscreenChunks, { timeout: 4_000 }); - } else { - window.setTimeout(warmFullscreenChunks, 2_000); - } -} - /** * Shared thread detail pane. Used by the /thread/:id route and, in the wide * layout, by the /threads route (where the list lives in the sidebar and the @@ -139,30 +133,39 @@ function ThreadDetail(props: { readonly thread: Thread | null; readonly hideHead const remote = useRemote(); const navigate = useNavigate(); const thread = props.thread; + if (!thread) { + return ( +
+ } + title={No thread selected} + hint={Pick a thread from the list to follow the agent from here.} + /> +
+ ); + } // Still fetching this thread's history when no snapshot matches it yet. - const loading = Boolean(thread) && remote.selectedThreadSnapshot?.thread.id !== thread?.id; + const loading = remote.selectedThreadSnapshot?.thread.id !== thread.id; return ( - - runThreadAction(remote, thread, action, () => void navigate({ to: "/threads" })) - } - onSubmitInput={(prompt, segments) => remote.sendPrompt(prompt, segments)} - onOpenWorkspace={(tab) => { - if (thread) { + + + runThreadAction(remote, thread, action, () => void navigate({ to: "/threads" })) + } + onSubmitInput={(prompt, segments) => remote.sendPrompt(prompt, segments)} + onOpenWorkspace={(tab) => { void navigate({ to: "/workspace/$threadId", params: { threadId: thread.id }, search: { tab }, }); - } - }} - onOpenWorkspaceFile={(path, lineNumber) => { - if (thread) { + }} + onOpenWorkspaceFile={(path, lineNumber) => { void navigate({ to: "/workspace/$threadId", params: { threadId: thread.id }, @@ -172,19 +175,15 @@ function ThreadDetail(props: { readonly thread: Thread | null; readonly hideHead ...(lineNumber !== undefined ? { line: lineNumber } : {}), }, }); - } - }} - onOpenWorkspaceFolder={(path) => { - if (thread) { + }} + onOpenWorkspaceFolder={(path) => { void navigate({ to: "/workspace/$threadId", params: { threadId: thread.id }, search: { tab: "files", folder: path }, }); - } - }} - onOpenTerminal={() => { - if (thread) { + }} + onOpenTerminal={() => { void navigate({ to: "/terminal/$projectId", params: { projectId: thread.projectId }, @@ -193,17 +192,17 @@ function ThreadDetail(props: { readonly thread: Thread | null; readonly hideHead ...(thread.worktreePath ? { worktree: thread.worktreePath } : {}), }, }); - } - }} - onNewThreadInWorktree={(input) => { - preselectWorktreeDraft(input); - void navigate({ to: "/new" }); - }} - onDeleteWorktreeGroup={(input) => { - void remote.deleteWorktreeGroup(input); - void navigate({ to: "/threads" }); - }} - /> + }} + onNewThreadInWorktree={(input) => { + preselectWorktreeDraft(input); + void navigate({ to: "/new" }); + }} + onDeleteWorktreeGroup={(input) => { + void remote.deleteWorktreeGroup(input); + void navigate({ to: "/threads" }); + }} + /> + ); } @@ -248,6 +247,24 @@ export function ThreadsRoute() { if (!isWide) useAppStore.getState().openHome(); }, [isWide]); + // Once a desktop is connected, warm the fullscreen chunks after first paint + // so their push transition normally captures real content. Disconnected + // startup keeps them off the network entirely. + const activeDesktopId = remote.activeDesktop?.desktopId; + useEffect(() => { + if (!activeDesktopId) return; + const warmFullscreenChunks = () => { + void import("./views/WorkspaceView"); + void import("./views/TerminalView"); + }; + if (typeof window.requestIdleCallback === "function") { + const handle = window.requestIdleCallback(warmFullscreenChunks, { timeout: 4_000 }); + return () => window.cancelIdleCallback(handle); + } + const handle = window.setTimeout(warmFullscreenChunks, 2_000); + return () => window.clearTimeout(handle); + }, [activeDesktopId]); + // Wide: the sidebar owns the list; this pane shows the selected thread. if (isWide) { return ; @@ -305,14 +322,16 @@ export function ThreadsRoute() { {...(setupEmptyState ? { emptyStateOverride: setupEmptyState } : {})} /> {readyToCompose ? ( - { - setComposeExpanded(false); - void navigate({ to: "/thread/$threadId", params: { threadId } }); - }} - /> + + { + setComposeExpanded(false); + void navigate({ to: "/thread/$threadId", params: { threadId } }); + }} + /> + ) : null} ); @@ -343,12 +362,14 @@ export function ThreadRoute() { export function NewThreadRoute() { const navigate = useNavigate(); return ( - void navigate({ to: "/thread/$threadId", params: { threadId } })} - onSetupAction={(kind) => - void navigate(kind === "desktop" ? { to: "/desktops" } : { to: "/projects" }) - } - /> + + void navigate({ to: "/thread/$threadId", params: { threadId } })} + onSetupAction={(kind) => + void navigate(kind === "desktop" ? { to: "/desktops" } : { to: "/projects" }) + } + /> + ); } diff --git a/src/mobile/router.tsx b/src/mobile/router.tsx index c94900c9a..55fc4c445 100644 --- a/src/mobile/router.tsx +++ b/src/mobile/router.tsx @@ -5,6 +5,7 @@ import { createRoute, createRouteMask, createRouter, + lazyRouteComponent, Navigate, redirect, } from "@tanstack/react-router"; @@ -29,12 +30,19 @@ import { UsageRoute, WorkspaceRoute, } from "./routeComponents"; -import { PrChangesPage } from "./views/pr/PrChangesPage"; -import { PrChecksPage } from "./views/pr/PrChecksPage"; -import { PrCommitsPage } from "./views/pr/PrCommitsPage"; -import { PrConversationPage } from "./views/pr/PrConversationPage"; -import { PrLayout } from "./views/pr/PrLayout"; -import { PrOverviewPage } from "./views/pr/PrOverviewPage"; + +const PrLayout = lazyRouteComponent(() => import("./views/pr/PrLayout"), "PrLayout"); +const PrOverviewPage = lazyRouteComponent( + () => import("./views/pr/PrOverviewPage"), + "PrOverviewPage", +); +const PrChangesPage = lazyRouteComponent(() => import("./views/pr/PrChangesPage"), "PrChangesPage"); +const PrCommitsPage = lazyRouteComponent(() => import("./views/pr/PrCommitsPage"), "PrCommitsPage"); +const PrChecksPage = lazyRouteComponent(() => import("./views/pr/PrChecksPage"), "PrChecksPage"); +const PrConversationPage = lazyRouteComponent( + () => import("./views/pr/PrConversationPage"), + "PrConversationPage", +); // Snapshot pairing credentials before history reads the launch URL, then // migrate bookmarks from the former hash/state-backed routers. diff --git a/src/mobile/routing.test.ts b/src/mobile/routing.test.ts index 42f56959f..61098cf87 100644 --- a/src/mobile/routing.test.ts +++ b/src/mobile/routing.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { legacyBrowserRouteUrl, mobileRouterBasePath } from "./routing"; +import { legacyBrowserRouteUrl, mobileRouterBasePath, mobileServiceWorkerScope } from "./routing"; describe("mobileRouterBasePath", () => { it("resolves hosted, desktop-served, and development bases", () => { @@ -9,6 +9,12 @@ describe("mobileRouterBasePath", () => { }); }); +describe("mobileServiceWorkerScope", () => { + it("controls exact entry URLs and their canonical descendants", () => { + expect(mobileServiceWorkerScope()).toBe("/"); + }); +}); + describe("legacyBrowserRouteUrl", () => { it("keeps the internal Connections landing at the hosted PWA root", () => { expect(legacyBrowserRouteUrl("https://poracode.com/pwa#/desktops", "/pwa/")).toBe( diff --git a/src/mobile/routing.ts b/src/mobile/routing.ts index 245c234cb..b83317fc0 100644 --- a/src/mobile/routing.ts +++ b/src/mobile/routing.ts @@ -13,6 +13,11 @@ export function mobileRouterBasePath(pathname: string, buildBasePath: string): s return "/"; } +/** Own every public PWA alias; the worker itself ignores unrelated site routes. */ +export function mobileServiceWorkerScope(): string { + return "/"; +} + function validInternalRoute(value: unknown): string | null { return typeof value === "string" && value.startsWith("/") && !value.startsWith("//") ? value diff --git a/src/mobile/storeSync.applyThreadSnapshot.test.tsx b/src/mobile/storeSync.applyThreadSnapshot.test.tsx index 9f58fbd0f..ea96343b7 100644 --- a/src/mobile/storeSync.applyThreadSnapshot.test.tsx +++ b/src/mobile/storeSync.applyThreadSnapshot.test.tsx @@ -43,11 +43,15 @@ function makeItem(input: { id: string; assistantText?: string }): PersistedRunti function makeSnapshot(input: { status: Thread["status"]; items: PersistedRuntimeItem[]; + runtimeNextCursor?: number | null; }): RemoteThreadSnapshot { return { snapshotSeq: 1, thread: makeThread(input.status), runtimeItems: input.items, + ...(input.runtimeNextCursor !== undefined + ? { runtimeNextCursor: input.runtimeNextCursor } + : {}), completedTurns: [], contextUsage: null, updatedAt: "2026-03-21T10:00:00.000Z", @@ -144,6 +148,42 @@ describe("applyThreadSnapshot", () => { expect(useAppStore.getState().runtimeItemIdsByThread[THREAD_ID]).toEqual(["a"]); }); + it("refreshes a paged tail without discarding older pages already loaded", () => { + const store = useAppStore.getState(); + store.applyRuntimeEvents(THREAD_ID, [ + { type: "item.started", threadId: THREAD_ID, itemId: "old-a", itemType: "assistant_message" }, + { type: "item.started", threadId: THREAD_ID, itemId: "old-b", itemType: "assistant_message" }, + { + type: "item.started", + threadId: THREAD_ID, + itemId: "tail-a", + itemType: "assistant_message", + }, + { + type: "item.started", + threadId: THREAD_ID, + itemId: "tail-b", + itemType: "assistant_message", + }, + ]); + + applyThreadSnapshot( + makeSnapshot({ + status: "idle", + items: [makeItem({ id: "tail-a", assistantText: "updated" }), makeItem({ id: "tail-b" })], + runtimeNextCursor: 10, + }), + ); + + expect(useAppStore.getState().runtimeItemIdsByThread[THREAD_ID]).toEqual([ + "old-a", + "old-b", + "tail-a", + "tail-b", + ]); + expect(assistantStreamText("tail-a")).toBe("updated"); + }); + it("does not let an empty fresh server snapshot erase a streamed transcript", () => { const store = useAppStore.getState(); store.applyRuntimeEvents(THREAD_ID, [ diff --git a/src/mobile/useRemoteDesktop.test.tsx b/src/mobile/useRemoteDesktop.test.tsx index 0cad9bcac..4374969ef 100644 --- a/src/mobile/useRemoteDesktop.test.tsx +++ b/src/mobile/useRemoteDesktop.test.tsx @@ -288,6 +288,11 @@ class FakeWebSocket { this.readyState = 3; for (const cb of this.listeners.get("close") ?? []) cb({}); } + message(message: unknown) { + for (const cb of this.listeners.get("message") ?? []) { + cb({ data: JSON.stringify(message) }); + } + } } import { useRemoteDesktop } from "./useRemoteDesktop"; @@ -338,6 +343,27 @@ describe("useRemoteDesktop", () => { vi.unstubAllGlobals(); }); + it("rebuilds a manually reconnected socket from the latest applied event sequence", async () => { + const desktop = makeDesktop("A"); + const client = clientFor("A"); + client.parseSocketMessage.mockImplementation((raw) => JSON.parse(raw) as unknown); + const view = await mountWith([desktop], "A"); + await waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); + + act(() => { + FakeWebSocket.instances[0]?.message({ + type: "event", + seq: 8, + event: { type: "thread-runtime-event", threadId: "t", event: { type: "noop" } }, + }); + view.result.current.reconnect(); + }); + + expect(view.result.current.connection).toBe("reconnecting"); + await waitFor(() => expect(FakeWebSocket.instances).toHaveLength(2)); + expect(client.websocketUrl).toHaveBeenLastCalledWith("ticket", 8); + }); + it("[#1] ignores a late refresh that resolves after the user switched desktops", async () => { const dA = makeDesktop("A"); const dB = makeDesktop("B"); diff --git a/src/mobile/useRemoteDesktop.ts b/src/mobile/useRemoteDesktop.ts index 3edcb86e0..65d5aaff8 100644 --- a/src/mobile/useRemoteDesktop.ts +++ b/src/mobile/useRemoteDesktop.ts @@ -24,6 +24,7 @@ import { } from "@/shared/remote"; import { performThreadInputSubmit } from "@/renderer/actions/threadRuntimeActions"; import { useAppStore } from "@/renderer/state/appStore"; +import { seedOlderThreadRuntimeItemsCursor } from "@/renderer/state/chatRuntimePersister"; import { readBridge } from "@/renderer/bridge"; import type { DraftStartInput } from "@/renderer/components/thread/ThreadDraftComposerArea"; import { i18n } from "@/renderer/i18n/i18n"; @@ -38,6 +39,7 @@ import { RemoteDesktopClient } from "./remoteClient"; import { createRemoteSocketCoordinator, isUnauthorizedRemoteError, + type RemoteSocketCoordinator, } from "./remoteSocketCoordinator"; import { applyDesktopSettings, resetDesktopSettings } from "./settingsSync"; import { sortThreadsByRecency } from "./presentation"; @@ -187,6 +189,14 @@ export function useRemoteDesktop() { // on every session change so the first refresh after boot/switch always // persists (bumping lastConnectedAt/updatedAt for list ordering). const persistedShellSeqRef = useRef>(new Map()); + // Latest event cursor applied by each live socket. A coordinator rebuild + // (manual reconnect or desktop switch) must resume from this cursor rather + // than replaying from the older sequence last persisted with a shell snapshot. + const liveSocketSeqRef = useRef>(new Map()); + const socketCoordinatorRef = useRef<{ + readonly desktopId: string; + readonly coordinator: RemoteSocketCoordinator; + } | null>(null); // Last transcript-snapshot save time per `desktopId:threadId`, used to throttle // full-blob writes while a thread is actively streaming. const threadSnapshotSavedAtRef = useRef>(new Map()); @@ -293,9 +303,14 @@ export function useRemoteDesktop() { const desktopCandidate = activeDesktop; if (!desktopCandidate) return; const desktop: StoredDesktop = desktopCandidate; + const liveSocketSeq = liveSocketSeqRef.current; + const initialLastSeenSeq = Math.max( + desktop.lastSeenSeq, + liveSocketSeq.get(desktop.desktopId) ?? 0, + ); const coordinator = createRemoteSocketCoordinator({ createClient: () => clientFor(desktop), - initialLastSeenSeq: desktop.lastSeenSeq, + initialLastSeenSeq, getSelectedThreadId: () => selectedThreadIdRef.current, requestRefresh: (options) => { void refresh(desktop, options); @@ -307,8 +322,15 @@ export function useRemoteDesktop() { }, getPairingExpiredMessage: () => i18n._(msg`Pairing expired — pair again to reconnect.`), }); + socketCoordinatorRef.current = { desktopId: desktop.desktopId, coordinator }; coordinator.start(); - return () => coordinator.dispose(); + return () => { + if (socketCoordinatorRef.current?.coordinator === coordinator) { + socketCoordinatorRef.current = null; + } + liveSocketSeq.set(desktop.desktopId, coordinator.getLastSeenSeq()); + coordinator.dispose(); + }; // The socket is keyed on the connection identity (not the desktop object, // which is replaced after every refresh) so refreshes don't tear it down. // reconnectNonce forces a fresh connect when the user taps Reconnect. @@ -461,6 +483,13 @@ export function useRemoteDesktop() { if (desktop.desktopId !== activeDesktopIdRef.current) return null; applyShellSnapshot(next); setSnapshot(next); + liveSocketSeqRef.current.set( + desktop.desktopId, + Math.max(liveSocketSeqRef.current.get(desktop.desktopId) ?? 0, next.snapshotSeq), + ); + if (socketCoordinatorRef.current?.desktopId === desktop.desktopId) { + socketCoordinatorRef.current.coordinator.advanceLastSeenSeq(next.snapshotSeq); + } // Auxiliary data (agent statuses + remote-editable AI settings) is // independent of the thread list and streams as live events, so only // re-poll it on explicit/initial/reconnect refreshes. Never let either @@ -606,6 +635,7 @@ export function useRemoteDesktop() { // Bail if the active desktop changed while the fetch was in flight. if (desktop.desktopId !== activeDesktopIdRef.current) return latest; latest = { snapshot: next, fromServer: true }; + seedOlderThreadRuntimeItemsCursor(threadId, next.runtimeNextCursor ?? null); setThreadSnapshot(next); // A fresh server history IS authoritative (fromServer defaults to true). applyThreadSnapshot(next, { fromServer: true }); @@ -909,7 +939,7 @@ export function useRemoteDesktop() { * connection banner/pill so the user is never stuck waiting out a backoff. */ function reconnect() { - setConnection((current) => (current === "online" ? current : "reconnecting")); + setConnection("reconnecting"); setMessage(""); void (async () => { try { diff --git a/src/renderer/locales/de/messages.po b/src/renderer/locales/de/messages.po index 34c05a3aa..4b464d965 100644 --- a/src/renderer/locales/de/messages.po +++ b/src/renderer/locales/de/messages.po @@ -6296,6 +6296,7 @@ msgstr "Auf diesem Remotecomputer sind keine unterstützten Agenten installiert. msgid "No supported agents detected" msgstr "Keine unterstützten Agenten erkannt" +#: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "No thread selected" msgstr "Kein Thread ausgewählt" @@ -6984,6 +6985,7 @@ msgstr "Ausstehende Lenkung" msgid "permission" msgstr "Berechtigung" +#: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "Pick a thread from the list to follow the agent from here." msgstr "Wähle einen Thread aus der Liste, um dem Agenten von hier aus zu folgen." diff --git a/src/renderer/locales/en/messages.po b/src/renderer/locales/en/messages.po index 35c72e204..a44fc4df3 100644 --- a/src/renderer/locales/en/messages.po +++ b/src/renderer/locales/en/messages.po @@ -6296,6 +6296,7 @@ msgstr "No supported agents are installed on this remote machine. Install and si msgid "No supported agents detected" msgstr "No supported agents detected" +#: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "No thread selected" msgstr "No thread selected" @@ -6984,6 +6985,7 @@ msgstr "Pending steer" msgid "permission" msgstr "permission" +#: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "Pick a thread from the list to follow the agent from here." msgstr "Pick a thread from the list to follow the agent from here." diff --git a/src/renderer/locales/es/messages.po b/src/renderer/locales/es/messages.po index c57deb265..daccce9d1 100644 --- a/src/renderer/locales/es/messages.po +++ b/src/renderer/locales/es/messages.po @@ -6296,6 +6296,7 @@ msgstr "No hay agentes compatibles instalados en esta máquina remota. Instala u msgid "No supported agents detected" msgstr "No se detectaron agentes compatibles" +#: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "No thread selected" msgstr "No hay hilo seleccionado" @@ -6984,6 +6985,7 @@ msgstr "Dirección pendiente" msgid "permission" msgstr "permiso" +#: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "Pick a thread from the list to follow the agent from here." msgstr "Elige un hilo de la lista para seguir al agente desde aquí." diff --git a/src/renderer/locales/fr/messages.po b/src/renderer/locales/fr/messages.po index 4d5247966..5a324c008 100644 --- a/src/renderer/locales/fr/messages.po +++ b/src/renderer/locales/fr/messages.po @@ -6295,6 +6295,7 @@ msgstr "Aucun agent compatible n’est installé sur cette machine distante. Ins msgid "No supported agents detected" msgstr "Aucun agent pris en charge détecté" +#: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "No thread selected" msgstr "Aucun fil sélectionné" @@ -6983,6 +6984,7 @@ msgstr "En attente de direction" msgid "permission" msgstr "autorisation" +#: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "Pick a thread from the list to follow the agent from here." msgstr "Choisissez un fil dans la liste pour suivre l'agent depuis ici." diff --git a/src/renderer/locales/ja/messages.po b/src/renderer/locales/ja/messages.po index 6ffed5af7..437afc8e9 100644 --- a/src/renderer/locales/ja/messages.po +++ b/src/renderer/locales/ja/messages.po @@ -6294,6 +6294,7 @@ msgstr "このリモートマシンには対応エージェントがインスト msgid "No supported agents detected" msgstr "サポートされているエージェントが検出されませんでした" +#: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "No thread selected" msgstr "スレッドが選択されていません" @@ -6982,6 +6983,7 @@ msgstr "保留中のステアリング" msgid "permission" msgstr "許可" +#: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "Pick a thread from the list to follow the agent from here." msgstr "ここからエージェントを追うには、一覧からスレッドを選択してください。" diff --git a/src/renderer/locales/ko/messages.po b/src/renderer/locales/ko/messages.po index 1b7527da6..d07d5cdf0 100644 --- a/src/renderer/locales/ko/messages.po +++ b/src/renderer/locales/ko/messages.po @@ -6296,6 +6296,7 @@ msgstr "이 원격 머신에 지원되는 에이전트가 설치되어 있지 msgid "No supported agents detected" msgstr "지원되는 에이전트가 감지되지 않았습니다." +#: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "No thread selected" msgstr "선택된 스레드 없음" @@ -6984,6 +6985,7 @@ msgstr "조정 대기 중" msgid "permission" msgstr "권한" +#: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "Pick a thread from the list to follow the agent from here." msgstr "여기에서 에이전트를 따라가려면 목록에서 스레드를 선택하세요." diff --git a/src/renderer/locales/pl/messages.po b/src/renderer/locales/pl/messages.po index f17c6bf2d..eeb91e31a 100644 --- a/src/renderer/locales/pl/messages.po +++ b/src/renderer/locales/pl/messages.po @@ -6296,6 +6296,7 @@ msgstr "Na tym komputerze zdalnym nie zainstalowano obsługiwanych agentów. Zai msgid "No supported agents detected" msgstr "Nie wykryto obsługiwanych agentów" +#: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "No thread selected" msgstr "Nie wybrano wątku" @@ -6984,6 +6985,7 @@ msgstr "Oczekujące sterowanie" msgid "permission" msgstr "pozwolenie" +#: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "Pick a thread from the list to follow the agent from here." msgstr "Wybierz wątek z listy, aby śledzić agenta stąd." diff --git a/src/renderer/locales/pt-BR/messages.po b/src/renderer/locales/pt-BR/messages.po index 216d908e6..e78fedf65 100644 --- a/src/renderer/locales/pt-BR/messages.po +++ b/src/renderer/locales/pt-BR/messages.po @@ -6296,6 +6296,7 @@ msgstr "Nenhum agente compatível está instalado nesta máquina remota. Instale msgid "No supported agents detected" msgstr "Nenhum agente compatível detectado" +#: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "No thread selected" msgstr "Nenhuma thread selecionada" @@ -6984,6 +6985,7 @@ msgstr "Orientação pendente" msgid "permission" msgstr "permissão" +#: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "Pick a thread from the list to follow the agent from here." msgstr "Escolha uma thread da lista para acompanhar o agente daqui." diff --git a/src/renderer/locales/ru/messages.po b/src/renderer/locales/ru/messages.po index 62b1a95d4..9f46f4033 100644 --- a/src/renderer/locales/ru/messages.po +++ b/src/renderer/locales/ru/messages.po @@ -6296,6 +6296,7 @@ msgstr "На этом удалённом компьютере не устано msgid "No supported agents detected" msgstr "Поддерживаемые агенты не обнаружены" +#: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "No thread selected" msgstr "Поток не выбран" @@ -6984,6 +6985,7 @@ msgstr "Ожидающее направление" msgid "permission" msgstr "разрешение" +#: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "Pick a thread from the list to follow the agent from here." msgstr "Выберите поток из списка, чтобы отслеживать агента отсюда." diff --git a/src/renderer/locales/tr/messages.po b/src/renderer/locales/tr/messages.po index 422daf8ba..141ca733b 100644 --- a/src/renderer/locales/tr/messages.po +++ b/src/renderer/locales/tr/messages.po @@ -6296,6 +6296,7 @@ msgstr "Bu uzak makinede desteklenen bir aracı yüklü değil. Bir iş parçac msgid "No supported agents detected" msgstr "Desteklenen aracı algılanmadı" +#: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "No thread selected" msgstr "İş parçacığı seçilmedi" @@ -6984,6 +6985,7 @@ msgstr "Yönlendirme bekleniyor" msgid "permission" msgstr "izin" +#: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "Pick a thread from the list to follow the agent from here." msgstr "Ajanı buradan takip etmek için listeden bir iş parçacığı seçin." diff --git a/src/renderer/locales/uk/messages.po b/src/renderer/locales/uk/messages.po index bfae5c745..f1efb8561 100644 --- a/src/renderer/locales/uk/messages.po +++ b/src/renderer/locales/uk/messages.po @@ -6296,6 +6296,7 @@ msgstr "На цьому віддаленому комп’ютері не вст msgid "No supported agents detected" msgstr "Підтримуваних агентів не виявлено" +#: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "No thread selected" msgstr "Потік не вибрано" @@ -6984,6 +6985,7 @@ msgstr "Очікуване скерування" msgid "permission" msgstr "дозвіл" +#: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "Pick a thread from the list to follow the agent from here." msgstr "Виберіть потік зі списку, щоб стежити за агентом звідси." diff --git a/src/renderer/locales/vi/messages.po b/src/renderer/locales/vi/messages.po index 76e9b74dd..cefa2371f 100644 --- a/src/renderer/locales/vi/messages.po +++ b/src/renderer/locales/vi/messages.po @@ -6296,6 +6296,7 @@ msgstr "Không có tác tử được hỗ trợ nào được cài đặt trên msgid "No supported agents detected" msgstr "Không phát hiện thấy tác nhân được hỗ trợ nào" +#: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "No thread selected" msgstr "Chưa chọn luồng" @@ -6984,6 +6985,7 @@ msgstr "Đang chờ chỉ đạo" msgid "permission" msgstr "sự cho phép" +#: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "Pick a thread from the list to follow the agent from here." msgstr "Chọn một luồng trong danh sách để theo dõi agent từ đây." diff --git a/src/renderer/locales/zh-CN/messages.po b/src/renderer/locales/zh-CN/messages.po index fde2efff9..2ab6135be 100644 --- a/src/renderer/locales/zh-CN/messages.po +++ b/src/renderer/locales/zh-CN/messages.po @@ -6295,6 +6295,7 @@ msgstr "此远程计算机上未安装受支持的代理。请先在远程安装 msgid "No supported agents detected" msgstr "未检测到支持的代理" +#: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "No thread selected" msgstr "未选择线程" @@ -6983,6 +6984,7 @@ msgstr "等待引导" msgid "permission" msgstr "权限" +#: src/mobile/routeComponents.tsx #: src/mobile/views/ThreadView.tsx msgid "Pick a thread from the list to follow the agent from here." msgstr "从列表中选择一个线程,即可在此跟进代理。" diff --git a/src/renderer/state/chatRuntimePersister.test.ts b/src/renderer/state/chatRuntimePersister.test.ts index 55d065237..fc7cb94b4 100644 --- a/src/renderer/state/chatRuntimePersister.test.ts +++ b/src/renderer/state/chatRuntimePersister.test.ts @@ -5,6 +5,7 @@ import { compactRuntimeItemsForHydration, hydrateThreadRuntimeItems, loadOlderThreadRuntimeItems, + seedOlderThreadRuntimeItemsCursor, } from "./chatRuntimePersister"; const { bridge } = vi.hoisted(() => ({ @@ -209,4 +210,24 @@ describe("paged runtime hydration", () => { await expect(loadOlderThreadRuntimeItems("paged-thread")).resolves.toBe(false); expect(bridge.dbGetThreadRuntimeItemsPage).toHaveBeenCalledTimes(2); }); + + it("loads older items from a cursor supplied by a remote tail snapshot", async () => { + seedOlderThreadRuntimeItemsCursor("remote-paged-thread", 77); + bridge.dbGetThreadRuntimeItemsPage.mockResolvedValueOnce({ + items: [makeItem({ id: "remote-older", type: "assistant_message" })], + nextCursor: null, + }); + + await expect(loadOlderThreadRuntimeItems("remote-paged-thread")).resolves.toBe(true); + + expect(bridge.dbGetThreadRuntimeItemsPage).toHaveBeenCalledWith({ + threadId: "remote-paged-thread", + beforePosition: 77, + limit: 500, + targetTimelineEntryCount: 40, + }); + expect(useAppStore.getState().runtimeItemIdsByThread["remote-paged-thread"]).toEqual([ + "remote-older", + ]); + }); }); diff --git a/src/renderer/state/chatRuntimePersister.ts b/src/renderer/state/chatRuntimePersister.ts index 9d3ef006f..df80c4eda 100644 --- a/src/renderer/state/chatRuntimePersister.ts +++ b/src/renderer/state/chatRuntimePersister.ts @@ -20,6 +20,11 @@ const pendingOlderRuntimePages = new Map>(); const retainedThreadRuntimeCounts = new Map(); const inactiveThreadRuntimeLru = new Set(); +/** Seed the older-page cursor when a remote thread snapshot supplies its tail. */ +export function seedOlderThreadRuntimeItemsCursor(threadId: string, cursor: number | null): void { + olderRuntimePageCursorByThread.set(threadId, cursor); +} + export function hasHydratedThreadRuntimeItems(threadId: string): boolean { return ( hydratedThreadRuntimeIds.has(threadId) || diff --git a/src/renderer/state/remote/sync.ts b/src/renderer/state/remote/sync.ts index 6d9a6bfd1..64e7c2f8a 100644 --- a/src/renderer/state/remote/sync.ts +++ b/src/renderer/state/remote/sync.ts @@ -73,7 +73,16 @@ export function applyThreadSnapshot( if (pendingRuntimeEvents.has(threadId)) { flushPendingRuntimeEventsSync(); } - const items = snapshot.runtimeItems.map(toRuntimeChatItem); + const snapshotItems = snapshot.runtimeItems.map(toRuntimeChatItem); + const firstSnapshotItemId = snapshotItems[0]?.id; + const overlapIndex = firstSnapshotItemId ? existingIds.indexOf(firstSnapshotItemId) : -1; + const preservedOlderItems = + snapshot.runtimeNextCursor !== undefined && overlapIndex > 0 + ? existingIds + .slice(0, overlapIndex) + .flatMap((itemId) => (existingItems?.[itemId] ? [existingItems[itemId]] : [])) + : []; + const items = [...preservedOlderItems, ...snapshotItems]; useAppStore.setState((current) => ({ runtimeItemIdsByThread: { ...current.runtimeItemIdsByThread, diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 4a753ed1d..4e220fcf8 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -1,14 +1,14 @@ /* Poracode brand type — Geist (Vercel, SIL OFL), bundled for offline use. */ @font-face { font-family: "Geist"; - src: url("./fonts/Geist-Variable.woff2") format("woff2"); + src: url("../renderer/fonts/Geist-Variable.woff2") format("woff2"); font-weight: 100 900; font-style: normal; font-display: swap; } @font-face { font-family: "Geist Mono"; - src: url("./fonts/GeistMono-Variable.woff2") format("woff2"); + src: url("../renderer/fonts/GeistMono-Variable.woff2") format("woff2"); font-weight: 100 900; font-style: normal; font-display: swap; diff --git a/src/server/relay/relayHost.test.ts b/src/server/relay/relayHost.test.ts index 8c8a7d375..1c20241c2 100644 --- a/src/server/relay/relayHost.test.ts +++ b/src/server/relay/relayHost.test.ts @@ -250,6 +250,39 @@ describe("startRelayHost", () => { handle.dispose(); }); + it("drops high-volume stream frames before they disconnect every relay channel", () => { + const control = fakeSocket(); + const terminalLocal = fakeSocket(); + const otherLocal = fakeSocket(); + const locals = [terminalLocal, otherLocal]; + const handle = startRelayHost({ + relayUrl: "ws://relay.test/host", + serverId: "srv-1", + secret: "secret", + localHttpUrl: "http://127.0.0.1:38987", + maxWebSocketOutboundBufferBytes: 512, + socketFactory: () => control, + wsFactory: () => locals.shift()!, + }); + + control.onopen?.(); + control.onmessage?.(frame({ t: "ws-open", id: "terminal", path: "/ws?ticket=t1" })); + control.onmessage?.(frame({ t: "ws-open", id: "other", path: "/ws?ticket=t2" })); + control.bufferedAmount = 300; + terminalLocal.onmessage?.({ + data: JSON.stringify({ type: "terminal-output", id: "thread-1", data: "noisy" }), + }); + otherLocal.onmessage?.({ data: JSON.stringify({ type: "ready", seq: 1 }) }); + + const sent = control.sent.map((data) => JSON.parse(data) as { t: string; id?: string }); + expect(sent).not.toContainEqual(expect.objectContaining({ t: "ws-data", id: "terminal" })); + expect(sent).toContainEqual(expect.objectContaining({ t: "ws-data", id: "other" })); + expect(control.closed).toBe(false); + expect(terminalLocal.closed).toBe(false); + expect(otherLocal.closed).toBe(false); + handle.dispose(); + }); + it("closes the relay channel when opening the local websocket fails", () => { const error = new Error("local open failed"); const control = fakeSocket(); diff --git a/src/server/relay/relayHost.ts b/src/server/relay/relayHost.ts index 91de1e8b2..b365c038a 100644 --- a/src/server/relay/relayHost.ts +++ b/src/server/relay/relayHost.ts @@ -85,6 +85,14 @@ interface LocalWsChannel { const DEFAULT_REQUEST_TIMEOUT_MS = 60_000; const WEB_SOCKET_OPEN = 1; +const DROPPABLE_STREAM_SOFT_BUFFER_BYTES = 1_500_000; + +function isDroppableStreamFrame(data: string): boolean { + const parsed = safeJsonParse(data); + if (!parsed || typeof parsed !== "object") return false; + const type = (parsed as { type?: unknown }).type; + return type === "terminal-output" || type === "browser-frame"; +} /** * Build the synthetic `x-forwarded-for` value the host forwards to its own @@ -111,6 +119,10 @@ export function startRelayHost(options: RelayHostOptions): RelayHostHandle { options.maxWebSocketPayloadBytes ?? relayWebSocketPayloadLimit(maxBodyBytes); const maxWebSocketOutboundBufferBytes = options.maxWebSocketOutboundBufferBytes ?? relayWebSocketPayloadLimit(maxBodyBytes); + const droppableStreamSoftBufferBytes = Math.min( + DROPPABLE_STREAM_SOFT_BUFFER_BYTES, + Math.floor(maxWebSocketOutboundBufferBytes / 2), + ); let disposed = false; let control: RelaySocket | null = null; @@ -325,7 +337,14 @@ export function startRelayHost(options: RelayHostOptions): RelayHostHandle { }; local.onmessage = (event) => { if (control === sourceControl) { - if (!sendOn(sourceControl, { t: "ws-data", id: frame.id, data: String(event.data) })) { + const data = String(event.data); + if ( + (sourceControl.bufferedAmount ?? 0) > droppableStreamSoftBufferBytes && + isDroppableStreamFrame(data) + ) { + return; + } + if (!sendOn(sourceControl, { t: "ws-data", id: frame.id, data })) { if (wsChannels.delete(frame.id)) closeSocket(local); } } diff --git a/src/shared/remote/client.test.ts b/src/shared/remote/client.test.ts index 6e3c790e3..c6b4aa43b 100644 --- a/src/shared/remote/client.test.ts +++ b/src/shared/remote/client.test.ts @@ -97,6 +97,38 @@ describe("RemoteDesktopClient", () => { expect(authorization).toBe("Bearer lc_access_test"); }); + it("requests a tail snapshot and encodes older runtime page cursors", async () => { + const requestedUrls: string[] = []; + const client = new RemoteDesktopClient( + "https://relay.example.test/s/server-1/", + "lc_access_test", + async (url) => { + requestedUrls.push(String(url)); + return new Response(JSON.stringify({ items: [], nextCursor: null }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + ); + + await expect( + client.threadRuntimeItemsPage({ + threadId: "thread one", + beforePosition: 42, + limit: 500, + targetTimelineEntryCount: 40, + }), + ).resolves.toEqual({ items: [], nextCursor: null }); + await client.threadHistory("thread one").catch(() => undefined); + + expect(requestedUrls[0]).toBe( + "https://relay.example.test/s/server-1/api/threads/thread%20one/history/items?limit=500&beforePosition=42&targetTimelineEntryCount=40", + ); + expect(requestedUrls[1]).toBe( + "https://relay.example.test/s/server-1/api/threads/thread%20one/history?runtimePage=1", + ); + }); + it("passes an abort signal to remote fetches", async () => { let signal: AbortSignal | undefined; const client = new RemoteDesktopClient( @@ -175,6 +207,23 @@ describe("RemoteDesktopClient", () => { }); }); + it("allows WebSocket ticket requests to use the connection deadline", async () => { + vi.useFakeTimers(); + const client = new RemoteDesktopClient( + "http://127.0.0.1:38987/", + undefined, + () => new Promise(() => {}), + ); + + const request = client.websocketTicket(15).catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(15); + + await expect(request).resolves.toMatchObject({ + code: "timeout", + message: "Remote request timed out after 15ms.", + }); + }); + it("rejects direct remote responses above the configured body limit", async () => { const client = new RemoteDesktopClient( "http://127.0.0.1:38987/", diff --git a/src/shared/remote/client.ts b/src/shared/remote/client.ts index 5dfd3e90d..766dc3211 100644 --- a/src/shared/remote/client.ts +++ b/src/shared/remote/client.ts @@ -17,6 +17,7 @@ import { remoteSettingsSchema, remoteSchedulesResponseSchema, remoteProjectCommandResultSchema, + remoteRuntimeItemsPageSchema, remoteShellSnapshotSchema, remoteThreadSnapshotSchema, remoteWebSocketServerMessageSchema, @@ -35,6 +36,8 @@ import { type RemoteProjectCommand, type RemoteProjectCommandResult, type RemotePushRegistration, + type RemoteRuntimeItemsPage, + type RemoteRuntimeItemsPageRequest, type RemoteSettings, type RemoteSettingsPatch, type RemoteScheduleCommand, @@ -448,7 +451,26 @@ export class RemoteDesktopClient { async threadHistory(threadId: string): Promise { return remoteThreadSnapshotSchema.parse( - await this.requestJson(`/api/threads/${encodeURIComponent(threadId)}/history`), + await this.requestJson(`/api/threads/${encodeURIComponent(threadId)}/history?runtimePage=1`), + ); + } + + async threadRuntimeItemsPage( + input: RemoteRuntimeItemsPageRequest, + ): Promise { + const search = new URLSearchParams({ + limit: String(input.limit), + ...(input.beforePosition !== undefined + ? { beforePosition: String(input.beforePosition) } + : {}), + ...(input.targetTimelineEntryCount !== undefined + ? { targetTimelineEntryCount: String(input.targetTimelineEntryCount) } + : {}), + }); + return remoteRuntimeItemsPageSchema.parse( + await this.requestJson( + `/api/threads/${encodeURIComponent(input.threadId)}/history/items?${search}`, + ), ); } @@ -686,9 +708,12 @@ export class RemoteDesktopClient { await this.requestJson("/api/push/unregister", { method: "POST", body: { deviceId } }); } - async websocketTicket(): Promise { + async websocketTicket(timeoutMs?: number): Promise { const result = remoteWebSocketTicketResultSchema.parse( - await this.requestJson("/api/auth/websocket-ticket", { method: "POST" }), + await this.requestJson("/api/auth/websocket-ticket", { + method: "POST", + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + }), ); return result.ticket; } diff --git a/src/shared/remote/protocol.ts b/src/shared/remote/protocol.ts index d162425c6..0774e0629 100644 --- a/src/shared/remote/protocol.ts +++ b/src/shared/remote/protocol.ts @@ -442,6 +442,8 @@ export const remoteThreadSnapshotSchema = z.object({ snapshotSeq: z.number().int().nonnegative(), thread: threadSchema, runtimeItems: z.array(persistedRuntimeItemSchema), + /** Cursor for older runtime items when the server returned a tail page. */ + runtimeNextCursor: z.number().int().nonnegative().nullable().optional(), completedTurns: z.array(persistedCompletedTurnSchema), contextUsage: threadContextUsageSchema.nullable(), terminalScrollback: z.string().optional(), @@ -450,6 +452,20 @@ export const remoteThreadSnapshotSchema = z.object({ }); export type RemoteThreadSnapshot = z.infer; +export const remoteRuntimeItemsPageRequestSchema = z.object({ + threadId: z.string().min(1), + beforePosition: z.number().int().nonnegative().optional(), + limit: z.number().int().min(1).max(500), + targetTimelineEntryCount: z.number().int().min(1).max(100).optional(), +}); +export type RemoteRuntimeItemsPageRequest = z.infer; + +export const remoteRuntimeItemsPageSchema = z.object({ + items: z.array(persistedRuntimeItemSchema), + nextCursor: z.number().int().nonnegative().nullable(), +}); +export type RemoteRuntimeItemsPage = z.infer; + /** * Desktop settings editable from a remote client ("Remote settings" in the * PWA, as opposed to its device-local settings). Only settings the desktop diff --git a/vercel.json b/vercel.json index 6b9e7b2bc..0868076e1 100644 --- a/vercel.json +++ b/vercel.json @@ -18,7 +18,7 @@ "source": "/pwa/service-worker.js", "headers": [ { "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }, - { "key": "Service-Worker-Allowed", "value": "/pwa/" } + { "key": "Service-Worker-Allowed", "value": "/" } ] }, { diff --git a/vite.config.ts b/vite.config.ts index 599784218..315cf8881 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -425,7 +425,7 @@ export default defineConfig(({ mode }) => ({ !/[\\/]@shikijs[\\/](?:langs|themes)[\\/]/.test(id), priority: 10, }, - ], + ].filter((group) => !mobileOnly || group.name === "ui" || group.name === "framework"), }, }, }, diff --git a/website/next.config.js b/website/next.config.js index 49967ba60..418a5cbf8 100644 --- a/website/next.config.js +++ b/website/next.config.js @@ -49,6 +49,10 @@ const nextConfig = { source: "/app", destination: "https://poracode-pwa.vercel.app/pwa/app", }, + { + source: "/app/:path*", + destination: "https://poracode-pwa.vercel.app/pwa/:path*", + }, { source: "/pwa/:path*", destination: "https://poracode-pwa.vercel.app/pwa/:path*",