diff --git a/src/main/db.ts b/src/main/db.ts index 2a88c7dcd..4515670f5 100644 --- a/src/main/db.ts +++ b/src/main/db.ts @@ -35,6 +35,7 @@ export { onProjectThreadDataChanged } from "./db/projectThreadChanges"; export { dbReadThreadRuntimeSummaries, dbGetThreadRuntimeSummaries, + dbGetThreadRuntimeItem, dbGetThreadRuntimeItems, dbGetThreadRuntimeItemsPage, dbTruncateThreadRuntimeAfter, diff --git a/src/main/db/runtimeItems.ts b/src/main/db/runtimeItems.ts index fe19dfbec..4862348a6 100644 --- a/src/main/db/runtimeItems.ts +++ b/src/main/db/runtimeItems.ts @@ -190,6 +190,25 @@ export function dbGetThreadRuntimeItems(threadId: string): PersistedRuntimeItem[ return rows.map(mapRuntimeItemRow); } +/** + * Reads one runtime item by id. Exists so the remote image endpoint can resolve + * a single inline image without loading a whole thread's payloads — a + * screenshot-heavy transcript is hundreds of megabytes, so + * `dbGetThreadRuntimeItems` is not an option on that path. + */ +export function dbGetThreadRuntimeItem( + threadId: string, + itemId: string, +): PersistedRuntimeItem | null { + const sqlite = getSqlite(); + const row = sqlite + .prepare( + "SELECT item_id, type, state, payload, streams, parent_item_id FROM thread_runtime_items WHERE thread_id = ? AND item_id = ?", + ) + .get(threadId, itemId) as PersistedRuntimeItemRow | undefined; + return row ? mapRuntimeItemRow(row) : null; +} + export function dbGetThreadRuntimeItemsPage( threadId: string, beforePosition: number | undefined, diff --git a/src/main/gitState/GitStateService.test.ts b/src/main/gitState/GitStateService.test.ts new file mode 100644 index 000000000..f9a484fe9 --- /dev/null +++ b/src/main/gitState/GitStateService.test.ts @@ -0,0 +1,227 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { GitStatusResult, PrData, PrDetails, Project } from "@/shared/contracts"; +import { gitTargetKey, pullRequestBranchKey, pullRequestKey } from "@/shared/gitState"; +import { + GitStateService, + type GitStateExecutor, + type GitStateServiceOptions, +} from "./GitStateService"; + +const project: Project = { + id: "project-1", + name: "Repo", + location: { kind: "posix", path: "/repo" }, + createdAt: "2026-07-28T00:00:00.000Z", +}; + +function status(branch = "feature/unified"): GitStatusResult { + return { + isRepo: true, + branch, + tracking: `origin/${branch}`, + hasRemote: true, + remoteInfo: { + url: "git@github.com:poracode/repo.git", + platform: "github", + owner: "poracode", + repo: "repo", + }, + ahead: 0, + behind: 0, + staged: [], + unstaged: [], + totalInsertions: 0, + totalDeletions: 0, + }; +} + +function pr(overrides: Partial = {}): PrData { + return { + number: 42, + state: "open", + title: "Unify Git state", + url: "https://github.test/poracode/repo/pull/42", + baseBranch: "main", + isDraft: false, + checksStatus: "PENDING", + updatedAt: "2026-07-28T12:00:00.000Z", + ...overrides, + }; +} + +function details(conclusion = ""): PrDetails { + return { + number: 42, + title: "Unify Git state", + body: "", + baseBranch: "main", + headBranch: "feature/unified", + additions: 12, + deletions: 3, + changedFiles: 4, + commits: [], + comments: [], + reviews: [], + checks: [{ name: "build", state: "IN_PROGRESS", conclusion }], + }; +} + +function executor(): GitStateExecutor { + return { + gitProjectSnapshot: vi.fn(async () => ({ + status: status(), + branches: { current: "feature/unified", branches: [] }, + worktrees: [], + ghAvailable: true, + })), + getGitStatus: vi.fn(async () => status()), + gitWorktreeStatusBatch: vi.fn(async () => ({ + statuses: {}, + })), + gitGetWorktreeSourceBranch: vi.fn(async () => ({ + sourceBranch: "main", + commitsAhead: 1, + sourceAhead: 0, + })), + ghGetPrForBranch: vi.fn(async () => pr()), + ghGetPrDetails: vi.fn(async () => ({ + details: details(), + })), + ghGetPrFiles: vi.fn(async () => ({ files: [] })), + ghGetPrDiff: vi.fn(async () => ({ diff: "" })), + ghGetPrReviewComments: vi.fn(async () => ({ + threads: [], + comments: [], + })), + ghListPullRequests: vi.fn(async () => ({ + pullRequests: [], + viewerLogin: "viewer", + })), + }; +} + +function createService(overrides: Partial = {}): { + service: GitStateService; + executor: GitStateExecutor; + patches: ReturnType>>; +} { + const fakeExecutor = executor(); + const patches = vi.fn>(); + return { + executor: fakeExecutor, + patches, + service: new GitStateService({ + hostId: "host-1", + executor: fakeExecutor, + getProject: (projectId) => (projectId === project.id ? project : null), + onPatch: patches, + now: () => new Date("2026-07-28T12:00:00.000Z"), + ...overrides, + }), + }; +} + +describe("GitStateService", () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it("normalizes a branch PR once and associates every matching target", async () => { + const { service, executor: fakeExecutor } = createService(); + const worktreeA = "/repo/.poracode/worktrees/a"; + const worktreeB = "/repo/.poracode/worktrees/b"; + + await Promise.all([ + service.refreshTarget({ + projectId: project.id, + worktreePath: worktreeA, + branch: "feature/unified", + }), + service.refreshTarget({ + projectId: project.id, + worktreePath: worktreeB, + branch: "feature/unified", + }), + ]); + + const snapshot = service.getSnapshot(); + const prRef = { hostId: "host-1", projectId: project.id, prNumber: 42 }; + const prKey = pullRequestKey(prRef); + expect(snapshot.pullRequests[prKey]?.data.title).toBe("Unify Git state"); + expect( + snapshot.targets[ + gitTargetKey({ + hostId: "host-1", + projectId: project.id, + worktreePath: worktreeA, + }) + ]?.pullRequestKey, + ).toBe(prKey); + expect( + snapshot.targets[ + gitTargetKey({ + hostId: "host-1", + projectId: project.id, + worktreePath: worktreeB, + }) + ]?.pullRequestKey, + ).toBe(prKey); + expect(fakeExecutor.ghGetPrForBranch).toHaveBeenCalledTimes(1); + }); + + it("publishes same-branch core and details changes through one canonical PR", async () => { + const { service, executor: fakeExecutor, patches } = createService(); + const getPr = vi.mocked(fakeExecutor.ghGetPrForBranch); + const getDetails = vi.mocked(fakeExecutor.ghGetPrDetails); + getPr.mockResolvedValueOnce(pr()).mockResolvedValueOnce( + pr({ + checksStatus: "SUCCESS", + updatedAt: "2026-07-28T12:01:00.000Z", + }), + ); + getDetails + .mockResolvedValueOnce({ details: details("") }) + .mockResolvedValueOnce({ details: details("SUCCESS") }); + + await service.refreshPullRequestForBranch(project.id, "feature/unified", { + includeDetails: true, + }); + await service.refreshPullRequestForBranch(project.id, "feature/unified", { + includeDetails: true, + }); + + const key = pullRequestKey({ hostId: "host-1", projectId: project.id, prNumber: 42 }); + expect(service.getSnapshot().pullRequests[key]?.data.checksStatus).toBe("SUCCESS"); + expect(service.getSnapshot().pullRequests[key]?.details?.checks[0]?.conclusion).toBe("SUCCESS"); + expect( + service.getSnapshot().pullRequestKeyByBranch[ + pullRequestBranchKey({ hostId: "host-1", projectId: project.id }, "feature/unified") + ], + ).toBe(key); + expect(patches).toHaveBeenCalledTimes(2); + }); + + it("refreshes visible interests on a host timer without renderer focus", async () => { + vi.useFakeTimers(); + const { service, executor: fakeExecutor } = createService({ pollIntervalMs: 1000 }); + service.start(); + service.setInterests("remote-session", [ + { + kind: "target", + projectId: project.id, + worktreePath: "/repo/.poracode/worktrees/a", + branch: "feature/unified", + includePrDetails: true, + }, + ]); + await vi.waitFor(() => { + expect(fakeExecutor.ghGetPrForBranch).toHaveBeenCalledTimes(1); + }); + + await vi.advanceTimersByTimeAsync(1000); + + expect(fakeExecutor.ghGetPrForBranch).toHaveBeenCalledTimes(2); + expect(fakeExecutor.ghGetPrDetails).toHaveBeenCalledTimes(2); + service.dispose(); + }); +}); diff --git a/src/main/gitState/GitStateService.ts b/src/main/gitState/GitStateService.ts new file mode 100644 index 000000000..d7d53ed67 --- /dev/null +++ b/src/main/gitState/GitStateService.ts @@ -0,0 +1,462 @@ +import type { + GhGetPrDetailsResult, + GhGetPrDiffResult, + GhGetPrFilesResult, + GhGetPrReviewThreadsResult, + GhListPullRequestsResult, + GitGetWorktreeSourceBranchResult, + GitProjectSnapshotResult, + GitStatusResult, + GitWorktreeStatusBatchResult, + Project, + ProjectLocation, +} from "@/shared/contracts"; +import { + applyGitStatePatch, + emptyGitStateSnapshot, + gitProjectKey, + gitTargetKey, + pullRequestBranchKey, + pullRequestKey, + type GitStateInterest, + type GitStatePatch, + type GitStateSnapshot, + type GitTargetRef, + type PullRequestState, +} from "@/shared/gitState"; +import type { SupervisorEvent } from "@/shared/ipc"; +import { buildWorktreeLocation } from "@/shared/worktree"; + +const DEFAULT_POLL_INTERVAL_MS = 30_000; + +export interface GitStateExecutor { + gitProjectSnapshot(input: { + projectLocation: ProjectLocation; + includeGhCheck: boolean; + }): Promise; + getGitStatus(input: { projectLocation: ProjectLocation }): Promise; + gitWorktreeStatusBatch(input: { + projectLocation: ProjectLocation; + worktreePaths: string[]; + detail?: "summary" | "full"; + }): Promise; + gitGetWorktreeSourceBranch(input: { + projectLocation: ProjectLocation; + branch: string; + }): Promise; + ghGetPrForBranch(input: { + projectLocation: ProjectLocation; + branch: string; + }): Promise; + ghGetPrDetails(input: { + projectLocation: ProjectLocation; + prNumber: number; + }): Promise; + ghGetPrFiles(input: { + projectLocation: ProjectLocation; + prNumber: number; + }): Promise; + ghGetPrDiff(input: { + projectLocation: ProjectLocation; + prNumber: number; + }): Promise; + ghGetPrReviewComments(input: { + projectLocation: ProjectLocation; + prNumber: number; + }): Promise; + ghListPullRequests(input: { + projectLocation: ProjectLocation; + }): Promise; +} + +export interface GitStateServiceOptions { + readonly hostId: string; + readonly executor: GitStateExecutor; + readonly getProject: (projectId: string) => Project | null; + readonly onPatch?: ((patch: GitStatePatch) => void) | undefined; + readonly now?: (() => Date) | undefined; + readonly pollIntervalMs?: number | undefined; +} + +export class GitStateService { + private snapshot: GitStateSnapshot = emptyGitStateSnapshot(); + private readonly inFlight = new Map>(); + private readonly interestsByOwner = new Map(); + private pollTimer: ReturnType | null = null; + private disposed = false; + + constructor(private readonly options: GitStateServiceOptions) {} + + start(): void { + if (this.pollTimer || this.disposed) return; + this.pollTimer = setInterval( + () => void this.refreshInterests(), + this.options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, + ); + this.pollTimer.unref?.(); + } + + dispose(): void { + this.disposed = true; + if (this.pollTimer) clearInterval(this.pollTimer); + this.pollTimer = null; + this.interestsByOwner.clear(); + } + + getSnapshot(): GitStateSnapshot { + return this.snapshot; + } + + setInterests(ownerId: string, interests: readonly GitStateInterest[]): void { + if (interests.length === 0) { + this.interestsByOwner.delete(ownerId); + return; + } + this.interestsByOwner.set(ownerId, interests); + void this.refreshInterests(interests); + } + + clearInterests(ownerId: string): void { + this.interestsByOwner.delete(ownerId); + } + + observeSupervisorEvent(event: SupervisorEvent): void { + if (event.type !== "git-changed" && event.type !== "project-tree-changed") return; + void this.refreshInterestedProject(event.projectId); + } + + async refreshProject(projectId: string): Promise { + const project = this.requireProject(projectId); + await this.dedupe(`project:${projectId}`, async () => { + const result = await this.options.executor.gitProjectSnapshot({ + projectLocation: project.location, + includeGhCheck: true, + }); + const refreshedAt = this.timestamp(); + const projectRef = { hostId: this.options.hostId, projectId }; + const projectKey = gitProjectKey(projectRef); + const targetRef: GitTargetRef = projectRef; + const targetKey = gitTargetKey(targetRef); + this.publish({ + projects: { + [projectKey]: { + ref: projectRef, + ...(result.status ? { status: result.status } : {}), + ...(result.branches ? { branches: result.branches } : {}), + ...(result.worktrees ? { worktrees: result.worktrees } : {}), + ...(result.ghAvailable !== null ? { ghAvailable: result.ghAvailable } : {}), + refreshedAt, + }, + }, + targets: { + [targetKey]: { + ...this.snapshot.targets[targetKey], + ref: targetRef, + ...(result.status ? { status: result.status } : {}), + refreshedAt, + }, + }, + }); + + if (result.status?.branch && result.ghAvailable !== false) { + await this.refreshPullRequestForBranch(projectId, result.status.branch); + } + }); + } + + async refreshTarget(input: { + readonly projectId: string; + readonly worktreePath?: string | undefined; + readonly branch?: string | undefined; + readonly includePrDetails?: boolean | undefined; + }): Promise { + if (!input.worktreePath) { + await this.refreshProject(input.projectId); + return; + } + const project = this.requireProject(input.projectId); + const targetRef: GitTargetRef = { + hostId: this.options.hostId, + projectId: input.projectId, + worktreePath: input.worktreePath, + }; + const targetKey = gitTargetKey(targetRef); + await this.dedupe(`target:${targetKey}`, async () => { + const projectLocation = buildWorktreeLocation(project.location, input.worktreePath!); + const status = await this.options.executor.getGitStatus({ projectLocation }); + const branch = input.branch ?? status.branch; + const sourceInfo = branch + ? await this.options.executor + .gitGetWorktreeSourceBranch({ + projectLocation: project.location, + branch, + }) + .catch(() => undefined) + : undefined; + this.publish({ + targets: { + [targetKey]: { + ...this.snapshot.targets[targetKey], + ref: targetRef, + status, + ...(sourceInfo ? { sourceInfo } : {}), + refreshedAt: this.timestamp(), + }, + }, + }); + if (branch) { + await this.refreshPullRequestForBranch(input.projectId, branch, { + includeDetails: input.includePrDetails, + }); + } + }); + } + + async refreshPullRequestForBranch( + projectId: string, + branch: string, + options: { readonly includeDetails?: boolean | undefined } = {}, + ): Promise { + const project = this.requireProject(projectId); + const projectRef = { hostId: this.options.hostId, projectId }; + const branchKey = pullRequestBranchKey(projectRef, branch); + return this.dedupe(`pr-branch:${branchKey}:${Boolean(options.includeDetails)}`, async () => { + const data = await this.options.executor.ghGetPrForBranch({ + projectLocation: project.location, + branch, + }); + if (!data) { + this.publish({ + pullRequestKeyByBranch: { [branchKey]: null }, + targets: this.targetsForBranch(projectId, branch, null), + }); + return null; + } + + const ref = { ...projectRef, prNumber: data.number }; + const key = pullRequestKey(ref); + const existing = this.snapshot.pullRequests[key]; + const fetchedAt = this.timestamp(); + const details = options.includeDetails + ? await this.options.executor + .ghGetPrDetails({ + projectLocation: project.location, + prNumber: data.number, + }) + .then((result) => result.details) + .catch(() => undefined) + : undefined; + this.publish({ + pullRequests: { + [key]: { + ...existing, + ref, + data, + ...(details ? { details } : {}), + freshness: { + ...existing?.freshness, + core: fetchedAt, + ...(details ? { details: fetchedAt } : {}), + }, + }, + }, + pullRequestKeyByBranch: { [branchKey]: key }, + targets: this.targetsForBranch(projectId, branch, key), + }); + return data; + }); + } + + async refreshPullRequestReviewBundle(input: { + readonly projectId: string; + readonly prNumber: number; + readonly branch?: string | undefined; + }): Promise { + const project = this.requireProject(input.projectId); + const ref = { + hostId: this.options.hostId, + projectId: input.projectId, + prNumber: input.prNumber, + }; + const key = pullRequestKey(ref); + await this.dedupe(`pr-bundle:${key}`, async () => { + if (input.branch) { + await this.refreshPullRequestForBranch(input.projectId, input.branch); + } else if (!this.snapshot.pullRequests[key]) { + await this.refreshProjectPullRequests(input.projectId); + } + const [detailsResult, filesResult, diffResult, reviewResult] = await Promise.all([ + this.options.executor.ghGetPrDetails({ + projectLocation: project.location, + prNumber: input.prNumber, + }), + this.options.executor.ghGetPrFiles({ + projectLocation: project.location, + prNumber: input.prNumber, + }), + this.options.executor.ghGetPrDiff({ + projectLocation: project.location, + prNumber: input.prNumber, + }), + this.options.executor.ghGetPrReviewComments({ + projectLocation: project.location, + prNumber: input.prNumber, + }), + ]); + const existing = this.snapshot.pullRequests[key]; + if (!existing) { + throw new Error( + `Pull request ${input.projectId}#${input.prNumber} has no core state; refresh its branch or project list first.`, + ); + } + const fetchedAt = this.timestamp(); + this.publish({ + pullRequests: { + [key]: { + ...existing, + details: detailsResult.details, + files: filesResult.files, + diff: diffResult.diff, + reviewThreads: reviewResult.threads, + freshness: { + ...existing.freshness, + details: fetchedAt, + files: fetchedAt, + diff: fetchedAt, + reviewThreads: fetchedAt, + }, + }, + }, + }); + }); + } + + async refreshProjectPullRequests(projectId: string): Promise { + const project = this.requireProject(projectId); + const projectRef = { hostId: this.options.hostId, projectId }; + const projectKey = gitProjectKey(projectRef); + await this.dedupe(`pr-list:${projectKey}`, async () => { + const result = await this.options.executor.ghListPullRequests({ + projectLocation: project.location, + }); + const refreshedAt = this.timestamp(); + const pullRequests: Record = {}; + const aliases: Record = {}; + const keys: string[] = []; + for (const summary of result.pullRequests) { + const ref = { ...projectRef, prNumber: summary.pr.number }; + const key = pullRequestKey(ref); + const existing = this.snapshot.pullRequests[key]; + keys.push(key); + pullRequests[key] = { + ...existing, + ref, + data: summary.pr, + freshness: { ...existing?.freshness, core: refreshedAt }, + }; + aliases[pullRequestBranchKey(projectRef, summary.headBranch)] = key; + } + this.publish({ + pullRequests, + pullRequestKeyByBranch: aliases, + projectPullRequestLists: { + [projectKey]: { + project: projectRef, + pullRequestKeys: keys, + ...(result.viewerLogin ? { viewerLogin: result.viewerLogin } : {}), + refreshedAt, + }, + }, + }); + }); + } + + async refreshInterests(explicitInterests?: readonly GitStateInterest[]): Promise { + if (this.disposed) return; + const interests = + explicitInterests ?? + [...this.interestsByOwner.values()].flatMap((ownerInterests) => ownerInterests); + const tasks = new Map>(); + for (const interest of interests) { + if (interest.kind === "target") { + const key = JSON.stringify(interest); + tasks.set(key, this.refreshTarget(interest)); + continue; + } + if (interest.kind === "project-pull-requests") { + tasks.set( + `list:${interest.projectId}`, + this.refreshProjectPullRequests(interest.projectId), + ); + continue; + } + const key = `pr:${interest.projectId}:${interest.prNumber}:${interest.branch ?? ""}`; + const task = interest.includeReviewBundle + ? this.refreshPullRequestReviewBundle(interest) + : interest.branch + ? this.refreshPullRequestForBranch(interest.projectId, interest.branch).then( + () => undefined, + ) + : Promise.resolve(); + tasks.set(key, task); + } + await Promise.allSettled(tasks.values()); + } + + private async refreshInterestedProject(projectId: string): Promise { + const interests = [...this.interestsByOwner.values()] + .flat() + .filter((interest) => interest.projectId === projectId); + if (interests.length === 0) return; + await this.refreshInterests(interests); + } + + private targetsForBranch( + projectId: string, + branch: string, + prKey: string | null, + ): Record { + const targets: Record = {}; + for (const [key, target] of Object.entries(this.snapshot.targets)) { + if ( + target.ref.projectId !== projectId || + target.ref.hostId !== this.options.hostId || + target.status?.branch !== branch + ) { + continue; + } + targets[key] = { + ...target, + pullRequestKey: prKey, + }; + } + return targets; + } + + private publish(patch: Omit): void { + const revision = this.snapshot.revision + 1; + const revisioned = { ...patch, revision }; + this.snapshot = applyGitStatePatch(this.snapshot, revisioned); + this.options.onPatch?.(revisioned); + } + + private dedupe(key: string, task: () => Promise): Promise { + const existing = this.inFlight.get(key); + if (existing) return existing as Promise; + const pending = task().finally(() => { + if (this.inFlight.get(key) === pending) this.inFlight.delete(key); + }); + this.inFlight.set(key, pending); + return pending; + } + + private requireProject(projectId: string): Project { + const project = this.options.getProject(projectId); + if (!project) throw new Error(`Project "${projectId}" was not found.`); + return project; + } + + private timestamp(): string { + return (this.options.now?.() ?? new Date()).toISOString(); + } +} diff --git a/src/main/gitState/index.ts b/src/main/gitState/index.ts new file mode 100644 index 000000000..278e7395d --- /dev/null +++ b/src/main/gitState/index.ts @@ -0,0 +1,34 @@ +import type { + IpcProcedurePayload, + IpcProcedureResult, + SupervisorProcedureName, +} from "@/shared/ipc"; +import { + GitStateService, + type GitStateExecutor, + type GitStateServiceOptions, +} from "./GitStateService"; + +export interface GitStateSupervisorCaller { + ( + name: Name, + payload: IpcProcedurePayload, + ): Promise>; +} + +export function createGitStateExecutor(call: GitStateSupervisorCaller): GitStateExecutor { + return { + gitProjectSnapshot: (payload) => call("gitProjectSnapshot", payload), + getGitStatus: (payload) => call("getGitStatus", payload), + gitWorktreeStatusBatch: (payload) => call("gitWorktreeStatusBatch", payload), + gitGetWorktreeSourceBranch: (payload) => call("gitGetWorktreeSourceBranch", payload), + ghGetPrForBranch: (payload) => call("ghGetPrForBranch", payload), + ghGetPrDetails: (payload) => call("ghGetPrDetails", payload), + ghGetPrFiles: (payload) => call("ghGetPrFiles", payload), + ghGetPrDiff: (payload) => call("ghGetPrDiff", payload), + ghGetPrReviewComments: (payload) => call("ghGetPrReviewComments", payload), + ghListPullRequests: (payload) => call("ghListPullRequests", payload), + }; +} + +export { GitStateService, type GitStateExecutor, type GitStateServiceOptions }; diff --git a/src/main/main.ts b/src/main/main.ts index fa2188b24..27b9cbd47 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -93,6 +93,8 @@ import { import { configureSecretStorageKey } from "@/shared/secretStorage"; import { readOrCreateSafeStorageSecretKey } from "./secretStorageKey"; import { createDesktopRemoteAccessController, type DesktopRemoteAccessController } from "./remote"; +import { readOrCreateRemoteAccessIdentity } from "./remote/identity"; +import { createGitStateExecutor, GitStateService } from "./gitState"; import { SshConnectionManager } from "./ssh/SshConnectionManager"; import { createDeviceScheduleService, @@ -718,6 +720,7 @@ if (!hasSingleInstanceLock) { // fires once the supervisor is started, by which point it is set. let scheduleRunCoordinator: ScheduleRunCoordinator | null = null; let prWatchService: PrWatchService | null = null; + let gitStateService: GitStateService | null = null; const supervisorClient = new SupervisorClient({ appVersion: app.getVersion(), isDev, @@ -789,6 +792,7 @@ if (!hasSingleInstanceLock) { appControlsMcpIngress?.observeSupervisorEvent(event); scheduleRunCoordinator?.observeSupervisorEvent(event); prWatchService?.observeSupervisorEvent(event); + gitStateService?.observeSupervisorEvent(event); remoteAccessController?.handleSupervisorEvent(event); mainWindow?.webContents.send(IPC_EVENT_CHANNELS.supervisorEvent, event); forwardAgentStatusEventToQuickComposer(event); @@ -871,6 +875,18 @@ if (!hasSingleInstanceLock) { }, worktreeExists: existsSync, }); + gitStateService = new GitStateService({ + hostId: readOrCreateRemoteAccessIdentity(paths.baseDir).desktopId, + executor: createGitStateExecutor((name, payload) => supervisorClient.call(name, payload)), + getProject: dbGetProject, + onPatch: (patch) => { + remoteAccessController?.getServer()?.publishSupervisorEvent({ + type: "remote-git-state", + patch, + }); + mainWindow?.webContents.send(IPC_EVENT_CHANNELS.gitStateChanged, patch); + }, + }); // Latest updater status, captured from the auto-updater's status stream so // the app-controls `check_for_update` tool can report the most recent // result (the check itself is fire-and-forget and event-driven). @@ -1025,9 +1041,13 @@ if (!hasSingleInstanceLock) { notifyRemoteAccessPairingChanged: (info) => { mainWindow?.webContents.send(IPC_EVENT_CHANNELS.remoteAccessPairingChanged, info); }, + notifyProjectStateChanged: (projects) => { + mainWindow?.webContents.send(IPC_EVENT_CHANNELS.projectStateChanged, { projects }); + }, reportError: captureMainException, scheduleService, prWatchService, + gitStateService, }); remoteAccessController = controller; @@ -1159,6 +1179,16 @@ if (!hasSingleInstanceLock) { supervisorClient.start(paths.baseDir); scheduleService.start(); prWatchService.start(); + gitStateService.start(); + gitStateService.setInterests( + "desktop-renderer", + dbGetThreads().map((thread) => ({ + kind: "target", + projectId: thread.projectId, + ...(thread.worktreePath ? { worktreePath: thread.worktreePath } : {}), + includePrDetails: true, + })), + ); void controller.startIfEnabled(); @@ -1206,6 +1236,7 @@ if (!hasSingleInstanceLock) { pendingQuickComposerSubmissions.length = 0; scheduleService.dispose(); prWatchService.dispose(); + gitStateService.dispose(); supervisorClient.dispose(); windowsJobObjectManager?.dispose(); windowsJobObjectManager = null; diff --git a/src/main/preload.ts b/src/main/preload.ts index 72f182ce2..257068d23 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -3,6 +3,7 @@ import { type PoracodeChannel, normalizeChannel } from "@/shared/channel"; import type { RemoteThreadCommand } from "@/shared/contracts"; import type { RemoteAccessPairingInfo } from "@/shared/remote"; import type { SharedSettings } from "@/shared/settings"; +import type { GitStatePatch } from "@/shared/gitState"; import { createInvokeBridge, IPC_EVENT_CHANNELS, @@ -189,6 +190,15 @@ const bridge: PoracodeBridge = { ipcRenderer.removeListener(IPC_EVENT_CHANNELS.projectStateChanged, handler); }; }, + onGitStateChanged(listener) { + const handler = (_event: Electron.IpcRendererEvent, patch: GitStatePatch) => { + listener(patch); + }; + ipcRenderer.on(IPC_EVENT_CHANNELS.gitStateChanged, handler); + return () => { + ipcRenderer.removeListener(IPC_EVENT_CHANNELS.gitStateChanged, handler); + }; + }, onThreadOpenRequested(listener) { const handler = (_event: Electron.IpcRendererEvent, payload: ThreadOpenRequestedEvent) => { listener(payload); diff --git a/src/main/remote/DesktopRemoteAccessController.test.ts b/src/main/remote/DesktopRemoteAccessController.test.ts index d99574c9f..7ea3daf2a 100644 --- a/src/main/remote/DesktopRemoteAccessController.test.ts +++ b/src/main/remote/DesktopRemoteAccessController.test.ts @@ -233,9 +233,12 @@ function createController( notifyRemoteAccessPairingChanged: notifyRemoteAccessPairingChanged ?? vi.fn(), + notifyProjectStateChanged: + vi.fn(), reportError, scheduleService: {} as never, prWatchService: {} as never, + gitStateService: {} as never, }); } diff --git a/src/main/remote/DesktopRemoteAccessController.ts b/src/main/remote/DesktopRemoteAccessController.ts index ed98c7936..9ef918337 100644 --- a/src/main/remote/DesktopRemoteAccessController.ts +++ b/src/main/remote/DesktopRemoteAccessController.ts @@ -1,3 +1,4 @@ +import { nativeImage } from "electron"; import type { BrowserPanelManager } from "../browser"; import { dbGetProject, dbGetProjects, dbGetThreads } from "../db"; import { patchSharedSettingsFile, readSharedSettingsFile } from "../sharedSettingsFile"; @@ -17,9 +18,11 @@ import { type RemoteGitSummaries, } from "@/shared/remote"; import type { SharedSettings } from "@/shared/settings"; +import type { Project } from "@/shared/contracts"; import { resolveMcpLaunchSnapshot } from "@/shared/contracts"; import type { ScheduleService } from "../schedules/ScheduleService"; import type { PrWatchService } from "../prWatch"; +import type { GitStateService } from "../gitState"; import { createPersistentRemoteAuthStore } from "./auth"; import { remoteAccessAdvertisedHost, @@ -28,6 +31,7 @@ import { resolveRemoteAccessPort, } from "./config"; import { readOrCreateRemoteAccessIdentity } from "./identity"; +import { setImagePreviewGenerator } from "./server/imagePreview"; import { getRemoteAccessPairingInfo } from "./pairingInfo"; import { createPortForwarding, type PortForwarding } from "./portForward/portForwarding"; import { @@ -71,9 +75,11 @@ export interface DesktopRemoteAccessControllerOptions { readonly getBrowserPanelManager: () => BrowserPanelManager | null; readonly notifySharedSettingsChanged: (settings: SharedSettings) => void; readonly notifyRemoteAccessPairingChanged: (info: RemoteAccessPairingInfo) => void; + readonly notifyProjectStateChanged: (projects: readonly Project[]) => void; readonly reportError: (error: unknown, tags?: PoracodeDiagnosticTags) => void; readonly scheduleService: ScheduleService; readonly prWatchService: PrWatchService; + readonly gitStateService: GitStateService; } export interface DesktopRemoteAccessController { @@ -332,11 +338,35 @@ export function createDesktopRemoteAccessController( }); attempt.coordinator = coordinator; pushCoordinator = coordinator; + // Blurred placeholders for referenced images. `nativeImage` is Electron-only, + // which is why this is injected rather than imported by the projector: the + // headless server has no resizer and simply ships references without a + // preview. Kept to ~24px so the inline cost stays a few hundred bytes. + setImagePreviewGenerator(({ data }) => { + const image = nativeImage.createFromBuffer(data); + if (image.isEmpty()) return null; + const { width, height } = image.getSize(); + if (width <= 0 || height <= 0) return null; + const preview = image.resize({ + width: 24, + height: Math.max(1, Math.round((24 * height) / width)), + quality: "good", + }); + // JPEG at low quality is the smallest useful encoding for a blurred + // stand-in; transparency is irrelevant once it is blurred behind the card. + const url = preview.toJPEG(40).toString("base64"); + return url.length > 0 ? `data:image/jpeg;base64,${url}` : null; + }); const server = new RemoteAccessServer({ appVersion: options.appVersion, identity, isDev: Boolean(options.devServerUrl), ownsSupervisorPersistence: false, + onOversizedEventDropped: ({ type, bytes }) => { + console.warn( + `[remote] ${type} event of ${bytes} bytes exceeded the live stream budget; clients asked to resync`, + ); + }, authStore, host: remoteHost, port, @@ -360,6 +390,7 @@ export function createDesktopRemoteAccessController( portForward: portForwarding.gateway, portProxy: portForwarding.proxy, gitSummaries: () => remoteGitSummaries, + gitState: options.gitStateService, settings: { read: () => pickRemoteSettings(readSharedSettingsFile(options.paths.settingsPath)), update: (patch) => { @@ -384,6 +415,7 @@ export function createDesktopRemoteAccessController( onPairingChanged: () => { options.notifyRemoteAccessPairingChanged(getRemoteAccessPairingInfo(server)); }, + onProjectsChanged: options.notifyProjectStateChanged, }); attempt.server = server; remoteAccessServer = server; diff --git a/src/main/remote/RemoteAccessServer.test.ts b/src/main/remote/RemoteAccessServer.test.ts index 905c126a0..adbfe6f35 100644 --- a/src/main/remote/RemoteAccessServer.test.ts +++ b/src/main/remote/RemoteAccessServer.test.ts @@ -22,8 +22,14 @@ import type { Thread, } from "@/shared/contracts"; import type { SupervisorEvent } from "@/shared/ipc"; -import { pickRemoteSettings, type RemoteSettings } from "@/shared/remote"; +import { + isRemoteOmittedField, + pickRemoteSettings, + readRemoteImageRef, + type RemoteSettings, +} from "@/shared/remote"; import { defaultSharedSettings } from "@/shared/settings"; +import { emptyGitStateSnapshot } from "@/shared/gitState"; import type { BrowserPanelManager } from "../browser"; import { dbAppendThreadCompletedTurn, @@ -47,6 +53,7 @@ import { dbReplaceThreadRuntimeSnapshot, dbSetState, dbSetProjectNotes, + dbTruncateThreadRuntimeAfter, dbUpsertProject, dbUpsertThread, } from "../db"; @@ -97,6 +104,7 @@ vi.mock("../db", () => { appState.set(key, value); }), dbSetProjectNotes: vi.fn<(notes: unknown) => void>(), + dbTruncateThreadRuntimeAfter: vi.fn<(...args: unknown[]) => void>(), dbGetAllUsageEvents: vi.fn<() => unknown[]>(() => []), getProfileDataGeneration: vi.fn<() => number>(() => profileDataGeneration), bumpProfileDataGeneration: vi.fn<() => void>(() => { @@ -160,6 +168,7 @@ afterEach(async () => { dbSetState("poracode-experiments-v1", ""); vi.mocked(dbSetState).mockClear(); vi.mocked(dbSetProjectNotes).mockReset(); + vi.mocked(dbTruncateThreadRuntimeAfter).mockReset(); }); function createTestProject(overrides: Partial = {}): Project { @@ -1025,6 +1034,118 @@ describe("RemoteAccessServer", () => { expect(dbGetThreadContextUsage).not.toHaveBeenCalled(); }); + it("serves host Git state and owns WebSocket interest lifetimes", async () => { + type GitStateGateway = NonNullable; + const setInterests = vi.fn(); + const clearInterests = vi.fn(); + const gitState: GitStateGateway = { + getSnapshot: () => ({ ...emptyGitStateSnapshot(), revision: 7 }), + setInterests, + clearInterests, + refreshTarget: vi.fn(async () => {}), + refreshPullRequestReviewBundle: vi.fn( + async () => {}, + ), + refreshProjectPullRequests: vi.fn( + async () => {}, + ), + }; + const server = new RemoteAccessServer({ + appVersion: "1.0.0", + identity: { desktopId: "desktop-test", label: "Test Desktop" }, + host: "127.0.0.1", + port: 0, + callSupervisor: vi.fn( + async () => undefined as never, + ), + gitState, + }); + servers.push(server); + const info = await server.start(); + const token = await issueAccessToken(info, ["session:read"]); + const snapshotResponse = await fetch(new URL("/api/snapshot", info.httpBaseUrl), { + headers: { authorization: `Bearer ${token}` }, + }); + await expect(snapshotResponse.json()).resolves.toMatchObject({ + gitState: { revision: 7 }, + }); + + const ticket = await issueWebSocketTicket(info, token); + const wsUrl = new URL("/ws", info.wsBaseUrl); + wsUrl.searchParams.set("ticket", ticket); + const ws = new WebSocket(wsUrl); + // Register the reader before awaiting `open`: the server sends `ready` + // immediately on connection, so a listener attached after the open event can + // miss a frame delivered in the same tick. + const readyPromise = readWsMessage(ws); + await new Promise((resolve, reject) => { + ws.once("open", resolve); + ws.once("error", reject); + }); + await readyPromise; + ws.send( + JSON.stringify({ + type: "git-state-interests", + interests: [ + { + kind: "target", + projectId: "project-1", + worktreePath: "/repo/worktree", + includePrDetails: true, + }, + ], + }), + ); + await vi.waitFor(() => expect(setInterests).toHaveBeenCalledTimes(1)); + const ownerId = setInterests.mock.calls[0]![0] as string; + expect(setInterests).toHaveBeenCalledWith(ownerId, [ + { + kind: "target", + projectId: "project-1", + worktreePath: "/repo/worktree", + includePrDetails: true, + }, + ]); + + const closed = new Promise((resolve) => ws.once("close", () => resolve())); + ws.close(); + await closed; + await vi.waitFor(() => expect(clearInterests).toHaveBeenCalledWith(ownerId)); + }); + + it("serves and guards the image-reference endpoint", async () => { + const server = new RemoteAccessServer({ + appVersion: "1.0.0", + identity: { desktopId: "desktop-test", label: "Test Desktop" }, + host: "127.0.0.1", + port: 0, + callSupervisor: vi.fn(async () => "" as never), + }); + servers.push(server); + const info = await server.start(); + const token = await issueAccessToken(info, ["session:read"]); + + const refUrl = (path: string, threadId = "thread-1", itemId = "item-1") => { + const url = new URL(`/api/threads/${threadId}/items/${itemId}/image`, info.httpBaseUrl); + url.searchParams.set("path", path); + url.searchParams.set("access_token", token); + return url; + }; + + // Unauthenticated requests are rejected before any lookup happens. + const noToken = new URL("/api/threads/thread-1/items/item-1/image", info.httpBaseUrl); + noToken.searchParams.set("path", '["images",0]'); + expect((await fetch(noToken)).status).toBe(401); + + // A malformed reference path is a client error, not a lookup. + expect((await fetch(refUrl("not-json"))).status).toBe(400); + expect((await fetch(refUrl("[]"))).status).toBe(400); + + // These tests have no DB attached, so a well-formed reference resolves to + // nothing — which must be a clean 404 rather than a crash. + expect((await fetch(refUrl('["images",0]'))).status).toBe(404); + }); + it("serves local image files over the authenticated image endpoint", async () => { const dir = createTempDir(); const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); @@ -1185,6 +1306,324 @@ describe("RemoteAccessServer", () => { await closed; }); + it("scopes transcript content per connection without breaking approvals or replay", async () => { + const server = new RemoteAccessServer({ + appVersion: "1.0.0", + identity: { desktopId: "desktop-test", label: "Test Desktop" }, + host: "127.0.0.1", + port: 0, + ownsSupervisorPersistence: false, + callSupervisor: vi.fn(async () => "" as never), + }); + servers.push(server); + const info = await server.start(); + + // One client watches thread-A; the other never declares interests at all, + // standing in for an older client that must keep receiving everything. Both + // ride one access token because a pairing credential is single-use. + const token = await issueAccessToken(info, ["session:read"]); + const openSocket = async () => { + const ticket = await issueWebSocketTicket(info, token); + const url = new URL("/ws", info.wsBaseUrl); + url.searchParams.set("ticket", ticket); + const ws = new WebSocket(url); + // Reader before `open`: `ready` can arrive in the same tick. + const next = createWsReader(ws); + await new Promise((resolve, reject) => { + ws.once("open", resolve); + ws.once("error", reject); + }); + expect(await next()).toMatchObject({ type: "ready" }); + return { ws, next }; + }; + const watcher = await openSocket(); + const legacy = await openSocket(); + const watcherNext = watcher.next; + const legacyNext = legacy.next; + watcher.ws.send(JSON.stringify({ type: "thread-item-interests", threadIds: ["thread-A"] })); + await vi.waitFor(() => { + const interests = (server as unknown as { itemInterests: Map> }) + .itemInterests; + expect(interests.size).toBe(1); + }); + + const itemEventFor = (threadId: string) => + ({ + type: "thread-runtime-event", + threadId, + event: { + type: "item.completed", + threadId, + itemId: `${threadId}-item`, + payload: { name: "bash", result: "R".repeat(5_000) }, + }, + }) as const; + + // Content for the watched thread arrives intact. + server.publishSupervisorEvent(itemEventFor("thread-A")); + const watchedFrame = (await watcherNext()) as { + seq: number; + event: { event: { payload: { result: string } } }; + }; + expect(watchedFrame.seq).toBe(1); + expect(watchedFrame.event.event.payload.result).toHaveLength(5_000); + expect(await legacyNext()).toMatchObject({ seq: 1 }); + + // Content for an unwatched thread is emptied for the watcher... + server.publishSupervisorEvent(itemEventFor("thread-B")); + const scoped = (await watcherNext()) as { + seq: number; + event: { type: string; threadId: string; events: unknown[] }; + }; + expect(scoped.seq).toBe(2); + expect(scoped.event).toEqual({ + type: "thread-runtime-events", + threadId: "thread-B", + events: [], + }); + // ...while the client that never declared interests still gets it in full. + const legacyFrame = (await legacyNext()) as { + seq: number; + event: { event: { payload: { result: string } } }; + }; + expect(legacyFrame.seq).toBe(2); + expect(legacyFrame.event.event.payload.result).toHaveLength(5_000); + + // A permission request on an UNWATCHED thread must still reach the watcher, + // or a background thread would block on an approval nobody can see. + server.publishSupervisorEvent({ + type: "thread-runtime-event", + threadId: "thread-B", + event: { + type: "request.opened", + threadId: "thread-B", + requestId: "req-1", + requestType: "tool_call_approval", + payload: { summary: "Allow this tool?" }, + }, + }); + expect(await watcherNext()).toMatchObject({ + seq: 3, + event: { event: { type: "request.opened", requestId: "req-1" } }, + }); + + // Every seq was delivered to both clients, so the replay contiguity check + // still holds and neither client is forced into a spurious resync. + const buffer = (server as unknown as { eventBuffer: unknown[] }).eventBuffer; + expect(buffer).toHaveLength(3); + }); + + it("replaces inline images with references on the live event stream", async () => { + const server = new RemoteAccessServer({ + appVersion: "1.0.0", + identity: { desktopId: "desktop-test", label: "Test Desktop" }, + host: "127.0.0.1", + port: 0, + ownsSupervisorPersistence: false, + callSupervisor: vi.fn(async () => "" as never), + }); + servers.push(server); + const info = await server.start(); + const { ws, ready } = await openPairedSocket(info); + expect(ready).toMatchObject({ type: "ready", seq: 0 }); + const nextMessage = createWsReader(ws); + + const clients = (server as unknown as { clients: Map }).clients; + const serverSocket = [...clients.keys()][0]; + + // 3 MB of inline PNG, prefixed with a real 1x1 PNG header so the host can + // read its intrinsic size. Before the projection this rode the socket in full + // and was the dominant source of remote traffic. + const pngHeader = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8AABAwMDAwMDAAAAP//AwABAAEAAQABAAEA"; + const inline = `data:image/png;base64,${pngHeader}${"A".repeat(3_000_000)}`; + server.publishSupervisorEvent({ + type: "thread-runtime-event", + threadId: "thread-1", + event: { + type: "item.completed", + threadId: "thread-1", + itemId: "image_view-1", + payload: { name: "imageView", status: "success", images: [inline] }, + }, + }); + + const message = (await nextMessage()) as { + type: string; + event: { event: { payload: { images: unknown[]; name: string } } }; + }; + expect(message.type).toBe("event"); + const payload = message.event.event.payload; + const ref = readRemoteImageRef(payload.images[0]); + expect(ref).toMatchObject({ + threadId: "thread-1", + itemId: "image_view-1", + path: ["images", 0], + mime: "image/png", + }); + // Intrinsic size rides along even on the LIVE path, so a streaming image + // lands in a slot the timeline already reserved instead of shoving the + // transcript down when its bytes arrive. + expect(ref?.width).toBe(1); + expect(ref?.height).toBe(1); + expect(payload.name).toBe("imageView"); + // The frame is now tiny, so the size cap never had to withhold anything. + expect(JSON.stringify(message).length).toBeLessThan(4000); + expect(clients.has(serverSocket!)).toBe(true); + }); + + it("gzips and revalidates the shell snapshot", async () => { + const server = new RemoteAccessServer({ + appVersion: "1.0.0", + identity: { desktopId: "desktop-test", label: "Test Desktop" }, + host: "127.0.0.1", + port: 0, + callSupervisor: vi.fn(async () => "" as never), + }); + servers.push(server); + const info = await server.start(); + const token = await issueAccessToken(info, ["session:read"]); + + const first = await fetch(new URL("/api/snapshot", info.httpBaseUrl), { + headers: { authorization: `Bearer ${token}`, "accept-encoding": "gzip" }, + }); + expect(first.status).toBe(200); + expect(first.headers.get("vary")).toBe("Accept-Encoding"); + const etag = first.headers.get("etag"); + expect(etag).toBeTruthy(); + // The snapshot still parses through the transparent gzip decode. + await expect(first.json()).resolves.toMatchObject({ threads: expect.any(Array) }); + + // Unchanged content revalidates to a bodyless 304 instead of resending the + // whole snapshot — this is the refetch-on-every-status-event path. + const second = await fetch(new URL("/api/snapshot", info.httpBaseUrl), { + headers: { + authorization: `Bearer ${token}`, + "accept-encoding": "gzip", + "if-none-match": etag!, + }, + }); + expect(second.status).toBe(304); + expect(await second.text()).toBe(""); + + // A stale tag from a different body must not be honored. + const third = await fetch(new URL("/api/snapshot", info.httpBaseUrl), { + headers: { authorization: `Bearer ${token}`, "if-none-match": '"stale-tag"' }, + }); + expect(third.status).toBe(200); + await third.arrayBuffer(); + }); + + it("keeps clients connected when a runtime event exceeds the outbound budget", async () => { + const server = new RemoteAccessServer({ + appVersion: "1.0.0", + identity: { desktopId: "desktop-test", label: "Test Desktop" }, + host: "127.0.0.1", + port: 0, + ownsSupervisorPersistence: false, + maxWebSocketOutboundBufferBytes: 200_000, + callSupervisor: vi.fn(async () => "" as never), + }); + servers.push(server); + const info = await server.start(); + const { ws, ready } = await openPairedSocket(info); + expect(ready).toMatchObject({ type: "ready", seq: 0 }); + const nextMessage = createWsReader(ws); + + const clients = (server as unknown as { clients: Map }).clients; + const serverSocket = [...clients.keys()][0]; + expect(serverSocket).toBeDefined(); + + // A 400 KB tool result against a 200 KB socket budget: before the size guard + // this terminated every connected client. Inline images take the lossless + // reference path instead (covered separately), so this exercises the cap with + // the payload shape that genuinely cannot be referenced — bulk text. + server.publishSupervisorEvent({ + type: "thread-runtime-event", + threadId: "thread-1", + event: { + type: "item.completed", + threadId: "thread-1", + itemId: "tool_call-1", + payload: { + name: "bash", + args: { command: "cat huge.log" }, + status: "success", + result: "R".repeat(400_000), + }, + }, + }); + + const message = (await nextMessage()) as { + type: string; + seq: number; + event: { event: { payload: Record } }; + }; + expect(message.type).toBe("event"); + expect(message.seq).toBe(1); + // Descriptive fields survive; only the bulk field is withheld. + const payload = message.event.event.payload; + expect(payload.name).toBe("bash"); + expect(payload.args).toEqual({ command: "cat huge.log" }); + expect(isRemoteOmittedField(payload.result)).toBe(true); + + // The client is still connected and still on the live stream. + expect(clients.has(serverSocket!)).toBe(true); + expect(ws.readyState).toBe(WebSocket.OPEN); + + server.publishSupervisorEvent({ + type: "thread-state", + threadId: "thread-1", + status: "idle", + attention: "none", + canResumeWithConfig: false, + }); + expect(await nextMessage()).toMatchObject({ type: "event", seq: 2 }); + expect(clients.has(serverSocket!)).toBe(true); + }); + + it("asks clients to resync when an event cannot be shrunk to fit", async () => { + const onOversizedEventDropped = + vi.fn>(); + const server = new RemoteAccessServer({ + appVersion: "1.0.0", + identity: { desktopId: "desktop-test", label: "Test Desktop" }, + host: "127.0.0.1", + port: 0, + ownsSupervisorPersistence: false, + maxWebSocketOutboundBufferBytes: 200_000, + onOversizedEventDropped, + callSupervisor: vi.fn(async () => "" as never), + }); + servers.push(server); + const info = await server.start(); + const { ws, ready } = await openPairedSocket(info); + expect(ready).toMatchObject({ type: "ready", seq: 0 }); + const nextMessage = createWsReader(ws); + + const clients = (server as unknown as { clients: Map }).clients; + const serverSocket = [...clients.keys()][0]; + + // `remote-git-state` carries no runtime payload the guard can strip, so an + // oversized patch must degrade to a resync rather than a disconnect. + server.publishSupervisorEvent({ + type: "remote-git-state", + patch: { revision: 1, pullRequests: { pr: { diff: "d".repeat(300_000) } } } as never, + }); + + expect(await nextMessage()).toMatchObject({ type: "resync-required", seq: 1 }); + expect(onOversizedEventDropped).toHaveBeenCalledWith( + expect.objectContaining({ type: "remote-git-state" }), + ); + expect(clients.has(serverSocket!)).toBe(true); + expect(ws.readyState).toBe(WebSocket.OPEN); + + // The undeliverable event is not buffered, so a client reconnecting from an + // older cursor is told to resync instead of being fed it again. + const eventBuffer = (server as unknown as { eventBuffer: unknown[] }).eventBuffer; + expect(eventBuffer).toHaveLength(0); + }); + it("drops websocket clients before outbound buffers grow without bound", async () => { const server = new RemoteAccessServer({ appVersion: "1.0.0", @@ -2303,6 +2742,148 @@ describe("RemoteAccessServer", () => { ]); }); + it("enqueues a new worktree setup exactly once before launching the remote thread", async () => { + const project = createTestProject(); + vi.mocked(dbGetProjects).mockReturnValue([project]); + mockThreadDb(); + const callSupervisor = vi.fn( + async () => ({ threadId: "thread-worktree" }) as never, + ); + const dispatchThreadCommand = vi.fn< + NonNullable + >(() => true); + const server = new RemoteAccessServer({ + appVersion: "1.0.0", + identity: { desktopId: "desktop-test", label: "Test Desktop" }, + host: "127.0.0.1", + port: 0, + callSupervisor, + dispatchThreadCommand, + }); + servers.push(server); + const info = await server.start(); + const token = await issueAccessToken(info, ["session:operate"]); + + const response = await fetch( + new URL("/api/threads/thread-worktree/command", info.httpBaseUrl), + { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + kind: "start", + projectId: project.id, + agentKind: "codex", + config: { model: "gpt-5" }, + prompt: "start from the PWA", + worktreePath: "/repo/worktrees/mobile-fix", + worktreeBranch: "mobile-fix", + isNewWorktree: true, + }), + }, + ); + + expect(response.status).toBe(200); + expect(dispatchThreadCommand).toHaveBeenNthCalledWith(1, { + kind: "prepare-worktree", + threadId: "thread-worktree", + projectId: project.id, + worktreePath: "/repo/worktrees/mobile-fix", + }); + expect(callSupervisor).toHaveBeenCalledWith( + "startThread", + expect.objectContaining({ threadId: "thread-worktree" }), + ); + expect(dispatchThreadCommand).toHaveBeenCalledTimes(2); + expect(dispatchThreadCommand).toHaveBeenNthCalledWith( + 2, + expect.not.objectContaining({ isNewWorktree: true }), + ); + expect(dispatchThreadCommand.mock.invocationCallOrder[0]).toBeLessThan( + callSupervisor.mock.invocationCallOrder[0]!, + ); + }); + + it("does not enqueue setup when a remote thread reuses an existing worktree", async () => { + const project = createTestProject(); + vi.mocked(dbGetProjects).mockReturnValue([project]); + mockThreadDb(); + const dispatchThreadCommand = vi.fn< + NonNullable + >(() => true); + const server = new RemoteAccessServer({ + appVersion: "1.0.0", + identity: { desktopId: "desktop-test", label: "Test Desktop" }, + host: "127.0.0.1", + port: 0, + callSupervisor: vi.fn( + async () => ({ threadId: "thread-existing" }) as never, + ), + dispatchThreadCommand, + }); + servers.push(server); + const info = await server.start(); + const token = await issueAccessToken(info, ["session:operate"]); + + const response = await fetch( + new URL("/api/threads/thread-existing/command", info.httpBaseUrl), + { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + kind: "start", + projectId: project.id, + agentKind: "codex", + config: { model: "gpt-5" }, + prompt: "reuse from the PWA", + worktreePath: "/repo/worktrees/existing", + worktreeBranch: "existing", + }), + }, + ); + + expect(response.status).toBe(200); + expect(dispatchThreadCommand).toHaveBeenCalledTimes(1); + expect(dispatchThreadCommand).not.toHaveBeenCalledWith( + expect.objectContaining({ kind: "prepare-worktree" }), + ); + }); + + it("truncates durable remote runtime history during a PWA checkpoint revert", async () => { + const server = new RemoteAccessServer({ + appVersion: "1.0.0", + identity: { desktopId: "desktop-test", label: "Test Desktop" }, + host: "127.0.0.1", + port: 0, + callSupervisor: vi.fn( + async () => undefined as never, + ), + }); + servers.push(server); + const info = await server.start(); + const token = await issueAccessToken(info, ["session:operate"]); + + const response = await fetch( + new URL("/api/threads/thread-1/runtime/truncate", info.httpBaseUrl), + { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ itemId: "user-2" }), + }, + ); + + expect(response.status).toBe(200); + expect(dbTruncateThreadRuntimeAfter).toHaveBeenCalledWith("thread-1", "user-2"); + }); + it("only restarts existing remote threads through the legacy start endpoint", async () => { mockThreadDb([createTestThread()]); const callSupervisor = vi.fn( @@ -2532,6 +3113,7 @@ describe("RemoteAccessServer", () => { worktreePath: "/repo/wt", threadIds: ["thread-1", "thread-2"], }); + expect(db.threads()).toEqual([]); rendererAvailable = false; const unavailableResponse = await fetch( @@ -2759,6 +3341,7 @@ describe("RemoteAccessServer", () => { it("applies project commands only for projects:manage tokens and broadcasts changes", async () => { let projects: Project[] = []; + const onProjectsChanged = vi.fn<(projects: readonly Project[]) => void>(); vi.mocked(dbGetProjects).mockImplementation(() => projects); vi.mocked(dbUpsertProject).mockImplementation((project) => { const parsed = project as Project; @@ -2770,6 +3353,7 @@ describe("RemoteAccessServer", () => { host: "127.0.0.1", port: 0, callSupervisor: vi.fn(async () => "" as never), + onProjectsChanged, }); servers.push(server); const info = await server.start(); @@ -2843,6 +3427,7 @@ describe("RemoteAccessServer", () => { expect.objectContaining({ name: "new-app" }), expect.any(Number), ); + expect(onProjectsChanged).toHaveBeenCalledWith([expect.objectContaining({ name: "new-app" })]); expect(await read()).toMatchObject({ type: "event", event: { diff --git a/src/main/remote/RemoteAccessServer.ts b/src/main/remote/RemoteAccessServer.ts index f36605266..b585b2a4f 100644 --- a/src/main/remote/RemoteAccessServer.ts +++ b/src/main/remote/RemoteAccessServer.ts @@ -13,8 +13,10 @@ import { type RemoteSettingsPatch, type RemoteWebSocketServerMessage, } from "@/shared/remote"; +import type { GitStateInterest, GitStateSnapshot } from "@/shared/gitState"; import type { McpLaunchSnapshot, + Project, PrWatch, PrWatchInput, RemoteThreadCommand, @@ -42,12 +44,22 @@ import { DEFAULT_MAX_WEBSOCKET_OUTBOUND_BUFFER_BYTES, DEFAULT_MAX_WEBSOCKET_PAYLOAD_BYTES, handleUpgrade, + REMOTE_PER_MESSAGE_DEFLATE, WebSocketHeartbeat, } from "./server/wsConnections"; import { handleHttp } from "./server/httpRouter"; import { persistSupervisorEvent } from "./server/runtimePersistence"; +import { projectGitStatePatchForInterests } from "./server/gitStateProjection"; +import { filterEventForItemInterests } from "./server/itemInterestFilter"; +import { + capBroadcastEvent, + DEFAULT_EVENT_BUFFER_MAX_BYTES, + maxBroadcastEventBytes, + trimEventBuffer, +} from "./server/eventSizeGuard"; const EVENT_BUFFER_LIMIT = 500; +const EVENT_BUFFER_MAX_BYTES = DEFAULT_EVENT_BUFFER_MAX_BYTES; const DEFAULT_LISTEN_RETRY_ATTEMPTS = 5; const DEFAULT_LISTEN_RETRY_DELAY_MS = 500; @@ -118,6 +130,12 @@ export interface RemoteAccessServerOptions { * desktop servers opt out because desktop main persists before broadcasting. */ readonly ownsSupervisorPersistence?: boolean; + /** + * Notified when an event could not be shrunk enough to ride the live stream + * and clients were told to resync instead. Diagnostics only — the transport + * self-heals either way. + */ + readonly onOversizedEventDropped?: (info: { type: string; bytes: number }) => void; callSupervisor( name: Name, payload: IpcProcedurePayload, @@ -171,6 +189,24 @@ export interface RemoteAccessServerOptions { }; /** Latest per-thread git/PR summaries published by the desktop renderer. */ gitSummaries?(): RemoteGitSummaries; + /** Canonical Git/PR read model owned by the host process. */ + readonly gitState?: { + getSnapshot(): GitStateSnapshot; + setInterests(ownerId: string, interests: readonly GitStateInterest[]): void; + clearInterests(ownerId: string): void; + refreshTarget(input: { + projectId: string; + worktreePath?: string | undefined; + branch?: string | undefined; + includePrDetails?: boolean | undefined; + }): Promise; + refreshPullRequestReviewBundle(input: { + projectId: string; + prNumber: number; + branch?: string | undefined; + }): Promise; + refreshProjectPullRequests(projectId: string): Promise; + }; /** * Push-notification registration sink. The server stays pure — the store and * `PushCoordinator` live in the wiring layer (`main.ts` / headless host) and @@ -183,6 +219,8 @@ export interface RemoteAccessServerOptions { }; /** Notifies the desktop shell after the active pairing code rotates. */ readonly onPairingChanged?: () => void; + /** Keeps a live desktop renderer in sync with project mutations made over HTTP. */ + readonly onProjectsChanged?: (projects: readonly Project[]) => void; } /** @@ -221,6 +259,7 @@ const REMOTELY_CONSUMED_EVENT_TYPES: ReadonlySet = "wsl-agent-statuses", // Out-of-band remote events. "remote-git-summaries", + "remote-git-state", "remote-projects-changed", "remote-threads-changed", ]); @@ -235,6 +274,10 @@ export class RemoteAccessServer { private readonly clientLiveness = new Map(); /** Per-connection terminal ids the client opted into live `terminal-output` for. */ private readonly terminalWatches = new Map>(); + /** Per-connection Git interests, so PR bodies only reach clients that asked. */ + private readonly gitStateInterests = new Map(); + /** Per-connection transcript-content scoping; absent = receives everything. */ + private readonly itemInterests = new Map>(); private readonly eventBuffer: BufferedSupervisorEvent[] = []; private readonly context: RemoteServerContext; private seq = 0; @@ -252,6 +295,7 @@ export class RemoteAccessServer { this.wss = new WebSocketServer({ noServer: true, maxPayload: options.maxWebSocketPayloadBytes ?? DEFAULT_MAX_WEBSOCKET_PAYLOAD_BYTES, + perMessageDeflate: REMOTE_PER_MESSAGE_DEFLATE, }); this.heartbeat = new WebSocketHeartbeat({ intervalMs: options.webSocketHeartbeatIntervalMs, @@ -277,6 +321,8 @@ export class RemoteAccessServer { clients: this.clients, clientLiveness: this.clientLiveness, terminalWatches: this.terminalWatches, + gitStateInterests: this.gitStateInterests, + itemInterests: this.itemInterests, eventBuffer: this.eventBuffer, get seq() { return server.seq; @@ -429,16 +475,81 @@ export class RemoteAccessServer { return; } const seq = ++this.seq; - const entry = { seq, event }; - this.eventBuffer.push(entry); - if (this.eventBuffer.length > EVENT_BUFFER_LIMIT) { - this.eventBuffer.splice(0, this.eventBuffer.length - EVENT_BUFFER_LIMIT); - } - this.broadcast({ - type: "event", - seq, + // An event larger than the per-event budget would make `sendRaw` terminate + // every connected client, and would do it again on replay after they + // reconnect. Withhold its largest payload fields so the live stream stays + // deliverable; the full payload was persisted above and reaches clients on + // the next HTTP history fetch. + const capped = capBroadcastEvent( event, - }); + maxBroadcastEventBytes( + this.options.maxWebSocketOutboundBufferBytes ?? DEFAULT_MAX_WEBSOCKET_OUTBOUND_BUFFER_BYTES, + ), + ); + if (capped.kind === "undeliverable") { + // Nothing about this event can ride the socket. `seq` has still advanced, + // so leaving it out of the replay buffer makes both currently-connected + // and later-reconnecting clients converge on the same self-healing path: + // refetch authoritative state over HTTP. + this.options.onOversizedEventDropped?.({ type: event.type, bytes: capped.bytes }); + this.broadcast({ + type: "resync-required", + seq, + reason: "Event too large for the live stream; request a fresh snapshot.", + }); + return; + } + this.eventBuffer.push({ seq, event: capped.event, bytes: capped.bytes }); + trimEventBuffer(this.eventBuffer, EVENT_BUFFER_LIMIT, EVENT_BUFFER_MAX_BYTES); + // Some events are tailored per connection: pull-request bodies go only to the + // client reviewing that PR, and transcript content only to clients watching + // that thread. Every client still receives an event for every seq — only the + // content differs — which keeps the replay contiguity check valid. + if (this.needsPerClientScoping(capped.event)) { + for (const client of this.clients.keys()) { + const scoped = this.scopeEventForClient(capped.event, client); + this.sendRaw( + client, + scoped === capped.event + ? `{"type":"event","seq":${seq},"event":${capped.json}}` + : JSON.stringify({ type: "event", seq, event: scoped }), + ); + } + return; + } + // The wrapper is assembled by concatenation so a multi-megabyte event body + // is serialized exactly once per publish rather than once here and again in + // `broadcast`. + this.broadcastRaw(`{"type":"event","seq":${seq},"event":${capped.json}}`); + } + + /** True for event types whose content varies per connection. */ + private needsPerClientScoping(event: RemoteBroadcastEvent): boolean { + return ( + event.type === "remote-git-state" || + event.type === "thread-runtime-event" || + event.type === "thread-runtime-events" || + event.type === "thread-runtime-events-multi" + ); + } + + /** Applies every per-connection projection for `client`. */ + private scopeEventForClient( + event: RemoteBroadcastEvent, + client: WebSocket, + ): RemoteBroadcastEvent { + if (event.type === "remote-git-state") return this.scopeGitStateEvent(event, client); + return filterEventForItemInterests(event, this.itemInterests.get(client) ?? null); + } + + /** Narrows a git-state patch to what `client` declared an interest in. */ + private scopeGitStateEvent( + event: Extract, + client: WebSocket, + ): RemoteBroadcastEvent { + const interests = this.gitStateInterests.get(client) ?? []; + const patch = projectGitStatePatchForInterests(event.patch, interests); + return patch === event.patch ? event : { ...event, patch }; } private publishThreadsChanged(threadIds: readonly string[]): void { @@ -577,7 +688,12 @@ export class RemoteAccessServer { } private broadcast(message: RemoteWebSocketServerMessage): void { - const data = JSON.stringify(message); + this.broadcastRaw(JSON.stringify(message)); + } + + /** Fans an already-serialized message out to every client. Lets the caller + * serialize a large body once instead of per send. */ + private broadcastRaw(data: string): void { for (const client of this.clients.keys()) { this.sendRaw(client, data); } diff --git a/src/main/remote/server/context.ts b/src/main/remote/server/context.ts index 9bce6503e..822a5fdd3 100644 --- a/src/main/remote/server/context.ts +++ b/src/main/remote/server/context.ts @@ -4,11 +4,13 @@ import type { RemoteAccessTokenResult, RemoteClientMetadata, RemoteGitSummariesEvent, + RemoteGitStateEvent, RemoteProjectsChangedEvent, RemoteThreadsChangedEvent, RemoteWebSocketServerMessage, } from "@/shared/remote"; import type { SupervisorEvent } from "@/shared/ipc"; +import type { GitStateInterest } from "@/shared/gitState"; import type { AuthenticatedRemoteSession, RemoteAuthStore } from "../auth"; import type { PortProxy } from "../portForward/portProxy"; import type { RemoteBrowserGateway } from "../RemoteBrowserGateway"; @@ -19,12 +21,16 @@ import type { RemoteServerSecurity } from "./security"; export type RemoteBroadcastEvent = | SupervisorEvent | RemoteGitSummariesEvent + | RemoteGitStateEvent | RemoteProjectsChangedEvent | RemoteThreadsChangedEvent; export interface BufferedSupervisorEvent { readonly seq: number; readonly event: RemoteBroadcastEvent; + /** Serialized size of `event`, so the replay buffer can enforce a byte budget + * and not just an entry count (see `eventSizeGuard.trimEventBuffer`). */ + readonly bytes: number; } /** @@ -42,6 +48,12 @@ export interface RemoteServerContext { readonly clients: Map; readonly clientLiveness: Map; readonly terminalWatches: Map>; + /** Git-state interests declared by each connection, so pull-request bodies are + * only sent to the client that asked for them. */ + readonly gitStateInterests: Map; + /** Threads each connection wants live transcript content for. Absent entry = + * the client never declared any, so it keeps receiving everything. */ + readonly itemInterests: Map>; readonly eventBuffer: BufferedSupervisorEvent[]; /** Live in-memory event sequence; read through a getter so replays see the * current value rather than a snapshot taken at context-build time. */ diff --git a/src/main/remote/server/eventSizeGuard.test.ts b/src/main/remote/server/eventSizeGuard.test.ts new file mode 100644 index 000000000..e517c6343 --- /dev/null +++ b/src/main/remote/server/eventSizeGuard.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from "vitest"; +import { isRemoteOmittedField, payloadHasOmittedField, readRemoteImageRef } from "@/shared/remote"; +import type { RuntimeEvent } from "@/shared/contracts"; +import { capBroadcastEvent, maxBroadcastEventBytes, trimEventBuffer } from "./eventSizeGuard"; +import type { BufferedSupervisorEvent, RemoteBroadcastEvent } from "./context"; + +/** + * A bulk-text payload. Cap tests use text rather than an image because inline + * images now take the lossless reference path before the cap is consulted. + */ +function textItemEvent(bytes: number, itemId = "tool_call-1"): RuntimeEvent { + return { + type: "item.completed", + threadId: "thread-1", + itemId, + payload: { + name: "bash", + args: { command: "cat huge.log" }, + status: "success", + result: "R".repeat(bytes), + }, + }; +} + +function imageItemEvent(bytes: number, itemId = "image_view-1"): RuntimeEvent { + return { + type: "item.completed", + threadId: "thread-1", + itemId, + payload: { + name: "imageView", + args: { path: "/tmp/shot.png" }, + status: "success", + images: [`data:image/png;base64,${"A".repeat(bytes)}`], + }, + }; +} + +function runtimeEvent(event: RuntimeEvent): RemoteBroadcastEvent { + return { type: "thread-runtime-event", threadId: "thread-1", event }; +} + +describe("maxBroadcastEventBytes", () => { + it("never exceeds the socket budget, so a sendable verdict is truly sendable", () => { + for (const budget of [64, 1024, 256 * 1024, 4 * 1024 * 1024]) { + expect(maxBroadcastEventBytes(budget)).toBeLessThanOrEqual(budget); + } + }); + + it("caps events at half the 4MB default budget", () => { + expect(maxBroadcastEventBytes(4 * 1024 * 1024)).toBe(2 * 1024 * 1024); + }); +}); + +describe("capBroadcastEvent", () => { + it("passes a deliverable event through untouched", () => { + const event = runtimeEvent(textItemEvent(100)); + const result = capBroadcastEvent(event, 1024 * 1024); + expect(result.kind).toBe("sendable"); + if (result.kind !== "sendable") return; + expect(result.event).toBe(event); + expect(result.omittedBytes).toBe(0); + expect(JSON.parse(result.json)).toEqual(event); + }); + + it("withholds the oversized field so the event fits the budget", () => { + const event = runtimeEvent(textItemEvent(200_000)); + const result = capBroadcastEvent(event, 50_000); + expect(result.kind).toBe("sendable"); + if (result.kind !== "sendable") return; + expect(result.bytes).toBeLessThanOrEqual(50_000); + expect(result.omittedBytes).toBeGreaterThan(150_000); + const payload = (result.event as { event: { payload: Record } }).event.payload; + // The heavy field is replaced by a marker; the cheap descriptive fields that + // drive the collapsed row survive. + expect(isRemoteOmittedField(payload.result)).toBe(true); + expect(payload.name).toBe("bash"); + expect(payload.args).toEqual({ command: "cat huge.log" }); + expect(payload.status).toBe("success"); + expect(payloadHasOmittedField(payload)).toBe(true); + }); + + it("keeps the smaller fields when withholding only the largest is enough", () => { + const event = runtimeEvent({ + type: "item.completed", + threadId: "thread-1", + itemId: "tool-1", + payload: { + name: "bash", + result: "R".repeat(300_000), + stderr: "E".repeat(1_000), + }, + }); + const result = capBroadcastEvent(event, 20_000); + expect(result.kind).toBe("sendable"); + if (result.kind !== "sendable") return; + const payload = (result.event as { event: { payload: Record } }).event.payload; + expect(isRemoteOmittedField(payload.result)).toBe(true); + expect(payload.stderr).toBe("E".repeat(1_000)); + }); + + it("does not mutate the original event", () => { + const event = runtimeEvent(textItemEvent(200_000)); + const before = JSON.stringify(event); + capBroadcastEvent(event, 50_000); + expect(JSON.stringify(event)).toBe(before); + }); + + it("replaces inline images with references before consulting the budget", () => { + // Lossless: the client fetches each image on demand, so nothing is withheld + // and the frame drops far under budget without the cap acting at all. + const event = runtimeEvent(imageItemEvent(200_000)); + const result = capBroadcastEvent(event, 50_000); + expect(result.kind).toBe("sendable"); + if (result.kind !== "sendable") return; + expect(result.omittedBytes).toBe(0); + expect(result.bytes).toBeLessThan(1_000); + const payload = (result.event as { event: { payload: Record } }).event.payload; + const ref = readRemoteImageRef((payload.images as unknown[])[0]); + expect(ref).toMatchObject({ threadId: "thread-1", itemId: "image_view-1", mime: "image/png" }); + expect(payload.name).toBe("imageView"); + }); + + it("caps every payload in a batched multi-thread event", () => { + const event: RemoteBroadcastEvent = { + type: "thread-runtime-events-multi", + batches: [ + { threadId: "thread-1", events: [textItemEvent(200_000, "a")] }, + { threadId: "thread-2", events: [textItemEvent(200_000, "b")] }, + ], + }; + const result = capBroadcastEvent(event, 60_000); + expect(result.kind).toBe("sendable"); + if (result.kind !== "sendable") return; + expect(result.bytes).toBeLessThanOrEqual(60_000); + const batches = ( + result.event as { + batches: ReadonlyArray<{ events: ReadonlyArray<{ payload: Record }> }>; + } + ).batches; + expect(isRemoteOmittedField(batches[0]!.events[0]!.payload.result)).toBe(true); + expect(isRemoteOmittedField(batches[1]!.events[0]!.payload.result)).toBe(true); + }); + + it("reports undeliverable when the event carries no shrinkable payload", () => { + const event: RemoteBroadcastEvent = { + type: "thread-state", + threadId: "t".repeat(5_000), + status: "idle", + attention: "none", + canResumeWithConfig: false, + }; + const result = capBroadcastEvent(event, 500); + expect(result.kind).toBe("undeliverable"); + }); + + it("reports undeliverable when even a fully stripped payload cannot fit", () => { + const event = runtimeEvent(textItemEvent(200_000)); + const result = capBroadcastEvent(event, 10); + expect(result.kind).toBe("undeliverable"); + }); +}); + +describe("trimEventBuffer", () => { + const entry = (seq: number, bytes: number): BufferedSupervisorEvent => ({ + seq, + bytes, + event: { type: "thread-reset", threadId: `thread-${seq}` }, + }); + + it("drops the oldest entries past the count limit", () => { + const buffer = [entry(1, 10), entry(2, 10), entry(3, 10)]; + trimEventBuffer(buffer, 2, 1_000); + expect(buffer.map((e) => e.seq)).toEqual([2, 3]); + }); + + it("drops the oldest entries past the byte budget", () => { + const buffer = [entry(1, 800), entry(2, 500), entry(3, 500)]; + trimEventBuffer(buffer, 500, 1_000); + expect(buffer.map((e) => e.seq)).toEqual([2, 3]); + }); + + it("keeps the newest entry even when it alone exceeds the byte budget", () => { + const buffer = [entry(1, 100), entry(2, 5_000)]; + trimEventBuffer(buffer, 500, 1_000); + expect(buffer.map((e) => e.seq)).toEqual([2]); + }); + + it("leaves a buffer within both limits untouched", () => { + const buffer = [entry(1, 10), entry(2, 10)]; + trimEventBuffer(buffer, 500, 1_000); + expect(buffer.map((e) => e.seq)).toEqual([1, 2]); + }); +}); diff --git a/src/main/remote/server/eventSizeGuard.ts b/src/main/remote/server/eventSizeGuard.ts new file mode 100644 index 000000000..be77a4243 --- /dev/null +++ b/src/main/remote/server/eventSizeGuard.ts @@ -0,0 +1,261 @@ +import { remoteOmittedField } from "@/shared/remote"; +import { projectPayloadImageRefs } from "./imageRefProjection"; +import type { RuntimeEvent } from "@/shared/contracts"; +import type { BufferedSupervisorEvent, RemoteBroadcastEvent } from "./context"; + +/** + * Keeps a single broadcast event small enough that `sendRaw` will actually send + * it. + * + * `RemoteAccessServer.sendRaw` terminates any socket whose + * `bufferedAmount + messageBytes` would exceed the outbound budget, so one + * oversized event disconnects every connected client — and, because the event + * also lands in the replay buffer, disconnects them again on reconnect until + * the replay window rolls over. Runtime payloads carrying inline base64 images + * routinely reach 5-12 MB against a 4 MB default budget, so this is a live + * failure and not a theoretical one. + * + * The cap is deliberately minimal: fields are withheld largest-first and only + * until the event fits, so an event that is already deliverable is passed + * through untouched and a merely large one keeps everything it can. Withheld + * bytes are never lost — the full payload is persisted to SQLite before the cap + * is applied and reaches the client over HTTP on the next history fetch. + */ + +/** + * Fraction of the outbound socket budget a single event may occupy, leaving room + * for one other in-flight message. Deliberately generous: the goal is to + * withhold only what cannot be delivered, not to shrink everything large. On the + * 4 MB default this caps events at 2 MB — measured against a real 6.5k-item + * database that withholds fields from 18 items (all ≥2 MB, i.e. one queued + * message away from killing the socket anyway) while the 39 items between 1 and + * 2 MB keep streaming exactly as they do today. + */ +const EVENT_BUDGET_FRACTION = 0.5; + +/** Total bytes of capped events retained for replay, independent of the entry + * count limit. Bounds desktop memory when many large-but-deliverable events + * arrive in a burst. */ +export const DEFAULT_EVENT_BUFFER_MAX_BYTES = 8 * 1024 * 1024; + +/** Never exceeds the socket budget itself, so a "sendable" verdict always means + * the event can actually leave the server. */ +export function maxBroadcastEventBytes(outboundBudgetBytes: number): number { + return Math.max(1, Math.floor(outboundBudgetBytes * EVENT_BUDGET_FRACTION)); +} + +export type CappedBroadcastEvent = + | { + /** Fits as-is; `json` is the serialized event, reusable by the caller. */ + readonly kind: "sendable"; + readonly event: RemoteBroadcastEvent; + readonly json: string; + readonly bytes: number; + readonly omittedBytes: number; + } + | { + /** Cannot be shrunk to fit. Callers must fall back to a resync rather + * than pushing it onto the wire. */ + readonly kind: "undeliverable"; + readonly bytes: number; + }; + +/** + * Applies `fn` to every runtime-item payload reachable from a broadcast event, + * rebuilding only the objects along the path so untouched subtrees stay shared + * (important: the payloads being withheld are multi-megabyte). + * + * `slot` is a stable per-payload identifier so a measuring pass and a + * rewriting pass agree on which payload is which. + */ +function mapRuntimePayloads( + event: RemoteBroadcastEvent, + fn: (payload: unknown, slot: string, threadId: string, itemId: string | undefined) => unknown, +): RemoteBroadcastEvent { + const mapRuntimeEvent = ( + runtimeEvent: RuntimeEvent, + slot: string, + threadId: string, + ): RuntimeEvent => { + switch (runtimeEvent.type) { + case "item.started": + case "item.updated": + case "item.completed": + case "request.opened": { + if (runtimeEvent.payload === undefined) return runtimeEvent; + // `request.opened` is keyed by requestId, not itemId: it has no + // addressable persisted item, so image projection skips it (an omission + // marker from the size cap is still available). + const itemId = "itemId" in runtimeEvent ? runtimeEvent.itemId : undefined; + const next = fn(runtimeEvent.payload, slot, threadId, itemId); + return next === runtimeEvent.payload + ? runtimeEvent + : ({ ...runtimeEvent, payload: next } as RuntimeEvent); + } + default: + return runtimeEvent; + } + }; + + const mapList = ( + events: readonly RuntimeEvent[], + prefix: string, + threadId: string, + ): readonly RuntimeEvent[] => { + let changed = false; + const next = events.map((runtimeEvent, index) => { + const mapped = mapRuntimeEvent(runtimeEvent, `${prefix}:${index}`, threadId); + if (mapped !== runtimeEvent) changed = true; + return mapped; + }); + return changed ? next : events; + }; + + switch (event.type) { + case "thread-runtime-event": { + const mapped = mapRuntimeEvent(event.event, "e", event.threadId); + return mapped === event.event ? event : { ...event, event: mapped }; + } + case "thread-runtime-events": { + const mapped = mapList(event.events, "l", event.threadId); + return mapped === event.events ? event : { ...event, events: [...mapped] }; + } + case "thread-runtime-events-multi": { + let changed = false; + const batches = event.batches.map((batch, index) => { + const mapped = mapList(batch.events, `b${index}`, batch.threadId); + if (mapped === batch.events) return batch; + changed = true; + return { ...batch, events: [...mapped] }; + }); + return changed ? { ...event, batches } : event; + } + default: + return event; + } +} + +interface OmissionCandidate { + readonly slot: string; + readonly field: string; + readonly bytes: number; +} + +function byteLength(value: unknown): number { + try { + const json = JSON.stringify(value); + return json === undefined ? 0 : Buffer.byteLength(json, "utf8"); + } catch { + // Circular / non-serializable payloads cannot be sized; treat them as + // enormous so they are withheld rather than crashing the publish path. + return Number.MAX_SAFE_INTEGER; + } +} + +function collectCandidates(event: RemoteBroadcastEvent): OmissionCandidate[] { + const candidates: OmissionCandidate[] = []; + mapRuntimePayloads(event, (payload, slot) => { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return payload; + for (const [field, value] of Object.entries(payload as Record)) { + candidates.push({ slot, field, bytes: byteLength(value) }); + } + return payload; + }); + return candidates.sort((a, b) => b.bytes - a.bytes); +} + +/** + * Replaces inline image bytes with host-minted references before the event is + * measured. This runs first because it is the *lossless* reduction — the client + * can fetch every referenced image on demand — whereas the size cap below + * withholds a field until the next history fetch. In practice this keeps image + * events far under budget, so the cap rarely has to act at all. + */ +function projectEventImageRefs(event: RemoteBroadcastEvent): RemoteBroadcastEvent { + return mapRuntimePayloads(event, (payload, _slot, threadId, itemId) => { + if (itemId === undefined) return payload; + return projectPayloadImageRefs(threadId, itemId, payload).payload; + }); +} + +/** + * Serializes `event`, and when it exceeds `maxBytes` withholds its largest + * runtime payload fields until it fits. + */ +export function capBroadcastEvent( + original: RemoteBroadcastEvent, + maxBytes: number, +): CappedBroadcastEvent { + const event = projectEventImageRefs(original); + const json = JSON.stringify(event); + const bytes = json === undefined ? 0 : Buffer.byteLength(json, "utf8"); + if (bytes <= maxBytes) { + return { kind: "sendable", event, json: json ?? "null", bytes, omittedBytes: 0 }; + } + + const candidates = collectCandidates(event); + if (candidates.length === 0) return { kind: "undeliverable", bytes }; + + // Withhold largest-first, estimating the saving as (field bytes - marker + // bytes) so only as many fields as necessary are dropped. The estimate is + // verified by a real re-serialize below. + const omit = new Map>(); + let omittedBytes = 0; + let projected = bytes; + for (const candidate of candidates) { + if (projected <= maxBytes) break; + const markerBytes = + byteLength(remoteOmittedField(candidate.bytes)) + candidate.field.length + 4; + if (candidate.bytes <= markerBytes) continue; + const fields = omit.get(candidate.slot) ?? new Set(); + fields.add(candidate.field); + omit.set(candidate.slot, fields); + omittedBytes += candidate.bytes; + projected -= candidate.bytes - markerBytes; + } + if (omit.size === 0) return { kind: "undeliverable", bytes }; + + const capped = mapRuntimePayloads(event, (payload, slot) => { + const fields = omit.get(slot); + if (!fields || !payload || typeof payload !== "object" || Array.isArray(payload)) + return payload; + const next: Record = { ...(payload as Record) }; + for (const field of fields) { + next[field] = remoteOmittedField(byteLength((payload as Record)[field])); + } + return next; + }); + + const cappedJson = JSON.stringify(capped); + const cappedBytes = cappedJson === undefined ? 0 : Buffer.byteLength(cappedJson, "utf8"); + if (cappedBytes > maxBytes) return { kind: "undeliverable", bytes: cappedBytes }; + return { + kind: "sendable", + event: capped, + json: cappedJson ?? "null", + bytes: cappedBytes, + omittedBytes, + }; +} + +/** Drops the oldest replay entries until both the count and byte limits hold. */ +export function trimEventBuffer( + buffer: BufferedSupervisorEvent[], + maxEntries: number, + maxBytes: number, +): void { + if (buffer.length > maxEntries) { + buffer.splice(0, buffer.length - maxEntries); + } + let total = 0; + for (const entry of buffer) total += entry.bytes; + let dropped = 0; + while (dropped < buffer.length && total > maxBytes) { + total -= buffer[dropped]!.bytes; + dropped += 1; + } + // Never empty the buffer entirely on a single oversized entry: keeping the + // newest one preserves the "client is current" fast path on reconnect. + if (dropped >= buffer.length) dropped = buffer.length - 1; + if (dropped > 0) buffer.splice(0, dropped); +} diff --git a/src/main/remote/server/gitStateProjection.test.ts b/src/main/remote/server/gitStateProjection.test.ts new file mode 100644 index 000000000..20f26f7da --- /dev/null +++ b/src/main/remote/server/gitStateProjection.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { emptyGitStateSnapshot, pullRequestKey, type PullRequestState } from "@/shared/gitState"; +import { + projectGitStatePatchForInterests, + projectGitStateSnapshotForRemote, +} from "./gitStateProjection"; + +const ref = { hostId: "host-1", projectId: "project-1", prNumber: 42 }; +const otherRef = { hostId: "host-1", projectId: "project-1", prNumber: 7 }; +const key = pullRequestKey(ref); +const otherKey = pullRequestKey(otherRef); + +function prState(overrides: Partial = {}): PullRequestState { + return { + ref, + data: { number: 42, title: "A PR" } as PullRequestState["data"], + diff: "diff --git a/x b/x\n+lots of text", + files: [{ path: "x" }] as unknown as PullRequestState["files"], + reviewThreads: [{ id: "t1" }] as unknown as PullRequestState["reviewThreads"], + freshness: { core: "2026-01-01T00:00:00.000Z", diff: "2026-01-01T00:00:00.000Z" }, + ...overrides, + }; +} + +describe("projectGitStateSnapshotForRemote", () => { + it("removes every pull-request body but keeps core data and freshness", () => { + const snapshot = { + ...emptyGitStateSnapshot(), + revision: 3, + pullRequests: { [key]: prState() }, + }; + const projected = projectGitStateSnapshotForRemote(snapshot); + const state = projected.pullRequests[key]!; + expect(state.diff).toBeUndefined(); + expect(state.files).toBeUndefined(); + expect(state.reviewThreads).toBeUndefined(); + // The badge/summary data the thread list needs is untouched. + expect(state.data).toEqual(snapshot.pullRequests[key]!.data); + expect(state.freshness).toEqual(snapshot.pullRequests[key]!.freshness); + expect(projected.revision).toBe(3); + }); + + it("does not copy a snapshot that has no bodies to strip", () => { + const snapshot = { + ...emptyGitStateSnapshot(), + pullRequests: { + [key]: prState({ diff: undefined, files: undefined, reviewThreads: undefined }), + }, + }; + expect(projectGitStateSnapshotForRemote(snapshot)).toBe(snapshot); + }); + + it("leaves an empty snapshot alone", () => { + const snapshot = emptyGitStateSnapshot(); + expect(projectGitStateSnapshotForRemote(snapshot)).toBe(snapshot); + }); +}); + +describe("projectGitStatePatchForInterests", () => { + const patch = { + revision: 5, + pullRequests: { [key]: prState(), [otherKey]: prState({ ref: otherRef }) }, + }; + + it("strips bodies for a connection that asked for nothing", () => { + const scoped = projectGitStatePatchForInterests(patch, []); + expect(scoped.pullRequests?.[key]?.diff).toBeUndefined(); + expect(scoped.pullRequests?.[otherKey]?.diff).toBeUndefined(); + }); + + it("keeps bodies only for the pull request this connection is reviewing", () => { + const scoped = projectGitStatePatchForInterests(patch, [ + { kind: "pull-request", projectId: "project-1", prNumber: 42, includeReviewBundle: true }, + ]); + expect(scoped.pullRequests?.[key]?.diff).toBe(patch.pullRequests[key]!.diff); + expect(scoped.pullRequests?.[key]?.files).toBeDefined(); + // A different PR in the same patch is still stripped. + expect(scoped.pullRequests?.[otherKey]?.diff).toBeUndefined(); + }); + + it("strips when the interest does not ask for the review bundle", () => { + const scoped = projectGitStatePatchForInterests(patch, [ + { kind: "pull-request", projectId: "project-1", prNumber: 42 }, + ]); + expect(scoped.pullRequests?.[key]?.diff).toBeUndefined(); + }); + + it("ignores interests of other kinds", () => { + const scoped = projectGitStatePatchForInterests(patch, [ + { kind: "target", projectId: "project-1" }, + { kind: "project-pull-requests", projectId: "project-1" }, + ]); + expect(scoped.pullRequests?.[key]?.diff).toBeUndefined(); + }); + + it("passes through a patch with no pull requests by identity", () => { + const targetsOnly = { revision: 6, targets: {} }; + expect(projectGitStatePatchForInterests(targetsOnly, [])).toBe(targetsOnly); + }); + + it("does not copy a patch whose pull requests carry no bodies", () => { + const light = { + revision: 7, + pullRequests: { + [key]: prState({ diff: undefined, files: undefined, reviewThreads: undefined }), + }, + }; + expect(projectGitStatePatchForInterests(light, [])).toBe(light); + }); + + it("never mutates the input patch", () => { + const before = JSON.stringify(patch); + projectGitStatePatchForInterests(patch, []); + expect(JSON.stringify(patch)).toBe(before); + }); +}); diff --git a/src/main/remote/server/gitStateProjection.ts b/src/main/remote/server/gitStateProjection.ts new file mode 100644 index 000000000..62dcb6702 --- /dev/null +++ b/src/main/remote/server/gitStateProjection.ts @@ -0,0 +1,101 @@ +import type { + GitStateInterest, + GitStatePatch, + GitStateSnapshot, + PullRequestState, +} from "@/shared/gitState"; + +/** + * Keeps unbounded pull-request bodies off the remote wire. + * + * `PullRequestState` carries a raw `diff` string, a `files[]` list and + * `reviewThreads[]`. Those are fetched only when a client opens a PR review, but + * once fetched they live in the host's global Git snapshot — which is embedded in + * the shell snapshot that clients re-fetch on every status-affecting event, and + * re-broadcast to every connected client on every ordinary PR poll. A single + * large PR can therefore add megabytes to traffic nobody asked for. + * + * Two rules, both chosen so no client can lose data it already holds (patch + * application replaces a pull-request entry wholesale, so silently dropping a + * field would erase the client's copy): + * + * - The **shell snapshot** never carries these bodies. It is a bootstrap of + * whole-workspace state, and a client that wants a review bundle declares the + * interest — which triggers a fresh fetch and a patch carrying it. + * - A **patch** keeps them only for the pull requests that *this connection* + * currently declares a review-bundle interest in. A connection with no such + * interest never received these bodies in the first place, so stripping them + * cannot remove anything from its store. + */ + +const HEAVY_PR_FIELDS = ["diff", "files", "reviewThreads"] as const; + +function stripHeavyFields(state: PullRequestState): PullRequestState { + let next: Record | undefined; + for (const field of HEAVY_PR_FIELDS) { + if (state[field] === undefined) continue; + next ??= { ...state } as unknown as Record; + delete next[field]; + } + // `freshness` is deliberately preserved: it is small, and it tells the client + // these resources exist on the host and how stale they are. + return (next as unknown as PullRequestState | undefined) ?? state; +} + +function stripRecord( + pullRequests: Readonly>, + keep: (key: string) => boolean, +): { readonly value: Readonly>; readonly changed: boolean } { + let changed = false; + const next: Record = {}; + for (const [key, state] of Object.entries(pullRequests)) { + if (keep(key)) { + next[key] = state; + continue; + } + const stripped = stripHeavyFields(state); + if (stripped !== state) changed = true; + next[key] = stripped; + } + return { value: changed ? next : pullRequests, changed }; +} + +/** Git snapshot with every pull-request body removed. */ +export function projectGitStateSnapshotForRemote(snapshot: GitStateSnapshot): GitStateSnapshot { + const { value, changed } = stripRecord(snapshot.pullRequests, () => false); + return changed ? { ...snapshot, pullRequests: value } : snapshot; +} + +/** + * True when `interests` asks for the full review bundle of the pull request + * stored under `key`. Matched on the key suffix because the interest names a + * project + number while the snapshot is keyed by an encoded composite. + */ +function bundleInterestMatcher(interests: readonly GitStateInterest[]): (key: string) => boolean { + const wanted = interests.filter( + (interest) => interest.kind === "pull-request" && interest.includeReviewBundle === true, + ); + if (wanted.length === 0) return () => false; + return (key) => + wanted.some( + (interest) => + interest.kind === "pull-request" && + key.includes(interest.projectId) && + key.includes(String(interest.prNumber)), + ); +} + +/** + * Patch with pull-request bodies kept only where this connection declared a + * review-bundle interest. Returns the original patch when nothing was stripped, + * so the common case costs no allocation and callers can reuse a shared + * serialization. + */ +export function projectGitStatePatchForInterests( + patch: GitStatePatch, + interests: readonly GitStateInterest[], +): GitStatePatch { + if (!patch.pullRequests) return patch; + const { value, changed } = stripRecord(patch.pullRequests, bundleInterestMatcher(interests)); + return changed ? { ...patch, pullRequests: value } : patch; +} diff --git a/src/main/remote/server/httpCompression.test.ts b/src/main/remote/server/httpCompression.test.ts new file mode 100644 index 000000000..05a8c74a7 --- /dev/null +++ b/src/main/remote/server/httpCompression.test.ts @@ -0,0 +1,167 @@ +import { randomBytes } from "node:crypto"; +import { createServer, request as httpRequest, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { gunzipSync } from "node:zlib"; +import { afterEach, describe, expect, it } from "vitest"; +import { computeEtag, etagMatches, writeNegotiatedJson } from "./httpCompression"; + +const servers: Server[] = []; + +afterEach(async () => { + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve) => { + server.close(() => resolve()); + }), + ), + ); +}); + +async function serve(body: string): Promise { + const server = createServer((req, res) => { + void writeNegotiatedJson(req, res, 200, body); + }); + servers.push(server); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + return `http://127.0.0.1:${port}/`; +} + +/** A body large and redundant enough to compress well. */ +const bigBody = JSON.stringify({ + items: Array.from({ length: 400 }, (_, i) => ({ + id: i, + kind: "assistant_message", + text: "the quick brown fox", + })), +}); + +describe("computeEtag", () => { + it("is stable for identical bodies and differs for different ones", () => { + expect(computeEtag("a")).toBe(computeEtag("a")); + expect(computeEtag("a")).not.toBe(computeEtag("b")); + }); + + it("is quoted so it is a valid HTTP entity-tag", () => { + expect(computeEtag("a")).toMatch(/^"[A-Za-z0-9_-]+-[A-Za-z0-9_-]+"$/); + }); +}); + +describe("etagMatches", () => { + const req = (value: string | undefined) => + ({ headers: value === undefined ? {} : { "if-none-match": value } }) as never; + + it("matches an exact tag, a weak tag, a list, and a wildcard", () => { + expect(etagMatches(req('"abc"'), '"abc"')).toBe(true); + expect(etagMatches(req('W/"abc"'), '"abc"')).toBe(true); + expect(etagMatches(req('"other", "abc"'), '"abc"')).toBe(true); + expect(etagMatches(req("*"), '"abc"')).toBe(true); + }); + + it("does not match a different tag or a missing header", () => { + expect(etagMatches(req('"other"'), '"abc"')).toBe(false); + expect(etagMatches(req(undefined), '"abc"')).toBe(false); + }); +}); + +describe("writeNegotiatedJson", () => { + it("gzips a large body for a client that accepts it", async () => { + const url = await serve(bigBody); + const response = await fetch(url, { headers: { "accept-encoding": "gzip" } }); + expect(response.status).toBe(200); + expect(response.headers.get("content-encoding")).toBe("gzip"); + expect(response.headers.get("vary")).toBe("Accept-Encoding"); + // `fetch` decodes transparently, so compare against the original body. + expect(await response.text()).toBe(bigBody); + }); + + it("compresses substantially", async () => { + const url = await serve(bigBody); + // `fetch` hides the encoded length, so read the raw socket response. + const response = await fetch(url, { headers: { "accept-encoding": "gzip" } }); + const encodedLength = Number(response.headers.get("content-length")); + await response.arrayBuffer(); + expect(encodedLength).toBeGreaterThan(0); + expect(encodedLength).toBeLessThan(Buffer.byteLength(bigBody) / 3); + }); + + it("sends plain JSON when the client does not accept gzip", async () => { + const url = await serve(bigBody); + const response = await fetch(url, { headers: { "accept-encoding": "identity" } }); + expect(response.headers.get("content-encoding")).toBeNull(); + expect(await response.text()).toBe(bigBody); + }); + + it("leaves a small body uncompressed", async () => { + const url = await serve('{"ok":true}'); + const response = await fetch(url, { headers: { "accept-encoding": "gzip" } }); + expect(response.headers.get("content-encoding")).toBeNull(); + expect(await response.text()).toBe('{"ok":true}'); + }); + + it("barely shrinks an inline base64 image, unlike transcript JSON", async () => { + // Documents why compression alone is not the answer for image payloads. + // Base64 is not incompressible — it uses only 64 of 256 byte values, so + // deflate reliably recovers ~25% — but that is nowhere near the >3x it gets + // on redundant transcript JSON. Images have to leave the payload instead. + const imageBody = JSON.stringify({ images: [randomBytes(8192).toString("base64")] }); + const url = await serve(imageBody); + const response = await fetch(url, { headers: { "accept-encoding": "gzip" } }); + const encodedLength = Number(response.headers.get("content-length")); + await response.arrayBuffer(); + const ratio = Buffer.byteLength(imageBody) / encodedLength; + expect(ratio).toBeGreaterThan(1); + expect(ratio).toBeLessThan(1.6); + }); + + it("answers 304 with no body when the client's ETag still matches", async () => { + const url = await serve(bigBody); + const first = await fetch(url, { headers: { "accept-encoding": "gzip" } }); + const etag = first.headers.get("etag"); + await first.arrayBuffer(); + expect(etag).toBeTruthy(); + + const second = await fetch(url, { + headers: { "accept-encoding": "gzip", "if-none-match": etag! }, + }); + expect(second.status).toBe(304); + expect(await second.text()).toBe(""); + }); + + it("returns a fresh 200 once the body changes", async () => { + const url = await serve(bigBody); + const first = await fetch(url); + const etag = first.headers.get("etag"); + await first.arrayBuffer(); + + const otherUrl = await serve(`${bigBody} `); + const second = await fetch(otherUrl, { headers: { "if-none-match": etag! } }); + expect(second.status).toBe(200); + }); + + it("produces a body the standard gzip reader can inflate", async () => { + const server = createServer((req, res) => { + void writeNegotiatedJson(req, res, 200, bigBody); + }); + servers.push(server); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + // Bypass fetch's transparent decoding to assert real gzip framing. + const raw = await new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + const request = httpRequest( + `http://127.0.0.1:${port}/`, + { headers: { "accept-encoding": "gzip" } }, + (res) => { + expect(res.headers["content-encoding"]).toBe("gzip"); + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => resolve(Buffer.concat(chunks))); + }, + ); + request.on("error", reject); + request.end(); + }); + expect(gunzipSync(raw).toString()).toBe(bigBody); + }); +}); diff --git a/src/main/remote/server/httpCompression.ts b/src/main/remote/server/httpCompression.ts new file mode 100644 index 000000000..6473c923b --- /dev/null +++ b/src/main/remote/server/httpCompression.ts @@ -0,0 +1,126 @@ +import { createHash, randomUUID } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { gzip } from "node:zlib"; + +/** + * Content encoding + conditional GET for the remote JSON API. + * + * Remote snapshot/history responses are the largest thing this server sends over + * HTTP and were previously written as raw `JSON.stringify` with no `ETag`, so a + * phone re-fetched the entire shell snapshot on every status-affecting event + * even when nothing in it had changed. + * + * Two deliberate design points: + * + * - Compression is async (`zlib.gzip`, not `gzipSync`). This code runs on the + * Electron **main** process, where a synchronous multi-megabyte deflate would + * block the IPC/event loop that every window and the supervisor bridge share. + * + * - The `ETag` is a content hash prefixed with a per-process boot id, never the + * snapshot's `snapshotSeq`. `snapshotSeq` is in-memory and restarts at 0 while + * bearer sessions persist across restarts, so a client holding `"500"` from a + * previous boot could be handed a false `304` once the new stream's sequence + * climbed back to 500 with different content. A hash also gets a higher hit + * rate, because the sequence bumps on every broadcast event whether or not the + * shell snapshot's own content moved. + */ + +/** Below this, framing overhead and CPU outweigh the saving. */ +const MIN_COMPRESS_BYTES = 1024; + +/** Distinguishes ETags minted by different runs of this process. */ +const BOOT_ID = randomUUID().slice(0, 8); + +const GZIP_OPTIONS = { level: 6 } as const; + +export function acceptsGzip(req: IncomingMessage): boolean { + const header = req.headers["accept-encoding"]; + const value = Array.isArray(header) ? header.join(",") : (header ?? ""); + return /\bgzip\b/i.test(value); +} + +export function computeEtag(body: string): string { + const hash = createHash("sha1").update(body).digest("base64url").slice(0, 22); + return `"${BOOT_ID}-${hash}"`; +} + +/** RFC-compliant enough for our own client: exact match, or `*`. */ +export function etagMatches(req: IncomingMessage, etag: string): boolean { + const header = req.headers["if-none-match"]; + if (!header) return false; + const value = Array.isArray(header) ? header.join(",") : header; + if (value.trim() === "*") return true; + return value + .split(",") + .map((candidate) => candidate.trim()) + .some((candidate) => candidate === etag || candidate === `W/${etag}`); +} + +async function gzipAsync(body: string): Promise { + return await new Promise((resolve, reject) => { + gzip(body, GZIP_OPTIONS, (error, result) => { + if (error) reject(error); + else resolve(result); + }); + }); +} + +/** + * Writes a JSON body with `ETag`, negotiated gzip, and a `304` short-circuit. + * + * Callers pass the already-serialized body so the ETag is computed over exactly + * the bytes that would be sent. + */ +export async function writeNegotiatedJson( + req: IncomingMessage, + res: ServerResponse, + status: number, + body: string, +): Promise { + const etag = computeEtag(body); + res.setHeader("Content-Type", "application/json; charset=utf-8"); + // Responses are per-session private and must be revalidated, never reused + // blind: `no-cache` still permits the conditional request that yields the 304. + res.setHeader("Cache-Control", "private, no-cache"); + res.setHeader("ETag", etag); + // Caches keyed only on URL would otherwise be able to hand a gzip body to a + // client that cannot decode it. + res.setHeader("Vary", "Accept-Encoding"); + + if (status === 200 && etagMatches(req, etag)) { + res.statusCode = 304; + res.end(); + return; + } + + const rawBytes = Buffer.byteLength(body, "utf8"); + if (!acceptsGzip(req) || rawBytes < MIN_COMPRESS_BYTES) { + res.statusCode = status; + res.setHeader("Content-Length", String(rawBytes)); + res.end(body); + return; + } + + let compressed: Buffer; + try { + compressed = await gzipAsync(body); + } catch { + // Never fail a response because compression failed. + res.statusCode = status; + res.setHeader("Content-Length", String(rawBytes)); + res.end(body); + return; + } + // A compressed body that grew (already-entropic payloads: base64 images) is + // pointless overhead on both ends. + if (compressed.byteLength >= rawBytes) { + res.statusCode = status; + res.setHeader("Content-Length", String(rawBytes)); + res.end(body); + return; + } + res.statusCode = status; + res.setHeader("Content-Encoding", "gzip"); + res.setHeader("Content-Length", String(compressed.byteLength)); + res.end(compressed); +} diff --git a/src/main/remote/server/httpResponses.ts b/src/main/remote/server/httpResponses.ts index e1d9ca435..e01b0e708 100644 --- a/src/main/remote/server/httpResponses.ts +++ b/src/main/remote/server/httpResponses.ts @@ -1,13 +1,30 @@ -import type { ServerResponse } from "node:http"; +import type { IncomingMessage, ServerResponse } from "node:http"; import { ZodError } from "zod"; import { remoteHttpErrorSchema } from "@/shared/remote"; import { writeJsonResponse } from "@/shared/http"; import { RemoteHttpError } from "../auth"; +import { writeNegotiatedJson } from "./httpCompression"; export function writeJson(res: ServerResponse, status: number, data: unknown): void { writeJsonResponse(res, status, data, { trailingNewline: true }); } +/** + * `writeJson` plus gzip negotiation and a revalidating `ETag`. Used by the large + * read endpoints (shell snapshot, thread history, runtime pages) — the responses + * that dominate remote bandwidth and that clients re-fetch on every + * status-affecting event. Small fixed-shape replies stay on `writeJson`; they sit + * under the compression threshold anyway. + */ +export async function writeNegotiatedJsonResponse( + req: IncomingMessage, + res: ServerResponse, + status: number, + data: unknown, +): Promise { + await writeNegotiatedJson(req, res, status, `${JSON.stringify(data)}\n`); +} + export function writeHtml(res: ServerResponse, status: number, html: string): void { writeText(res, status, html, "text/html; charset=utf-8"); } diff --git a/src/main/remote/server/httpRouter.ts b/src/main/remote/server/httpRouter.ts index 1466be975..aea471da8 100644 --- a/src/main/remote/server/httpRouter.ts +++ b/src/main/remote/server/httpRouter.ts @@ -38,6 +38,7 @@ import { startThreadPayloadSchema, writeTerminalPayloadSchema, } from "@/shared/contracts"; +import { dbTruncateRuntimeItemsPayloadSchema } from "@/shared/ipc/schemas"; import { dbClaimRemoteCommand, dbCompleteRemoteCommand, @@ -46,6 +47,7 @@ import { dbGetProjectNotes, dbGetThread, dbSetProjectNotes, + dbTruncateThreadRuntimeAfter, } from "../../db"; import { getProfileCoreStats, @@ -67,8 +69,15 @@ import { } from "../pairingPage"; import { tryServeBuiltMobileApp } from "../staticMobileApp"; import type { RemoteServerContext } from "./context"; -import { writeError, writeHtml, writeJson, writeText } from "./httpResponses"; +import { + writeError, + writeHtml, + writeJson, + writeNegotiatedJsonResponse, + writeText, +} from "./httpResponses"; import { writeLocalImageFile } from "./localImageFile"; +import { parseImageRefPath, resolveImageRef } from "./imageRefProjection"; import { buildForwardSessionCookieHeader, isReservedForwardProxyPath, @@ -376,12 +385,12 @@ export async function handleHttp( } if (req.method === "GET" && url.pathname === "/api/snapshot") { ctx.security.requireBearer(req, ["session:read"]); - writeJson(res, 200, buildShellSnapshot(ctx)); + await writeNegotiatedJsonResponse(req, res, 200, buildShellSnapshot(ctx)); return; } if (req.method === "GET" && url.pathname === "/api/agent-statuses") { ctx.security.requireBearer(req, ["session:read"]); - writeJson(res, 200, await buildAgentStatuses(ctx)); + await writeNegotiatedJsonResponse(req, res, 200, await buildAgentStatuses(ctx)); return; } if (req.method === "GET" && url.pathname === "/api/provider-usage") { @@ -432,6 +441,45 @@ export async function handleHttp( await writeLocalImageFile(res, url.searchParams.get("path")); return; } + // Resolves a host-minted image reference back to bytes. Unlike + // `/api/files/image` this takes NO caller-supplied filesystem path: it + // addresses a location inside the thread's own persisted runtime payload and + // re-verifies that the addressed value really is an inline image, so a + // prompt-injected tool result cannot steer it at the filesystem or network. + // Shares the `access_token` query-param affordance because tags cannot + // send an Authorization header. + const refImageMatch = /^\/api\/threads\/([^/]+)\/items\/([^/]+)\/image$/.exec(url.pathname); + if (req.method === "GET" && refImageMatch) { + const header = Array.isArray(req.headers.authorization) + ? req.headers.authorization[0] + : req.headers.authorization; + const token = parseBearerAuthorizationHeader(header) ?? url.searchParams.get("access_token"); + if (!token) { + throw new RemoteHttpError("missing_access_token", "Missing access token.", 401); + } + ctx.auth.authenticateBearerToken(token, ["session:read"]); + const path = parseImageRefPath(url.searchParams.get("path")); + if (!path) { + throw new RemoteHttpError("invalid_path", "An image reference path is required.", 400); + } + const resolved = resolveImageRef( + decodeURIComponent(refImageMatch[1]!), + decodeURIComponent(refImageMatch[2]!), + path, + ); + if (!resolved) { + throw new RemoteHttpError("image_not_found", "No inline image at that reference.", 404); + } + res.writeHead(200, { + "content-type": resolved.mime, + "content-length": resolved.data.length, + // Immutable: a runtime item's image bytes never change under the same + // id, so the client can reuse it for the life of the transcript. + "cache-control": "private, max-age=31536000, immutable", + }); + res.end(resolved.data); + return; + } if (req.method === "POST" && url.pathname === "/api/files/attachment") { ctx.security.requireBearer(req, ["session:operate"]); const threadId = url.searchParams.get("threadId")?.trim(); @@ -615,6 +663,7 @@ export async function handleHttp( type: "remote-projects-changed", projects: result.projects, }); + ctx.options.onProjectsChanged?.(result.projects); writeJson(res, 200, result); return; } @@ -654,14 +703,15 @@ export async function handleHttp( ? { targetTimelineEntryCount: Number(targetTimelineEntryCount) } : {}), }); - writeJson(res, 200, buildThreadRuntimeItemsPage(input)); + await writeNegotiatedJsonResponse(req, res, 200, buildThreadRuntimeItemsPage(input)); return; } const historyThreadId = threadIdFromPath(url.pathname, "/history"); if (req.method === "GET" && historyThreadId) { ctx.security.requireBearer(req, ["session:read"]); const targetTimelineEntryCount = url.searchParams.get("targetTimelineEntryCount"); - writeJson( + await writeNegotiatedJsonResponse( + req, res, 200, await buildThreadSnapshot(ctx, historyThreadId, { @@ -710,6 +760,19 @@ export async function handleHttp( return; } const commandThreadId = threadIdFromPath(url.pathname, "/command"); + const truncateThreadId = threadIdFromPath(url.pathname, "/runtime/truncate"); + if (req.method === "POST" && truncateThreadId) { + ctx.security.requireBearer(req, ["session:operate"]); + const body = await readJsonBody(req); + const payload = dbTruncateRuntimeItemsPayloadSchema.parse({ + ...(typeof body === "object" && body !== null ? body : {}), + threadId: truncateThreadId, + }); + dbTruncateThreadRuntimeAfter(payload.threadId, payload.itemId); + ctx.publishThreadsChanged([payload.threadId]); + writeJson(res, 200, { ok: true }); + return; + } if (req.method === "POST" && commandThreadId) { ctx.security.requireBearer(req, ["session:operate"]); const body = await readJsonBody(req); @@ -719,6 +782,34 @@ export async function handleHttp( }); assertRemoteThreadCommandExperimentSafe(command); const dispatch = async () => { + if (command.kind === "delete-worktree-group") { + if (ctx.options.dispatchThreadCommand?.(command) !== true) { + throw new RemoteHttpError( + "desktop_unavailable", + "The desktop app is not available to apply this change.", + 503, + ); + } + await applyRemoteThreadCommand(ctx, command); + ctx.publishThreadsChanged(command.threadIds); + return { ok: true }; + } + if (command.kind === "start" && command.isNewWorktree && command.worktreePath) { + const prepared = + ctx.options.dispatchThreadCommand?.({ + kind: "prepare-worktree", + threadId: command.threadId, + projectId: command.projectId, + worktreePath: command.worktreePath, + }) ?? false; + if (!prepared) { + throw new RemoteHttpError( + "desktop_unavailable", + "The desktop app is not available to prepare the worktree.", + 503, + ); + } + } const requiresRenderer = await applyRemoteThreadCommand(ctx, command); if (requiresRenderer && ctx.options.dispatchThreadCommand?.(command) !== true) { throw new RemoteHttpError( @@ -728,8 +819,11 @@ export async function handleHttp( ); } if (!requiresRenderer) { - const rendererCommand = - command.kind === "start" ? { ...command, launchRuntime: false } : command; + const rendererCommand = (() => { + if (command.kind !== "start") return command; + const { isNewWorktree: _isNewWorktree, ...startCommand } = command; + return { ...startCommand, launchRuntime: false }; + })(); ctx.options.dispatchThreadCommand?.(rendererCommand); if (command.kind === "acknowledge") { ctx.publishSupervisorEvent({ diff --git a/src/main/remote/server/imagePreview.test.ts b/src/main/remote/server/imagePreview.test.ts new file mode 100644 index 000000000..90faa70cb --- /dev/null +++ b/src/main/remote/server/imagePreview.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + getCachedImagePreview, + imagePreviewKey, + resetImagePreviews, + scheduleImagePreview, + setImagePreviewGenerator, +} from "./imagePreview"; + +const flush = () => new Promise((resolve) => setImmediate(() => setImmediate(resolve))); +const source = () => ({ data: Buffer.from([1, 2, 3]), mime: "image/png" }); + +afterEach(() => { + resetImagePreviews(); +}); + +describe("image previews", () => { + it("does nothing without a generator, so a headless host simply has none", async () => { + const key = imagePreviewKey("t", "i", ["images", 0]); + scheduleImagePreview(key, source); + await flush(); + expect(getCachedImagePreview(key)).toBeUndefined(); + }); + + it("generates off the critical path and caches the result", async () => { + setImagePreviewGenerator(() => "data:image/jpeg;base64,AAA"); + const key = imagePreviewKey("t", "i", ["images", 0]); + scheduleImagePreview(key, source); + // Deliberately not available synchronously: the decode must not block the + // response that asked for it. + expect(getCachedImagePreview(key)).toBeUndefined(); + await flush(); + expect(getCachedImagePreview(key)).toBe("data:image/jpeg;base64,AAA"); + }); + + it("generates once per image no matter how often it is projected", async () => { + const generator = vi.fn<() => string>(() => "data:image/jpeg;base64,AAA"); + setImagePreviewGenerator(generator); + const key = imagePreviewKey("t", "i", ["images", 0]); + scheduleImagePreview(key, source); + scheduleImagePreview(key, source); + await flush(); + scheduleImagePreview(key, source); + await flush(); + expect(generator).toHaveBeenCalledTimes(1); + }); + + it("keys previews per image location", async () => { + setImagePreviewGenerator(({ mime }) => `data:image/jpeg;base64,${mime.length}`); + const a = imagePreviewKey("t", "i", ["images", 0]); + const b = imagePreviewKey("t", "i", ["images", 1]); + expect(a).not.toBe(b); + scheduleImagePreview(a, source); + scheduleImagePreview(b, source); + await flush(); + expect(getCachedImagePreview(a)).toBeDefined(); + expect(getCachedImagePreview(b)).toBeDefined(); + }); + + it("survives a generator that throws on a malformed image", async () => { + setImagePreviewGenerator(() => { + throw new Error("corrupt"); + }); + const key = imagePreviewKey("t", "i", ["images", 0]); + expect(() => scheduleImagePreview(key, source)).not.toThrow(); + await flush(); + expect(getCachedImagePreview(key)).toBeUndefined(); + }); + + it("skips an image whose bytes cannot be decoded", async () => { + const generator = vi.fn<() => string>(() => "data:image/jpeg;base64,AAA"); + setImagePreviewGenerator(generator); + const key = imagePreviewKey("t", "i", ["images", 0]); + scheduleImagePreview(key, () => null); + await flush(); + expect(generator).not.toHaveBeenCalled(); + expect(getCachedImagePreview(key)).toBeUndefined(); + }); + + it("drains a burst without losing any entry", async () => { + setImagePreviewGenerator(() => "data:image/jpeg;base64,AAA"); + const keys = Array.from({ length: 25 }, (_, i) => imagePreviewKey("t", `i${i}`, ["images", 0])); + for (const key of keys) scheduleImagePreview(key, source); + for (let i = 0; i < 60; i += 1) await flush(); + expect(keys.every((key) => getCachedImagePreview(key) !== undefined)).toBe(true); + }); +}); diff --git a/src/main/remote/server/imagePreview.ts b/src/main/remote/server/imagePreview.ts new file mode 100644 index 000000000..8d89dfc4f Binary files /dev/null and b/src/main/remote/server/imagePreview.ts differ diff --git a/src/main/remote/server/imageRefProjection.test.ts b/src/main/remote/server/imageRefProjection.test.ts new file mode 100644 index 000000000..98fa65cd2 --- /dev/null +++ b/src/main/remote/server/imageRefProjection.test.ts @@ -0,0 +1,273 @@ +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import Database from "better-sqlite3"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { Thread } from "@/shared/contracts"; +import { readRemoteImageRef, remoteImageRef } from "@/shared/remote"; +import { closeDatabase, initDatabase } from "../../db/connection"; +import { dbUpsertProject, dbUpsertThread } from "../../db/projectsThreads"; +import { dbReplaceThreadRuntimeItems } from "../../db/runtimeItems"; +import { resetImagePreviews, setImagePreviewGenerator } from "./imagePreview"; +import { + parseImageRefPath, + projectPayloadImageRefs, + projectRuntimeItemsImageRefs, + resolveImageRef, +} from "./imageRefProjection"; + +const serverNativeBinding = join(process.cwd(), "dist", "server-native", "better_sqlite3.node"); +let nativeBindingEnv: string | undefined; +let sqliteAvailable = true; +try { + new Database(":memory:").close(); +} catch { + if (existsSync(serverNativeBinding)) { + nativeBindingEnv = serverNativeBinding; + } else { + sqliteAvailable = false; + } +} + +/** A real 1x1 PNG, base64-encoded, padded so it clears the 8KB ref threshold. */ +const PNG_1X1 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8AABAwMDAwMDAAAAP//AwABAAEAAQABAAEA"; +const bigPngBase64 = `${PNG_1X1}${"A".repeat(9000)}`; +const bigPngDataUrl = `data:image/png;base64,${bigPngBase64}`; + +function testThread(): Thread { + return { + id: "thread-1", + projectId: "project-1", + title: "Images", + agentKind: "codex", + config: { model: "gpt-5" }, + status: "idle", + attention: "none", + canResumeWithConfig: false, + archived: false, + done: false, + starred: false, + presentationMode: "gui", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +describe("projectPayloadImageRefs", () => { + it("replaces a large inline image with a reference carrying its metadata", () => { + const payload = { + name: "imageView", + args: { path: "/tmp/shot.png" }, + status: "success", + images: [bigPngDataUrl], + }; + const { payload: projected, omittedBytes } = projectPayloadImageRefs("t1", "i1", payload); + expect(omittedBytes).toBeGreaterThan(9000); + const record = projected as { images: unknown[]; name: string; args: unknown }; + const ref = readRemoteImageRef(record.images[0]); + expect(ref).toMatchObject({ + threadId: "t1", + itemId: "i1", + path: ["images", 0], + mime: "image/png", + }); + // Dimensions ride along so the timeline can reserve layout without fetching. + expect(ref?.width).toBe(1); + expect(ref?.height).toBe(1); + // Descriptive fields are untouched. + expect(record.name).toBe("imageView"); + expect(record.args).toEqual({ path: "/tmp/shot.png" }); + }); + + it("leaves small images inline — a round trip would cost more than the bytes", () => { + const payload = { images: [`data:image/png;base64,${PNG_1X1}`] }; + const { payload: projected, omittedBytes } = projectPayloadImageRefs("t1", "i1", payload); + expect(omittedBytes).toBe(0); + expect(projected).toBe(payload); + }); + + it("leaves a payload with no image untouched by identity", () => { + const payload = { name: "bash", result: "ok" }; + expect(projectPayloadImageRefs("t1", "i1", payload).payload).toBe(payload); + }); + + it("does not mutate the original payload", () => { + const payload = { images: [bigPngDataUrl] }; + const before = JSON.stringify(payload); + projectPayloadImageRefs("t1", "i1", payload); + expect(JSON.stringify(payload)).toBe(before); + }); + + it("projects an image nested in a structured result", () => { + const payload = { result: { content: [{ type: "text" }, { data: bigPngBase64 }] } }; + const { payload: projected } = projectPayloadImageRefs("t1", "i1", payload); + const nested = (projected as { result: { content: Array> } }).result + .content[1]!; + expect(readRemoteImageRef(nested.data)?.path).toEqual(["result", "content", 1, "data"]); + }); + + it("projects every image when a payload carries several", () => { + const { payload: projected, omittedBytes } = projectPayloadImageRefs("t1", "i1", { + images: [bigPngDataUrl, bigPngDataUrl], + }); + const images = (projected as { images: unknown[] }).images; + expect(readRemoteImageRef(images[0])?.path).toEqual(["images", 0]); + expect(readRemoteImageRef(images[1])?.path).toEqual(["images", 1]); + expect(omittedBytes).toBeGreaterThan(18000); + }); +}); + +describe("parseImageRefPath", () => { + it("accepts a key/index list", () => { + expect(parseImageRefPath('["images",0]')).toEqual(["images", 0]); + }); + + it("rejects malformed, empty, over-long, and non-scalar paths", () => { + for (const raw of [ + null, + "", + "not json", + "[]", + '{"images":0}', + '[{"a":1}]', + '["a","b","c","d","e","f","g","h","i"]', + '["a",1.5]', + ]) { + expect(parseImageRefPath(raw)).toBeNull(); + } + }); +}); + +describe.skipIf(!sqliteAvailable)("resolveImageRef", () => { + let dir: string; + + beforeEach(() => { + if (nativeBindingEnv) { + process.env.PORACODE_BETTER_SQLITE3_NATIVE_BINDING = nativeBindingEnv; + } + dir = mkdtempSync(join(tmpdir(), "poracode-imageref-test-")); + initDatabase(join(dir, "state.sqlite")); + dbUpsertProject( + { + id: "project-1", + name: "Test project", + location: { kind: "posix", path: "/tmp/project" }, + createdAt: "2026-01-01T00:00:00.000Z", + }, + 0, + ); + dbUpsertThread(testThread(), 0); + }); + + afterEach(() => { + closeDatabase(); + rmSync(dir, { recursive: true, force: true }); + delete process.env.PORACODE_BETTER_SQLITE3_NATIVE_BINDING; + }); + + function persist(payload: unknown): void { + dbReplaceThreadRuntimeItems("thread-1", [ + { id: "item-1", type: "image_view", state: "completed", payload, streams: {} }, + ]); + } + + it("resolves a projected reference back to the exact bytes", () => { + persist({ images: [bigPngDataUrl] }); + const resolved = resolveImageRef("thread-1", "item-1", ["images", 0]); + expect(resolved?.mime).toBe("image/png"); + expect(resolved?.data.equals(Buffer.from(bigPngBase64, "base64"))).toBe(true); + }); + + it("resolves bare base64 as well as data URLs", () => { + persist({ images: [bigPngBase64] }); + expect(resolveImageRef("thread-1", "item-1", ["images", 0])?.data.byteLength).toBeGreaterThan( + 0, + ); + }); + + it("round-trips a reference produced by the projection", () => { + const payload = { images: [bigPngDataUrl] }; + persist(payload); + const projected = projectRuntimeItemsImageRefs("thread-1", [ + { id: "item-1", type: "image_view", state: "completed", payload, streams: {} }, + ]); + const ref = readRemoteImageRef((projected[0]!.payload as { images: unknown[] }).images[0]); + expect(ref).not.toBeNull(); + expect(resolveImageRef(ref!.threadId, ref!.itemId, ref!.path)?.data.byteLength).toBe( + Buffer.from(bigPngBase64, "base64").byteLength, + ); + }); + + it("refuses to serve a filesystem path even though the payload holds one", () => { + // The security boundary: `path` addresses a location in our own row, and the + // value there is re-verified as an inline image. A tool result naming a local + // file resolves to nothing rather than being read off disk. + persist({ images: ["/etc/passwd"], args: { path: "/etc/passwd" } }); + expect(resolveImageRef("thread-1", "item-1", ["images", 0])).toBeNull(); + expect(resolveImageRef("thread-1", "item-1", ["args", "path"])).toBeNull(); + }); + + it("refuses to serve an agent-supplied http(s) or file URL", () => { + persist({ images: ["https://tracker.example/pixel.png", "file:///etc/hosts"] }); + expect(resolveImageRef("thread-1", "item-1", ["images", 0])).toBeNull(); + expect(resolveImageRef("thread-1", "item-1", ["images", 1])).toBeNull(); + }); + + it("returns null for an unknown thread, item, or path", () => { + persist({ images: [bigPngDataUrl] }); + expect(resolveImageRef("nope", "item-1", ["images", 0])).toBeNull(); + expect(resolveImageRef("thread-1", "nope", ["images", 0])).toBeNull(); + expect(resolveImageRef("thread-1", "item-1", ["images", 9])).toBeNull(); + expect(resolveImageRef("thread-1", "item-1", ["missing"])).toBeNull(); + }); + + it("does not resolve a reference object left in place of the image", () => { + // Guards against a projected payload ever being persisted by mistake. + persist({ + images: [ + remoteImageRef({ + threadId: "thread-1", + itemId: "item-1", + path: ["images", 0], + mime: "image/png", + bytes: 10, + }), + ], + }); + expect(resolveImageRef("thread-1", "item-1", ["images", 0])).toBeNull(); + }); +}); + +describe("preview attachment", () => { + afterEach(() => { + resetImagePreviews(); + }); + + it("omits the preview on first sight and includes it once generated", async () => { + setImagePreviewGenerator(() => "data:image/jpeg;base64,QQ=="); + const payload = { images: [bigPngDataUrl] }; + + // First projection: nothing cached yet, so the reference ships without a + // preview rather than blocking on a decode. + const first = projectPayloadImageRefs("t1", "i1", payload).payload; + expect(readRemoteImageRef((first as { images: unknown[] }).images[0])?.preview).toBeUndefined(); + + await new Promise((resolve) => setImmediate(() => setImmediate(resolve))); + + // Second projection picks up the cached preview. + const second = projectPayloadImageRefs("t1", "i1", payload).payload; + const ref = readRemoteImageRef((second as { images: unknown[] }).images[0]); + expect(ref?.preview).toBe("data:image/jpeg;base64,QQ=="); + // Intrinsic size still rides along so the slot is reserved either way. + expect(ref?.width).toBe(1); + expect(ref?.height).toBe(1); + }); + + it("still mints usable references when the host cannot make previews", () => { + const projected = projectPayloadImageRefs("t1", "i1", { images: [bigPngDataUrl] }).payload; + const ref = readRemoteImageRef((projected as { images: unknown[] }).images[0]); + expect(ref).not.toBeNull(); + expect(ref?.preview).toBeUndefined(); + }); +}); diff --git a/src/main/remote/server/imageRefProjection.ts b/src/main/remote/server/imageRefProjection.ts new file mode 100644 index 000000000..4d0d839fc --- /dev/null +++ b/src/main/remote/server/imageRefProjection.ts @@ -0,0 +1,200 @@ +import { + classifyInlineImageCandidate, + collectInlineImageLocationsDeep, + type InlineImageClassification, + type InlineImagePath, +} from "@/shared/inlineImagePayload"; +import { readImageDimensions } from "@/shared/imageDimensions"; +import { remoteImageRef, type RemoteImageRefValue } from "@/shared/remote"; +import type { PersistedRuntimeItem } from "@/shared/ipc"; +import { dbGetThreadRuntimeItem } from "../../db"; +import { getCachedImagePreview, imagePreviewKey, scheduleImagePreview } from "./imagePreview"; + +/** + * Replaces inline image bytes in remote-bound payloads with host-minted + * references, and resolves those references back to bytes on request. + * + * This is the remote projection only: SQLite and the desktop's own IPC keep the + * full inline payload, so the desktop renderer is untouched and the bytes are + * always recoverable. Only what crosses the remote boundary is slimmed. + */ + +/** + * Images below this stay inline. A round trip costs a request, a header set and + * a cache entry; for a small icon that is more expensive than the bytes. Sized to + * catch the payloads that actually matter (measured: real images run 0.1-12 MB) + * while leaving favicon-scale art alone. + */ +const MIN_REF_BYTES = 8 * 1024; + +function setAtPath(root: unknown, path: InlineImagePath, value: unknown): unknown { + if (path.length === 0) return value; + const [head, ...rest] = path; + if (head === undefined) return value; + if (typeof head === "number") { + if (!Array.isArray(root)) return root; + const next = [...root]; + next[head] = setAtPath(root[head], rest, value); + return next; + } + if (!root || typeof root !== "object" || Array.isArray(root)) return root; + const record = root as Record; + return { ...record, [head]: setAtPath(record[head], rest, value) }; +} + +function readAtPath(root: unknown, path: InlineImagePath): unknown { + let current: unknown = root; + for (const part of path) { + if (current === null || current === undefined) return undefined; + if (typeof part === "number") { + if (!Array.isArray(current)) return undefined; + current = current[part]; + continue; + } + if (typeof current !== "object" || Array.isArray(current)) return undefined; + current = (current as Record)[part]; + } + return current; +} + +/** + * Returns `payload` with every sufficiently large inline image swapped for a + * reference. Returns the original object identity when nothing changed, so + * callers can cheaply detect a no-op. + */ +export function projectPayloadImageRefs( + threadId: string, + itemId: string, + payload: unknown, +): { readonly payload: unknown; readonly omittedBytes: number } { + const locations = collectInlineImageLocationsDeep(payload); + if (locations.length === 0) return { payload, omittedBytes: 0 }; + + let next = payload; + let omittedBytes = 0; + for (const location of locations) { + const bytes = Buffer.byteLength(location.value, "utf8"); + if (bytes < MIN_REF_BYTES) continue; + const dimensions = readImageDimensions(location.value, location.classification); + // A blurred stand-in for the reserved slot. Only ever read from cache here — + // generation is queued so a multi-megabyte decode never blocks this response. + const previewKey = imagePreviewKey(threadId, itemId, location.path); + const preview = getCachedImagePreview(previewKey); + if (preview === undefined) { + scheduleImagePreview(previewKey, () => + decodeInlineImage(location.value, location.classification), + ); + } + const ref: RemoteImageRefValue = { + threadId, + itemId, + path: location.path, + mime: location.classification.mime, + bytes, + ...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}), + ...(preview ? { preview } : {}), + }; + next = setAtPath(next, location.path, remoteImageRef(ref)); + omittedBytes += bytes; + } + return { payload: next, omittedBytes }; +} + +/** Runtime item with its payload's large inline images replaced by references. */ +export function projectRuntimeItemImageRefs( + threadId: string, + item: PersistedRuntimeItem, +): PersistedRuntimeItem { + if (item.payload === undefined) return item; + const projected = projectPayloadImageRefs(threadId, item.id, item.payload); + if (projected.payload === item.payload) return item; + return { ...item, payload: projected.payload }; +} + +export function projectRuntimeItemsImageRefs( + threadId: string, + items: readonly PersistedRuntimeItem[], +): PersistedRuntimeItem[] { + return items.map((item) => projectRuntimeItemImageRefs(threadId, item)); +} + +export interface ResolvedRefImage { + readonly mime: string; + readonly data: Buffer; +} + +/** + * Re-reads the persisted item and decodes the image at `path`. + * + * Returns null unless the addressed value is still a self-contained inline image + * — the same check the renderer applies before it will render anything. That + * re-verification is the security boundary: even though `path` arrives from the + * client, it can only ever name a location inside this thread's own stored + * payload, and a location holding anything other than inline image bytes (a + * file path, an `http(s)://` URL, arbitrary text) resolves to nothing. + */ +export function resolveImageRef( + threadId: string, + itemId: string, + path: InlineImagePath, +): ResolvedRefImage | null { + // The coordinates come from a client, so a failed lookup must degrade to "no + // such image" (404) rather than surfacing an internal error. This also keeps + // the endpoint quiet on a host whose database is unavailable or mid-recovery. + let item; + try { + item = dbGetThreadRuntimeItem(threadId, itemId); + } catch { + return null; + } + if (!item || item.payload === undefined) return null; + const value = readAtPath(item.payload, path); + if (typeof value !== "string" || value.length === 0) return null; + const classification = classifyInlineImageCandidate(value); + if (!classification) return null; + + if (classification.kind === "rawSvg") { + return { mime: "image/svg+xml", data: Buffer.from(value, "utf8") }; + } + const base64 = + classification.kind === "dataUrl" ? readDataUrlBase64Body(value) : value.replace(/\s+/g, ""); + if (!base64) return null; + const data = Buffer.from(base64, "base64"); + if (data.byteLength === 0) return null; + return { mime: classification.mime, data }; +} + +/** Decodes an inline image string to raw bytes, or null when it is not one. */ +function decodeInlineImage( + value: string, + classification: InlineImageClassification, +): { readonly data: Buffer; readonly mime: string } | null { + if (classification.kind === "rawSvg") return null; // vector: nothing to downscale + const base64 = + classification.kind === "dataUrl" ? readDataUrlBase64Body(value) : value.replace(/\s+/g, ""); + if (!base64) return null; + const data = Buffer.from(base64, "base64"); + return data.byteLength > 0 ? { data, mime: classification.mime } : null; +} + +function readDataUrlBase64Body(value: string): string | null { + const commaIndex = value.indexOf(","); + if (commaIndex < 0) return null; + const header = value.slice(0, commaIndex).toLowerCase(); + if (!header.includes(";base64")) return null; + return value.slice(commaIndex + 1).replace(/\s+/g, ""); +} + +/** Parses the `path` query parameter, rejecting anything not a key/index list. */ +export function parseImageRefPath(raw: string | null): InlineImagePath | null { + if (!raw) return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + return null; + } + if (!Array.isArray(parsed) || parsed.length === 0 || parsed.length > 8) return null; + if (!parsed.every((part) => typeof part === "string" || Number.isSafeInteger(part))) return null; + return parsed as InlineImagePath; +} diff --git a/src/main/remote/server/itemInterestFilter.test.ts b/src/main/remote/server/itemInterestFilter.test.ts new file mode 100644 index 000000000..424702c20 --- /dev/null +++ b/src/main/remote/server/itemInterestFilter.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import type { RuntimeEvent } from "@/shared/contracts"; +import { filterEventForItemInterests } from "./itemInterestFilter"; +import type { RemoteBroadcastEvent } from "./context"; + +const itemEvent: RuntimeEvent = { + type: "item.completed", + threadId: "watched", + itemId: "i1", + payload: { name: "bash", result: "big" }, +}; +const delta: RuntimeEvent = { + type: "content.delta", + threadId: "watched", + itemId: "i1", + stream: "assistant_text", + delta: "hello", +}; +const request: RuntimeEvent = { + type: "request.opened", + threadId: "watched", + requestId: "r1", + requestType: "tool_call_approval", + payload: { summary: "Allow rm -rf?" }, +}; +const turn: RuntimeEvent = { + type: "turn.completed", + threadId: "watched", + turnId: "t1", + state: "completed", +}; + +describe("filterEventForItemInterests", () => { + it("passes everything through when the client never declared interests", () => { + const event: RemoteBroadcastEvent = { + type: "thread-runtime-event", + threadId: "other", + event: itemEvent, + }; + expect(filterEventForItemInterests(event, null)).toBe(event); + }); + + it("keeps content for a watched thread", () => { + const event: RemoteBroadcastEvent = { + type: "thread-runtime-event", + threadId: "watched", + event: itemEvent, + }; + expect(filterEventForItemInterests(event, new Set(["watched"]))).toBe(event); + }); + + it("empties content for an unwatched thread instead of dropping the event", () => { + // Dropping it would break the replay contiguity check and force a resync. + const event: RemoteBroadcastEvent = { + type: "thread-runtime-event", + threadId: "other", + event: itemEvent, + }; + const filtered = filterEventForItemInterests(event, new Set(["watched"])); + expect(filtered).toEqual({ type: "thread-runtime-events", threadId: "other", events: [] }); + expect(JSON.stringify(filtered).length).toBeLessThan(80); + }); + + it("still delivers a permission request for an unwatched thread", () => { + // The critical invariant: a background thread blocking on approval must + // surface, and a thread snapshot cannot recover an open request later. + const event: RemoteBroadcastEvent = { + type: "thread-runtime-event", + threadId: "other", + event: request, + }; + expect(filterEventForItemInterests(event, new Set(["watched"]))).toBe(event); + }); + + it("keeps lifecycle events but strips content from a mixed batch", () => { + const event: RemoteBroadcastEvent = { + type: "thread-runtime-events", + threadId: "other", + events: [itemEvent, request, delta, turn], + }; + const filtered = filterEventForItemInterests(event, new Set(["watched"])); + expect(filtered.type).toBe("thread-runtime-events"); + const events = (filtered as unknown as { events: RuntimeEvent[] }).events; + expect(events.map((e) => e.type)).toEqual(["request.opened", "turn.completed"]); + }); + + it("scopes each batch of a multi-thread event independently", () => { + const event: RemoteBroadcastEvent = { + type: "thread-runtime-events-multi", + batches: [ + { threadId: "watched", events: [itemEvent] }, + { threadId: "other", events: [itemEvent, request] }, + ], + }; + const filtered = filterEventForItemInterests(event, new Set(["watched"])); + const batches = ( + filtered as unknown as { + batches: Array<{ threadId: string; events: RuntimeEvent[] }>; + } + ).batches; + expect(batches[0]!.events.map((e) => e.type)).toEqual(["item.completed"]); + expect(batches[1]!.events.map((e) => e.type)).toEqual(["request.opened"]); + }); + + it("leaves non-runtime events alone", () => { + const event: RemoteBroadcastEvent = { + type: "thread-state", + threadId: "other", + status: "idle", + attention: "none", + canResumeWithConfig: false, + }; + expect(filterEventForItemInterests(event, new Set(["watched"]))).toBe(event); + }); + + it("does not copy a batch that needed no filtering", () => { + const event: RemoteBroadcastEvent = { + type: "thread-runtime-events", + threadId: "watched", + events: [itemEvent], + }; + expect(filterEventForItemInterests(event, new Set(["watched"]))).toBe(event); + }); +}); diff --git a/src/main/remote/server/itemInterestFilter.ts b/src/main/remote/server/itemInterestFilter.ts new file mode 100644 index 000000000..486bc8933 --- /dev/null +++ b/src/main/remote/server/itemInterestFilter.ts @@ -0,0 +1,82 @@ +import type { RuntimeEvent } from "@/shared/contracts"; +import type { RemoteBroadcastEvent } from "./context"; + +/** + * Withholds live transcript *content* for threads a connection is not watching. + * + * Runtime events are broadcast to every client for every thread, so a phone + * viewing one thread also downloads every other thread's tool payloads. Terminal + * output has had per-connection `terminal-watch` scoping for this reason; item + * content never did. + * + * Two invariants make this safe: + * + * 1. **Only bulk content is scoped.** `request.opened`/`request.resolved`, + * `turn.*`, `session.*`, `context.updated`, `usage.spent`, warnings and errors + * always pass through. Permission and question prompts ride + * `request.opened`, the mobile approval UI reads them out of + * `runtimeRequestsByThread`, and a thread snapshot has no field to recover an + * open request from — so scoping those would silently strand a background + * thread waiting on an approval the user never sees. + * + * 2. **Events are emptied, never dropped.** Reconnect replay validates that it + * received exactly `seq - lastSeenSeq` entries; omitting an event entirely + * would look like packet loss and force a spurious full resync. An event whose + * content was fully withheld is therefore still delivered, carrying an empty + * `events` array (which the client's dispatch treats as a no-op). + */ + +/** Runtime event kinds that carry bulk transcript content. */ +function isScopedContentEvent(event: RuntimeEvent): boolean { + switch (event.type) { + case "item.started": + case "item.updated": + case "item.completed": + case "content.delta": + return true; + default: + return false; + } +} + +function filterEvents(events: readonly RuntimeEvent[], wanted: boolean): readonly RuntimeEvent[] { + if (wanted) return events; + const kept = events.filter((event) => !isScopedContentEvent(event)); + return kept.length === events.length ? events : kept; +} + +/** + * Narrows `event` to what this connection asked for. `interests` of `null` means + * the client never declared any (older clients), so everything passes through. + * Returns the original event when nothing was withheld. + */ +export function filterEventForItemInterests( + event: RemoteBroadcastEvent, + interests: ReadonlySet | null, +): RemoteBroadcastEvent { + if (!interests) return event; + switch (event.type) { + case "thread-runtime-event": { + if (!isScopedContentEvent(event.event) || interests.has(event.threadId)) return event; + // Collapse to the plural form so the delivered frame stays a valid, + // content-free event and the replay count still lines up. + return { type: "thread-runtime-events", threadId: event.threadId, events: [] }; + } + case "thread-runtime-events": { + const events = filterEvents(event.events, interests.has(event.threadId)); + return events === event.events ? event : { ...event, events: [...events] }; + } + case "thread-runtime-events-multi": { + let changed = false; + const batches = event.batches.map((batch) => { + const events = filterEvents(batch.events, interests.has(batch.threadId)); + if (events === batch.events) return batch; + changed = true; + return { ...batch, events: [...events] }; + }); + return changed ? { ...event, batches } : event; + } + default: + return event; + } +} diff --git a/src/main/remote/server/snapshots.ts b/src/main/remote/server/snapshots.ts index ce0cd54a7..273489d50 100644 --- a/src/main/remote/server/snapshots.ts +++ b/src/main/remote/server/snapshots.ts @@ -26,6 +26,9 @@ import { } from "../../db"; import { RemoteHttpError } from "../auth"; import type { RemoteServerContext } from "./context"; +import { withStableUpdatedAt } from "./stableUpdatedAt"; +import { projectRuntimeItemsImageRefs } from "./imageRefProjection"; +import { projectGitStateSnapshotForRemote } from "./gitStateProjection"; /** Sort order for a thread already known to the DB; remote-created rows that * aren't present yet sort to the top via a descending timestamp. */ @@ -74,14 +77,18 @@ export function buildShellSnapshot(ctx: RemoteServerContext): RemoteShellSnapsho ...(summary.contextUsage ? { contextUsage: summary.contextUsage } : {}), }; } - return remoteShellSnapshotSchema.parse({ - snapshotSeq: ctx.seq, - projects: dbGetProjects(), - threads, - runtimeSummariesByThread, - gitSummariesByThread: ctx.options.gitSummaries?.() ?? {}, - updatedAt: new Date().toISOString(), - }); + return remoteShellSnapshotSchema.parse( + withStableUpdatedAt("shell", { + snapshotSeq: ctx.seq, + projects: dbGetProjects(), + threads, + runtimeSummariesByThread, + gitSummariesByThread: ctx.options.gitSummaries?.() ?? {}, + ...(ctx.options.gitState + ? { gitState: projectGitStateSnapshotForRemote(ctx.options.gitState.getSnapshot()) } + : {}), + }), + ); } export async function buildAgentStatuses(ctx: RemoteServerContext): Promise { @@ -93,11 +100,12 @@ export async function buildAgentStatuses(ctx: RemoteServerContext): Promise { + beforeEach(() => { + resetStableUpdatedAt(); + }); + + it("reuses the previous timestamp while content is unchanged", () => { + let tick = 0; + const now = () => `2026-01-01T00:00:0${tick++}.000Z`; + const first = withStableUpdatedAt("shell", { threads: [1, 2] }, now); + const second = withStableUpdatedAt("shell", { threads: [1, 2] }, now); + expect(second.updatedAt).toBe(first.updatedAt); + // Byte-identical serialization is what makes a content ETag revalidate. + expect(JSON.stringify(second)).toBe(JSON.stringify(first)); + }); + + it("advances the timestamp when content changes", () => { + let tick = 0; + const now = () => `2026-01-01T00:00:0${tick++}.000Z`; + const first = withStableUpdatedAt("shell", { threads: [1] }, now); + const second = withStableUpdatedAt("shell", { threads: [1, 2] }, now); + expect(second.updatedAt).not.toBe(first.updatedAt); + }); + + it("tracks keys independently so one thread does not evict another's identity", () => { + let tick = 0; + const now = () => `2026-01-01T00:00:0${tick++}.000Z`; + const a1 = withStableUpdatedAt("thread:a", { items: 1 }, now); + withStableUpdatedAt("thread:b", { items: 2 }, now); + const a2 = withStableUpdatedAt("thread:a", { items: 1 }, now); + expect(a2.updatedAt).toBe(a1.updatedAt); + }); + + it("preserves the payload fields alongside the timestamp", () => { + const result = withStableUpdatedAt("shell", { threads: [1], seq: 4 }); + expect(result).toMatchObject({ threads: [1], seq: 4 }); + expect(typeof result.updatedAt).toBe("string"); + }); + + it("bounds how many keys it remembers", () => { + const now = () => "2026-01-01T00:00:00.000Z"; + for (let i = 0; i < 200; i += 1) { + withStableUpdatedAt(`thread:${i}`, { i }, now); + } + // The oldest entries were evicted, so an early key is treated as new again. + let called = false; + withStableUpdatedAt("thread:0", { i: 0 }, () => { + called = true; + return "2026-02-02T00:00:00.000Z"; + }); + expect(called).toBe(true); + }); +}); diff --git a/src/main/remote/server/stableUpdatedAt.ts b/src/main/remote/server/stableUpdatedAt.ts new file mode 100644 index 000000000..84aab4bc4 --- /dev/null +++ b/src/main/remote/server/stableUpdatedAt.ts @@ -0,0 +1,54 @@ +import { createHash } from "node:crypto"; + +/** + * Makes a snapshot's `updatedAt` describe when its content last *changed* rather + * than when the client happened to ask. + * + * Stamping `new Date().toISOString()` per request makes every response + * byte-unique, which silently defeats any content-based `ETag` — the shell + * snapshot is re-fetched on every status-affecting event, so that is precisely + * the response that most needs to revalidate to a `304`. Reusing the previous + * timestamp while the rest of the payload is unchanged makes the serialization + * byte-identical and lets the conditional request succeed. + * + * This is also the more honest semantic: `updatedAt` now answers "as of when is + * this state accurate", which is what a caching client wants to know. + */ + +/** Bounded so a long-lived host with many threads cannot grow this without end. */ +const MAX_TRACKED_KEYS = 128; + +const lastByKey = new Map(); + +/** Exposed for tests; production has one server per process. */ +export function resetStableUpdatedAt(): void { + lastByKey.clear(); +} + +/** + * Returns `payload` with an `updatedAt` that only advances when the rest of the + * payload changes. `key` scopes the memo (e.g. one entry per thread). + */ +export function withStableUpdatedAt( + key: string, + payload: T, + now: () => string = () => new Date().toISOString(), +): T & { readonly updatedAt: string } { + const hash = createHash("sha1").update(JSON.stringify(payload)).digest("base64url"); + const previous = lastByKey.get(key); + if (previous?.hash === hash) { + // Refresh recency without changing the timestamp. + lastByKey.delete(key); + lastByKey.set(key, previous); + return { ...payload, updatedAt: previous.updatedAt }; + } + const updatedAt = now(); + lastByKey.delete(key); + lastByKey.set(key, { hash, updatedAt }); + while (lastByKey.size > MAX_TRACKED_KEYS) { + const oldest = lastByKey.keys().next(); + if (oldest.done) break; + lastByKey.delete(oldest.value); + } + return { ...payload, updatedAt }; +} diff --git a/src/main/remote/server/threadCommands.ts b/src/main/remote/server/threadCommands.ts index 6226fb678..88fe4a2e5 100644 --- a/src/main/remote/server/threadCommands.ts +++ b/src/main/remote/server/threadCommands.ts @@ -108,6 +108,8 @@ export async function applyRemoteThreadCommand( command: RemoteThreadCommand, ): Promise { switch (command.kind) { + case "prepare-worktree": + return true; case "start": await startRemoteThread(ctx, command); return false; @@ -182,6 +184,8 @@ export async function applyRemoteThreadCommand( dbDeleteThread(command.threadId); return false; case "delete-worktree-group": + await Promise.all(command.threadIds.map((threadId) => closeThreadBestEffort(ctx, threadId))); + for (const threadId of command.threadIds) dbDeleteThread(threadId); return true; } } diff --git a/src/main/remote/server/wsConnections.ts b/src/main/remote/server/wsConnections.ts index a29bf0eaf..7af0f40bb 100644 --- a/src/main/remote/server/wsConnections.ts +++ b/src/main/remote/server/wsConnections.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import type { IncomingMessage } from "node:http"; import type { Duplex } from "node:stream"; import { WebSocket } from "ws"; @@ -7,9 +8,44 @@ import type { RemoteBrowserFrame } from "../RemoteBrowserGateway"; import type { RemoteServerContext } from "./context"; import { isReservedForwardProxyPath, proxyForwardedWebSocketUpgrade } from "./portForwardProxy"; import { MAX_JSON_BODY_BYTES } from "./requestBody"; +import { projectGitStatePatchForInterests } from "./gitStateProjection"; +import { filterEventForItemInterests } from "./itemInterestFilter"; export const DEFAULT_MAX_WEBSOCKET_PAYLOAD_BYTES = MAX_JSON_BODY_BYTES; export const DEFAULT_MAX_WEBSOCKET_OUTBOUND_BUFFER_BYTES = 4 * 1024 * 1024; + +/** + * permessage-deflate settings for the remote event socket. + * + * Compression is worth it here — runtime transcript frames are highly redundant + * JSON — but `ws` warns that it carries real CPU/memory cost, and this server + * runs on the Electron **main** process, so every knob below is deliberate: + * + * - Context takeover is DISABLED in both directions. With it on, every + * connection retains a persistent zlib context (hundreds of KB each way) for + * the life of the socket. Per-message contexts cost some ratio but keep memory + * flat and predictable across many paired devices. + * - `concurrencyLimit` bounds simultaneous zlib jobs so a burst of large frames + * cannot starve the main process's event loop. + * - `level: 3` favors throughput over ratio; transcript JSON is already + * redundant enough that higher levels buy little. + * - `threshold` skips small frames, which is most of the stream (status + * transitions, `content.delta` chunks) — those are cheaper sent as-is. + * + * Note the interaction with `sendRaw`'s backpressure guard: `bufferedAmount` + * does not account for frames queued inside the deflate pipeline, so the guard + * is checked against uncompressed size. That is intentionally conservative — it + * can only drop a client earlier than strictly necessary, never later, and the + * publish-time size cap (`eventSizeGuard`) keeps single events well clear of it. + */ +export const REMOTE_PER_MESSAGE_DEFLATE = { + serverNoContextTakeover: true, + clientNoContextTakeover: true, + serverMaxWindowBits: 10, + concurrencyLimit: 4, + threshold: 1024, + zlibDeflateOptions: { level: 3 }, +} as const; export const DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS = 30_000; const MAX_TIMEOUT_DELAY_MS = 2_147_483_647; @@ -103,6 +139,7 @@ function handleConnection( ctx.clients.set(ws, session); ctx.clientLiveness.set(ws, true); ctx.terminalWatches.set(ws, new Set()); + const gitStateInterestOwnerId = `${session.sessionId}:${randomUUID()}`; // Browser mirroring is per-connection opt-in (frames are heavy); the // gateway's screencast stops once the last watcher unsubscribes. let browserWatch: (() => void) | null = null; @@ -127,6 +164,9 @@ function handleConnection( } browserWatch?.(); browserWatch = null; + ctx.gitStateInterests.delete(ws); + ctx.itemInterests.delete(ws); + ctx.options.gitState?.clearInterests(gitStateInterestOwnerId); ctx.terminalWatches.delete(ws); ctx.clients.delete(ws); ctx.clientLiveness.delete(ws); @@ -193,6 +233,17 @@ function handleConnection( if (message.type === "terminal-unwatch") { ctx.terminalWatches.get(ws)?.delete(message.id); } + if (message.type === "thread-item-interests") { + if (!session.scopes.includes("session:read")) return; + ctx.itemInterests.set(ws, new Set(message.threadIds)); + } + if (message.type === "git-state-interests") { + if (!session.scopes.includes("session:read")) return; + // Remembered per connection so `remote-git-state` patches only carry + // pull-request bodies to the client that asked for them. + ctx.gitStateInterests.set(ws, message.interests); + ctx.options.gitState?.setInterests(gitStateInterestOwnerId, message.interests); + } } catch { // Ignore invalid client messages; all state changes go through HTTP in this slice. } @@ -228,10 +279,25 @@ function handleConnection( return; } for (const entry of replay) { + // A reconnecting client has not re-declared its Git interests yet, so a + // replayed patch is scoped to "nothing requested" and drops pull-request + // bodies. The client re-declares on open, which triggers a fresh fetch of + // whatever review it is actually looking at. + const itemScoped = filterEventForItemInterests(entry.event, ctx.itemInterests.get(ws) ?? null); + const event = + itemScoped.type === "remote-git-state" + ? { + ...itemScoped, + patch: projectGitStatePatchForInterests( + itemScoped.patch, + ctx.gitStateInterests.get(ws) ?? [], + ), + } + : itemScoped; ctx.send(ws, { type: "event", seq: entry.seq, - event: entry.event, + event, }); } } diff --git a/src/mobile/bridge.ts b/src/mobile/bridge.ts index a1871aff9..a8f234ae6 100644 --- a/src/mobile/bridge.ts +++ b/src/mobile/bridge.ts @@ -28,6 +28,7 @@ import { type RemoteRuntimeItemsPageRequest, } from "@/shared/remote"; import { setRemoteLocalImageResolver } from "@/shared/localImageDisplay"; +import { setRemoteImageRefResolver } from "@/shared/imageRefDisplay"; import { resolveLocalFileUrlPath } from "@/shared/promptContent"; import type { SharedSettingsInput } from "@/shared/settings"; import { useBrowserMirrorStore } from "./browserMirror"; @@ -63,6 +64,10 @@ export function setRemoteBridgeClient( // shell; in the PWA, swap them for the desktop's authenticated HTTP image // endpoint at render time (see shared/localImageDisplay.ts). setRemoteLocalImageResolver(client ? (url) => remoteLocalImageUrl(client, url) : null); + // The host strips inline image bytes out of remote transcripts and sends a + // reference instead; resolve those to its authenticated image endpoint (see + // shared/imageRefDisplay.ts). + setRemoteImageRefResolver(client ? (ref) => client.imageRefUrl(ref) : null); } /** @@ -321,7 +326,8 @@ const remoteBridge = { payload.beforePosition === undefined ? Promise.resolve({ items: [], nextCursor: null }) : requireClient().threadRuntimeItemsPage(payload), - dbTruncateThreadRuntimeAfter: () => Promise.resolve(), + dbTruncateThreadRuntimeAfter: (payload: { threadId: string; itemId: string }) => + requireClient().truncateThreadRuntimeAfter(payload), dbGetThreadCompletedTurns: () => Promise.resolve([]), dbGetThreadContextUsage: () => Promise.resolve(null), @@ -382,6 +388,7 @@ const remoteBridge = { // Event subscriptions: remote events arrive over the WebSocket instead. onSupervisorEvent: () => () => undefined, + onGitStateChanged: () => () => undefined, onUpdateStatus: () => () => undefined, onBrowserEvent: () => () => undefined, onRemoteThreadCommand: () => () => undefined, diff --git a/src/mobile/mobileNavigationStyles.test.ts b/src/mobile/mobileNavigationStyles.test.ts index 998df5a3c..0c93d310f 100644 --- a/src/mobile/mobileNavigationStyles.test.ts +++ b/src/mobile/mobileNavigationStyles.test.ts @@ -4,6 +4,21 @@ import { describe, expect, it } from "vitest"; const css = readFileSync(new URL("./styles.css", import.meta.url), "utf8"); describe("mobile navigation transition styles", () => { + it("frosts the sticky desktop-sidebar search row with the sidebar surface", () => { + expect(css).toMatch( + /\.m-sidebar\s*\{[^}]*--m-sidebar-surface:\s*color-mix\([^}]*--m-sidebar-picker-alpha:\s*78%;[^}]*--m-sidebar-picker-backdrop:\s*saturate\(140%\) blur\(16px\);[^}]*background:\s*var\(--m-sidebar-surface\);/s, + ); + expect(css).toMatch( + /html\[data-mobile-platform="macos"\] \.m-sidebar\s*\{[^}]*--m-sidebar-picker-alpha:\s*68%;[^}]*--m-sidebar-picker-backdrop:\s*saturate\(165%\) blur\(20px\);/s, + ); + expect(css).toMatch( + /html\[data-mobile-platform="windows"\] \.m-sidebar\s*\{[^}]*--m-sidebar-picker-alpha:\s*88%;[^}]*--m-sidebar-picker-backdrop:\s*saturate\(135%\) blur\(20px\) brightness\(1\.04\);/s, + ); + expect(css).toMatch( + /\.m-sidebar \.m-threads__picker\s*\{[^}]*background:\s*color-mix\([^}]*var\(--m-sidebar-surface\) var\(--m-sidebar-picker-alpha\),[^}]*transparent[^}]*\);[^}]*backdrop-filter:\s*var\(--m-sidebar-picker-backdrop\);/s, + ); + }); + it("only compacts the desktop tool rail when it materially overlaps the thread column", () => { expect(css).toMatch( /\.m-wide-content\s*\{[^}]*container-name:\s*m-wide-content;[^}]*container-type:\s*inline-size;/s, diff --git a/src/mobile/mobilePlatform.test.ts b/src/mobile/mobilePlatform.test.ts index ec85da841..61ef62365 100644 --- a/src/mobile/mobilePlatform.test.ts +++ b/src/mobile/mobilePlatform.test.ts @@ -13,4 +13,26 @@ describe("getMobileRuntimePlatform", () => { expect(getMobileRuntimePlatform()).toBe("windows"); }); + + it("identifies macOS browsers for platform-specific glass styling", () => { + vi.stubGlobal("navigator", { + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15", + platform: "MacIntel", + maxTouchPoints: 0, + }); + + expect(getMobileRuntimePlatform()).toBe("macos"); + }); + + it("keeps touch-capable MacIntel devices classified as iOS", () => { + vi.stubGlobal("navigator", { + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1", + platform: "MacIntel", + maxTouchPoints: 5, + }); + + expect(getMobileRuntimePlatform()).toBe("ios"); + }); }); diff --git a/src/mobile/mobilePlatform.ts b/src/mobile/mobilePlatform.ts index ac536b25d..8974cc9b9 100644 --- a/src/mobile/mobilePlatform.ts +++ b/src/mobile/mobilePlatform.ts @@ -1,4 +1,4 @@ -export type MobileRuntimePlatform = "android" | "ios" | "web" | "windows"; +export type MobileRuntimePlatform = "android" | "ios" | "macos" | "web" | "windows"; type CapacitorGlobal = { readonly Capacitor?: { @@ -21,6 +21,7 @@ export function getMobileRuntimePlatform(): MobileRuntimePlatform { ) { return "ios"; } + if (/Macintosh|Mac OS X/i.test(navigator.userAgent)) return "macos"; } return "web"; diff --git a/src/mobile/navHelpers.ts b/src/mobile/navHelpers.ts index ed3f33a69..3e34c5b8c 100644 --- a/src/mobile/navHelpers.ts +++ b/src/mobile/navHelpers.ts @@ -4,6 +4,7 @@ import { isHomeProject } from "@/shared/homeScope"; import { getBasename } from "@/shared/pathUtils"; import { buildWorktreeLocation } from "@/shared/worktree"; import { useAppStore } from "@/renderer/state/appStore"; +import { worktreePlacementPayload } from "@/renderer/actions/worktreePlacement"; import type { DraftStartInput } from "@/renderer/components/thread/ThreadDraftComposerArea"; import { isFullscreenScreenPath } from "./fullscreenScreenPath"; import { useGitSummariesStore } from "./gitSummaries"; @@ -34,6 +35,7 @@ export function buildGitAddWorktreePayload( ...(project.scripts?.worktreeCopyPatterns ? { copyIgnoredPatterns: project.scripts.worktreeCopyPatterns } : {}), + ...worktreePlacementPayload(project), transferUncommitted: input.worktreeTransferUncommitted ?? false, // "Worktree + changes" copies (keeps on source); a plain move clears it. keepChangesInSource: input.worktreeTransferUncommitted ?? false, diff --git a/src/mobile/remoteSocketCoordinator.test.ts b/src/mobile/remoteSocketCoordinator.test.ts index 1b8e573a7..2f9973c7c 100644 --- a/src/mobile/remoteSocketCoordinator.test.ts +++ b/src/mobile/remoteSocketCoordinator.test.ts @@ -232,6 +232,41 @@ describe("remoteSocketCoordinator", () => { }); }); + it("publishes current Git interests on open and whenever they change", async () => { + const harness = track(createHarness()); + const socket = await start(harness); + harness.coordinator.setGitStateInterests([ + { + kind: "target", + projectId: "project-1", + worktreePath: "/repo/worktree", + includePrDetails: true, + }, + ]); + expect(socket.sent).toEqual([]); + + socket.open(); + expect(socket.sent.map((message) => JSON.parse(message))).toContainEqual({ + type: "git-state-interests", + interests: [ + { + kind: "target", + projectId: "project-1", + worktreePath: "/repo/worktree", + includePrDetails: true, + }, + ], + }); + + harness.coordinator.setGitStateInterests([ + { kind: "project-pull-requests", projectId: "project-1" }, + ]); + expect(JSON.parse(socket.sent.at(-1)!)).toEqual({ + type: "git-state-interests", + interests: [{ kind: "project-pull-requests", projectId: "project-1" }], + }); + }); + it("advances to an authoritative snapshot sequence and ignores covered replay events", async () => { const harness = track(createHarness({ initialLastSeenSeq: 2 })); const socket = await start(harness); @@ -460,7 +495,13 @@ describe("remoteSocketCoordinator", () => { const firstSocket = await start(harness); firstSocket.open(); document.dispatchEvent(new Event("visibilitychange")); - expect(firstSocket.sent).toHaveLength(1); + // On open the coordinator declares its Git and transcript-content interests; + // the visibility change then adds a health ping. + expect(firstSocket.sent.map((raw) => (JSON.parse(raw) as { type: string }).type)).toEqual([ + "git-state-interests", + "thread-item-interests", + "ping", + ]); firstSocket.beginClosing(); window.dispatchEvent(new Event("online")); @@ -469,7 +510,10 @@ describe("remoteSocketCoordinator", () => { expect(secondSocket).not.toBe(firstSocket); secondSocket?.open(); document.dispatchEvent(new Event("visibilitychange")); - expect(secondSocket?.sent).toHaveLength(1); + // Same three as the first socket: both interest declarations, then the ping. + expect( + (secondSocket?.sent ?? []).map((raw) => (JSON.parse(raw) as { type: string }).type), + ).toEqual(["git-state-interests", "thread-item-interests", "ping"]); harness.onConnectionChange.mockClear(); harness.onMessageChange.mockClear(); diff --git a/src/mobile/remoteSocketCoordinator.ts b/src/mobile/remoteSocketCoordinator.ts index 1650af868..46f5cbb2b 100644 --- a/src/mobile/remoteSocketCoordinator.ts +++ b/src/mobile/remoteSocketCoordinator.ts @@ -1,4 +1,5 @@ import { reconnectBackoffDelay } from "@/shared/remote/backoff"; +import type { GitStateInterest } from "@/shared/gitState"; import { handleBrowserServerMessage, setBrowserSocketSender } from "./browserMirror"; import { RemoteClientError, type RemoteDesktopClient } from "./remoteClient"; import { createRemoteSocketSender } from "./remoteSocketSender"; @@ -50,6 +51,9 @@ export interface RemoteSocketCoordinator { start(): void; getLastSeenSeq(): number; advanceLastSeenSeq(seq: number): void; + setGitStateInterests(interests: readonly GitStateInterest[]): void; + /** Threads to receive live transcript content for; others send lifecycle only. */ + setThreadItemInterests(threadIds: readonly string[]): void; dispose(): void; } @@ -103,6 +107,30 @@ export function createRemoteSocketCoordinator( let pendingPingId: string | null = null; let connectWatchdog = 0; let heartbeat = 0; + let gitStateInterests: readonly GitStateInterest[] = []; + let threadItemInterests: readonly string[] = []; + + function publishThreadItemInterests(): void { + const socket = ws; + if (socket?.readyState !== WebSocket.OPEN) return; + socket.send( + JSON.stringify({ + type: "thread-item-interests", + threadIds: threadItemInterests, + }), + ); + } + + function publishGitStateInterests(): void { + const socket = ws; + if (socket?.readyState !== WebSocket.OPEN) return; + socket.send( + JSON.stringify({ + type: "git-state-interests", + interests: gitStateInterests, + }), + ); + } function scheduleRefresh( request: { @@ -197,6 +225,8 @@ export function createRemoteSocketCoordinator( const socketSender = createRemoteSocketSender(socket); setBrowserSocketSender(socketSender); setTerminalSocketSender(socketSender); + publishGitStateInterests(); + publishThreadItemInterests(); scheduleRefresh({ recovery: true }); }); @@ -337,6 +367,14 @@ export function createRemoteSocketCoordinator( advanceLastSeenSeq(seq) { if (Number.isInteger(seq) && seq >= 0) lastSeenSeq = Math.max(lastSeenSeq, seq); }, + setGitStateInterests(interests) { + gitStateInterests = interests; + publishGitStateInterests(); + }, + setThreadItemInterests(threadIds) { + threadItemInterests = threadIds; + publishThreadItemInterests(); + }, dispose() { if (closed) return; closed = true; diff --git a/src/mobile/storeSync.applyShellSnapshot.test.tsx b/src/mobile/storeSync.applyShellSnapshot.test.tsx index 23a0ffac7..717e5ecc5 100644 --- a/src/mobile/storeSync.applyShellSnapshot.test.tsx +++ b/src/mobile/storeSync.applyShellSnapshot.test.tsx @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Thread } from "@/shared/contracts"; import type { RemoteShellSnapshot } from "@/shared/remote"; import { useAppStore } from "@/renderer/state/appStore"; +import { useGitReadModelStore } from "@/renderer/state/gitReadModelStore"; +import { emptyGitStateSnapshot } from "@/shared/gitState"; import { applyShellSnapshot, dispatchRemoteSupervisorEvent, resetRemoteStores } from "./storeSync"; // The thread-state dispatch fans out to the desktop notification helper, which @@ -166,4 +168,13 @@ describe("applyShellSnapshot", () => { expect(after[0]).not.toBe(before[0]); expect(after[1]).toBe(before[1]); }); + + it("hydrates the host-owned Git read model from the shell snapshot", () => { + applyShellSnapshot({ + ...makeShellSnapshot([]), + gitState: { ...emptyGitStateSnapshot(), revision: 4 }, + }); + + expect(useGitReadModelStore.getState().revision).toBe(4); + }); }); diff --git a/src/mobile/storeSync.resetRemoteStores.test.tsx b/src/mobile/storeSync.resetRemoteStores.test.tsx index ca38c1f66..739b82054 100644 --- a/src/mobile/storeSync.resetRemoteStores.test.tsx +++ b/src/mobile/storeSync.resetRemoteStores.test.tsx @@ -12,6 +12,8 @@ import { useDevTerminalStore } from "@/renderer/state/devTerminalStore"; import { useFileEditorStore } from "@/renderer/state/fileEditorStore"; import { useGitReviewActionStore } from "@/renderer/state/gitReviewActionStore"; import { useGitStore } from "@/renderer/state/gitStore"; +import { useGitReadModelStore } from "@/renderer/state/gitReadModelStore"; +import { emptyGitStateSnapshot } from "@/shared/gitState"; import { useNotesStore } from "@/renderer/state/notesStore"; import { useProviderUsageStore } from "@/renderer/state/providerUsageStore"; import { useProjectTreeStore } from "@/renderer/state/projectTreeStore"; @@ -68,6 +70,7 @@ describe("resetRemoteStores", () => { statuses: { p: { isRepo: false } as never }, prFiles: { "p#1": [] }, }); + useGitReadModelStore.setState({ ...emptyGitStateSnapshot(), revision: 3 }); useGitReviewActionStore.setState({ panels: { p: { @@ -115,6 +118,7 @@ describe("resetRemoteStores", () => { statuses: {}, prFiles: {}, }); + expect(useGitReadModelStore.getState().revision).toBe(0); expect(useGitReviewActionStore.getState().panels).toEqual({}); expect(useDevTerminalStore.getState()).toMatchObject({ isOpen: false, diff --git a/src/mobile/storeSync.ts b/src/mobile/storeSync.ts index 86468b7c5..f38ef8e4a 100644 --- a/src/mobile/storeSync.ts +++ b/src/mobile/storeSync.ts @@ -9,6 +9,8 @@ import { useAgentStatusesStore } from "@/renderer/state/agentStatusesStore"; import { resetDevTerminalStore } from "@/renderer/state/devTerminalStore"; import { useFileEditorStore } from "@/renderer/state/fileEditorStore"; import { resetGitReviewActionStore } from "@/renderer/state/gitReviewActionStore"; +import { useGitReadModelStore } from "@/renderer/state/gitReadModelStore"; +import { projectGitReadModelIntoLegacyStore } from "@/renderer/state/gitReadModelLegacyProjection"; import { resetGitStoreCache } from "@/renderer/state/gitStore"; import { useNotesStore } from "@/renderer/state/notesStore"; import { useProviderUsageStore } from "@/renderer/state/providerUsageStore"; @@ -87,6 +89,10 @@ export function applyShellSnapshot(snapshot: RemoteShellSnapshot): void { if (snapshot.gitSummariesByThread) { useGitSummariesStore.getState().setAll(snapshot.gitSummariesByThread); } + if (snapshot.gitState) { + useGitReadModelStore.getState().replaceSnapshot(snapshot.gitState); + projectGitReadModelIntoLegacyStore(snapshot.gitState); + } } /** Drop everything tied to the previous desktop when switching/unpairing. */ @@ -104,6 +110,7 @@ export function resetRemoteStores(): void { resetDevTerminalStore(); useDesktopPanelStore.getState().reset(); useGitSummariesStore.getState().reset(); + useGitReadModelStore.getState().reset(); useProviderUsageStore.getState().setSnapshots([]); useAppStore.setState({ projects: [], @@ -158,6 +165,11 @@ const mobileDispatchHooks: RemoteDispatchHooks = { onThreadReset: (threadId) => emitTerminalReset(threadId), onThreadExited: ({ threadId, exitCode }) => emitTerminalExited(threadId, exitCode), onGitSummaries: (summaries) => useGitSummariesStore.getState().setAll(summaries), + onGitState: (patch) => { + const store = useGitReadModelStore.getState(); + store.applyPatch(patch); + projectGitReadModelIntoLegacyStore(useGitReadModelStore.getState()); + }, }; export function dispatchRemoteSupervisorEvent(value: unknown): void { diff --git a/src/mobile/styles.css b/src/mobile/styles.css index 643fea08f..ae5c76dca 100644 --- a/src/mobile/styles.css +++ b/src/mobile/styles.css @@ -1119,6 +1119,14 @@ html[data-theme="dark"][data-theme-preset="default"] .m-shell:not(.m-shell--wide /* ── Sidebar + detail (wide layout) ────────────────────────────── */ .m-sidebar { + --m-sidebar-surface: color-mix( + in oklab, + var(--foreground, #fafafa) 1.5%, + var(--sidebar-background, #0e0e14) + ); + --m-sidebar-picker-alpha: 78%; + --m-sidebar-picker-backdrop: saturate(140%) blur(16px); + position: relative; display: flex; min-height: 0; @@ -1128,16 +1136,26 @@ html[data-theme="dark"][data-theme-preset="default"] .m-shell:not(.m-shell--wide foreground lift over `--sidebar-background` reads as frosted glass without a decorative wash behind it. Bevel highlights match the desktop app's glass fallback. */ - background: color-mix( - in oklab, - var(--foreground, #fafafa) 1.5%, - var(--sidebar-background, #0e0e14) - ); + background: var(--m-sidebar-surface); box-shadow: inset 1px 0 0 color-mix(in oklab, var(--foreground, #fafafa) 7%, transparent), inset 0 1px 0 color-mix(in oklab, var(--foreground, #fafafa) 9%, transparent); } +/* Browser PWAs cannot request the OS window material used by Electron, but + backdrop-filter can match its character over app content. WebKit carries a + lighter, more saturated vibrancy-like layer; Chromium on Windows needs a + denser acrylic-like tint because its backdrop blur is visually weaker. */ +html[data-mobile-platform="macos"] .m-sidebar { + --m-sidebar-picker-alpha: 68%; + --m-sidebar-picker-backdrop: saturate(165%) blur(20px); +} + +html[data-mobile-platform="windows"] .m-sidebar { + --m-sidebar-picker-alpha: 88%; + --m-sidebar-picker-backdrop: saturate(135%) blur(20px) brightness(1.04); +} + .m-sidebar__resize-handle { position: absolute; z-index: 20; @@ -1323,14 +1341,18 @@ html[data-theme="dark"][data-theme-preset="default"] .m-shell:not(.m-shell--wide background: var(--background); } -/* Inside the wide sidebar the pane behind is translucent glass. Any tint - here would stack on the glass and read as a different shade at rest, so - the sticky picker stays transparent and only blurs the rows that scroll - under it (like the desktop app's transparent overlay header). */ +/* Keep the sticky wide-sidebar controls legible when thread rows scroll under + them. The tint uses the exact sidebar surface, so compositing it over the + sidebar keeps the resting color unchanged while the blur supplies the glass + separation. */ .m-sidebar .m-threads__picker { - /* Fully transparent: any tint or backdrop-filter composites against the - sidebar's own glass layer and renders as a visibly darker strip. */ - background: transparent; + background: color-mix( + in oklab, + var(--m-sidebar-surface) var(--m-sidebar-picker-alpha), + transparent + ); + backdrop-filter: var(--m-sidebar-picker-backdrop); + -webkit-backdrop-filter: var(--m-sidebar-picker-backdrop); } .m-thread-search { @@ -3219,12 +3241,7 @@ html[data-touch-device] .m-compose-bubble [data-composer-input-anchor]:not(:focu * (or the typed prompt) with the send button pinned right whenever there is a * prompt to send; the toolbar stays invisible (opacity 0) and fades in on * expand / out on collapse. Focusing the input grows the bubble and reveals - * the full toolbar row. The workspace entry above the chat is the git - * surface, so the composer's own branch affordance is dropped entirely on - * this device. */ -.m-shell [data-composer-branch] { - display: none; -} + * the full toolbar row. */ :is(.m-main, .m-detail) .m-thread .poracode-composer-shell [data-composer-input-anchor] { overflow: hidden; diff --git a/src/mobile/useGitSummaryHydration.test.tsx b/src/mobile/useGitSummaryHydration.test.tsx index 77f5161c0..05175bf6a 100644 --- a/src/mobile/useGitSummaryHydration.test.tsx +++ b/src/mobile/useGitSummaryHydration.test.tsx @@ -1,7 +1,9 @@ -import { renderHook, waitFor } from "@testing-library/react"; +import { act, renderHook, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useGitStore } from "@/renderer/state/gitStore"; -import type { GitStatusResult, PrData, Project, Thread } from "@/shared/contracts"; +import { useGitReadModelStore } from "@/renderer/state/gitReadModelStore"; +import type { GitStatusResult, PrData, PrDetails, Project, Thread } from "@/shared/contracts"; +import { emptyGitStateSnapshot } from "@/shared/gitState"; import type { RemoteThreadGitSummary } from "@/shared/remote"; import { useGitSummariesStore } from "./gitSummaries"; import { useGitSummaryHydration } from "./useGitSummaryHydration"; @@ -9,6 +11,7 @@ import { useGitSummaryHydration } from "./useGitSummaryHydration"; const bridge = vi.hoisted(() => ({ getGitStatus: vi.fn<(payload: unknown) => Promise>(), ghGetPrForBranch: vi.fn<(payload: unknown) => Promise>(), + ghGetPrDetails: vi.fn<(payload: unknown) => Promise<{ details: PrDetails }>>(), })); vi.mock("@/renderer/bridge", () => ({ @@ -85,13 +88,39 @@ function makePr(overrides: Partial = {}): PrData { }; } +function makeDetails(checkConclusion: string): PrDetails { + return { + number: 42, + title: "Fix mobile PR status", + body: "", + baseBranch: "main", + headBranch: "feature/mobile", + additions: 10, + deletions: 2, + changedFiles: 3, + commits: [], + comments: [], + reviews: [], + checks: [ + { + name: "build", + state: checkConclusion === "SUCCESS" ? "COMPLETED" : "IN_PROGRESS", + conclusion: checkConclusion, + }, + ], + }; +} + describe("useGitSummaryHydration", () => { beforeEach(() => { bridge.getGitStatus.mockReset(); bridge.ghGetPrForBranch.mockReset(); bridge.ghGetPrForBranch.mockResolvedValue(null); + bridge.ghGetPrDetails.mockReset(); + bridge.ghGetPrDetails.mockRejectedValue(new Error("details unavailable")); useGitSummariesStore.getState().reset(); - useGitStore.setState({ statuses: {}, worktreeStatuses: {}, prData: {} }); + useGitReadModelStore.getState().reset(); + useGitStore.setState({ statuses: {}, worktreeStatuses: {}, prData: {}, prDetails: {} }); }); it("hydrates a missing thread git summary from the remote bridge", async () => { @@ -108,6 +137,19 @@ describe("useGitSummaryHydration", () => { expect(useGitStore.getState().statuses[project.id]?.branch).toBe("feature/mobile"); }); + it("does not run legacy status hydration when the host read model is available", async () => { + const project = makeProject(); + const thread = makeThread(); + useGitReadModelStore.getState().replaceSnapshot(emptyGitStateSnapshot()); + + renderHook(() => useGitSummaryHydration(thread, project)); + + await act(async () => { + await Promise.resolve(); + }); + expect(bridge.getGitStatus).not.toHaveBeenCalled(); + }); + it("keeps local fallbacks when desktop summaries omit the thread", () => { useGitSummariesStore.getState().setThread("thread-1", makeSummary("local")); @@ -176,4 +218,102 @@ describe("useGitSummaryHydration", () => { checksStatus: "SUCCESS", }); }); + + it("does not duplicate PR hydration when the host read model is available", async () => { + const project = makeProject(); + const worktreePath = "/repo/.poracode/worktrees/mobile"; + const thread = makeThread({ + worktreePath, + worktreeBranch: "feature/mobile", + prNumber: 42, + }); + useGitSummariesStore.getState().setAll({ + [thread.id]: { + ...makeSummary("feature/mobile"), + pr: { + number: 42, + state: "open", + title: "Host-owned PR", + url: "https://github.test/repo/pull/42", + isDraft: false, + checksStatus: "PENDING", + }, + }, + }); + useGitReadModelStore.getState().replaceSnapshot(emptyGitStateSnapshot()); + + renderHook(() => useGitSummaryHydration(thread, project)); + + await act(async () => { + await Promise.resolve(); + }); + expect(bridge.ghGetPrForBranch).not.toHaveBeenCalled(); + expect(bridge.ghGetPrDetails).not.toHaveBeenCalled(); + }); + + it("refreshes the full PR cache when a streamed same-branch PR changes", async () => { + const project = makeProject(); + const worktreePath = "/repo/.poracode/worktrees/mobile"; + const thread = makeThread({ + worktreePath, + worktreeBranch: "feature/mobile", + prNumber: 42, + }); + const pendingPr = makePr({ + title: "Pending checks", + checksStatus: "PENDING", + updatedAt: "2026-07-23T12:00:00.000Z", + }); + const completedPr = makePr({ + title: "Checks completed", + checksStatus: "SUCCESS", + updatedAt: "2026-07-23T12:01:00.000Z", + }); + const pendingSummary: RemoteThreadGitSummary = { + ...makeSummary("feature/mobile"), + pr: { + number: pendingPr.number, + state: pendingPr.state, + title: pendingPr.title, + url: pendingPr.url, + isDraft: pendingPr.isDraft, + checksStatus: pendingPr.checksStatus, + }, + }; + const completedSummary: RemoteThreadGitSummary = { + ...pendingSummary, + pr: { + number: completedPr.number, + state: completedPr.state, + title: completedPr.title, + url: completedPr.url, + isDraft: completedPr.isDraft, + checksStatus: completedPr.checksStatus, + }, + }; + bridge.ghGetPrForBranch.mockResolvedValueOnce(pendingPr).mockResolvedValueOnce(completedPr); + bridge.ghGetPrDetails + .mockResolvedValueOnce({ details: makeDetails("") }) + .mockResolvedValueOnce({ details: makeDetails("SUCCESS") }); + useGitSummariesStore.getState().setAll({ [thread.id]: pendingSummary }); + + renderHook(() => useGitSummaryHydration(thread, project)); + + await waitFor(() => { + expect(useGitStore.getState().prData[worktreePath]).toEqual(pendingPr); + }); + + act(() => { + useGitSummariesStore.getState().setAll({ [thread.id]: completedSummary }); + }); + + await waitFor(() => { + expect(useGitStore.getState().prData[worktreePath]).toEqual(completedPr); + expect(useGitStore.getState().prDetails["project-1#42"]?.checks[0]?.conclusion).toBe( + "SUCCESS", + ); + }); + expect(bridge.ghGetPrForBranch).toHaveBeenCalledTimes(2); + expect(bridge.ghGetPrDetails).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/mobile/useGitSummaryHydration.ts b/src/mobile/useGitSummaryHydration.ts index f81181dda..bf1a4133b 100644 --- a/src/mobile/useGitSummaryHydration.ts +++ b/src/mobile/useGitSummaryHydration.ts @@ -1,13 +1,14 @@ import { useEffect } from "react"; import { readBridge } from "@/renderer/bridge"; +import { refreshSinglePr } from "@/renderer/state/gitRefresh"; import { resolvePrKey } from "@/renderer/state/gitSelectors"; import { useGitStore } from "@/renderer/state/gitStore"; +import { useGitReadModelStore } from "@/renderer/state/gitReadModelStore"; import type { GitStatusResult, PrData, Project, ProjectLocation, Thread } from "@/shared/contracts"; import type { RemoteThreadGitSummary } from "@/shared/remote"; import { buildWorktreeLocation } from "@/shared/worktree"; import { useGitSummariesStore } from "./gitSummaries"; -const pendingPrFetches = new Map>(); const GIT_STATUS_BRIDGE_RETRY_DELAY_MS = 250; const GIT_STATUS_BRIDGE_RETRY_LIMIT = 40; @@ -23,34 +24,35 @@ function prSummaryFromData(pr: PrData | null): RemoteThreadGitSummary["pr"] { }; } -function fetchPrForBranch(projectLocation: ProjectLocation, branch: string) { - const key = `${JSON.stringify(projectLocation)}\0${branch}`; - const existing = pendingPrFetches.get(key); - if (existing) return existing; - - const pending = readBridge() - .ghGetPrForBranch({ projectLocation, branch }) - .finally(() => { - if (pendingPrFetches.get(key) === pending) pendingPrFetches.delete(key); - }); - pendingPrFetches.set(key, pending); - return pending; -} - /** * Fetch the authoritative PR for a mobile thread from its paired host and * hydrate both caches used by the PWA: the reused desktop Git panel reads * gitStore, while thread/workspace badges read gitSummariesStore. */ export async function refreshMobilePrData(input: { + readonly projectId: string; readonly projectLocation: ProjectLocation; readonly branch: string; readonly prKey: string; + readonly prNumber?: number | undefined; readonly threadId?: string | undefined; }): Promise { try { - const pr = await fetchPrForBranch(input.projectLocation, input.branch); - useGitStore.getState().setPrData(input.prKey, pr); + const currentPrNumber = useGitStore.getState().prData[input.prKey]?.number; + const prNumber = input.prNumber ?? currentPrNumber; + const pr = await refreshSinglePr({ + projectId: input.projectId, + projectLocation: input.projectLocation, + branch: input.branch, + prKey: input.prKey, + ...(prNumber + ? { + prNumber, + detailsCacheKey: `${input.projectId}#${prNumber}`, + } + : {}), + }); + if (pr === undefined) return undefined; if (input.threadId) { useGitSummariesStore.getState().setThreadPr(input.threadId, prSummaryFromData(pr)); } @@ -102,9 +104,15 @@ export function useGitSummaryHydration( const cached = useGitSummariesStore((s) => (thread ? s.byThread[thread.id] : undefined)); const cachedBranch = cached?.isRepo ? cached.branch : undefined; const hasKnownPr = Boolean(cached?.pr || thread?.prNumber); + const prKey = thread ? resolvePrKey(thread.projectId, thread.worktreePath) : undefined; + const hydratedPr = useGitStore((state) => (prKey ? state.prData[prKey] : undefined)); + const hostGitStateAvailable = useGitReadModelStore((state) => state.hostAvailable); + const cachedPrFingerprint = JSON.stringify(cached?.pr ?? null); + const cachedPrNumber = cached?.pr?.number; + const hydratedPrFingerprint = JSON.stringify(prSummaryFromData(hydratedPr ?? null)); useEffect(() => { - if (!thread || !project || cached) return; + if (!thread || !project || cached || hostGitStateAvailable) return; const currentThread = thread; const currentProject = project; let cancelled = false; @@ -147,19 +155,31 @@ export function useGitSummaryHydration( cancelled = true; if (retryTimer) clearTimeout(retryTimer); }; - }, [thread, project, cached]); + }, [thread, project, cached, hostGitStateAvailable]); // The compact desktop summary powers the thread header, but the reused Git // panel reads full PR data from gitStore. Refresh known PRs on the phone so a // worktree panel does not fall back to "Create PR" or retain stale checks. useEffect(() => { - if (!thread || !project || !cachedBranch || !hasKnownPr) return; + if (hostGitStateAvailable || !thread || !project || !cachedBranch || !hasKnownPr) return; + if (cachedPrFingerprint === hydratedPrFingerprint) return; useGitStore.getState().setGhAvailable(project.id, true); void refreshMobilePrData({ + projectId: project.id, projectLocation: project.location, branch: cachedBranch, prKey: resolvePrKey(thread.projectId, thread.worktreePath), + ...(cachedPrNumber ? { prNumber: cachedPrNumber } : {}), threadId: thread.id, }); - }, [cachedBranch, hasKnownPr, project, thread]); + }, [ + cachedBranch, + cachedPrFingerprint, + cachedPrNumber, + hasKnownPr, + hostGitStateAvailable, + hydratedPrFingerprint, + project, + thread, + ]); } diff --git a/src/mobile/useRemoteDesktop.test.tsx b/src/mobile/useRemoteDesktop.test.tsx index 99a2a2a1b..15b170e02 100644 --- a/src/mobile/useRemoteDesktop.test.tsx +++ b/src/mobile/useRemoteDesktop.test.tsx @@ -20,6 +20,7 @@ type ClientMock = { startThread: Mock<(...a: unknown[]) => Promise>; startNewThread: Mock<(...a: unknown[]) => Promise>; sendThreadCommand: Mock<(...a: unknown[]) => Promise>; + sendThreadInput: Mock<(...a: unknown[]) => Promise>; }; // ── Hoisted mock state ────────────────────────────────────────────── @@ -77,6 +78,10 @@ const h = vi.hoisted(() => { ); }, ), + gitAddWorktree: vi.fn<(...a: unknown[]) => Promise<{ path: string }>>(async () => ({ + path: "/repo/.poracode/worktrees/mobile-fix", + })), + captureFileCheckpoint: vi.fn<(...a: unknown[]) => Promise>(async () => {}), }; }); @@ -163,6 +168,7 @@ function clientFor(desktopId: string): ClientMock { threadId: crypto.randomUUID(), })), sendThreadCommand: vi.fn<(...a: unknown[]) => Promise>(async () => {}), + sendThreadInput: vi.fn<(...a: unknown[]) => Promise>(async () => {}), }; h.clients.set(desktopId, client); } @@ -193,6 +199,7 @@ vi.mock("./remoteClient", () => ({ startThread = (...a: unknown[]) => this.#c().startThread(...a); startNewThread = (...a: unknown[]) => this.#c().startNewThread(...a); sendThreadCommand = (...a: unknown[]) => this.#c().sendThreadCommand(...a); + sendThreadInput = (...a: unknown[]) => this.#c().sendThreadInput(...a); }, })); @@ -237,11 +244,17 @@ vi.mock("./storeSync", () => ({ vi.mock("@/renderer/state/chatRuntimePersister", () => ({ seedOlderThreadRuntimeItemsCursor: (...a: unknown[]) => h.seedOlderThreadRuntimeItemsCursor(...a), })); +vi.mock("@/renderer/state/fileCheckpointActions", () => ({ + captureFileCheckpoint: (...args: unknown[]) => h.captureFileCheckpoint(...args), +})); vi.mock("./settingsSync", () => ({ applyDesktopSettings: (...a: unknown[]) => h.applyDesktopSettings(...a), resetDesktopSettings: (...a: unknown[]) => h.resetDesktopSettings(...a), })); +vi.mock("@/renderer/bridge", () => ({ + readBridge: () => ({ gitAddWorktree: h.gitAddWorktree }), +})); vi.mock("./bridge", () => ({ setRemoteBridgeClient: vi.fn<(...a: unknown[]) => void>() })); vi.mock("./browserMirror", () => ({ handleBrowserServerMessage: vi.fn<(...a: unknown[]) => boolean>(() => false), @@ -359,6 +372,8 @@ describe("useRemoteDesktop", () => { h.getSshCredential, h.deleteSshCredential, h.updateDesktopEndpoint, + h.gitAddWorktree, + h.captureFileCheckpoint, ]) { fn.mockClear(); } @@ -555,6 +570,112 @@ describe("useRemoteDesktop", () => { }); }); + it("marks a newly created PWA worktree for desktop setup and forwards copy patterns", async () => { + const desktop = makeDesktop("d1"); + const client = clientFor("d1"); + let createdThreadId = ""; + client.startNewThread.mockImplementation(async (input) => { + createdThreadId = (input as { threadId: string }).threadId; + return { threadId: createdThreadId }; + }); + client.snapshot.mockImplementation(async () => + snapshotFor("d1", createdThreadId ? [createdThreadId] : []), + ); + h.applyShellSnapshot.mockImplementation((value) => { + const snapshot = value as RemoteShellSnapshot; + useAppStore.setState({ threads: snapshot.threads }); + }); + const view = await mountWith([desktop], "d1"); + const project = { + id: "p", + name: "Repo", + location: { kind: "posix" as const, path: "/repo" }, + scripts: { + actions: [], + setupScript: "direnv allow\npnpm ci", + worktreeCopyPatterns: [".envrc", ".env.*"], + }, + worktreeLocation: { mode: "project-relative" as const }, + createdAt: "2026-01-01T00:00:00.000Z", + }; + + await act(async () => { + await view.result.current.startThread(project, { + agentKind: "codex", + config: { model: "m" }, + prompt: "Fix it", + presentationMode: "gui", + worktreeBranch: "poracode/mobile-fix", + worktreeBaseBranch: "main", + worktreeIsNewBranch: true, + }); + }); + + expect(h.gitAddWorktree).toHaveBeenCalledTimes(1); + expect(h.gitAddWorktree).toHaveBeenCalledWith({ + projectLocation: project.location, + branch: "poracode/mobile-fix", + createBranch: true, + startPoint: "main", + copyIgnoredPatterns: [".envrc", ".env.*"], + worktreeRoot: "/repo/.poracode/worktrees", + worktreeOmitRepoDir: true, + transferUncommitted: false, + keepChangesInSource: false, + }); + expect(client.startNewThread).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: project.id, + worktreePath: "/repo/.poracode/worktrees/mobile-fix", + worktreeBranch: "poracode/mobile-fix", + isNewWorktree: true, + }), + ); + }); + + it("captures a pre-turn file checkpoint for a PWA prompt", async () => { + const desktop = makeDesktop("d1"); + const project = { + id: "p", + name: "Repo", + location: { kind: "posix" as const, path: "/repo" }, + createdAt: "2026-01-01T00:00:00.000Z", + }; + const thread = { + id: "thread-1", + projectId: project.id, + title: "Thread", + agentKind: "codex", + config: { model: "m" }, + status: "idle" as const, + attention: "none" as const, + canResumeWithConfig: false, + archived: false, + done: false, + starred: false, + presentationMode: "gui" as const, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; + useAppStore.setState({ + projects: [project], + threads: [thread], + }); + const view = await mountWith([desktop], "d1"); + + await act(async () => { + await view.result.current.sendPrompt("continue remotely"); + }); + + expect(h.captureFileCheckpoint).toHaveBeenCalledTimes(1); + expect(h.captureFileCheckpoint).toHaveBeenCalledWith({ + threadId: thread.id, + checkpointItemId: expect.stringMatching(/^user-/), + projectLocation: project.location, + }); + expect(clientFor("d1").sendThreadInput).toHaveBeenCalledTimes(1); + }); + it("restores an SSH tunnel before refreshing the active desktop", async () => { const connection = { id: "a5fe6f57-e779-4efe-aad8-6cd9ec0c38fb", @@ -1028,6 +1149,59 @@ describe("useRemoteDesktop", () => { expect(view.result.current.message).toBe("http blip"); }); + it("[#7b] declares the new thread's content interest before asking for its history", async () => { + // Live transcript content is scoped per thread on the host. If the interest + // for a newly selected thread landed AFTER the history fetch, deltas arriving + // in between would be filtered out and the transcript could show a gap. So + // `selectThread` must publish the interest before it requests history. + const d = makeDesktop("d1"); + const client = clientFor("d1"); + const view = await mountWith([d], "d1"); + + await waitFor(() => expect(FakeWebSocket.instances.length).toBeGreaterThan(0)); + const socket = FakeWebSocket.instances[0]!; + client.parseSocketMessage.mockReturnValue({ type: "ready", seq: 0 }); + await act(async () => { + socket.readyState = 1; + for (const cb of socket.listeners.get("open") ?? []) cb({}); + }); + await waitFor(() => expect(view.result.current.connection).toBe("online")); + + // Record the order of the two observable effects. + const order: string[] = []; + const realSend = socket.send.bind(socket); + socket.send = (raw: string) => { + const parsed = JSON.parse(raw) as { type: string; threadIds?: string[] }; + if (parsed.type === "thread-item-interests" && parsed.threadIds?.includes("t-new")) { + order.push("interests"); + } + realSend(raw); + }; + client.threadHistory.mockImplementation(async () => { + order.push("history"); + return { + snapshotSeq: 1, + thread: { id: "t-new", status: "idle", presentationMode: "gui" }, + runtimeItems: [], + completedTurns: [], + contextUsage: null, + updatedAt: "", + }; + }); + + await act(async () => { + await view.result.current.openThread({ + id: "t-new", + projectId: "p1", + status: "working", + presentationMode: "gui", + } as never); + }); + + await waitFor(() => expect(order).toContain("history")); + expect(order[0]).toBe("interests"); + }); + it("[#8] does not claim offline while cached data renders during the first boot refresh", async () => { const d = makeDesktop("d1"); const client = clientFor("d1"); diff --git a/src/mobile/useRemoteDesktop.ts b/src/mobile/useRemoteDesktop.ts index 9ae5e0d26..64e5db7df 100644 --- a/src/mobile/useRemoteDesktop.ts +++ b/src/mobile/useRemoteDesktop.ts @@ -12,6 +12,7 @@ import { type ThreadStatus, } from "@/shared/contracts"; import { buildWorktreeLocation } from "@/shared/worktree"; +import { isHomeProjectId } from "@/shared/homeScope"; import { waitForRemoteThreadAppearance } from "@/shared/remote/threadAppearance"; import type { SshConnectionConfig } from "@/shared/ssh"; import { @@ -23,6 +24,7 @@ import { type RemoteThreadSnapshot, } from "@/shared/remote"; import { performThreadInputSubmit } from "@/renderer/actions/threadRuntimeActions"; +import { captureFileCheckpoint } from "@/renderer/state/fileCheckpointActions"; import { useAppStore } from "@/renderer/state/appStore"; import { seedOlderThreadRuntimeItemsCursor } from "@/renderer/state/chatRuntimePersister"; import { readBridge } from "@/renderer/bridge"; @@ -360,6 +362,38 @@ export function useRemoteDesktop() { reconnectNonce, ]); + useEffect(() => { + const coordinator = socketCoordinatorRef.current; + if (!coordinator || coordinator.desktopId !== activeDesktopId) return; + coordinator.coordinator.setGitStateInterests( + threads.map((thread) => ({ + kind: "target" as const, + projectId: thread.projectId, + ...(thread.worktreePath ? { worktreePath: thread.worktreePath } : {}), + includePrDetails: true, + })), + ); + }, [activeDesktopId, threads]); + + // Live transcript content is only needed for the thread on screen; every other + // thread still delivers lifecycle events (status, permission prompts), so + // badges and approvals keep working. The selected thread's history is fetched + // over HTTP on selection, so nothing is missing when the user switches. + // + // `selectThread` publishes eagerly; this effect is the backstop that covers + // every other way the selection can change (restore, first snapshot, desktop + // switch, reconnect). + useEffect(() => { + publishSelectedThreadItemInterest(selectedThreadId); + }, [activeDesktopId, selectedThreadId]); + + /** Tells the host which thread's live transcript content this client wants. */ + function publishSelectedThreadItemInterest(threadId: string | null): void { + const coordinator = socketCoordinatorRef.current; + if (!coordinator || coordinator.desktopId !== activeDesktopIdRef.current) return; + coordinator.coordinator.setThreadItemInterests(threadId ? [threadId] : []); + } + async function reloadDesktops(nextActive?: string) { const stored = await listStoredDesktops(); setDesktops(stored); @@ -646,6 +680,13 @@ export function useRemoteDesktop() { // compares against it and must not read the previous thread while the // re-render that would update it is still pending. selectedThreadIdRef.current = thread.id; + // Declare this thread's content interest before any history request goes + // out. The effect that watches `selectedThreadId` would only run a render + // later — and much later under load — and until the host has the new + // interest, this thread's live deltas are filtered out. Publishing first + // keeps the window closed: the host learns what we want before we ask what + // we missed. + publishSelectedThreadItemInterest(thread.id); setThreadSnapshot((current) => (current?.thread.id === thread.id ? current : null)); const desktop = activeDesktop; if (!desktop) return; @@ -832,10 +873,8 @@ export function useRemoteDesktop() { /** * The desktop's submit core with the remote client as transport: optimistic - * user_message paint, working flip, rollback, and touch all come from - * `performThreadInputSubmit`. No checkpoint capture — checkpoints are a - * desktop-local concern. Rejects on transport failure so callers (the - * mobile composer dock collapse) only react to successful sends. + * user_message paint, working flip, checkpoint capture, rollback, and touch + * all follow the same lifecycle as a local prompt. */ async function sendPrompt(prompt: string, segments?: PromptSegment[]) { const desktop = activeDesktop; @@ -846,6 +885,16 @@ export function useRemoteDesktop() { prompt, ...(segments ? { segments } : {}), transport: clientFor(desktop), + captureCheckpoint: async (checkpointItemId) => { + if (isHomeProjectId(thread.projectId)) return; + const projectLocation = resolveThreadProjectLocation(thread); + if (!projectLocation) return; + await captureFileCheckpoint({ + threadId: thread.id, + checkpointItemId, + projectLocation, + }); + }, }); } @@ -919,6 +968,12 @@ export function useRemoteDesktop() { // off the ref, which otherwise still points at the previous thread until // the next render. selectedThreadIdRef.current = threadId; + // Declare the new thread's content interest HERE rather than leaving it to + // the effect below. The effect runs a render later — and much later under + // load — and until the host has the new interest, this thread's live deltas + // are filtered out. Publishing before the history fetch is issued keeps the + // window closed: the host learns what we want before we ask what we missed. + publishSelectedThreadItemInterest(threadId); void loadThreadSnapshot(threadId, desktop, { preferCache: false }); return threadId; } diff --git a/src/mobile/views/GitView.test.tsx b/src/mobile/views/GitView.test.tsx index b696f4eca..cfe8dcc94 100644 --- a/src/mobile/views/GitView.test.tsx +++ b/src/mobile/views/GitView.test.tsx @@ -5,6 +5,7 @@ import type { GitProjectSnapshotResult, GitStatusResult, PrData, + PrDetails, Project, } from "@/shared/contracts"; import { renderWithI18n as render } from "@/renderer/testUtils/i18n"; @@ -18,6 +19,7 @@ const bridge = vi.hoisted(() => ({ gitFetch: vi.fn<(payload: unknown) => Promise>(), gitProjectSnapshot: vi.fn<(payload: unknown) => Promise>(), ghGetPrForBranch: vi.fn<(payload: unknown) => Promise>(), + ghGetPrDetails: vi.fn<(payload: unknown) => Promise<{ details: PrDetails }>>(), })); vi.mock("@/renderer/bridge", () => ({ @@ -114,6 +116,8 @@ describe("GitView", () => { bridge.gitFetch.mockReset(); bridge.gitProjectSnapshot.mockReset(); bridge.ghGetPrForBranch.mockReset(); + bridge.ghGetPrDetails.mockReset(); + bridge.ghGetPrDetails.mockRejectedValue(new Error("details unavailable")); bridge.getGitStatus.mockResolvedValue(status); bridge.gitFetch.mockResolvedValue(undefined); bridge.ghGetPrForBranch.mockResolvedValue(null); diff --git a/src/mobile/views/GitView.tsx b/src/mobile/views/GitView.tsx index 9a3e566cb..df85821fb 100644 --- a/src/mobile/views/GitView.tsx +++ b/src/mobile/views/GitView.tsx @@ -130,6 +130,7 @@ export function GitView(props: { const branch = worktreeBranch ?? status?.branch; if (!branch) return; await refreshMobilePrData({ + projectId: project.id, projectLocation: project.location, branch, prKey: resolvePrKey(project.id, worktreePath), diff --git a/src/renderer/app.test.tsx b/src/renderer/app.test.tsx index f2a3f7f6d..a97a8b5fb 100644 --- a/src/renderer/app.test.tsx +++ b/src/renderer/app.test.tsx @@ -21,6 +21,7 @@ const { quickComposerSubmitListeners, projectStateChangedListeners, remoteThreadCommandListeners, + runWorktreeSetupScript, supervisorEventListeners, threadOpenRequestedListeners, } = vi.hoisted(() => { @@ -31,6 +32,7 @@ const { const projectListeners: Array<(event: { projects: unknown[] }) => void> = []; return { remoteThreadCommandListeners: listeners, + runWorktreeSetupScript: vi.fn<() => Promise>().mockResolvedValue(undefined), quickComposerSubmitListeners: quickListeners, supervisorEventListeners: supervisorListeners, threadOpenRequestedListeners: threadOpenListeners, @@ -185,6 +187,7 @@ const { projectListeners.push(listener); return () => undefined; }), + onGitStateChanged: vi.fn<() => () => void>(() => () => undefined), onThreadOpenRequested: vi.fn< (listener: (event: ThreadOpenRequestedEvent) => void) => () => void >((listener) => { @@ -213,6 +216,14 @@ vi.mock("./bridge", () => ({ isMac: () => false, })); +vi.mock("@/renderer/actions/worktreeLaunchActions", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runWorktreeSetupScript, + }; +}); + vi.mock("./components/ui/provider", () => ({ AppProvider: (props: { children: ReactNode }) => props.children, })); @@ -686,6 +697,77 @@ describe("App", () => { expect(bridge.startThread).not.toHaveBeenCalled(); }); + it("runs setup once and headlessly for a worktree newly created from the PWA", () => { + const project = { + id: "project-1", + name: "Repo", + location: { + kind: "windows" as const, + path: "C:\\repo", + }, + scripts: { + actions: [], + setupScript: "direnv allow\npnpm ci", + worktreeCopyPatterns: [".envrc", ".env.*"], + }, + createdAt: "2026-03-22T00:00:00.000Z", + }; + useAppStore.setState({ projects: [project], view: { kind: "home" } }); + render(); + + act(() => { + remoteThreadCommandListeners.at(-1)?.({ + kind: "prepare-worktree", + threadId: "remote-new-worktree", + projectId: project.id, + worktreePath: "C:\\worktrees\\mobile-fix", + }); + }); + + expect(runWorktreeSetupScript).toHaveBeenCalledTimes(1); + expect(runWorktreeSetupScript).toHaveBeenCalledWith( + project, + "C:\\worktrees\\mobile-fix", + "direnv allow\npnpm ci", + { openTerminalPanel: false }, + ); + }); + + it("does not run setup when the PWA reuses an existing worktree", () => { + const project = { + id: "project-1", + name: "Repo", + location: { + kind: "windows" as const, + path: "C:\\repo", + }, + scripts: { + actions: [], + setupScript: "direnv allow\npnpm ci", + }, + createdAt: "2026-03-22T00:00:00.000Z", + }; + useAppStore.setState({ projects: [project], view: { kind: "home" } }); + render(); + + act(() => { + remoteThreadCommandListeners.at(-1)?.({ + kind: "start", + threadId: "remote-existing-worktree", + projectId: project.id, + agentKind: "codex", + config: { model: "gpt-5.4" }, + prompt: "continue from phone", + presentationMode: "gui", + worktreePath: "C:\\worktrees\\existing", + worktreeBranch: "feature/existing", + launchRuntime: false, + }); + }); + + expect(runWorktreeSetupScript).not.toHaveBeenCalled(); + }); + it("adopts project changes made outside the renderer before the next store sync", () => { const project = { id: "mcp-project-1", diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index a2c8967dd..c122d76e9 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -18,6 +18,7 @@ import { } from "./notifications"; import { useAppStore } from "./state/appStore"; +import { useGitReadModelStore } from "./state/gitReadModelStore"; import { acknowledgeThread, archiveThread, @@ -300,6 +301,20 @@ const mainWindowCleanups: Array<() => void> = isMainWindow deleteWorktreeGroup(command.projectId, command.worktreePath, command.threadIds); return; } + if (command.kind === "prepare-worktree") { + const project = useAppStore + .getState() + .projects.find((entry) => entry.id === command.projectId); + if (!project) return; + void primeWorktreeGitState(project, command.worktreePath); + const setupScript = project.scripts?.setupScript; + if (setupScript) { + void runWorktreeSetupScript(project, command.worktreePath, setupScript, { + openTerminalPanel: false, + }); + } + return; + } if (command.kind === "start") { const store = useAppStore.getState(); if (store.threads.some((t) => t.id === command.threadId)) return; @@ -341,12 +356,6 @@ const mainWindowCleanups: Array<() => void> = isMainWindow } if (command.worktreePath) { void primeWorktreeGitState(project, command.worktreePath); - if (command.isNewWorktree) { - const setupScript = project.scripts?.setupScript; - if (setupScript) { - void runWorktreeSetupScript(project, command.worktreePath, setupScript); - } - } } return; } @@ -391,7 +400,9 @@ const mainWindowCleanups: Array<() => void> = isMainWindow void primeWorktreeGitState(project, command.worktreePath); const setupScript = project.scripts?.setupScript; if (setupScript) { - void runWorktreeSetupScript(project, command.worktreePath, setupScript); + void runWorktreeSetupScript(project, command.worktreePath, setupScript, { + openTerminalPanel: false, + }); } } } @@ -419,6 +430,9 @@ const mainWindowCleanups: Array<() => void> = isMainWindow readBridge().onProjectStateChanged(({ projects }) => { useAppStore.setState({ projects }); }), + readBridge().onGitStateChanged((patch) => { + useGitReadModelStore.getState().applyPatch(patch); + }), readBridge().onThreadOpenRequested(({ threadId }) => { openThread(threadId, { focusComposer: true }); }), diff --git a/src/renderer/components/layout/floatingGlass.ts b/src/renderer/components/layout/floatingGlass.ts new file mode 100644 index 000000000..c42f37a5a --- /dev/null +++ b/src/renderer/components/layout/floatingGlass.ts @@ -0,0 +1,13 @@ +/** + * Shared dark liquid-glass material for controls floating over app content. + * + * The marker classes resolve platform-specific tint, blur, edge, shadow, and + * selected-state treatment in styles.css. Keep shape and placement classes on + * each consumer so pills, circular scroll buttons, and vertical rails can share + * one material without sharing geometry. + */ +export const floatingGlassSurfaceClass = + "poracode-floating-chrome border border-border/15 bg-[var(--floating-chrome-surface)] shadow-lg backdrop-blur-md"; + +/** Denser, still-dark selected state for a floating glass control. */ +export const floatingGlassActiveClass = "poracode-floating-chrome--active"; diff --git a/src/renderer/components/layout/sidebarChrome.ts b/src/renderer/components/layout/sidebarChrome.ts index 8321aafbf..98facfd09 100644 --- a/src/renderer/components/layout/sidebarChrome.ts +++ b/src/renderer/components/layout/sidebarChrome.ts @@ -45,21 +45,6 @@ export const panelHeaderRowClass = export const panelHeaderTooltipTriggerResetClass = "min-w-0 shrink-0 justify-start rounded border-0 bg-transparent p-0 shadow-none outline-none"; -/** - * Floating chrome that hovers over a thread's conversation — the per-thread tool - * rail, the changes bubble above the composer, and the scroll-to-bottom button. - * One class so the three share a surface; the tint tracks `--sidebar-background` - * (see styles.css) so they read as app chrome in every theme. The - * `poracode-floating-chrome` marker lets styles.css push the blur further on - * Windows, which has no OS vibrancy behind these pills. - */ -export const floatingChromeSurfaceClass = - "poracode-floating-chrome border border-border/15 bg-[var(--floating-chrome-surface)] shadow-lg backdrop-blur-md"; - -/** Pressed/open state for a floating-chrome pill that toggles a panel. */ -export const floatingChromeActiveClass = - "!bg-[color:color-mix(in_oklab,var(--floating-chrome-surface)_94%,var(--foreground)_6%)]"; - /** Icon button in panel headers (tab toggles, close, expand). */ export const panelHeaderIconButtonClass = "inline-flex items-center justify-center rounded p-0.5 text-muted hover:text-foreground"; diff --git a/src/renderer/components/terminal/XTermSurface.tsx b/src/renderer/components/terminal/XTermSurface.tsx index 85ce85a69..d186225b3 100644 --- a/src/renderer/components/terminal/XTermSurface.tsx +++ b/src/renderer/components/terminal/XTermSurface.tsx @@ -30,7 +30,7 @@ import { clearActiveTerminalFind, setActiveTerminalFind, } from "@/renderer/components/find/terminalFindBridge"; -import { floatingChromeSurfaceClass } from "@/renderer/components/layout/sidebarChrome"; +import { floatingGlassSurfaceClass } from "@/renderer/components/layout/floatingGlass"; /** Decoration colors for in-terminal find matches (kept in sync with the CSS * highlight colors used elsewhere: amber for matches, orange for the active). */ @@ -1007,7 +1007,7 @@ export const XTermSurface = forwardRef< /* Same floating chrome as the chat pane's scroll-to-bottom and the thread tool rail, so the two scroll buttons match across the terminal/chat presentations. */ - className={`${floatingChromeSurfaceClass} absolute bottom-4 right-4 z-10 size-7 min-w-0 rounded-full transition-opacity duration-200 ease-out ${ + className={`${floatingGlassSurfaceClass} absolute bottom-4 right-4 z-10 size-7 min-w-0 rounded-full transition-opacity duration-200 ease-out ${ showScrollDown ? "opacity-80 hover:opacity-100" : "pointer-events-none opacity-0" }`} > diff --git a/src/renderer/components/thread/ChatPane/ChatScrollControls.tsx b/src/renderer/components/thread/ChatPane/ChatScrollControls.tsx index 0aa1a9971..eb2ba964f 100644 --- a/src/renderer/components/thread/ChatPane/ChatScrollControls.tsx +++ b/src/renderer/components/thread/ChatPane/ChatScrollControls.tsx @@ -10,7 +10,7 @@ import { import { Button } from "@heroui/react"; import { useLingui } from "@lingui/react/macro"; import { ArrowDown } from "lucide-react"; -import { floatingChromeSurfaceClass } from "@/renderer/components/layout/sidebarChrome"; +import { floatingGlassSurfaceClass } from "@/renderer/components/layout/floatingGlass"; import { useAppStore } from "@/renderer/state/appStore"; import { isPanelResizing, subscribePanelResize } from "@/renderer/state/panelResizeSignal"; import { @@ -761,7 +761,7 @@ export const ChatScrollControls = forwardRef< /* Centered via a negative margin, not `-translate-x-1/2`: HeroUI's pressed state animates `transform`, which would fight a translate and snap the button sideways on click. */ - className={`${floatingChromeSurfaceClass} absolute bottom-4 left-1/2 z-10 -ml-3.5 size-7 min-w-0 rounded-full transition-opacity duration-200 ease-out ${ + className={`${floatingGlassSurfaceClass} absolute bottom-4 left-1/2 z-10 -ml-3.5 size-7 min-w-0 rounded-full transition-opacity duration-200 ease-out ${ showScrollDown ? "opacity-80 hover:opacity-100" : "pointer-events-none opacity-0" }`} > diff --git a/src/renderer/components/thread/ChatPane/parts/items/ImageCard.test.tsx b/src/renderer/components/thread/ChatPane/parts/items/ImageCard.test.tsx new file mode 100644 index 000000000..1f53c7729 --- /dev/null +++ b/src/renderer/components/thread/ChatPane/parts/items/ImageCard.test.tsx @@ -0,0 +1,113 @@ +import { fireEvent, render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { AppProvider } from "@/renderer/components/ui/provider"; +import { ImageCard } from "./ImageCard"; +import type { ImageViewSource } from "./imageViewSource"; + +const PREVIEW = "data:image/jpeg;base64,QQ=="; + +function source(overrides: Partial = {}): ImageViewSource { + return { + src: "https://desktop.test/api/threads/t/items/i/image", + mime: "image/png", + extension: "png", + fileName: "shot.png", + alt: "A screenshot", + width: 800, + height: 600, + ...overrides, + }; +} + +function renderCard(s: ImageViewSource) { + const view = render( + + + , + ); + const img = view.container.querySelector("img")!; + const preview = view.container.querySelector("[aria-hidden='true'][style*='background-image']"); + return { view, img, preview }; +} + +describe("ImageCard", () => { + it("reserves the slot from intrinsic size so the timeline cannot shift on load", () => { + // width/height ride along on the host's image reference precisely so the + // browser can compute the box before any bytes arrive. + const { img } = renderCard(source()); + expect(img.getAttribute("width")).toBe("800"); + expect(img.getAttribute("height")).toBe("600"); + }); + + it("paints the blurred stand-in until the real image loads, then drops it", () => { + const { view, img } = renderCard(source({ preview: PREVIEW })); + let preview = view.container.querySelector("[aria-hidden='true'][style*='background-image']"); + expect(preview).not.toBeNull(); + expect(preview!.getAttribute("style")).toContain(PREVIEW); + expect(preview!.className).toContain("blur-lg"); + // The image fades in over the stand-in rather than popping in. + expect(img.className).toContain("opacity-0"); + expect(img.className).toContain("transition-opacity"); + + fireEvent.load(img); + preview = view.container.querySelector("[aria-hidden='true'][style*='background-image']"); + expect(preview).toBeNull(); + expect(img.className).toContain("opacity-100"); + }); + + it("still fades in when the host supplied no stand-in", () => { + const { img, preview } = renderCard(source()); + expect(preview).toBeNull(); + expect(img.className).toContain("opacity-0"); + fireEvent.load(img); + expect(img.className).toContain("opacity-100"); + }); + + it("shows an inline data image immediately — no fade, no flash", () => { + // Already decoded, so fading would only add perceived latency. + const { img, preview } = renderCard(source({ src: "data:image/png;base64,QQ==" })); + expect(preview).toBeNull(); + expect(img.className).not.toContain("transition-opacity"); + expect(img.className).not.toContain("opacity-0"); + }); + + it("reveals the image even if it fails to load, so the slot never stays blank", () => { + const { view, img } = renderCard(source({ preview: PREVIEW })); + fireEvent.error(img); + expect( + view.container.querySelector("[aria-hidden='true'][style*='background-image']"), + ).toBeNull(); + expect(img.className).toContain("opacity-100"); + }); +}); + +describe("reserved slot", () => { + it("gives a fetched image a definite pre-load box so nothing reflows", () => { + // Regression: width/height attributes alone leave an unloaded at 0x0 + // under `w-auto`, because there is no intrinsic size for aspect-ratio to + // resolve against — the transcript then jumps when the bytes land. + const { img } = renderCard(source({ width: 369, height: 800 })); + expect(img.style.aspectRatio).toBe("369 / 800"); + expect(img.style.height).toBe("auto"); + // The definite width itself (a nested `min()`/`calc()`) cannot be asserted + // here — jsdom's CSSOM rejects the value outright, so it appears in neither + // `style.width` nor the serialized attribute. `reserveInlineImageSlot` covers + // the computed string directly, and the real box stability was measured in + // Chrome against the running app. + }); + + it("leaves an inline data image on its natural sizing", () => { + const { img } = renderCard( + source({ src: "data:image/png;base64,QQ==", width: 10, height: 20 }), + ); + expect(img.style.aspectRatio).toBe(""); + expect(img.getAttribute("style") ?? "").not.toContain("width:"); + }); + + it("reserves nothing when the host could not read the image size", () => { + // exactOptionalPropertyTypes: build the source without the keys at all. + const { width: _w, height: _h, ...noSize } = source(); + const { img } = renderCard(noSize); + expect(img.getAttribute("style") ?? "").not.toContain("width:"); + }); +}); diff --git a/src/renderer/components/thread/ChatPane/parts/items/ImageCard.tsx b/src/renderer/components/thread/ChatPane/parts/items/ImageCard.tsx index 17b1ca2e3..276e2f06a 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/ImageCard.tsx +++ b/src/renderer/components/thread/ChatPane/parts/items/ImageCard.tsx @@ -5,7 +5,7 @@ import { memo, useState, type ReactNode } from "react"; import { readBridge } from "@/renderer/bridge"; import { openImageLightbox } from "@/renderer/components/composer/ImageLightbox"; import { friendlyError } from "@/shared/messages"; -import { chatInlineImageClass } from "./chatImageClass"; +import { chatInlineImageClass, reserveInlineImageSlot } from "./chatImageClass"; import type { ImageViewSource } from "./imageViewSource"; interface ImageCardProps { @@ -28,6 +28,16 @@ export const ImageCard = memo(function ImageCard({ const { t } = useLingui(); const imageAlt = source.alt || t`Image`; const openPreview = () => openImageLightbox([{ src: source.src, alt: imageAlt }], 0); + // A `data:` source is already in hand, so it paints on the first frame; fading + // it would only add perceived latency. Anything fetched over the network gets + // the crossfade (and the blurred stand-in, when the host supplied one). + const fadesIn = !source.src.startsWith("data:"); + const [loaded, setLoaded] = useState(!fadesIn); + const showPreview = fadesIn && !loaded && Boolean(source.preview); + // Reserve the final box up front so the transcript never reflows when a + // fetched image lands. Inline `data:` images paint immediately and keep the + // natural `w-auto` sizing. + const reservedSlot = fadesIn ? reserveInlineImageSlot(source.width, source.height) : undefined; return ( diff --git a/src/renderer/components/thread/ChatPane/parts/items/chatImageClass.test.ts b/src/renderer/components/thread/ChatPane/parts/items/chatImageClass.test.ts new file mode 100644 index 000000000..a7bb0b0b7 --- /dev/null +++ b/src/renderer/components/thread/ChatPane/parts/items/chatImageClass.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + chatInlineImageClass, + chatInlineImageMaxHeight, + reserveInlineImageSlot, +} from "./chatImageClass"; + +describe("reserveInlineImageSlot", () => { + it("derives the box the loaded image will settle on", () => { + expect(reserveInlineImageSlot(369, 800)).toEqual({ + width: `min(369px, calc(${chatInlineImageMaxHeight} * 369 / 800))`, + aspectRatio: "369 / 800", + height: "auto", + }); + }); + + it("uses no percentage term, which would collapse the box to 0x0", () => { + // The card is shrink-to-fit, so a `100%` width has no definite containing + // block and the element measures 0x0 until the bytes land. Measured in Chrome. + expect(reserveInlineImageSlot(369, 800)?.width).not.toContain("100%"); + }); + + it("reserves nothing without usable dimensions", () => { + expect(reserveInlineImageSlot(undefined, undefined)).toBeUndefined(); + expect(reserveInlineImageSlot(369, undefined)).toBeUndefined(); + expect(reserveInlineImageSlot(0, 800)).toBeUndefined(); + expect(reserveInlineImageSlot(-1, 800)).toBeUndefined(); + }); + + it("stays in step with the class that caps the height", () => { + // If these drift, the reserved box and the painted box disagree and the + // transcript reflows on load — the exact bug this guards. + expect(chatInlineImageClass).toContain(`max-h-[${chatInlineImageMaxHeight}]`); + }); +}); diff --git a/src/renderer/components/thread/ChatPane/parts/items/chatImageClass.ts b/src/renderer/components/thread/ChatPane/parts/items/chatImageClass.ts index 92150e5c8..52fcaad11 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/chatImageClass.ts +++ b/src/renderer/components/thread/ChatPane/parts/items/chatImageClass.ts @@ -1,2 +1,39 @@ -export const chatInlineImageClass = - "block max-h-[min(18rem,40vh)] w-auto max-w-full object-contain"; +/** Height cap for an inline chat image. Kept as a constant because the reserved + * slot below has to compute the same box the loaded image will occupy. */ +export const chatInlineImageMaxHeight = "min(18rem,40vh)"; + +export const chatInlineImageClass = `block max-h-[${chatInlineImageMaxHeight}] w-auto max-w-full object-contain`; + +/** + * Inline style that reserves an inline image's exact final box *before* it loads. + * + * `width`/`height` attributes are not enough on their own: with `width:auto` and + * `height:auto` an unloaded image has no intrinsic size for the UA's + * `aspect-ratio` to resolve against, so the element measures 0x0 and the + * transcript reflows the moment the bytes land. That is invisible for an inline + * `data:` image (it paints on the first frame) but very visible for one fetched + * over the network — which is every image on a remote client now that they are + * referenced rather than inlined. + * + * So give width a DEFINITE value — the smaller of the image's own width and the + * width implied by the height cap — and let `aspect-ratio` derive the height. + * `max-w-full` from the class still clamps it to a narrower container, and + * because the width is definite the ratio then resolves the height correctly. + * + * Deliberately no `100%` term: the image card is a shrink-to-fit box + * (`inline-flex` / `w-fit`), so a percentage width has no definite containing + * block to resolve against and the whole `min()` collapses the element to 0x0 + * while it loads — measured in Chrome, and the exact reflow this is meant to + * prevent. + */ +export function reserveInlineImageSlot( + width: number | undefined, + height: number | undefined, +): { readonly width: string; readonly aspectRatio: string; readonly height: "auto" } | undefined { + if (!width || !height || width <= 0 || height <= 0) return undefined; + return { + width: `min(${width}px, calc(${chatInlineImageMaxHeight} * ${width} / ${height}))`, + aspectRatio: `${width} / ${height}`, + height: "auto", + }; +} diff --git a/src/renderer/components/thread/ChatPane/parts/items/imageViewSource.test.ts b/src/renderer/components/thread/ChatPane/parts/items/imageViewSource.test.ts index 85ad377aa..5f1fa267c 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/imageViewSource.test.ts +++ b/src/renderer/components/thread/ChatPane/parts/items/imageViewSource.test.ts @@ -1,6 +1,8 @@ // @vitest-environment node -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; +import { remoteImageRef } from "@/shared/remote"; +import { setRemoteImageRefResolver } from "@/shared/imageRefDisplay"; import { imageViewRendersInline, imageViewSourceFromImageBlock, @@ -158,3 +160,63 @@ describe("imageViewRendersInline", () => { ).toBe(false); }); }); + +describe("host-minted image references", () => { + const ref = remoteImageRef({ + threadId: "t1", + itemId: "i1", + path: ["images", 0], + mime: "image/jpeg", + bytes: 4096, + width: 800, + height: 600, + }); + + afterEach(() => { + setRemoteImageRefResolver(null); + }); + + it("resolves a reference through the installed resolver", () => { + setRemoteImageRefResolver((value) => `https://desktop.test/img/${value.itemId}`); + const source = resolveImageViewSource({ + name: "imageView", + status: "success", + images: [ref], + args: { prompt: "a cat" }, + }); + expect(source).toMatchObject({ + src: "https://desktop.test/img/i1", + mime: "image/jpeg", + extension: "jpg", + alt: "a cat", + // Carried on the reference so the row reserves layout before loading. + width: 800, + height: 600, + }); + expect(source?.fileName).toBe("a-cat.jpg"); + }); + + it("groups as an inline image so the timeline does not demote the row", () => { + setRemoteImageRefResolver(() => "https://desktop.test/img/i1"); + expect(imageViewRendersInline({ status: "success", images: [ref] })).toBe(true); + }); + + it("falls back to the accordion when nothing can resolve the reference", () => { + // The desktop shell installs no resolver: better an inert accordion than a + // broken . + expect(resolveImageViewSource({ status: "success", images: [ref] })).toBeNull(); + setRemoteImageRefResolver(() => ""); + expect(resolveImageViewSource({ status: "success", images: [ref] })).toBeNull(); + }); + + it("shows the accordion for an errored payload even with a reference", () => { + setRemoteImageRefResolver(() => "https://desktop.test/img/i1"); + expect(imageViewRendersInline({ status: "error", images: [ref] })).toBe(false); + expect(resolveImageViewSource({ status: "error", images: [ref] })).toBeNull(); + }); + + it("still renders payloads that kept their inline bytes", () => { + setRemoteImageRefResolver(() => "https://desktop.test/img/i1"); + expect(resolveImageViewSource({ result: PNG_BASE64 })?.src).toContain("data:image/png;base64,"); + }); +}); diff --git a/src/renderer/components/thread/ChatPane/parts/items/imageViewSource.ts b/src/renderer/components/thread/ChatPane/parts/items/imageViewSource.ts index a1ece535a..ff1630ff7 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/imageViewSource.ts +++ b/src/renderer/components/thread/ChatPane/parts/items/imageViewSource.ts @@ -31,6 +31,9 @@ import { inlineImagePayloadRenders, type InlineImageClassification, } from "@/shared/inlineImagePayload"; +import { readImageDimensions } from "@/shared/imageDimensions"; +import { findDisplayableImageRef, type RemoteImageRefValue } from "@/shared/remote"; +import { resolveRemoteImageRefUrl } from "@/shared/imageRefDisplay"; export interface ImageViewSource { /** Renderable image URL. */ @@ -46,6 +49,12 @@ export interface ImageViewSource { /** Intrinsic dimensions when cheaply readable from the image header. */ width?: number; height?: number; + /** + * Tiny blurred stand-in painted in the reserved slot while the full image + * loads. Only present for host-held images, which are the ones that take a + * round trip to arrive. + */ + preview?: string; } const EXTENSION_BY_MIME: Record = { @@ -76,11 +85,17 @@ const MIME_BY_EXTENSION: Record = { * is shown only when the payload carries a renderable image AND did not error. */ export function imageViewRendersInline(payload: unknown): boolean { + // On remote clients the host replaces inline image bytes with a reference, so + // the row must still be recognized as an image here or grouping would demote + // it to a plain tool-call accordion. + if (readStatus(payload) !== "error" && findDisplayableImageRef(payload)) return true; return inlineImagePayloadRenders(payload); } /** Full resolution: returns the ``-ready source, or `null` when there's no image. */ export function resolveImageViewSource(payload: unknown): ImageViewSource | null { + const ref = readStatus(payload) === "error" ? null : findDisplayableImageRef(payload); + if (ref) return imageViewSourceFromRef(ref, payload); const found = findRenderableInlineImageCandidate(payload); if (!found) return null; const { value, classification } = found; @@ -101,6 +116,36 @@ export function resolveImageViewSource(payload: unknown): ImageViewSource | null }; } +/** + * Build an {@link ImageViewSource} for an image the host is holding on our + * behalf. Returns null when nothing can resolve the reference to a URL (the + * desktop shell, or a remote client without an active session) so the row falls + * back to the inert tool-call accordion rather than rendering a broken image. + * + * `width`/`height` ride along on the reference precisely so the timeline can + * reserve layout here without having fetched the image yet. + */ +function imageViewSourceFromRef( + ref: RemoteImageRefValue, + payload: unknown, +): ImageViewSource | null { + const src = resolveRemoteImageRefUrl(ref); + if (!src) return null; + const extension = EXTENSION_BY_MIME[ref.mime] ?? "png"; + const promptText = readPromptText(payload); + return { + src, + mime: ref.mime, + extension, + fileName: buildFileName(promptText ?? "", extension), + alt: promptText ?? i18n._(msg`Generated image`), + ...(ref.width !== undefined && ref.height !== undefined + ? { width: ref.width, height: ref.height } + : {}), + ...(ref.preview ? { preview: ref.preview } : {}), + }; +} + /** * Build an {@link ImageViewSource} from a canonical assistant-message image * content block (`{ kind: "image", dataUrl, mimeType?, name? }`). Returns null @@ -229,231 +274,6 @@ function buildSrc(value: string, classification: InlineImageClassification): str } } -function readImageDimensions( - value: string, - classification: InlineImageClassification, -): { width: number; height: number } | undefined { - if (classification.mime === "image/svg+xml") return readSvgDimensions(value, classification); - const bytes = readBase64BytesPrefix(value, classification, 8192); - if (!bytes) return undefined; - switch (classification.mime) { - case "image/png": - return readPngDimensions(bytes); - case "image/jpeg": - return readJpegDimensions(bytes); - case "image/gif": - return readGifDimensions(bytes); - case "image/webp": - return readWebpDimensions(bytes); - default: - return undefined; - } -} - -function readBase64BytesPrefix( - value: string, - classification: InlineImageClassification, - byteCount: number, -): Uint8Array | undefined { - const base64 = - classification.kind === "dataUrl" - ? readBase64DataUrlBody(value) - : classification.kind === "base64" - ? value - : null; - if (!base64) return undefined; - const clean = base64.replace(/\s+/g, ""); - if (clean.length === 0) return undefined; - const chars = Math.ceil(byteCount / 3) * 4; - const slice = clean.slice(0, chars); - const padded = slice.padEnd(Math.ceil(slice.length / 4) * 4, "="); - try { - const binary = atob(padded); - const out = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i); - return out; - } catch { - return undefined; - } -} - -function readBase64DataUrlBody(value: string): string | null { - const commaIndex = value.indexOf(","); - if (commaIndex < 0) return null; - return value.slice(0, commaIndex).toLowerCase().includes(";base64") - ? value.slice(commaIndex + 1) - : null; -} - -function readPngDimensions(bytes: Uint8Array) { - if ( - bytes.length < 24 || - bytes[0] !== 0x89 || - bytes[1] !== 0x50 || - bytes[2] !== 0x4e || - bytes[3] !== 0x47 || - bytes[12] !== 0x49 || - bytes[13] !== 0x48 || - bytes[14] !== 0x44 || - bytes[15] !== 0x52 - ) { - return undefined; - } - return readPositiveDimensions(readUint32BE(bytes, 16), readUint32BE(bytes, 20)); -} - -function readGifDimensions(bytes: Uint8Array) { - if ( - bytes.length < 10 || - bytes[0] !== 0x47 || - bytes[1] !== 0x49 || - bytes[2] !== 0x46 || - bytes[3] !== 0x38 - ) { - return undefined; - } - return readPositiveDimensions(readUint16LE(bytes, 6), readUint16LE(bytes, 8)); -} - -function readJpegDimensions(bytes: Uint8Array) { - if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return undefined; - let offset = 2; - while (offset + 9 < bytes.length) { - if (bytes[offset] !== 0xff) { - offset += 1; - continue; - } - const marker = bytes[offset + 1]; - if (marker === undefined) break; - offset += 2; - if (marker === 0xda || marker === 0xd9) break; - if (offset + 2 > bytes.length) break; - const length = readUint16BE(bytes, offset); - if (length < 2 || offset + length > bytes.length) break; - if (isJpegStartOfFrame(marker)) { - return readPositiveDimensions( - readUint16BE(bytes, offset + 5), - readUint16BE(bytes, offset + 3), - ); - } - offset += length; - } - return undefined; -} - -function readWebpDimensions(bytes: Uint8Array) { - if ( - bytes.length < 30 || - bytes[0] !== 0x52 || - bytes[1] !== 0x49 || - bytes[2] !== 0x46 || - bytes[3] !== 0x46 || - bytes[8] !== 0x57 || - bytes[9] !== 0x45 || - bytes[10] !== 0x42 || - bytes[11] !== 0x50 - ) { - return undefined; - } - const chunk = String.fromCharCode(bytes[12]!, bytes[13]!, bytes[14]!, bytes[15]!); - if (chunk === "VP8X") { - const width = 1 + bytes[24]! + (bytes[25]! << 8) + (bytes[26]! << 16); - const height = 1 + bytes[27]! + (bytes[28]! << 8) + (bytes[29]! << 16); - return readPositiveDimensions(width, height); - } - if (chunk === "VP8L") { - const width = 1 + bytes[21]! + ((bytes[22]! & 0x3f) << 8); - const height = 1 + ((bytes[22]! & 0xc0) >> 6) + (bytes[23]! << 2) + ((bytes[24]! & 0x0f) << 10); - return readPositiveDimensions(width, height); - } - if (chunk === "VP8 " && bytes[23] === 0x9d && bytes[24] === 0x01 && bytes[25] === 0x2a) { - const width = readUint16LE(bytes, 26) & 0x3fff; - const height = readUint16LE(bytes, 28) & 0x3fff; - return readPositiveDimensions(width, height); - } - return undefined; -} - -function readSvgDimensions(value: string, classification: InlineImageClassification) { - const svg = - classification.kind === "rawSvg" - ? value - : classification.kind === "dataUrl" - ? readTextDataUrlBody(value) - : null; - if (!svg) return undefined; - const width = readSvgLength(svg, "width"); - const height = readSvgLength(svg, "height"); - if (width && height) return { width, height }; - const viewBox = /\bviewBox\s*=\s*["']\s*[-.\d]+\s+[-.\d]+\s+([.\d]+)\s+([.\d]+)/i.exec(svg); - if (!viewBox) return undefined; - return readPositiveDimensions(Number(viewBox[1]), Number(viewBox[2])); -} - -function readTextDataUrlBody(value: string): string | null { - const commaIndex = value.indexOf(","); - if (commaIndex < 0) return null; - const body = value.slice(commaIndex + 1); - if (value.slice(0, commaIndex).toLowerCase().includes(";base64")) { - try { - return atob(body); - } catch { - return null; - } - } - try { - return decodeURIComponent(body); - } catch { - return body; - } -} - -function readSvgLength(svg: string, attr: "width" | "height") { - const match = new RegExp(`\\b${attr}\\s*=\\s*["']\\s*([.\\d]+)`, "i").exec(svg); - if (!match) return undefined; - const value = Number(match[1]); - return Number.isFinite(value) && value > 0 ? value : undefined; -} - -function isJpegStartOfFrame(marker: number) { - return ( - marker === 0xc0 || - marker === 0xc1 || - marker === 0xc2 || - marker === 0xc3 || - marker === 0xc5 || - marker === 0xc6 || - marker === 0xc7 || - marker === 0xc9 || - marker === 0xca || - marker === 0xcb || - marker === 0xcd || - marker === 0xce || - marker === 0xcf - ); -} - -function readPositiveDimensions(width: number, height: number) { - return Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0 - ? { width, height } - : undefined; -} - -function readUint16BE(bytes: Uint8Array, offset: number) { - return (bytes[offset]! << 8) + bytes[offset + 1]!; -} - -function readUint16LE(bytes: Uint8Array, offset: number) { - return bytes[offset]! + (bytes[offset + 1]! << 8); -} - -function readUint32BE(bytes: Uint8Array, offset: number) { - return ( - bytes[offset]! * 0x1000000 + - ((bytes[offset + 1]! << 16) | (bytes[offset + 2]! << 8) | bytes[offset + 3]!) - ); -} - function readPromptText(payload: unknown): string | undefined { if (!payload || typeof payload !== "object") return undefined; const record = payload as Record; @@ -479,3 +299,11 @@ function buildFileName(alt: string, extension: string): string { const base = slug.length > 0 ? slug : "generated-image"; return `${base}.${extension}`; } + +/** Mirrors `inlineImagePayload`'s status check: an errored tool call shows the + * accordion, never an image card. */ +function readStatus(payload: unknown): string | undefined { + if (!payload || typeof payload !== "object") return undefined; + const status = (payload as Record).status; + return typeof status === "string" ? status : undefined; +} diff --git a/src/renderer/components/thread/ThreadChangesBubble.test.tsx b/src/renderer/components/thread/ThreadChangesBubble.test.tsx new file mode 100644 index 000000000..e02664938 --- /dev/null +++ b/src/renderer/components/thread/ThreadChangesBubble.test.tsx @@ -0,0 +1,108 @@ +import { fireEvent, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { GitStatusResult } from "@/shared/contracts"; +import { useGitStore } from "@/renderer/state/gitStore"; +import { usePanelStore } from "@/renderer/state/panelStore"; +import { renderWithI18n as render } from "@/renderer/testUtils/i18n"; +import { ThreadChangesBubble } from "./ThreadChangesBubble"; + +vi.mock("@heroui/react", () => { + const Tooltip = Object.assign((props: { children: ReactNode }) => <>{props.children}, { + Trigger: (props: { children: ReactNode }) => <>{props.children}, + Content: (props: { children: ReactNode }) =>
{props.children}
, + }); + return { Tooltip }; +}); + +function makeStatus(overrides: Partial = {}): GitStatusResult { + return { + isRepo: true, + branch: "poracode/fix-pwa-worktree-setup", + tracking: "origin/poracode/fix-pwa-worktree-setup", + hasRemote: true, + remoteInfo: null, + ahead: 0, + behind: 0, + staged: [], + unstaged: [], + totalInsertions: 0, + totalDeletions: 0, + ...overrides, + }; +} + +describe("ThreadChangesBubble", () => { + beforeEach(() => { + useGitStore.setState({ + statuses: {}, + worktreeStatuses: {}, + }); + usePanelStore.setState({ + gitReviewContext: null, + gitReviewAsPanel: false, + gitOverlayOpen: false, + rightPanelTab: "git", + }); + }); + + it("keeps a clean worktree visible as an icon-only glass control with its name in a tooltip", () => { + const worktreePath = "/repo/.poracode/worktrees/poracode-fix-pwa-worktree-setup"; + useGitStore.setState({ + worktreeStatuses: { + [worktreePath]: makeStatus(), + }, + }); + + render( + , + ); + + const bubble = screen.getByRole("button", { name: "Review changes" }); + + expect(bubble).toHaveClass("poracode-floating-chrome", "w-7"); + expect(bubble.querySelector(".lucide-git-fork")).not.toBeNull(); + expect(screen.getByRole("tooltip")).toHaveTextContent("poracode/fix-pwa-worktree-setup"); + }); + + it("shows worktree changes beside the icon and opens Git review for that worktree", () => { + const worktreePath = "C:\\repo-worktrees\\calm-viper"; + useGitStore.setState({ + worktreeStatuses: { + [worktreePath]: makeStatus({ totalInsertions: 42, totalDeletions: 7 }), + }, + }); + + render(); + + const bubble = screen.getByRole("button", { name: "Review changes" }); + + expect(bubble).toHaveTextContent("+42"); + expect(bubble).toHaveTextContent("-7"); + expect(screen.getByRole("tooltip")).toHaveTextContent("calm-viper"); + + fireEvent.click(bubble); + + expect(usePanelStore.getState().gitReviewContext).toEqual({ + projectId: "project-1", + worktreePath, + }); + expect(usePanelStore.getState().gitReviewAsPanel).toBe(true); + }); + + it("stays hidden for a clean root project", () => { + useGitStore.setState({ + statuses: { + "project-1": makeStatus(), + }, + }); + + render(); + + expect(screen.queryByRole("button", { name: "Review changes" })).not.toBeInTheDocument(); + }); +}); diff --git a/src/renderer/components/thread/ThreadChangesBubble.tsx b/src/renderer/components/thread/ThreadChangesBubble.tsx index 694058ab7..f2062c734 100644 --- a/src/renderer/components/thread/ThreadChangesBubble.tsx +++ b/src/renderer/components/thread/ThreadChangesBubble.tsx @@ -1,23 +1,26 @@ import { useShallow } from "zustand/shallow"; +import { Tooltip } from "@heroui/react"; +import { GitFork } from "lucide-react"; import { useLingui } from "@lingui/react/macro"; +import { getBasename } from "@/shared/pathUtils"; import { closeAllPanels, showGitReviewPanel } from "@/renderer/actions/panelActions"; import { - floatingChromeActiveClass, - floatingChromeSurfaceClass, -} from "@/renderer/components/layout/sidebarChrome"; + floatingGlassActiveClass, + floatingGlassSurfaceClass, +} from "@/renderer/components/layout/floatingGlass"; import { useGitStore } from "@/renderer/state/gitStore"; import { usePanelStore } from "@/renderer/state/panelStore"; /** - * Translucent working-tree diff stat that floats over the top-right corner of - * the composer. Renders nothing when the thread's scope has no changes, and - * toggles the docked Git review panel for that scope on click — the same - * open/close behaviour as the thread tool rail's Git button, so the two entry - * points to the panel stay in lockstep. + * Translucent Git/worktree identity that floats over the top-right corner of + * the composer. Worktrees remain visible when clean as an icon-only control; + * root project scopes render only when they have changes. Clicking toggles the + * docked Git review panel for the same scope. */ export function ThreadChangesBubble(props: { projectId: string; worktreePath?: string | undefined; + worktreeName?: string | undefined; }) { const { t } = useLingui(); const { insertions, deletions } = useGitStore( @@ -40,19 +43,23 @@ export function ThreadChangesBubble(props: { s.gitReviewContext?.worktreePath === props.worktreePath, ); - if (insertions === 0 && deletions === 0) return null; + const hasChanges = insertions > 0 || deletions > 0; + const worktreeName = + props.worktreeName ?? (props.worktreePath ? getBasename(props.worktreePath) : undefined); - return ( + if (!hasChanges && !props.worktreePath) return null; + + const bubble = ( ); + + if (!worktreeName) return bubble; + + return ( + + {bubble} + {worktreeName} + + ); } diff --git a/src/renderer/components/thread/ThreadComposerSection.tsx b/src/renderer/components/thread/ThreadComposerSection.tsx index 7205308dc..4baf2b197 100644 --- a/src/renderer/components/thread/ThreadComposerSection.tsx +++ b/src/renderer/components/thread/ThreadComposerSection.tsx @@ -7,8 +7,8 @@ import { type ReactNode, type RefObject, } from "react"; -import { Tooltip, toast } from "@heroui/react"; -import { ChevronDown, GitFork, Monitor } from "lucide-react"; +import { toast } from "@heroui/react"; +import { ChevronDown, Monitor } from "lucide-react"; import { useLingui } from "@lingui/react/macro"; import type { AgentStatus, ProjectLocation, PromptSegment, Thread } from "@/shared/contracts"; import { friendlyError } from "@/shared/messages"; @@ -16,7 +16,6 @@ import { changeThreadConfig, clearThreadPendingSteer, } from "@/renderer/actions/threadRuntimeActions"; -import { BranchSelector, type BranchSelection } from "../common/BranchSelector/BranchSelector"; import { modelVisibilityKey } from "@/renderer/components/common/ProviderModelMenu/parts/providerIdentity"; import { AttachmentBar } from "../composer/AttachmentBar"; import { ComposerAddMenu } from "../composer/ComposerAddMenu"; @@ -430,38 +429,6 @@ function ThreadComposerSectionInner(props: ThreadComposerSectionProps & { thread }); } - function handleSwitchBranch(branch: string, createNew: boolean) { - readBridge() - .gitSwitchBranch({ - projectLocation, - branch, - createNew, - }) - .then((result) => { - const store = useGitStore.getState(); - const status = store.statuses[thread.projectId]; - if (status) { - store.setStatus(thread.projectId, { - ...status, - branch: result.branch, - tracking: result.tracking, - ahead: result.ahead, - behind: result.behind, - }); - } - }) - .catch((err: unknown) => { - console.error("[git] switch branch failed", err); - toast.danger(friendlyError(err)); - }); - } - - function handleBranchSelect(selection: BranchSelection) { - if (!selection.isWorktree && selection.branch !== branchName) { - handleSwitchBranch(selection.branch, false); - } - } - function submitPrompt(segments: PromptSegment[]) { const composerSession = composerSessionRef.current; submitComposerPrompt(segments, { @@ -616,6 +583,7 @@ function ThreadComposerSectionInner(props: ThreadComposerSectionProps & { thread
toast.danger(friendlyError(error))); }} /> - {branchName ? ( - // Marker span so the mobile stylesheet can drop the - // branch affordance (the PWA has its own git entry). - - {thread.worktreePath ? ( - - -
- - - {branchName} - - {thread.prNumber ? ( - - PR #{thread.prNumber} - - ) : null} -
-
- {branchName} -
- ) : ( - - )} -
- ) : null} ); const renderVoiceInput = () => ( diff --git a/src/renderer/components/thread/ThreadToolRail.tsx b/src/renderer/components/thread/ThreadToolRail.tsx index f82ac07ad..1523c749c 100644 --- a/src/renderer/components/thread/ThreadToolRail.tsx +++ b/src/renderer/components/thread/ThreadToolRail.tsx @@ -15,7 +15,7 @@ import { useDevTerminalStore } from "@/renderer/state/devTerminalStore"; import { usePanelStore } from "@/renderer/state/panelStore"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { usePanelVisibility } from "@/renderer/views/MainView/parts/AppShell/parts/usePanelVisibility"; -import { floatingChromeSurfaceClass } from "@/renderer/components/layout/sidebarChrome"; +import { floatingGlassSurfaceClass } from "@/renderer/components/layout/floatingGlass"; import { useThreadToolRailDrag } from "./useThreadToolRailDrag"; const railPillClass = "flex flex-col items-center gap-0.5 rounded-full p-1"; @@ -231,7 +231,7 @@ export function ThreadToolRail(props: { ref={pillRef} data-poracode-thread-tool-rail="" data-placement="side" - className={`${floatingChromeSurfaceClass} ${railPillClass} ${ + className={`${floatingGlassSurfaceClass} ${railPillClass} ${ isDragging ? "cursor-grabbing" : "cursor-default" } touch-none select-none`} {...dragHandlers} @@ -277,7 +277,7 @@ export function ThreadToolRail(props: { }`} >
-
{toolButtons}
+
{toolButtons}
diff --git a/src/renderer/state/gitReadModelLegacyProjection.test.ts b/src/renderer/state/gitReadModelLegacyProjection.test.ts new file mode 100644 index 000000000..934fb471a --- /dev/null +++ b/src/renderer/state/gitReadModelLegacyProjection.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + emptyGitStateSnapshot, + gitTargetKey, + pullRequestKey, + type GitStateSnapshot, +} from "@/shared/gitState"; +import { resetGitStoreCache, useGitStore } from "./gitStore"; +import { projectGitReadModelIntoLegacyStore } from "./gitReadModelLegacyProjection"; + +describe("projectGitReadModelIntoLegacyStore", () => { + beforeEach(() => resetGitStoreCache()); + + it("projects a canonical PR update into the existing worktree panel cache", () => { + const targetRef = { + hostId: "desktop-1", + projectId: "project-1", + worktreePath: "/repo/worktree", + }; + const prRef = { hostId: "desktop-1", projectId: "project-1", prNumber: 42 }; + const prKey = pullRequestKey(prRef); + const snapshot: GitStateSnapshot = { + ...emptyGitStateSnapshot(), + revision: 3, + targets: { + [gitTargetKey(targetRef)]: { + ref: targetRef, + pullRequestKey: prKey, + refreshedAt: "2026-07-28T00:00:00.000Z", + }, + }, + pullRequests: { + [prKey]: { + ref: prRef, + data: { + number: 42, + state: "open", + title: "Fresh title", + url: "https://github.test/pr/42", + baseBranch: "main", + isDraft: false, + checksStatus: "SUCCESS", + updatedAt: "2026-07-28T00:00:00.000Z", + }, + details: { + number: 42, + title: "Fresh title", + body: "", + baseBranch: "main", + headBranch: "feature", + additions: 1, + deletions: 0, + changedFiles: 1, + commits: [], + comments: [], + reviews: [], + checks: [{ name: "build", state: "COMPLETED", conclusion: "SUCCESS" }], + }, + freshness: { + core: "2026-07-28T00:00:00.000Z", + details: "2026-07-28T00:00:00.000Z", + }, + }, + }, + }; + + projectGitReadModelIntoLegacyStore(snapshot); + + expect(useGitStore.getState().prData["/repo/worktree"]).toMatchObject({ + title: "Fresh title", + checksStatus: "SUCCESS", + }); + expect(useGitStore.getState().prDetails["project-1#42"]?.checks).toEqual([ + { name: "build", state: "COMPLETED", conclusion: "SUCCESS" }, + ]); + }); +}); diff --git a/src/renderer/state/gitReadModelLegacyProjection.ts b/src/renderer/state/gitReadModelLegacyProjection.ts new file mode 100644 index 000000000..45c4fca17 --- /dev/null +++ b/src/renderer/state/gitReadModelLegacyProjection.ts @@ -0,0 +1,40 @@ +import type { GitStateSnapshot } from "@/shared/gitState"; +import { useGitStore } from "./gitStore"; + +/** + * Transitional adapter for existing Git surfaces while they migrate to + * gitReadModel selectors. The host snapshot is authoritative; this is a + * one-way projection and never feeds legacy cache state back to the host. + */ +export function projectGitReadModelIntoLegacyStore(snapshot: GitStateSnapshot): void { + const store = useGitStore.getState(); + + for (const project of Object.values(snapshot.projects)) { + store.setProjectSnapshot(project.ref.projectId, { + ...(project.status ? { status: project.status } : {}), + ...(project.branches ? { branches: project.branches } : {}), + ...(project.worktrees ? { worktrees: [...project.worktrees] } : {}), + ...(project.ghAvailable !== undefined ? { ghAvailable: project.ghAvailable } : {}), + }); + } + + for (const target of Object.values(snapshot.targets)) { + if (target.status) { + if (target.ref.worktreePath) { + store.setWorktreeStatus(target.ref.worktreePath, target.status); + } else { + store.setStatus(target.ref.projectId, target.status); + } + } + const pr = target.pullRequestKey ? snapshot.pullRequests[target.pullRequestKey] : undefined; + const legacyPrKey = target.ref.worktreePath ?? target.ref.projectId; + if (target.pullRequestKey === null) { + store.setPrData(legacyPrKey, null); + } else if (pr) { + store.setPrData(legacyPrKey, pr.data); + if (pr.details) { + store.setPrDetails(`${target.ref.projectId}#${pr.ref.prNumber}`, pr.details); + } + } + } +} diff --git a/src/renderer/state/gitReadModelSelectors.ts b/src/renderer/state/gitReadModelSelectors.ts new file mode 100644 index 000000000..b6da6aa91 --- /dev/null +++ b/src/renderer/state/gitReadModelSelectors.ts @@ -0,0 +1,44 @@ +import { + gitProjectKey, + gitTargetKey, + pullRequestBranchKey, + pullRequestKey, + type GitProjectRef, + type GitTargetRef, + type PullRequestRef, +} from "@/shared/gitState"; +import { useGitReadModelStore } from "./gitReadModelStore"; + +export function useGitProjectState(ref: GitProjectRef | null | undefined) { + const key = ref ? gitProjectKey(ref) : undefined; + return useGitReadModelStore((state) => (key ? state.projects[key] : undefined)); +} + +export function useGitTargetState(ref: GitTargetRef | null | undefined) { + const key = ref ? gitTargetKey(ref) : undefined; + return useGitReadModelStore((state) => (key ? state.targets[key] : undefined)); +} + +export function usePullRequestState(ref: PullRequestRef | null | undefined) { + const key = ref ? pullRequestKey(ref) : undefined; + return useGitReadModelStore((state) => (key ? state.pullRequests[key] : undefined)); +} + +export function usePullRequestForTarget(ref: GitTargetRef | null | undefined) { + const targetKey = ref ? gitTargetKey(ref) : undefined; + return useGitReadModelStore((state) => { + const prKey = targetKey ? state.targets[targetKey]?.pullRequestKey : undefined; + return prKey ? state.pullRequests[prKey] : undefined; + }); +} + +export function usePullRequestForBranch( + ref: GitProjectRef | null | undefined, + branch: string | null | undefined, +) { + const branchKey = ref && branch ? pullRequestBranchKey(ref, branch) : undefined; + return useGitReadModelStore((state) => { + const prKey = branchKey ? state.pullRequestKeyByBranch[branchKey] : undefined; + return prKey ? state.pullRequests[prKey] : undefined; + }); +} diff --git a/src/renderer/state/gitReadModelStore.test.ts b/src/renderer/state/gitReadModelStore.test.ts new file mode 100644 index 000000000..12f2f9e6a --- /dev/null +++ b/src/renderer/state/gitReadModelStore.test.ts @@ -0,0 +1,120 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + emptyGitStateSnapshot, + gitTargetKey, + pullRequestKey, + type GitTargetState, + type PullRequestState, +} from "@/shared/gitState"; +import { usePullRequestForTarget } from "./gitReadModelSelectors"; +import { useGitReadModelStore } from "./gitReadModelStore"; + +const targetRef = { + hostId: "host-a", + projectId: "project-a", + worktreePath: "/repo/worktree", +}; +const prRef = { hostId: "host-a", projectId: "project-a", prNumber: 42 }; +const targetKey = gitTargetKey(targetRef); +const prKey = pullRequestKey(prRef); + +function makePr(title: string): PullRequestState { + return { + ref: prRef, + data: { + number: 42, + state: "open", + title, + url: "https://example.test/pull/42", + baseBranch: "main", + isDraft: false, + updatedAt: "2026-07-28T12:00:00.000Z", + }, + freshness: { core: "2026-07-28T12:00:00.000Z" }, + }; +} + +describe("gitReadModelStore", () => { + beforeEach(() => { + useGitReadModelStore.getState().reset(); + }); + + it("updates every target consumer when the canonical PR entity changes", () => { + const target: GitTargetState = { + ref: targetRef, + pullRequestKey: prKey, + refreshedAt: "2026-07-28T12:00:00.000Z", + }; + act(() => { + useGitReadModelStore.getState().applyPatch({ + revision: 1, + targets: { [targetKey]: target }, + pullRequests: { [prKey]: makePr("Old title") }, + }); + }); + const view = renderHook(() => usePullRequestForTarget(targetRef)); + expect(view.result.current?.data.title).toBe("Old title"); + + act(() => { + useGitReadModelStore.getState().applyPatch({ + revision: 2, + pullRequests: { [prKey]: makePr("New title") }, + }); + }); + + expect(view.result.current?.data.title).toBe("New title"); + }); + + it("distinguishes a modern host's empty revision-zero snapshot from no host model", () => { + expect(useGitReadModelStore.getState().hostAvailable).toBe(false); + + act(() => { + useGitReadModelStore.getState().replaceSnapshot(emptyGitStateSnapshot()); + }); + + expect(useGitReadModelStore.getState().hostAvailable).toBe(true); + + act(() => { + useGitReadModelStore.getState().reset(); + }); + + expect(useGitReadModelStore.getState().hostAvailable).toBe(false); + }); + + it("isolates equal project and worktree identifiers across hosts", () => { + const otherRef = { ...targetRef, hostId: "host-b" }; + const otherKey = gitTargetKey(otherRef); + const otherPrRef = { ...prRef, hostId: "host-b" }; + const otherPrKey = pullRequestKey(otherPrRef); + act(() => { + useGitReadModelStore.getState().applyPatch({ + revision: 1, + targets: { + [targetKey]: { + ref: targetRef, + pullRequestKey: prKey, + refreshedAt: "2026-07-28T12:00:00.000Z", + }, + [otherKey]: { + ref: otherRef, + pullRequestKey: otherPrKey, + refreshedAt: "2026-07-28T12:00:00.000Z", + }, + }, + pullRequests: { + [prKey]: makePr("Host A"), + [otherPrKey]: { + ...makePr("Host B"), + ref: otherPrRef, + }, + }, + }); + }); + + const hostA = renderHook(() => usePullRequestForTarget(targetRef)); + const hostB = renderHook(() => usePullRequestForTarget(otherRef)); + expect(hostA.result.current?.data.title).toBe("Host A"); + expect(hostB.result.current?.data.title).toBe("Host B"); + }); +}); diff --git a/src/renderer/state/gitReadModelStore.ts b/src/renderer/state/gitReadModelStore.ts new file mode 100644 index 000000000..63c5a1c74 --- /dev/null +++ b/src/renderer/state/gitReadModelStore.ts @@ -0,0 +1,35 @@ +import { create } from "zustand"; +import { + applyGitStatePatch, + emptyGitStateSnapshot, + type GitStatePatch, + type GitStateSnapshot, +} from "@/shared/gitState"; + +interface GitReadModelActions { + replaceSnapshot(snapshot: GitStateSnapshot): void; + applyPatch(patch: GitStatePatch): void; + reset(): void; +} + +interface GitReadModelRuntimeState { + /** Distinguishes a modern host's valid revision-0 snapshot from no host model. */ + readonly hostAvailable: boolean; +} + +export const useGitReadModelStore = create< + GitStateSnapshot & GitReadModelRuntimeState & GitReadModelActions +>()((set) => ({ + ...emptyGitStateSnapshot(), + hostAvailable: false, + replaceSnapshot: (snapshot) => + set((current) => + snapshot.revision < current.revision ? current : { ...snapshot, hostAvailable: true }, + ), + applyPatch: (patch) => + set((current) => ({ + ...applyGitStatePatch(current, patch), + hostAvailable: true, + })), + reset: () => set({ ...emptyGitStateSnapshot(), hostAvailable: false }), +})); diff --git a/src/renderer/state/gitRefresh.ts b/src/renderer/state/gitRefresh.ts index 5c01b9099..2b107be3e 100644 --- a/src/renderer/state/gitRefresh.ts +++ b/src/renderer/state/gitRefresh.ts @@ -420,9 +420,10 @@ export async function refreshSinglePr(params: { projectLocation: ProjectLocation; prKey: string; branch: string; + projectId?: string; detailsCacheKey?: string; prNumber?: number; -}): Promise { +}): Promise { const bridge = readBridge(); const prPromise = bridge .ghGetPrForBranch({ projectLocation: params.projectLocation, branch: params.branch }) @@ -440,6 +441,17 @@ export async function refreshSinglePr(params: { if (params.detailsCacheKey && details) { useGitStore.getState().setPrDetails(params.detailsCacheKey, details.details); } + if (!params.detailsCacheKey && params.projectId && pr && pr.number !== params.prNumber) { + const discoveredDetails = await bridge + .ghGetPrDetails({ projectLocation: params.projectLocation, prNumber: pr.number }) + .catch(() => undefined); + if (discoveredDetails) { + useGitStore + .getState() + .setPrDetails(`${params.projectId}#${pr.number}`, discoveredDetails.details); + } + } + return pr; } async function refreshPendingPr(key: string): Promise { diff --git a/src/renderer/state/remote/sync.ts b/src/renderer/state/remote/sync.ts index d060adb46..ae4fa8a4d 100644 --- a/src/renderer/state/remote/sync.ts +++ b/src/renderer/state/remote/sync.ts @@ -1,7 +1,8 @@ import { isThreadTurnActive, type RuntimeEvent, type Thread } from "@/shared/contracts"; import type { SupervisorEvent } from "@/shared/ipc"; +import type { GitStatePatch } from "@/shared/gitState"; import type { RemoteGitSummaries, RemoteThreadSnapshot } from "@/shared/remote"; -import { remoteGitSummariesEventSchema } from "@/shared/remote"; +import { remoteGitStateEventSchema, remoteGitSummariesEventSchema } from "@/shared/remote"; import { useAppStore } from "@/renderer/state/appStore"; import { useAgentStatusesStore } from "@/renderer/state/agentStatusesStore"; import { handleThreadStateNotification } from "@/renderer/notifications"; @@ -429,6 +430,8 @@ export interface RemoteDispatchHooks { * these events out before dispatch and so supplies no hook. */ readonly onGitSummaries?: (summaries: RemoteGitSummaries) => void; + /** Applies the host-owned normalized Git/PR read model on remote clients. */ + readonly onGitState?: (patch: GitStatePatch) => void; } export function dispatchRemoteSupervisorEvent(value: unknown, hooks?: RemoteDispatchHooks): void { @@ -449,6 +452,11 @@ export function dispatchRemoteSupervisorEvent(value: unknown, hooks?: RemoteDisp hooks?.onGitSummaries?.(gitSummaries.data.summaries); return; } + const gitState = remoteGitStateEventSchema.safeParse(value); + if (gitState.success) { + hooks?.onGitState?.(gitState.data.patch); + return; + } const event = asSupervisorEvent(value); if (!event) return; diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 24d5c050d..6415d32c3 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -976,6 +976,13 @@ a set, tinted like the sidebar (side chrome) rather than the pane beneath it. Derived, so it follows --sidebar-background in every theme. */ --floating-chrome-surface: color-mix(in oklab, var(--sidebar-background) 82%, transparent); + /* Selected floating chrome stays dark: make the glass denser with the + sidebar tone instead of brightening it with the foreground color. */ + --floating-chrome-active-surface: color-mix( + in oklab, + var(--floating-chrome-surface) 84%, + var(--sidebar-background) 16% + ); --window-header-background: var(--sidebar-background); --window-overlay-background: rgba(0, 0, 0, 0); --composer-surface: var(--surface); @@ -1454,35 +1461,46 @@ html[data-platform="win32"][data-native-material="on"][data-theme="dark"] { --sidebar-glass-tint: color-mix(in oklab, var(--content-background) 85%, transparent); } -/* Windows frosting for the *floating chrome* (thread tool rail, changes bubble, +/* Platform frosting for the *floating chrome* (thread tool rail, changes bubble, chat + terminal scroll-to-bottom). These pills sit over the opaque - conversation pane, not over the OS material, so their glass is entirely ours - to author — and on Windows, where there is no vibrancy softening them, the - shared 82% tint reads as a flat plate stamped on the chat. - - Thinning the tint alone does not fix it: --sidebar-background and - --content-background are close in value (dark: #0e0e14 over #070709), so the - pill's *fill* composites to nearly the same color either way. What sells the - glass is the backdrop — hence a `brightness()` lift alongside the blur, so - the sampled conversation reads as lit frosting rather than a faded patch. - Dark leans on brightness (it has room to lift); light instead settles the - backdrop slightly, where a lift would blow out to white. - - macOS keeps the base token — vibrancy already carries this. Unlayered so both - the token and `backdrop-filter` beat Tailwind's utilities, but deliberately - *not* `box-shadow`, which carries the pill's `shadow-lg`. */ + conversation pane, not over the OS window material, so their glass is + entirely ours to author. + + The dark treatment stays deliberately low-alpha with a short blur: underlying + transcript shapes remain perceptible instead of being homogenized into a + bright frosted patch. A faint edge and shallow shadow separate the control + without the specular gradients or inset highlights of a raised glossy button. + Windows remains slightly denser because Chromium's blur is visually weaker. */ +html[data-platform="darwin"] { + --floating-chrome-surface: color-mix(in oklab, var(--sidebar-background) 38%, transparent); + --floating-chrome-backdrop: blur(12px) saturate(140%); +} +html[data-platform="darwin"].dark, +html[data-platform="darwin"][data-theme="dark"] { + --floating-chrome-surface: color-mix(in oklab, var(--sidebar-background) 24%, transparent); + --floating-chrome-backdrop: blur(10px) saturate(135%); +} html[data-platform="win32"] { - --floating-chrome-surface: color-mix(in oklab, var(--sidebar-background) 55%, transparent); - --floating-chrome-backdrop: blur(20px) saturate(150%) brightness(0.97); + --floating-chrome-surface: color-mix(in oklab, var(--sidebar-background) 44%, transparent); + --floating-chrome-backdrop: blur(12px) saturate(135%); } html[data-platform="win32"].dark, html[data-platform="win32"][data-theme="dark"] { - --floating-chrome-surface: color-mix(in oklab, var(--sidebar-background) 45%, transparent); - --floating-chrome-backdrop: blur(20px) saturate(160%) brightness(1.35); + --floating-chrome-surface: color-mix(in oklab, var(--sidebar-background) 30%, transparent); + --floating-chrome-backdrop: blur(10px) saturate(130%); } -html[data-platform="win32"] .poracode-floating-chrome { +html:is([data-platform="darwin"], [data-platform="win32"]) .poracode-floating-chrome { + border-color: color-mix(in oklab, var(--foreground) 8%, transparent); + background-image: none; backdrop-filter: var(--floating-chrome-backdrop); -webkit-backdrop-filter: var(--floating-chrome-backdrop); + box-shadow: 0 2px 10px rgb(0 0 0 / 0.18); +} +.poracode-floating-chrome--active { + background-color: var(--floating-chrome-active-surface); +} +html:is([data-platform="darwin"], [data-platform="win32"]) .poracode-floating-chrome--active { + border-color: color-mix(in oklab, var(--foreground) 12%, transparent); } /* 2. In-app fallback: opaque window, faux-translucent gradient sidebar. */ @@ -1554,13 +1572,12 @@ html[data-native-material="on"] backdrop-filter: none; box-shadow: none; } - /* The Windows floating chrome leans hard on backdrop blur (see the win32 block - above), so it needs its own opt-out: paint the pills solid and drop the - filter so the diff counts and rail icons keep full contrast. Set on the - element rather than the token — the dark-theme token override carries more - specificity than a bare html[data-platform] rule would here. */ - html[data-platform="win32"] .poracode-floating-chrome { + /* Floating chrome leans hard on backdrop blur, so paint the pills solid when + reduced transparency is requested. Set this on the element rather than the + token so it wins over the platform-specific dark-theme overrides above. */ + .poracode-floating-chrome { background: var(--sidebar-background); + background-image: none; backdrop-filter: none; -webkit-backdrop-filter: none; } @@ -2437,10 +2454,6 @@ html[data-app-unfocused] .poracode-provider-icon--finished { white-space: nowrap; } -.poracode-composer-worktree { - font-size: 0.75rem; -} - .poracode-context-indicator { display: inline-flex; height: 2.25rem; diff --git a/src/renderer/theme/baseControlStyles.test.ts b/src/renderer/theme/baseControlStyles.test.ts index 119e1792b..92611ac0d 100644 --- a/src/renderer/theme/baseControlStyles.test.ts +++ b/src/renderer/theme/baseControlStyles.test.ts @@ -17,4 +17,31 @@ describe("base control styles", () => { expect(ruleFor(selector)).toContain("--button-fg: var(--foreground)"); } }); + + it("uses dark, low-alpha liquid glass for macOS floating chrome", () => { + expect(styles).toMatch( + /html\[data-platform="darwin"\]\s*\{[^}]*--floating-chrome-surface:\s*color-mix\(in oklab, var\(--sidebar-background\) 38%, transparent\);[^}]*--floating-chrome-backdrop:\s*blur\(12px\) saturate\(140%\);/s, + ); + expect(styles).toMatch( + /html\[data-platform="darwin"\]\.dark,[^{]*html\[data-platform="darwin"\]\[data-theme="dark"\]\s*\{[^}]*--floating-chrome-surface:\s*color-mix\(in oklab, var\(--sidebar-background\) 24%, transparent\);[^}]*--floating-chrome-backdrop:\s*blur\(10px\) saturate\(135%\);/s, + ); + expect(styles).toMatch( + /html:is\(\[data-platform="darwin"\], \[data-platform="win32"\]\) \.poracode-floating-chrome\s*\{[^}]*border-color:\s*color-mix\(in oklab, var\(--foreground\) 8%, transparent\);[^}]*background-image:\s*none;[^}]*backdrop-filter:\s*var\(--floating-chrome-backdrop\);[^}]*box-shadow:\s*0 2px 10px rgb\(0 0 0 \/ 0\.18\);/s, + ); + expect(styles).toMatch( + /--floating-chrome-active-surface:\s*color-mix\(\s*in oklab,\s*var\(--floating-chrome-surface\) 84%,\s*var\(--sidebar-background\) 16%\s*\);/s, + ); + expect(styles).toMatch( + /\.poracode-floating-chrome--active\s*\{\s*background-color:\s*var\(--floating-chrome-active-surface\);/s, + ); + }); + + it("uses slightly denser dark liquid glass for Windows floating chrome", () => { + expect(styles).toMatch( + /html\[data-platform="win32"\]\s*\{[^}]*--floating-chrome-surface:\s*color-mix\(in oklab, var\(--sidebar-background\) 44%, transparent\);[^}]*--floating-chrome-backdrop:\s*blur\(12px\) saturate\(135%\);/s, + ); + expect(styles).toMatch( + /html\[data-platform="win32"\]\.dark,[^{]*html\[data-platform="win32"\]\[data-theme="dark"\]\s*\{[^}]*--floating-chrome-surface:\s*color-mix\(in oklab, var\(--sidebar-background\) 30%, transparent\);[^}]*--floating-chrome-backdrop:\s*blur\(10px\) saturate\(130%\);/s, + ); + }); }); diff --git a/src/server/createHeadlessRemoteHost.ts b/src/server/createHeadlessRemoteHost.ts index 3505c0300..aa0af96cc 100644 --- a/src/server/createHeadlessRemoteHost.ts +++ b/src/server/createHeadlessRemoteHost.ts @@ -53,6 +53,7 @@ import { createAppControlsSupervisorCaller, } from "@/main/app-controls"; import { createDevicePrWatchService, type PrWatchService } from "@/main/prWatch"; +import { createGitStateExecutor, GitStateService } from "@/main/gitState"; import { startRelayHost, type RelayHostHandle } from "./relay/relayHost"; /** @@ -181,6 +182,7 @@ export async function createHeadlessRemoteHost( let serverRef: RemoteAccessServer | null = null; let appControlsMcpIngress: AppControlsMcpIngress | null = null; let prWatchService: PrWatchService | null = null; + let gitStateService: GitStateService | null = null; // Assigned right after the supervisor client below; the `onEvent` tap only // fires once the supervisor is started, by which point it is set. let scheduleRunCoordinator: ScheduleRunCoordinator | null = null; @@ -205,6 +207,7 @@ export async function createHeadlessRemoteHost( options.onSupervisorEvent?.(event); appControlsMcpIngress?.observeSupervisorEvent(event); prWatchService?.observeSupervisorEvent(event); + gitStateService?.observeSupervisorEvent(event); scheduleRunCoordinator?.observeSupervisorEvent(event); serverRef?.publishSupervisorEvent(event); pushCoordinator.handleSupervisorEvent(event); @@ -280,6 +283,14 @@ export async function createHeadlessRemoteHost( }, worktreeExists: existsSync, }); + gitStateService = new GitStateService({ + hostId: identity.desktopId, + executor: createGitStateExecutor((name, payload) => supervisorClient.call(name, payload)), + getProject: dbGetProject, + onPatch: (patch) => { + serverRef?.publishSupervisorEvent({ type: "remote-git-state", patch }); + }, + }); appControlsMcpIngress = new AppControlsMcpIngress({ scheduleService, getThread: dbGetThread, @@ -343,6 +354,11 @@ export async function createHeadlessRemoteHost( identity, isDev, authStore, + onOversizedEventDropped: ({ type, bytes }) => { + console.warn( + `[remote] ${type} event of ${bytes} bytes exceeded the live stream budget; clients asked to resync`, + ); + }, host, port, advertisedHost, @@ -358,6 +374,7 @@ export async function createHeadlessRemoteHost( // so pass it directly instead of re-wrapping each method. schedules: scheduleService, prWatches: prWatchService, + gitState: gitStateService, pushRegistrations: { webPublicKey: createWebPushPublicKeyResolver(pushGatewayOptions), upsert: (registration) => pushStore.upsert(registration), @@ -378,6 +395,7 @@ export async function createHeadlessRemoteHost( supervisorClient.start(paths.baseDir); scheduleService.start(); prWatchService?.start(); + gitStateService?.start(); started = true; } const info = await server.start(); @@ -411,6 +429,8 @@ export async function createHeadlessRemoteHost( scheduleService.dispose(); prWatchService?.dispose(); prWatchService = null; + gitStateService?.dispose(); + gitStateService = null; appControlsMcpIngress?.dispose(); appControlsMcpIngress = null; portForwarding.dispose(); diff --git a/src/server/relay/relayHost.test.ts b/src/server/relay/relayHost.test.ts index 1c20241c2..46ef0b03a 100644 --- a/src/server/relay/relayHost.test.ts +++ b/src/server/relay/relayHost.test.ts @@ -420,6 +420,82 @@ describe("startRelayHost", () => { handle.dispose(); }); + it("never forwards a content-encoding for a body fetch already decoded", async () => { + const control = fakeSocket(); + const handle = startRelayHost({ + relayUrl: "ws://relay.test/host", + serverId: "srv-1", + secret: "secret", + localHttpUrl: "http://127.0.0.1:38987", + socketFactory: () => control, + // `fetch` transparently inflates a gzip response but leaves the header in + // place. Echoing it would label these plaintext bytes as gzip and the + // visitor would fail to decode them. + fetchImpl: async () => + new Response('{"ok":true}', { + headers: { + "content-type": "application/json", + "content-encoding": "gzip", + "content-length": "999", + etag: '"abc"', + }, + }), + }); + + control.onopen?.(); + control.onmessage?.( + frame({ t: "req", id: "req-1", method: "GET", path: "/api/snapshot", headers: {} }), + ); + + await vi.waitFor(() => { + const response = control.sent + .map((data) => JSON.parse(data) as { t: string; headers?: Record }) + .find((message) => message.t === "res"); + expect(response).toBeDefined(); + expect(response!.headers).not.toHaveProperty("content-encoding"); + // Stale once the body is re-encoded for the tunnel. + expect(response!.headers).not.toHaveProperty("content-length"); + // Validators must still survive so conditional GETs keep working. + expect(response!.headers).toMatchObject({ etag: '"abc"' }); + }); + handle.dispose(); + }); + + it("does not ask the local server to compress a loopback response", async () => { + const control = fakeSocket(); + let forwarded: Record | undefined; + const handle = startRelayHost({ + relayUrl: "ws://relay.test/host", + serverId: "srv-1", + secret: "secret", + localHttpUrl: "http://127.0.0.1:38987", + socketFactory: () => control, + fetchImpl: async (_url, init) => { + forwarded = init?.headers as Record; + return new Response('{"ok":true}'); + }, + }); + + control.onopen?.(); + control.onmessage?.( + frame({ + t: "req", + id: "req-1", + method: "GET", + path: "/api/snapshot", + headers: { "accept-encoding": "gzip, br", "if-none-match": '"abc"' }, + }), + ); + + await vi.waitFor(() => { + expect(forwarded).toBeDefined(); + expect(forwarded).not.toHaveProperty("accept-encoding"); + // Conditional-request headers must still reach the origin. + expect(forwarded).toMatchObject({ "if-none-match": '"abc"' }); + }); + handle.dispose(); + }); + it("rejects local HTTP responses that exceed the relay body limit", async () => { const control = fakeSocket(); const handle = startRelayHost({ diff --git a/src/server/relay/relayHost.ts b/src/server/relay/relayHost.ts index b365c038a..c7ca30710 100644 --- a/src/server/relay/relayHost.ts +++ b/src/server/relay/relayHost.ts @@ -206,7 +206,13 @@ export function startRelayHost(options: RelayHostOptions): RelayHostHandle { lower === "host" || lower === "content-length" || lower === "connection" || - lower === "x-forwarded-for" + lower === "x-forwarded-for" || + // The hop to the local server is loopback, and `fetch` transparently + // decodes whatever comes back — so forwarding the visitor's + // `accept-encoding` would only make the origin spend CPU compressing + // bytes this process immediately decompresses. The visitor's own hop + // is compressed by the relay/WS transport instead. + lower === "accept-encoding" ) continue; requestHeaders[key] = value; @@ -246,6 +252,13 @@ export function startRelayHost(options: RelayHostOptions): RelayHostHandle { // the one API that returns every value intact. const responseHeaders = headersToRecord(response.headers); delete responseHeaders["set-cookie"]; + // `readBoundedResponseBody` reads the DECODED body (`fetch` undoes any + // `content-encoding` transparently), so echoing the origin's + // `content-encoding` would label plaintext bytes as gzip and the visitor + // would fail to parse them. `content-length` describes the encoded body + // and is equally stale. Both must go now that the origin can compress. + delete responseHeaders["content-encoding"]; + delete responseHeaders["content-length"]; const setCookies = response.headers.getSetCookie(); if (control === sourceControl) { sendOn(sourceControl, { diff --git a/src/shared/contracts/thread.ts b/src/shared/contracts/thread.ts index be3efd4af..652db8edc 100644 --- a/src/shared/contracts/thread.ts +++ b/src/shared/contracts/thread.ts @@ -208,6 +208,18 @@ export interface PendingSteerState { * through the regular thread actions instead of writing to the DB directly. */ export const remoteThreadCommandSchema = z.discriminatedUnion("kind", [ + /** + * Host lifecycle preflight for a freshly-created worktree. The paired + * desktop enqueues setup before the host launches the thread. Keeping this + * separate from `start` prevents the post-launch metadata mirror from + * enqueueing setup a second time. + */ + z.object({ + kind: z.literal("prepare-worktree"), + threadId: z.string().min(1), + projectId: z.string().min(1), + worktreePath: z.string().min(1), + }), z.object({ kind: z.literal("start"), threadId: z.string().min(1), diff --git a/src/shared/gitState.test.ts b/src/shared/gitState.test.ts new file mode 100644 index 000000000..78540e080 --- /dev/null +++ b/src/shared/gitState.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { + applyGitStatePatch, + emptyGitStateSnapshot, + gitProjectKey, + gitTargetKey, + pullRequestBranchKey, + pullRequestKey, +} from "./gitState"; + +describe("git state references", () => { + it("namespaces project, target, branch, and PR identities by host", () => { + const project = { hostId: "host-a", projectId: "project-a" }; + expect(gitProjectKey(project)).not.toBe( + gitProjectKey({ hostId: "host-b", projectId: "project-a" }), + ); + expect(gitTargetKey(project)).not.toBe( + gitTargetKey({ ...project, worktreePath: "/repo/worktree" }), + ); + expect(pullRequestBranchKey(project, "feature/a")).not.toBe( + pullRequestBranchKey(project, "feature/b"), + ); + expect(pullRequestKey({ ...project, prNumber: 42 })).not.toBe( + pullRequestKey({ ...project, prNumber: 43 }), + ); + }); + + it("does not collide when key parts contain separators", () => { + expect( + gitTargetKey({ + hostId: "host\u0000project", + projectId: "a", + worktreePath: "/worktree", + }), + ).not.toBe( + gitTargetKey({ + hostId: "host", + projectId: "project\u0000a", + worktreePath: "/worktree", + }), + ); + }); +}); + +describe("applyGitStatePatch", () => { + it("applies normalized upserts and removals in revision order", () => { + const projectRef = { hostId: "host-a", projectId: "project-a" }; + const projectKey = gitProjectKey(projectRef); + const prKey = pullRequestKey({ ...projectRef, prNumber: 42 }); + const branchKey = pullRequestBranchKey(projectRef, "feature/a"); + const now = "2026-07-28T12:00:00.000Z"; + const project = { ref: projectRef, refreshedAt: now }; + const pr = { + ref: { ...projectRef, prNumber: 42 }, + data: { + number: 42, + state: "open" as const, + title: "Unify PR state", + url: "https://example.test/pull/42", + baseBranch: "main", + isDraft: false, + updatedAt: now, + }, + freshness: { core: now }, + }; + + const first = applyGitStatePatch(emptyGitStateSnapshot(), { + revision: 1, + projects: { [projectKey]: project }, + pullRequests: { [prKey]: pr }, + pullRequestKeyByBranch: { [branchKey]: prKey }, + }); + expect(first.projects[projectKey]).toBe(project); + expect(first.pullRequests[prKey]).toBe(pr); + expect(first.pullRequestKeyByBranch[branchKey]).toBe(prKey); + + const stale = applyGitStatePatch(first, { + revision: 1, + removePullRequests: [prKey], + }); + expect(stale).toBe(first); + + const removed = applyGitStatePatch(first, { + revision: 2, + removePullRequests: [prKey], + pullRequestKeyByBranch: { [branchKey]: null }, + }); + expect(removed.pullRequests[prKey]).toBeUndefined(); + expect(removed.pullRequestKeyByBranch[branchKey]).toBeUndefined(); + }); +}); diff --git a/src/shared/gitState.ts b/src/shared/gitState.ts new file mode 100644 index 000000000..5d3a1ea3e --- /dev/null +++ b/src/shared/gitState.ts @@ -0,0 +1,266 @@ +import { z } from "zod"; +import type { + GitBranchListResult, + GitStatusResult, + GitWorktreeInfo, + PrData, + PrDetails, + PrFile, + PrReviewThread, +} from "./contracts"; + +const KEY_SEPARATOR = "\u0000"; + +export interface GitProjectRef { + readonly hostId: string; + readonly projectId: string; +} + +export interface GitTargetRef extends GitProjectRef { + readonly worktreePath?: string | undefined; +} + +export interface PullRequestRef extends GitProjectRef { + readonly prNumber: number; +} + +export interface GitTargetSourceInfo { + readonly sourceBranch: string | null; + readonly commitsAhead: number; + readonly sourceAhead: number; +} + +export interface GitProjectState { + readonly ref: GitProjectRef; + readonly status?: GitStatusResult | undefined; + readonly branches?: GitBranchListResult | undefined; + readonly worktrees?: readonly GitWorktreeInfo[] | undefined; + readonly ghAvailable?: boolean | undefined; + readonly refreshedAt: string; +} + +export interface GitTargetState { + readonly ref: GitTargetRef; + readonly status?: GitStatusResult | undefined; + readonly sourceInfo?: GitTargetSourceInfo | undefined; + readonly pullRequestKey?: string | null | undefined; + readonly refreshedAt: string; +} + +export interface PullRequestResourceFreshness { + readonly core?: string | undefined; + readonly details?: string | undefined; + readonly files?: string | undefined; + readonly diff?: string | undefined; + readonly reviewThreads?: string | undefined; +} + +export interface PullRequestState { + readonly ref: PullRequestRef; + readonly data: PrData; + readonly details?: PrDetails | undefined; + readonly files?: readonly PrFile[] | undefined; + readonly diff?: string | undefined; + readonly reviewThreads?: readonly PrReviewThread[] | undefined; + readonly freshness: PullRequestResourceFreshness; +} + +export interface ProjectPullRequestListState { + readonly project: GitProjectRef; + readonly pullRequestKeys: readonly string[]; + readonly viewerLogin?: string | undefined; + readonly refreshedAt: string; +} + +export interface GitStateSnapshot { + readonly revision: number; + readonly projects: Readonly>; + readonly targets: Readonly>; + readonly pullRequests: Readonly>; + readonly pullRequestKeyByBranch: Readonly>; + readonly projectPullRequestLists: Readonly>; +} + +export interface GitStatePatch { + readonly revision: number; + readonly projects?: Readonly> | undefined; + readonly targets?: Readonly> | undefined; + readonly pullRequests?: Readonly> | undefined; + readonly pullRequestKeyByBranch?: Readonly> | undefined; + readonly projectPullRequestLists?: + | Readonly> + | undefined; + readonly removeProjects?: readonly string[] | undefined; + readonly removeTargets?: readonly string[] | undefined; + readonly removePullRequests?: readonly string[] | undefined; + readonly removeProjectPullRequestLists?: readonly string[] | undefined; +} + +export type GitStateInterest = + | { + readonly kind: "target"; + readonly projectId: string; + readonly worktreePath?: string | undefined; + readonly branch?: string | undefined; + readonly includePrDetails?: boolean | undefined; + } + | { + readonly kind: "pull-request"; + readonly projectId: string; + readonly prNumber: number; + readonly branch?: string | undefined; + readonly includeReviewBundle?: boolean | undefined; + } + | { + readonly kind: "project-pull-requests"; + readonly projectId: string; + }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export const gitStateSnapshotSchema = z.custom( + (value) => + isRecord(value) && + Number.isSafeInteger(value.revision) && + Number(value.revision) >= 0 && + isRecord(value.projects) && + isRecord(value.targets) && + isRecord(value.pullRequests) && + isRecord(value.pullRequestKeyByBranch) && + isRecord(value.projectPullRequestLists), + "Invalid Git state snapshot.", +); + +export const gitStatePatchSchema = z.custom( + (value) => + isRecord(value) && + Number.isSafeInteger(value.revision) && + Number(value.revision) > 0 && + (value.projects === undefined || isRecord(value.projects)) && + (value.targets === undefined || isRecord(value.targets)) && + (value.pullRequests === undefined || isRecord(value.pullRequests)) && + (value.pullRequestKeyByBranch === undefined || isRecord(value.pullRequestKeyByBranch)) && + (value.projectPullRequestLists === undefined || isRecord(value.projectPullRequestLists)), + "Invalid Git state patch.", +); + +export const gitStateInterestSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("target"), + projectId: z.string().min(1), + worktreePath: z.string().min(1).optional(), + branch: z.string().min(1).optional(), + includePrDetails: z.boolean().optional(), + }), + z.object({ + kind: z.literal("pull-request"), + projectId: z.string().min(1), + prNumber: z.number().int().positive(), + branch: z.string().min(1).optional(), + includeReviewBundle: z.boolean().optional(), + }), + z.object({ + kind: z.literal("project-pull-requests"), + projectId: z.string().min(1), + }), +]); + +function encodeKeyPart(value: string): string { + return `${value.length}:${value}`; +} + +function joinKey(kind: string, parts: readonly string[]): string { + return [kind, ...parts.map(encodeKeyPart)].join(KEY_SEPARATOR); +} + +export function gitProjectKey(ref: GitProjectRef): string { + return joinKey("project", [ref.hostId, ref.projectId]); +} + +export function gitTargetKey(ref: GitTargetRef): string { + return joinKey("target", [ref.hostId, ref.projectId, ref.worktreePath ?? ""]); +} + +export function pullRequestKey(ref: PullRequestRef): string { + return joinKey("pr", [ref.hostId, ref.projectId, String(ref.prNumber)]); +} + +export function pullRequestBranchKey(ref: GitProjectRef, branch: string): string { + return joinKey("pr-branch", [ref.hostId, ref.projectId, branch]); +} + +function omitKeys( + source: Readonly>, + keys: readonly string[] | undefined, +): Readonly> { + if (!keys || keys.length === 0) return source; + const removed = new Set(keys); + let changed = false; + const next: Record = {}; + for (const [key, value] of Object.entries(source)) { + if (removed.has(key)) { + changed = true; + continue; + } + next[key] = value; + } + return changed ? next : source; +} + +function mergeRecords( + current: Readonly>, + patch: Readonly> | undefined, +): Readonly> { + if (!patch || Object.keys(patch).length === 0) return current; + return { ...current, ...patch }; +} + +function mergeNullableRecords( + current: Readonly>, + patch: Readonly> | undefined, +): Readonly> { + if (!patch || Object.keys(patch).length === 0) return current; + const next = { ...current }; + for (const [key, value] of Object.entries(patch)) { + if (value === null) delete next[key]; + else next[key] = value; + } + return next; +} + +export function applyGitStatePatch( + current: GitStateSnapshot, + patch: GitStatePatch, +): GitStateSnapshot { + if (patch.revision <= current.revision) return current; + return { + revision: patch.revision, + projects: mergeRecords(omitKeys(current.projects, patch.removeProjects), patch.projects), + targets: mergeRecords(omitKeys(current.targets, patch.removeTargets), patch.targets), + pullRequests: mergeRecords( + omitKeys(current.pullRequests, patch.removePullRequests), + patch.pullRequests, + ), + pullRequestKeyByBranch: mergeNullableRecords( + current.pullRequestKeyByBranch, + patch.pullRequestKeyByBranch, + ), + projectPullRequestLists: mergeRecords( + omitKeys(current.projectPullRequestLists, patch.removeProjectPullRequestLists), + patch.projectPullRequestLists, + ), + }; +} + +export function emptyGitStateSnapshot(): GitStateSnapshot { + return { + revision: 0, + projects: {}, + targets: {}, + pullRequests: {}, + pullRequestKeyByBranch: {}, + projectPullRequestLists: {}, + }; +} diff --git a/src/shared/imageDimensions.ts b/src/shared/imageDimensions.ts new file mode 100644 index 000000000..289d093cf --- /dev/null +++ b/src/shared/imageDimensions.ts @@ -0,0 +1,243 @@ +/** + * Intrinsic image dimensions read from an image's own header bytes. + * + * Lives in `shared` because two processes need it: the renderer reads dimensions + * out of an inline base64 payload, and the host reads them while replacing that + * payload with a reference so the timeline can still reserve layout without + * fetching the image first. + * + * Every reader inspects only a short prefix — never the whole (multi-megabyte) + * image — and returns `undefined` rather than throwing on anything unrecognized. + */ + +import type { InlineImageClassification } from "./inlineImagePayload"; + +export interface ImageDimensions { + readonly width: number; + readonly height: number; +} + +export function readImageDimensions( + value: string, + classification: InlineImageClassification, +): { width: number; height: number } | undefined { + if (classification.mime === "image/svg+xml") return readSvgDimensions(value, classification); + const bytes = readBase64BytesPrefix(value, classification, 8192); + if (!bytes) return undefined; + switch (classification.mime) { + case "image/png": + return readPngDimensions(bytes); + case "image/jpeg": + return readJpegDimensions(bytes); + case "image/gif": + return readGifDimensions(bytes); + case "image/webp": + return readWebpDimensions(bytes); + default: + return undefined; + } +} + +function readBase64BytesPrefix( + value: string, + classification: InlineImageClassification, + byteCount: number, +): Uint8Array | undefined { + const base64 = + classification.kind === "dataUrl" + ? readBase64DataUrlBody(value) + : classification.kind === "base64" + ? value + : null; + if (!base64) return undefined; + const clean = base64.replace(/\s+/g, ""); + if (clean.length === 0) return undefined; + const chars = Math.ceil(byteCount / 3) * 4; + const slice = clean.slice(0, chars); + const padded = slice.padEnd(Math.ceil(slice.length / 4) * 4, "="); + try { + const binary = atob(padded); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i); + return out; + } catch { + return undefined; + } +} + +function readBase64DataUrlBody(value: string): string | null { + const commaIndex = value.indexOf(","); + if (commaIndex < 0) return null; + return value.slice(0, commaIndex).toLowerCase().includes(";base64") + ? value.slice(commaIndex + 1) + : null; +} + +function readPngDimensions(bytes: Uint8Array) { + if ( + bytes.length < 24 || + bytes[0] !== 0x89 || + bytes[1] !== 0x50 || + bytes[2] !== 0x4e || + bytes[3] !== 0x47 || + bytes[12] !== 0x49 || + bytes[13] !== 0x48 || + bytes[14] !== 0x44 || + bytes[15] !== 0x52 + ) { + return undefined; + } + return readPositiveDimensions(readUint32BE(bytes, 16), readUint32BE(bytes, 20)); +} + +function readGifDimensions(bytes: Uint8Array) { + if ( + bytes.length < 10 || + bytes[0] !== 0x47 || + bytes[1] !== 0x49 || + bytes[2] !== 0x46 || + bytes[3] !== 0x38 + ) { + return undefined; + } + return readPositiveDimensions(readUint16LE(bytes, 6), readUint16LE(bytes, 8)); +} + +function readJpegDimensions(bytes: Uint8Array) { + if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return undefined; + let offset = 2; + while (offset + 9 < bytes.length) { + if (bytes[offset] !== 0xff) { + offset += 1; + continue; + } + const marker = bytes[offset + 1]; + if (marker === undefined) break; + offset += 2; + if (marker === 0xda || marker === 0xd9) break; + if (offset + 2 > bytes.length) break; + const length = readUint16BE(bytes, offset); + if (length < 2 || offset + length > bytes.length) break; + if (isJpegStartOfFrame(marker)) { + return readPositiveDimensions( + readUint16BE(bytes, offset + 5), + readUint16BE(bytes, offset + 3), + ); + } + offset += length; + } + return undefined; +} + +function readWebpDimensions(bytes: Uint8Array) { + if ( + bytes.length < 30 || + bytes[0] !== 0x52 || + bytes[1] !== 0x49 || + bytes[2] !== 0x46 || + bytes[3] !== 0x46 || + bytes[8] !== 0x57 || + bytes[9] !== 0x45 || + bytes[10] !== 0x42 || + bytes[11] !== 0x50 + ) { + return undefined; + } + const chunk = String.fromCharCode(bytes[12]!, bytes[13]!, bytes[14]!, bytes[15]!); + if (chunk === "VP8X") { + const width = 1 + bytes[24]! + (bytes[25]! << 8) + (bytes[26]! << 16); + const height = 1 + bytes[27]! + (bytes[28]! << 8) + (bytes[29]! << 16); + return readPositiveDimensions(width, height); + } + if (chunk === "VP8L") { + const width = 1 + bytes[21]! + ((bytes[22]! & 0x3f) << 8); + const height = 1 + ((bytes[22]! & 0xc0) >> 6) + (bytes[23]! << 2) + ((bytes[24]! & 0x0f) << 10); + return readPositiveDimensions(width, height); + } + if (chunk === "VP8 " && bytes[23] === 0x9d && bytes[24] === 0x01 && bytes[25] === 0x2a) { + const width = readUint16LE(bytes, 26) & 0x3fff; + const height = readUint16LE(bytes, 28) & 0x3fff; + return readPositiveDimensions(width, height); + } + return undefined; +} + +function readSvgDimensions(value: string, classification: InlineImageClassification) { + const svg = + classification.kind === "rawSvg" + ? value + : classification.kind === "dataUrl" + ? readTextDataUrlBody(value) + : null; + if (!svg) return undefined; + const width = readSvgLength(svg, "width"); + const height = readSvgLength(svg, "height"); + if (width && height) return { width, height }; + const viewBox = /\bviewBox\s*=\s*["']\s*[-.\d]+\s+[-.\d]+\s+([.\d]+)\s+([.\d]+)/i.exec(svg); + if (!viewBox) return undefined; + return readPositiveDimensions(Number(viewBox[1]), Number(viewBox[2])); +} + +function readTextDataUrlBody(value: string): string | null { + const commaIndex = value.indexOf(","); + if (commaIndex < 0) return null; + const body = value.slice(commaIndex + 1); + if (value.slice(0, commaIndex).toLowerCase().includes(";base64")) { + try { + return atob(body); + } catch { + return null; + } + } + try { + return decodeURIComponent(body); + } catch { + return body; + } +} + +function readSvgLength(svg: string, attr: "width" | "height") { + const match = new RegExp(`\\b${attr}\\s*=\\s*["']\\s*([.\\d]+)`, "i").exec(svg); + if (!match) return undefined; + const value = Number(match[1]); + return Number.isFinite(value) && value > 0 ? value : undefined; +} + +function isJpegStartOfFrame(marker: number) { + return ( + marker === 0xc0 || + marker === 0xc1 || + marker === 0xc2 || + marker === 0xc3 || + marker === 0xc5 || + marker === 0xc6 || + marker === 0xc7 || + marker === 0xc9 || + marker === 0xca || + marker === 0xcb || + marker === 0xcd || + marker === 0xce || + marker === 0xcf + ); +} + +function readPositiveDimensions(width: number, height: number) { + return Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0 + ? { width, height } + : undefined; +} + +function readUint16BE(bytes: Uint8Array, offset: number) { + return (bytes[offset]! << 8) + bytes[offset + 1]!; +} + +function readUint16LE(bytes: Uint8Array, offset: number) { + return bytes[offset]! + (bytes[offset + 1]! << 8); +} + +function readUint32BE(bytes: Uint8Array, offset: number) { + return ( + bytes[offset]! * 0x1000000 + + ((bytes[offset + 1]! << 16) | (bytes[offset + 2]! << 8) | bytes[offset + 3]!) + ); +} diff --git a/src/shared/imageRefDisplay.ts b/src/shared/imageRefDisplay.ts new file mode 100644 index 000000000..746b9f02e --- /dev/null +++ b/src/shared/imageRefDisplay.ts @@ -0,0 +1,32 @@ +import type { RemoteImageRefValue } from "./remote/imageRef"; + +/** + * Display-time resolver for host-minted image references. + * + * Mirrors `localImageDisplay`: the remote bridge installs a resolver while a + * desktop connection is active, mapping a reference to that desktop's + * authenticated image endpoint. The desktop shell never installs one — its own + * IPC payloads keep their inline image bytes, so references never appear there + * and the renderer keeps working unchanged. + * + * Keeping this indirection in `shared` is what lets `imageViewSource` stay + * synchronous (the timeline grouping path depends on that) while still producing + * an ``-ready URL on the remote clients. + */ +let resolver: ((ref: RemoteImageRefValue) => string) | null = null; + +/** Installed by the remote bridge while a desktop connection is active. */ +export function setRemoteImageRefResolver(fn: ((ref: RemoteImageRefValue) => string) | null): void { + resolver = fn; +} + +/** + * Absolute URL for a reference, or `null` when nothing can resolve it (desktop + * shell, or a remote client with no active session). Callers fall back to the + * inert tool-call accordion in that case rather than rendering a broken image. + */ +export function resolveRemoteImageRefUrl(ref: RemoteImageRefValue): string | null { + if (!resolver) return null; + const url = resolver(ref); + return url.length > 0 ? url : null; +} diff --git a/src/shared/inlineImagePayload.ts b/src/shared/inlineImagePayload.ts index b9698dd11..0a6c67000 100644 --- a/src/shared/inlineImagePayload.ts +++ b/src/shared/inlineImagePayload.ts @@ -32,25 +32,155 @@ export interface InlineImageCandidate { classification: InlineImageClassification; } +/** Where an inline image sits inside a payload, as a walkable key/index path. */ +export type InlineImagePath = ReadonlyArray; + +export interface InlineImageLocation extends InlineImageCandidate { + /** e.g. `["images", 0]` or `["result", "content", 2, "data"]`. */ + path: InlineImagePath; +} + export function inlineImagePayloadRenders(payload: unknown): boolean { return readStatus(payload) !== "error" && findRenderableInlineImageCandidate(payload) !== null; } export function findRenderableInlineImageCandidate(payload: unknown): InlineImageCandidate | null { - if (!payload || typeof payload !== "object") return null; + // `firstOnly` keeps this the cheap O(1) probe the timeline grouping path + // relies on: it stops at the first match and never walks the rest. + const [first] = collectInlineImageLocations(payload, true); + return first ? { value: first.value, classification: first.classification } : null; +} + +/** + * Every inline image in `payload`, with the path needed to address it again + * later. Ordered exactly as {@link findRenderableInlineImageCandidate} searches + * (`images[]` before `result`), so the first entry is always the one the + * renderer would display. + * + * Used by the remote transport to replace inline image bytes with host-minted + * references, and by the endpoint that resolves such a reference back to bytes. + */ +export function collectInlineImageLocations( + payload: unknown, + firstOnly = false, +): InlineImageLocation[] { + const found: InlineImageLocation[] = []; + if (!payload || typeof payload !== "object") return found; const record = payload as Record; + + const push = (value: string, path: InlineImagePath): boolean => { + const classification = classifyInlineImageCandidate(value); + if (!classification) return false; + found.push({ value, classification, path }); + return firstOnly; + }; + if (Array.isArray(record.images)) { - for (const value of record.images) { + for (const [index, value] of record.images.entries()) { if (typeof value !== "string" || value.length === 0) continue; - const classification = classifyInlineImageCandidate(value); - if (classification) return { value, classification }; + if (push(value, ["images", index])) return found; } } - for (const value of collectResultCandidates(record.result)) { - const classification = classifyInlineImageCandidate(value); - if (classification) return { value, classification }; + for (const candidate of collectResultCandidates(record.result)) { + if (push(candidate.value, ["result", ...candidate.path])) return found; } - return null; + return found; +} + +/** Reads the value at an {@link InlineImagePath}, or undefined if absent. */ +export function readAtInlineImagePath(root: unknown, path: InlineImagePath): unknown { + let current: unknown = root; + for (const part of path) { + if (current === null || current === undefined) return undefined; + if (typeof part === "number") { + if (!Array.isArray(current)) return undefined; + current = current[part]; + continue; + } + if (typeof current !== "object" || Array.isArray(current)) return undefined; + current = (current as Record)[part]; + } + return current; +} + +/** + * The payload locations the renderer will actually *display* an image from, in + * priority order, regardless of what type of value currently sits there. + * + * Deliberately value-type-agnostic so the host and the client agree on one + * definition of "displayable": the host replaces inline bytes with a reference, + * and the client looks for a reference in exactly these places. Keeping this + * narrow matters — images buried elsewhere in a tool result (an MCP + * `screenshot.url`, say) are not rendered today, and must not start rendering + * just because the transport swapped their bytes for a reference. + */ +export function enumerateDisplayImageCandidatePaths(payload: unknown): InlineImagePath[] { + if (!payload || typeof payload !== "object") return []; + const record = payload as Record; + const paths: InlineImagePath[] = []; + if (Array.isArray(record.images)) { + for (let index = 0; index < record.images.length; index += 1) { + paths.push(["images", index]); + } + } + const result = record.result; + if (typeof result === "string") { + paths.push(["result"]); + } else if (result && typeof result === "object") { + const resultRecord = result as Record; + for (const key of RESULT_STRING_KEYS) { + if (key in resultRecord) paths.push(["result", key]); + } + for (const key of RESULT_ARRAY_KEYS) { + const value = resultRecord[key]; + if (!Array.isArray(value)) continue; + for (let index = 0; index < value.length; index += 1) { + paths.push(["result", key, index]); + const entry = value[index]; + if (entry && typeof entry === "object" && !Array.isArray(entry)) { + for (const innerKey of RESULT_STRING_KEYS) { + if (innerKey in (entry as Record)) { + paths.push(["result", key, index, innerKey]); + } + } + } + } + } + } + return paths; +} + +/** Guards the deep walk against pathological nesting. */ +const MAX_DEEP_WALK_DEPTH = 12; + +/** + * Every inline image anywhere in the payload, however deeply nested. + * + * Broader than {@link collectInlineImageLocations}, which only reports the + * locations the UI renders from. Used by the remote transport, because bytes the + * UI will never show are pure waste on the wire — in practice this is where + * screenshot-carrying MCP results hide most of their weight. + */ +export function collectInlineImageLocationsDeep(payload: unknown): InlineImageLocation[] { + const found: InlineImageLocation[] = []; + const walk = (value: unknown, path: InlineImagePath, depth: number): void => { + if (depth > MAX_DEEP_WALK_DEPTH) return; + if (typeof value === "string") { + const classification = classifyInlineImageCandidate(value); + if (classification) found.push({ value, classification, path }); + return; + } + if (Array.isArray(value)) { + for (const [index, entry] of value.entries()) walk(entry, [...path, index], depth + 1); + return; + } + if (!value || typeof value !== "object") return; + for (const [key, entry] of Object.entries(value as Record)) { + walk(entry, [...path, key], depth + 1); + } + }; + walk(payload, [], 0); + return found; } export function classifyInlineImageCandidate(value: string): InlineImageClassification | null { @@ -67,27 +197,33 @@ export function classifyInlineImageCandidate(value: string): InlineImageClassifi return null; } -function collectResultCandidates(result: unknown): string[] { - if (typeof result === "string") return result.length > 0 ? [result] : []; +interface ResultCandidate { + readonly value: string; + /** Path relative to `result`. Empty when `result` is itself the image string. */ + readonly path: InlineImagePath; +} + +function collectResultCandidates(result: unknown): ResultCandidate[] { + if (typeof result === "string") return result.length > 0 ? [{ value: result, path: [] }] : []; if (!result || typeof result !== "object") return []; const record = result as Record; - const candidates: string[] = []; + const candidates: ResultCandidate[] = []; for (const key of RESULT_STRING_KEYS) { const value = record[key]; - if (typeof value === "string" && value.length > 0) candidates.push(value); + if (typeof value === "string" && value.length > 0) candidates.push({ value, path: [key] }); } for (const key of RESULT_ARRAY_KEYS) { const value = record[key]; if (!Array.isArray(value)) continue; - for (const entry of value) { + for (const [index, entry] of value.entries()) { if (typeof entry === "string" && entry.length > 0) { - candidates.push(entry); + candidates.push({ value: entry, path: [key, index] }); } else if (entry && typeof entry === "object") { const inner = entry as Record; for (const innerKey of RESULT_STRING_KEYS) { const candidate = inner[innerKey]; if (typeof candidate === "string" && candidate.length > 0) { - candidates.push(candidate); + candidates.push({ value: candidate, path: [key, index, innerKey] }); } } } diff --git a/src/shared/ipc/bridge.ts b/src/shared/ipc/bridge.ts index 3fa208499..8a23b0b43 100644 --- a/src/shared/ipc/bridge.ts +++ b/src/shared/ipc/bridge.ts @@ -2,6 +2,7 @@ import type { PoracodeChannel } from "../channel"; import type { RemoteThreadCommand } from "../contracts"; import type { RemoteAccessPairingInfo } from "../remote"; import type { SharedSettings } from "../settings"; +import type { GitStatePatch } from "../gitState"; import { createChannel } from "./core"; import { ipcProcedureMap, @@ -62,6 +63,7 @@ export type PoracodeBridge = PoracodeInvokeBridge & { /** Shared settings rewritten outside this renderer (e.g. by a remote client). */ onSharedSettingsChanged(listener: (settings: SharedSettings) => void): () => void; onProjectStateChanged(listener: (event: ProjectStateChangedEvent) => void): () => void; + onGitStateChanged(listener: (patch: GitStatePatch) => void): () => void; onThreadOpenRequested(listener: (event: ThreadOpenRequestedEvent) => void): () => void; submitQuickComposer(submission: QuickComposerSubmission): Promise; dismissQuickComposer(): Promise; @@ -125,6 +127,7 @@ export const IPC_EVENT_CHANNELS = { remoteAccessPairingChanged: createChannel("remoteAccessPairingChanged"), sharedSettingsChanged: createChannel("sharedSettingsChanged"), projectStateChanged: createChannel("projectStateChanged"), + gitStateChanged: createChannel("gitStateChanged"), threadOpenRequested: createChannel("threadOpenRequested"), quickComposerSubmit: createChannel("quickComposerSubmit"), quickComposerDismissRequested: createChannel("quickComposerDismissRequested"), diff --git a/src/shared/remote/client.ts b/src/shared/remote/client.ts index b55d5979d..deaa83f40 100644 --- a/src/shared/remote/client.ts +++ b/src/shared/remote/client.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { remoteImageRefPath, type RemoteImageRefValue } from "./imageRef"; import { PORACODE_REMOTE_PROTOCOL_VERSION, REMOTE_COMMAND_ID_HEADER, @@ -653,6 +654,16 @@ export class RemoteDesktopClient { }); } + async truncateThreadRuntimeAfter(input: { + readonly threadId: string; + readonly itemId: string; + }): Promise { + await this.requestJson(`/api/threads/${encodeURIComponent(input.threadId)}/runtime/truncate`, { + method: "POST", + body: { itemId: input.itemId }, + }); + } + async setPendingSteer(input: SetPendingSteerPayload): Promise { await this.requestJson(`/api/threads/${encodeURIComponent(input.threadId)}/steer/set`, { method: "POST", @@ -865,6 +876,20 @@ export class RemoteDesktopClient { return url.toString(); } + /** + * Absolute URL for a host-minted image reference. Like {@link localImageUrl} + * the token rides in the query string because tags can't send an + * Authorization header — but unlike it, the location is addressed inside the + * host's own stored payload rather than by a filesystem path, so nothing the + * agent wrote can influence what gets served. Returns "" without a token. + */ + imageRefUrl(ref: RemoteImageRefValue): string { + if (!this.accessToken) return ""; + const url = endpointUrl(this.endpoint, remoteImageRefPath(ref)); + url.searchParams.set("access_token", this.accessToken); + return url.toString(); + } + parseSocketMessage(value: string): RemoteWebSocketServerMessage { return remoteWebSocketServerMessageSchema.parse(JSON.parse(value) as unknown); } @@ -917,6 +942,20 @@ export class RemoteDesktopClient { }), timeoutPromise, ]); + // The large read endpoints send a revalidating `ETag`. Browser clients + // (PWA, Electron renderer) resolve `304` against their own HTTP cache and + // surface it as a `200` with the stored body, so this is unreachable + // there. A non-browser `fetchImpl` — or an intermediary that revalidates + // on its own — could still surface a bare `304`, whose empty body would + // otherwise parse to `{}` and fail schema validation with a confusing + // error. Fail loudly instead. + if (response.status === 304) { + throw new RemoteClientError( + "Remote request returned 304 without a cached body.", + 304, + "not_modified", + ); + } const body = await Promise.race([ readBoundedResponseBody(response, this.maxResponseBodyBytes), timeoutPromise, diff --git a/src/shared/remote/imageRef.test.ts b/src/shared/remote/imageRef.test.ts new file mode 100644 index 000000000..a3972a3dd --- /dev/null +++ b/src/shared/remote/imageRef.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { + findDisplayableImageRef, + isRemoteImageRef, + payloadHasImageRef, + readRemoteImageRef, + remoteImageRef, + remoteImageRefPath, + type RemoteImageRefValue, +} from "./imageRef"; + +const ref: RemoteImageRefValue = { + threadId: "t1", + itemId: "i1", + path: ["images", 0], + mime: "image/png", + bytes: 1234, +}; + +describe("readRemoteImageRef", () => { + it("round-trips a minted reference", () => { + expect(readRemoteImageRef(remoteImageRef(ref))).toEqual(ref); + expect(isRemoteImageRef(remoteImageRef(ref))).toBe(true); + }); + + it("rejects anything that is not a well-formed reference", () => { + for (const value of [ + null, + undefined, + "data:image/png;base64,AAA", + [], + {}, + { __poracodeImageRef: {} }, + { __poracodeImageRef: { ...ref, threadId: "" } }, + { __poracodeImageRef: { ...ref, mime: "text/html" } }, + { __poracodeImageRef: { ...ref, path: [] } }, + { __poracodeImageRef: { ...ref, path: [{ nested: true }] } }, + { __poracodeImageRef: { ...ref, bytes: "big" } }, + ]) { + expect(isRemoteImageRef(value)).toBe(false); + } + }); +}); + +describe("findDisplayableImageRef", () => { + it("prefers images[] over result, matching the inline search order", () => { + const first = remoteImageRef({ ...ref, itemId: "from-images" }); + const second = remoteImageRef({ ...ref, itemId: "from-result" }); + const found = findDisplayableImageRef({ images: [first], result: { image: second } }); + expect(found?.itemId).toBe("from-images"); + }); + + it("finds a reference at a display-relevant nested result location", () => { + const payload = { result: { content: [{ type: "text" }, { data: remoteImageRef(ref) }] } }; + expect(findDisplayableImageRef(payload)?.itemId).toBe("i1"); + expect(payloadHasImageRef(payload)).toBe(true); + }); + + it("ignores a reference the renderer would never have displayed", () => { + // The host also references images buried outside the display paths (an MCP + // `screenshot.url`) purely to save bytes. Those rows rendered as a tool-call + // accordion when the bytes were inline and must keep doing so. + const payload = { result: { content: [{ screenshot: { url: remoteImageRef(ref) } }] } }; + expect(findDisplayableImageRef(payload)).toBeNull(); + expect(payloadHasImageRef(payload)).toBe(false); + }); + + it("skips non-reference entries in images[]", () => { + const found = findDisplayableImageRef({ images: ["not-an-image", remoteImageRef(ref)] }); + expect(found?.itemId).toBe("i1"); + }); + + it("returns null for a payload with no reference", () => { + expect(findDisplayableImageRef({ images: ["data:image/png;base64,AAA"] })).toBeNull(); + expect(payloadHasImageRef({ name: "bash", result: "text" })).toBe(false); + }); +}); + +describe("remoteImageRefPath", () => { + it("encodes the addressed location", () => { + expect(remoteImageRefPath(ref)).toBe( + "/api/threads/t1/items/i1/image?path=%5B%22images%22%2C0%5D", + ); + }); + + it("escapes ids that would otherwise break the path", () => { + expect(remoteImageRefPath({ ...ref, threadId: "a/b", itemId: "c d" })).toContain( + "/api/threads/a%2Fb/items/c%20d/image", + ); + }); +}); diff --git a/src/shared/remote/imageRef.ts b/src/shared/remote/imageRef.ts new file mode 100644 index 000000000..8321871a7 --- /dev/null +++ b/src/shared/remote/imageRef.ts @@ -0,0 +1,105 @@ +import { + enumerateDisplayImageCandidatePaths, + readAtInlineImagePath, + type InlineImagePath, +} from "../inlineImagePayload"; + +/** + * A host-minted stand-in for inline image bytes that were removed from a remote + * payload. + * + * Inline base64 images dominate remote traffic — on a real transcript database + * they are ~89% of all runtime payload bytes, with single items reaching 12 MB — + * and they are why a runtime event can blow past the WebSocket outbound budget. + * Replacing them with this reference lets the transcript stay small and lets the + * client fetch each image once, on demand, from a cache-friendly URL. + * + * SECURITY: the reference addresses the image by **where it sits in the host's + * own persisted payload** (`threadId` + `itemId` + `path`), never by a filesystem + * path or URL taken from the payload. That is deliberate and load-bearing: the + * renderer refuses to promote agent-supplied paths/URLs into an `` + * (see `imageViewSource`), because a prompt-injected tool result could otherwise + * trigger an outbound request or read a local file just because a thread was + * viewed. A reference carries no agent-controlled location, so resolving it + * cannot be steered — the endpoint re-reads the row and re-verifies that the + * addressed value really is an inline image before serving any bytes. + */ + +export const REMOTE_IMAGE_REF_KEY = "__poracodeImageRef"; + +export interface RemoteImageRefValue { + readonly threadId: string; + readonly itemId: string; + /** Path to the image within the item's payload, e.g. `["images", 0]`. */ + readonly path: InlineImagePath; + readonly mime: string; + /** Size of the withheld value, for diagnostics and download affordances. */ + readonly bytes: number; + /** Carried so the timeline can reserve layout before the image loads. */ + readonly width?: number; + readonly height?: number; + /** + * Tiny blurred stand-in (a data URL, a few hundred bytes) painted in the + * reserved slot until the real image arrives. Absent when the host cannot + * resize images, or the first time an image is seen — the preview is generated + * off the critical path, so it appears on a later fetch. + */ + readonly preview?: string; +} + +export interface RemoteImageRef { + readonly [REMOTE_IMAGE_REF_KEY]: RemoteImageRefValue; +} + +export function remoteImageRef(value: RemoteImageRefValue): RemoteImageRef { + return { [REMOTE_IMAGE_REF_KEY]: value }; +} + +export function isRemoteImageRef(value: unknown): value is RemoteImageRef { + return readRemoteImageRef(value) !== null; +} + +export function readRemoteImageRef(value: unknown): RemoteImageRefValue | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const candidate = (value as Record)[REMOTE_IMAGE_REF_KEY]; + if (!candidate || typeof candidate !== "object") return null; + const ref = candidate as Partial; + if (typeof ref.threadId !== "string" || ref.threadId.length === 0) return null; + if (typeof ref.itemId !== "string" || ref.itemId.length === 0) return null; + if (typeof ref.mime !== "string" || !ref.mime.startsWith("image/")) return null; + if (typeof ref.bytes !== "number") return null; + if (!Array.isArray(ref.path) || ref.path.length === 0) return null; + if (!ref.path.every((part) => typeof part === "string" || typeof part === "number")) return null; + // A preview is decoration: reject anything that is not an inline data URL so a + // malformed reference can never point an at a remote origin. + if (ref.preview !== undefined && !/^data:image\//i.test(ref.preview)) return null; + return ref as RemoteImageRefValue; +} + +/** True when the payload carries a reference the UI would display. */ +export function payloadHasImageRef(payload: unknown): boolean { + return findDisplayableImageRef(payload) !== null; +} + +/** + * The first reference the renderer would display. + * + * Restricted to the display-relevant locations — NOT a deep search. The host + * also references inline images buried elsewhere in a tool result (they are dead + * weight on the wire), and those must keep rendering as a plain tool-call + * accordion exactly as their inline form did. Searching deeply here would turn + * every screenshot-carrying MCP result into an image card. + */ +export function findDisplayableImageRef(payload: unknown): RemoteImageRefValue | null { + for (const path of enumerateDisplayImageCandidatePaths(payload)) { + const ref = readRemoteImageRef(readAtInlineImagePath(payload, path)); + if (ref) return ref; + } + return null; +} + +/** Relative request path for resolving a reference to bytes. */ +export function remoteImageRefPath(ref: RemoteImageRefValue): string { + const search = new URLSearchParams({ path: JSON.stringify(ref.path) }); + return `/api/threads/${encodeURIComponent(ref.threadId)}/items/${encodeURIComponent(ref.itemId)}/image?${search.toString()}`; +} diff --git a/src/shared/remote/index.ts b/src/shared/remote/index.ts index bbdbbde93..970a150d3 100644 --- a/src/shared/remote/index.ts +++ b/src/shared/remote/index.ts @@ -1,2 +1,4 @@ export * from "./protocol"; export * from "./gitProcedures"; +export * from "./omittedPayload"; +export * from "./imageRef"; diff --git a/src/shared/remote/omittedPayload.ts b/src/shared/remote/omittedPayload.ts new file mode 100644 index 000000000..b0a46604f --- /dev/null +++ b/src/shared/remote/omittedPayload.ts @@ -0,0 +1,51 @@ +/** + * Marker for a runtime-item payload field the host refused to put on the + * WebSocket because the event was too large to deliver. + * + * Why this exists: `RemoteAccessServer.sendRaw` terminates a socket whenever + * `bufferedAmount + messageBytes` exceeds the outbound budget (4 MB by + * default). A single oversized event — in practice an `image_view` / + * `mcp_tool_call` payload carrying a multi-megabyte inline base64 image — + * therefore disconnects *every* connected client, and because the same event + * stays in the replay buffer, the reconnecting client is dropped again on + * replay. That loop only ends when the replay window rolls over. Capping the + * event at the publish boundary keeps the live stream deliverable. + * + * The omitted bytes are never lost: the full payload is persisted to SQLite + * before the cap is applied, so the authoritative copy arrives over HTTP the + * next time the client fetches that thread's history. + * + * The value is REPLACED rather than deleted on purpose. `item.updated` payloads + * are shallow-merged into the previous payload client-side (see + * `runtimeEventReducer.mergePayload`), so deleting the key would silently leave + * the previous — possibly stale — value in place instead of recording that + * something was withheld. + */ + +export const REMOTE_OMITTED_FIELD_KEY = "__poracodeOmitted"; + +export interface RemoteOmittedField { + readonly [REMOTE_OMITTED_FIELD_KEY]: { + /** Serialized size of the withheld value, for diagnostics/UI. */ + readonly bytes: number; + }; +} + +export function remoteOmittedField(bytes: number): RemoteOmittedField { + return { [REMOTE_OMITTED_FIELD_KEY]: { bytes } }; +} + +export function isRemoteOmittedField(value: unknown): value is RemoteOmittedField { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const marker = (value as Record)[REMOTE_OMITTED_FIELD_KEY]; + if (!marker || typeof marker !== "object") return false; + return typeof (marker as { bytes?: unknown }).bytes === "number"; +} + +/** True when any top-level field of `payload` was withheld by the size cap. */ +export function payloadHasOmittedField(payload: unknown): boolean { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return isRemoteOmittedField(payload); + } + return Object.values(payload as Record).some(isRemoteOmittedField); +} diff --git a/src/shared/remote/protocol.ts b/src/shared/remote/protocol.ts index 05d97afd1..b517e50e2 100644 --- a/src/shared/remote/protocol.ts +++ b/src/shared/remote/protocol.ts @@ -11,6 +11,7 @@ import { threadSchema, } from "../contracts"; import { persistedCompletedTurnSchema, persistedRuntimeItemSchema } from "../ipc/schemas"; +import { gitStateInterestSchema, gitStatePatchSchema, gitStateSnapshotSchema } from "../gitState"; import { sharedSettingsSchema } from "../settings"; export const PORACODE_REMOTE_PROTOCOL_VERSION = 1; @@ -182,6 +183,12 @@ export const remoteGitSummariesEventSchema = z.object({ }); export type RemoteGitSummariesEvent = z.infer; +export const remoteGitStateEventSchema = z.object({ + type: z.literal("remote-git-state"), + patch: gitStatePatchSchema, +}); +export type RemoteGitStateEvent = z.infer; + /** * Remote project management. Lets a paired client add/clone/remove projects on * the desktop or a headless server. Locations are referenced by an absolute @@ -496,6 +503,8 @@ export const remoteShellSnapshotSchema = z.object({ runtimeSummariesByThread: z.record(z.string(), remoteRuntimeSummarySchema), /** Absent on desktops that predate git summaries. */ gitSummariesByThread: remoteGitSummariesSchema.optional(), + /** Normalized host-owned Git/PR state. Absent on legacy hosts. */ + gitState: gitStateSnapshotSchema.optional(), updatedAt: z.string().min(1), }); export type RemoteShellSnapshot = z.infer; @@ -749,6 +758,29 @@ export const remoteWebSocketClientMessageSchema = z.discriminatedUnion("type", [ // they only stream to clients that opted in via terminal-watch. z.object({ type: z.literal("terminal-watch"), id: z.string().min(1) }), z.object({ type: z.literal("terminal-unwatch"), id: z.string().min(1) }), + z.object({ + type: z.literal("git-state-interests"), + interests: z.array(gitStateInterestSchema).max(500), + }), + /** + * Threads this client wants live transcript *content* for. Runtime item and + * text-delta events for any other thread are withheld — a phone viewing one + * thread otherwise downloads every other thread's tool payloads too. + * + * Scoped to bulk content ONLY. Lifecycle and interaction events + * (`request.opened`/`request.resolved`, `turn.*`, `session.*`, warnings, + * errors, context/usage) always reach every client regardless of this list: + * a permission prompt on a thread the user is not looking at must still + * surface, and `RemoteThreadSnapshot` carries no open-requests field to + * recover it from later. + * + * A client that never sends this message keeps receiving everything, so older + * clients are unaffected. + */ + z.object({ + type: z.literal("thread-item-interests"), + threadIds: z.array(z.string().min(1)).max(200), + }), ]); export type RemoteWebSocketClientMessage = z.infer;