From f39fbd14ba4fb6203894bf94e3bf2d6874a594f4 Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 00:45:00 +0800 Subject: [PATCH 01/31] perf: optimize GitHub transport and caching --- README.md | 19 +- README.zh-CN.md | 8 +- src/github-resource-intake.server.test.ts | 345 +++++++++++++------ src/github-resource-intake.server.ts | 397 +++++++++++++--------- src/workbench-ui.client.tsx | 77 +++-- test.setup.ts | 1 + 6 files changed, 539 insertions(+), 308 deletions(-) diff --git a/README.md b/README.md index 6e736ff..e492575 100644 --- a/README.md +++ b/README.md @@ -44,17 +44,19 @@ detail action opens an existing Paseo workspace or creates one when needed. ## Requirements - Paseo 0.7 or newer. -- [GitHub CLI](https://cli.github.com/) (gh) installed on the machine running - the Paseo daemon. -- gh auth login completed for the GitHub account whose resources you want to - see. Accessing private repositories requires a token with the corresponding - repository permissions. +- Preferred: a GitHub token in `GH_TOKEN` (or `GITHUB_TOKEN`) on the machine + running the Paseo daemon. This uses GitHub's GraphQL API directly and does + not require the GitHub CLI. The token needs access to the repositories you + want to see. +- Or: [GitHub CLI](https://cli.github.com/) (gh) installed and authenticated + with `gh auth login` on the Paseo daemon host. This remains the zero-config + fallback. - Git installed for workspace and worktree actions. ## Known limitations -- GitHub Workbench supports GitHub only and relies on the GitHub CLI (gh) on - the Paseo daemon host. Other code-hosting providers are not supported. +- GitHub Workbench supports GitHub only. Other code-hosting providers are not + supported. ## Install @@ -64,7 +66,8 @@ Install directly from GitHub: Then open Paseo's plugin settings, enable plugins if necessary, and enable **GitHub Workbench**. Plugins are trusted code: its server side runs on the -Paseo daemon host and can invoke gh and git with that user's permissions. +Paseo daemon host and can access `GH_TOKEN`/`GITHUB_TOKEN` or invoke gh and +git with that user's permissions. To inspect its lifecycle or update a tracked installation: diff --git a/README.zh-CN.md b/README.zh-CN.md index 1c1fccf..f8cf55b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -28,13 +28,13 @@ GitHub Workbench 是一个 [Paseo](https://github.com/getpaseo/paseo) 插件, ## 环境要求 - Paseo 0.7 或更高版本。 -- 在运行 Paseo 守护进程的机器上安装 [GitHub CLI](https://cli.github.com/)(`gh`)。 -- 已为需要查看其资源的 GitHub 账户完成 `gh auth login`。访问私有仓库需要具有相应仓库权限的令牌。 +- 推荐:在运行 Paseo 守护进程的机器上设置 `GH_TOKEN`(或 `GITHUB_TOKEN`)。插件会直接调用 GitHub GraphQL API,无需安装 GitHub CLI;令牌需要具备访问目标仓库的权限。 +- 或者:在 Paseo 守护进程宿主机上安装 [GitHub CLI](https://cli.github.com/)(`gh`),并完成 `gh auth login`。这是无需额外配置令牌的兼容方案。 - 已安装 Git,以使用工作区和 worktree 功能。 ## 已知限制 -- GitHub Workbench 仅支持 GitHub,并依赖 Paseo 守护进程宿主机上的 GitHub CLI(`gh`);暂不支持其他代码托管平台。 +- GitHub Workbench 仅支持 GitHub;暂不支持其他代码托管平台。 ## 安装 @@ -42,7 +42,7 @@ GitHub Workbench 是一个 [Paseo](https://github.com/getpaseo/paseo) 插件, paseo plugin add AllenReder/paseo-github-workbench --ref main -然后打开 Paseo 的插件设置:如有需要先启用插件功能,再启用 **GitHub Workbench**。插件属于受信任代码:其服务端代码会在 Paseo 守护进程宿主机上运行,并可使用该用户的权限调用 `gh` 和 `git`。 +然后打开 Paseo 的插件设置:如有需要先启用插件功能,再启用 **GitHub Workbench**。插件属于受信任代码:其服务端代码会在 Paseo 守护进程宿主机上运行,并可访问 `GH_TOKEN`/`GITHUB_TOKEN`,或使用该用户的权限调用 `gh` 和 `git`。 查看插件状态、日志或更新已跟踪的安装: diff --git a/src/github-resource-intake.server.test.ts b/src/github-resource-intake.server.test.ts index e02f55c..16622cf 100644 --- a/src/github-resource-intake.server.test.ts +++ b/src/github-resource-intake.server.test.ts @@ -2,68 +2,73 @@ import { describe, expect, it } from "bun:test"; import { createGitHubResourceIntake } from "./github-resource-intake.server"; describe("GitHubResourceIntake", () => { - it("coalesces concurrent repository queries and caches results for 30 seconds", async () => { - let prCalls = 0; - let issueCalls = 0; + it("coalesces concurrent repository queries and caches the result", async () => { + let calls = 0; const intake = createGitHubResourceIntake(async (args) => { - if (args[0] === "pr") { - prCalls += 1; - return { - stdout: JSON.stringify([ - { - number: 42, - title: "Test PR", - url: "https://github.com/getpaseo/paseo/pull/42", - body: "PR list description", - author: { login: "alice" }, - headRefName: "feature-branch", - baseRefName: "main", - isDraft: false, - labels: { nodes: [{ name: "enhancement" }] }, - createdAt: "2026-02-01T00:00:00Z", - updatedAt: "2026-02-02T00:00:00Z", - reviewDecision: "APPROVED", - statusCheckRollup: { - state: "SUCCESS", - contexts: { - nodes: [ - { - name: "test", - status: "COMPLETED", - conclusion: "SUCCESS", + expect(args.slice(0, 2)).toEqual(["api", "graphql"]); + expect(args.join(" ")).toContain("contexts(first: 20)"); + calls += 1; + return { + stdout: JSON.stringify({ + data: { + repository: { + pullRequests: { + nodes: [ + { + number: 42, + title: "Test PR", + url: "https://github.com/getpaseo/paseo/pull/42", + body: "PR list description", + author: { login: "alice" }, + repository: { nameWithOwner: "getpaseo/paseo" }, + assignees: { nodes: [] }, + headRefName: "feature-branch", + baseRefName: "main", + isDraft: false, + labels: { nodes: [{ name: "enhancement" }] }, + createdAt: "2026-02-01T00:00:00Z", + updatedAt: "2026-02-02T00:00:00Z", + reviewDecision: "APPROVED", + statusCheckRollup: { + state: "SUCCESS", + contexts: { + nodes: [ + { + name: "test", + status: "COMPLETED", + conclusion: "SUCCESS", + }, + ], + }, }, - ], - }, + mergeable: "MERGEABLE", + comments: { totalCount: 2 }, + }, + ], + }, + issues: { + nodes: [ + { + number: 99, + title: "Test Issue", + body: "Issue list description", + url: "https://github.com/getpaseo/paseo/issues/99", + repository: { nameWithOwner: "getpaseo/paseo" }, + author: { login: "bob" }, + assignees: { nodes: [] }, + labels: { nodes: [] }, + milestone: null, + comments: { totalCount: 0 }, + createdAt: "2026-02-01T00:00:00Z", + updatedAt: "2026-02-01T00:00:00Z", + }, + ], }, - mergeable: "MERGEABLE", - comments: { totalCount: 2 }, - }, - ]), - stderr: "", - }; - } - if (args[0] === "issue") { - issueCalls += 1; - return { - stdout: JSON.stringify([ - { - number: 99, - title: "Test Issue", - body: "Issue list description", - url: "https://github.com/getpaseo/paseo/issues/99", - author: { login: "bob" }, - assignees: { nodes: [] }, - labels: { nodes: [] }, - milestone: null, - comments: { totalCount: 0 }, - createdAt: "2026-02-01T00:00:00Z", - updatedAt: "2026-02-01T00:00:00Z", }, - ]), - stderr: "", - }; - } - throw new Error(`Unexpected command: ${args.join(" ")}`); + }, + }), + stderr: "", + }; }); const [first, second] = await Promise.all([ @@ -77,8 +82,7 @@ describe("GitHubResourceIntake", () => { }), ]); - expect(prCalls).toBe(1); - expect(issueCalls).toBe(1); + expect(calls).toBe(1); expect( first.resources.find((resource) => resource.kind === "pull-request") ?.body, @@ -95,8 +99,7 @@ describe("GitHubResourceIntake", () => { scope: "repository", repository: "getpaseo/paseo", }); - expect(prCalls).toBe(1); - expect(issueCalls).toBe(1); + expect(calls).toBe(1); expect(third.resources).toHaveLength(2); const fourth = await intake.listResources({ @@ -104,8 +107,7 @@ describe("GitHubResourceIntake", () => { repository: "getpaseo/paseo", forceRefresh: true, }); - expect(prCalls).toBe(2); - expect(issueCalls).toBe(2); + expect(calls).toBe(2); expect(fourth.resources).toHaveLength(2); }); @@ -123,7 +125,7 @@ describe("GitHubResourceIntake", () => { repository: "getpaseo/paseo", }); - expect(calls).toBe(2); // pr list and issue list run concurrently + expect(calls).toBe(1); // one batched GraphQL query for the repository expect(result.resources).toHaveLength(0); expect(result.warnings).toHaveLength(1); expect(result.warnings[0].code).toBe("gh-cli-not-found"); @@ -133,14 +135,44 @@ describe("GitHubResourceIntake", () => { scope: "repository", repository: "getpaseo/paseo", }); - expect(calls).toBe(4); + expect(calls).toBe(2); + }); + + it("does not cache a null repository GraphQL error as an empty result", async () => { + let calls = 0; + const intake = createGitHubResourceIntake(async () => { + calls += 1; + return { + stdout: JSON.stringify({ + data: { repository: null }, + errors: [ + { + message: + "Could not resolve to a Repository with the name 'missing/repo'.", + }, + ], + }), + stderr: "", + }; + }); + + const first = await intake.listResources({ + scope: "repository", + repository: "missing/repo", + }); + const second = await intake.listResources({ + scope: "repository", + repository: "missing/repo", + }); + + expect(first.resources).toHaveLength(0); + expect(first.warnings[0]?.code).toBe("repository-unavailable"); + expect(second.warnings[0]?.code).toBe("repository-unavailable"); + expect(calls).toBe(2); }); it("handles account scope, resolves viewer, and merges relationship flags", async () => { const intake = createGitHubResourceIntake(async (args) => { - if (args[0] === "api" && args[1] === "user") { - return { stdout: "octocat\n", stderr: "" }; - } if (args[0] === "api" && args[1] === "graphql") { const queryArg = args.find((arg) => arg.startsWith("query=")) ?? @@ -167,6 +199,13 @@ describe("GitHubResourceIntake", () => { expect(hadOpenBrace).toBe(true); expect(braceDepth).toBe(0); + if (rawQuery.includes("WorkbenchViewer")) { + return { + stdout: JSON.stringify({ data: { viewer: { login: "octocat" } } }), + stderr: "", + }; + } + return { stdout: JSON.stringify({ data: { @@ -269,29 +308,37 @@ describe("GitHubResourceIntake", () => { expect(issue?.isAssignedToMe).toBe(true); }); - it("marks a completed successful gh pr view check rollup as passing", async () => { + it("marks a completed successful GraphQL check rollup as passing", async () => { const intake = createGitHubResourceIntake(async () => ({ stdout: JSON.stringify({ - number: 411, - title: "Completed CI", - url: "https://github.com/AllenReder/mc-agent-runtime/pull/411", - author: { login: "AllenReder" }, - headRefName: "fast-insect", - baseRefName: "main", - isDraft: false, - labels: [], - updatedAt: "2026-09-02T08:50:19Z", - createdAt: "2026-09-02T08:45:43Z", - reviewDecision: "", - statusCheckRollup: [ - { - name: "TypeScript checks", - status: "COMPLETED", - conclusion: "SUCCESS", + data: { + repository: { + pullRequest: { + number: 411, + title: "Completed CI", + url: "https://github.com/AllenReder/mc-agent-runtime/pull/411", + repository: { nameWithOwner: "AllenReder/mc-agent-runtime" }, + author: { login: "AllenReder" }, + assignees: { nodes: [] }, + headRefName: "fast-insect", + baseRefName: "main", + isDraft: false, + labels: [], + updatedAt: "2026-09-02T08:50:19Z", + createdAt: "2026-09-02T08:45:43Z", + reviewDecision: "", + statusCheckRollup: [ + { + name: "TypeScript checks", + status: "COMPLETED", + conclusion: "SUCCESS", + }, + ], + mergeable: "MERGEABLE", + comments: [], + }, }, - ], - mergeable: "MERGEABLE", - comments: [], + }, }), stderr: "", })); @@ -307,28 +354,40 @@ describe("GitHubResourceIntake", () => { checkDetails: [{ name: "TypeScript checks", status: "success" }], }); }); - it("refreshes a single resource with pr view or issue view", async () => { + it("refreshes a single resource with GraphQL", async () => { const intake = createGitHubResourceIntake(async (args) => { - if (args[0] === "pr" && args[1] === "view") { + if (args[0] === "api" && args[1] === "graphql") { return { stdout: JSON.stringify({ - number: 15, - title: "Refreshed PR", - body: "Refreshed description", - url: "https://github.com/owner/repo/pull/15", - author: { login: "dev" }, - headRefName: "feature", - baseRefName: "main", - isDraft: false, - labels: [], - updatedAt: "2026-02-05T00:00:00Z", - createdAt: "2026-02-01T00:00:00Z", - reviewDecision: "CHANGES_REQUESTED", - statusCheckRollup: [ - { name: "build", status: "COMPLETED", conclusion: "FAILURE" }, - ], - mergeable: "CONFLICTING", - comments: 5, + data: { + repository: { + pullRequest: { + number: 15, + title: "Refreshed PR", + body: "Refreshed description", + url: "https://github.com/owner/repo/pull/15", + repository: { nameWithOwner: "owner/repo" }, + author: { login: "dev" }, + assignees: { nodes: [] }, + headRefName: "feature", + baseRefName: "main", + isDraft: false, + labels: [], + updatedAt: "2026-02-05T00:00:00Z", + createdAt: "2026-02-01T00:00:00Z", + reviewDecision: "CHANGES_REQUESTED", + statusCheckRollup: [ + { + name: "build", + status: "COMPLETED", + conclusion: "FAILURE", + }, + ], + mergeable: "CONFLICTING", + comments: 5, + }, + }, + }, }), stderr: "", }; @@ -373,4 +432,74 @@ describe("GitHubResourceIntake", () => { "The GitHub repository is unavailable or you do not have access.", ); }); + + it("uses GH_TOKEN's native GraphQL transport without starting gh", async () => { + let ghCalls = 0; + let fetchCalls = 0; + const intake = createGitHubResourceIntake( + async () => { + ghCalls += 1; + throw new Error("gh must not run when a token is configured"); + }, + { + token: "test-token", + fetch: async (_url, init) => { + fetchCalls += 1; + const headers = init?.headers ?? {}; + expect((headers as Record).Authorization).toBe( + "Bearer test-token", + ); + return new Response( + JSON.stringify({ + data: { + repository: { + pullRequests: { nodes: [] }, + issues: { + nodes: [ + { + number: 99, + title: "Native API issue", + body: "No subprocess required", + url: "https://github.com/owner/repo/issues/99", + repository: { nameWithOwner: "owner/repo" }, + author: { login: "octocat" }, + assignees: { nodes: [] }, + labels: { nodes: [] }, + comments: { totalCount: 0 }, + createdAt: "2026-02-01T00:00:00Z", + updatedAt: "2026-02-01T00:00:00Z", + }, + ], + }, + }, + }, + errors: [{ message: "Resource not accessible by integration" }], + }), + { status: 200 }, + ); + }, + }, + ); + + const first = await intake.listResources({ + scope: "repository", + repository: "owner/repo", + }); + const second = await intake.listResources({ + scope: "repository", + repository: "owner/repo", + }); + + expect(first.resources).toHaveLength(1); + expect(second.resources).toHaveLength(1); + expect(first.warnings).toEqual([ + { + code: "github-query-failed", + message: + "Some GitHub fields could not be loaded: Resource not accessible by integration", + }, + ]); + expect(fetchCalls).toBe(1); + expect(ghCalls).toBe(0); + }); }); diff --git a/src/github-resource-intake.server.ts b/src/github-resource-intake.server.ts index f2645c0..7369666 100644 --- a/src/github-resource-intake.server.ts +++ b/src/github-resource-intake.server.ts @@ -15,6 +15,21 @@ export type GitHubCommandRunner = ( args: readonly string[], ) => Promise<{ stdout: string; stderr: string }>; +export type GitHubFetch = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + +export type GitHubResourceIntakeOptions = { + /** + * Uses GitHub's HTTP API directly when set. `undefined` reads GH_TOKEN or + * GITHUB_TOKEN from the daemon environment; `null` explicitly uses gh. + */ + token?: string | null; + fetch?: GitHubFetch; + apiUrl?: string; +}; + export type GitHubResourceIntake = { listResources( input: z.infer, @@ -26,65 +41,96 @@ export type GitHubResourceIntake = { type Warning = z.infer; type CacheValue = z.infer; +type GraphqlResult = { data: Record; error: string | null }; +type ResourceLoad = { resources: GitHubResource[]; warnings: Warning[] }; const execFile = promisify(execFileCallback); -const CACHE_TTL_MS = 30_000; - -const pullRequestJsonFields = [ - "number", - "title", - "url", - "body", - "author", - "headRefName", - "baseRefName", - "isDraft", - "state", - "mergedAt", - "closedAt", - "labels", - "updatedAt", - "createdAt", - "reviewDecision", - "statusCheckRollup", - "mergeable", - "comments", -].join(","); - -const issueJsonFields = [ - "number", - "title", - "url", - "body", - "author", - "assignees", - "labels", - "milestone", - "comments", - "createdAt", - "updatedAt", - "closedAt", - "state", -].join(","); +// Keep this slightly shorter than the five-minute client poll interval so a +// scheduled refetch always reaches GitHub rather than extending stale data. +const CACHE_TTL_MS = 4 * 60_000; +const GH_COMMAND_TIMEOUT_MS = 60_000; +const GH_COMMAND_MAX_BUFFER_BYTES = 8 * 1024 * 1024; +const MAX_CHECK_DETAILS_PER_PULL_REQUEST = 20; +const GITHUB_GRAPHQL_URL = "https://api.github.com/graphql"; + +const pullRequestSelection = ` +number title body url state mergedAt closedAt createdAt updatedAt isDraft headRefName baseRefName mergeable reviewDecision comments { totalCount } author { login } repository { nameWithOwner } labels(first: 20) { nodes { name } } assignees(first: 20) { nodes { login } } statusCheckRollup { state contexts(first: ${MAX_CHECK_DETAILS_PER_PULL_REQUEST}) { nodes { ... on CheckRun { name status conclusion } ... on StatusContext { context state } } } } +`; + +const issueSelection = ` +number title body url state closedAt createdAt updatedAt author { login } repository { nameWithOwner } labels(first: 20) { nodes { name } } assignees(first: 20) { nodes { login } } milestone { title } comments { totalCount } +`; const accountQuery = ` query Workbench($authoredPr: String!, $reviewPr: String!, $authoredIssue: String!, $assignedIssue: String!) { - authoredPr: search(query: $authoredPr, type: ISSUE, first: 100) { nodes { ... on PullRequest { number title body url state mergedAt closedAt createdAt updatedAt isDraft headRefName baseRefName mergeable reviewDecision comments { totalCount } author { login } repository { nameWithOwner } labels(first: 20) { nodes { name } } assignees(first: 20) { nodes { login } } statusCheckRollup { state contexts(first: 100) { nodes { ... on CheckRun { name status conclusion } ... on StatusContext { context state } } } } } } } - reviewPr: search(query: $reviewPr, type: ISSUE, first: 100) { nodes { ... on PullRequest { number title body url state mergedAt closedAt createdAt updatedAt isDraft headRefName baseRefName mergeable reviewDecision comments { totalCount } author { login } repository { nameWithOwner } labels(first: 20) { nodes { name } } assignees(first: 20) { nodes { login } } statusCheckRollup { state contexts(first: 100) { nodes { ... on CheckRun { name status conclusion } ... on StatusContext { context state } } } } } } } - authoredIssue: search(query: $authoredIssue, type: ISSUE, first: 100) { nodes { ... on Issue { number title body url state closedAt createdAt updatedAt author { login } repository { nameWithOwner } labels(first: 20) { nodes { name } } assignees(first: 20) { nodes { login } } milestone { title } comments { totalCount } } } } - assignedIssue: search(query: $assignedIssue, type: ISSUE, first: 100) { nodes { ... on Issue { number title body url state closedAt createdAt updatedAt author { login } repository { nameWithOwner } labels(first: 20) { nodes { name } } assignees(first: 20) { nodes { login } } milestone { title } comments { totalCount } } } } + authoredPr: search(query: $authoredPr, type: ISSUE, first: 100) { nodes { ... on PullRequest { ${pullRequestSelection} } } } + reviewPr: search(query: $reviewPr, type: ISSUE, first: 100) { nodes { ... on PullRequest { ${pullRequestSelection} } } } + authoredIssue: search(query: $authoredIssue, type: ISSUE, first: 100) { nodes { ... on Issue { ${issueSelection} } } } + assignedIssue: search(query: $assignedIssue, type: ISSUE, first: 100) { nodes { ... on Issue { ${issueSelection} } } } }`; +const viewerQuery = `query WorkbenchViewer { viewer { login } }`; + +const repositoryQuery = ` +query WorkbenchRepository($owner: String!, $name: String!, $pullRequestState: PullRequestState!, $issueState: IssueState!, $includeIssues: Boolean!) { + repository(owner: $owner, name: $name) { + pullRequests(states: [$pullRequestState], first: 100, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { ${pullRequestSelection} } } + issues(states: [$issueState], first: 100, orderBy: { field: UPDATED_AT, direction: DESC }) @include(if: $includeIssues) { nodes { ${issueSelection} } } + } +}`; + +function resourceQuery(kind: "pull-request" | "issue"): string { + const field = kind === "pull-request" ? "pullRequest" : "issue"; + const selection = + kind === "pull-request" ? pullRequestSelection : issueSelection; + return ` +query WorkbenchResource($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + ${field}(number: $number) { ${selection} } + } +}`; +} + function defaultCommandRunner( args: readonly string[], ): Promise<{ stdout: string; stderr: string }> { return execFile("gh", args, { - timeout: 30_000, - maxBuffer: 4 * 1024 * 1024, + // GraphQL can return hundreds of resources. Bound it, but do not discard a + // valid large response while gh is still parsing its output. + timeout: GH_COMMAND_TIMEOUT_MS, + maxBuffer: GH_COMMAND_MAX_BUFFER_BYTES, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, }); } +function environmentToken(): string | null { + const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN; + return token?.trim() || null; +} + +function graphqlErrorMessage(value: unknown): string | null { + const root = asRecord(value); + const errors = Array.isArray(root?.errors) ? root.errors : []; + const messages = errors.flatMap((error) => { + const message = asString(asRecord(error)?.message); + return message ? [message] : []; + }); + return messages.length > 0 ? messages.join("\n") : null; +} + +function partialDataWarning(error: string): Warning { + return { + code: "github-query-failed", + message: `Some GitHub fields could not be loaded: ${error}`, + }; +} + +function splitRepository(repository: string): [string, string] { + const [owner, name] = repository.split("/", 2); + if (!owner || !name) throw new Error("GitHub repository must be owner/name."); + return [owner, name]; +} + function errorWarning(error: unknown): Warning { const record = error as NodeJS.ErrnoException & { stderr?: string; @@ -96,11 +142,15 @@ function errorWarning(error: unknown): Warning { code: "gh-cli-not-found", message: "GitHub CLI (gh) is not installed on the Paseo daemon host.", }; - if (/authentication|not logged in|auth login/i.test(text)) + if ( + /authentication|not logged in|auth login|bad credentials|HTTP 401/i.test( + text, + ) + ) return { code: "gh-not-authenticated", message: - "Authenticate gh on the Paseo daemon host before using GitHub Workbench.", + "Authenticate gh or configure GH_TOKEN on the Paseo daemon host before using GitHub Workbench.", }; if (/rate limit|api rate limit|HTTP 403/i.test(text)) return { @@ -115,7 +165,7 @@ function errorWarning(error: unknown): Warning { }; return { code: "github-query-failed", - message: text || "GitHub CLI query failed.", + message: text || "GitHub query failed.", }; } @@ -377,11 +427,74 @@ function mergeResources(resources: GitHubResource[]): GitHubResource[] { export function createGitHubResourceIntake( run: GitHubCommandRunner = defaultCommandRunner, + options: GitHubResourceIntakeOptions = {}, ): GitHubResourceIntake { const cache = new Map(); const inFlight = new Map>(); const viewerLogins = new Map(); const viewerInFlight = new Map>(); + const token = + options.token === undefined + ? environmentToken() + : options.token?.trim() || null; + const fetcher = options.fetch ?? globalThis.fetch; + const apiUrl = options.apiUrl ?? GITHUB_GRAPHQL_URL; + + async function graphql( + query: string, + variables: Record, + ): Promise { + if (!token) { + const args = ["api", "graphql", "-f", `query=${query}`]; + for (const [key, value] of Object.entries(variables)) { + args.push(typeof value === "string" ? "-f" : "-F", `${key}=${value}`); + } + const { stdout } = await run(args); + const parsed: unknown = JSON.parse(stdout); + const error = graphqlErrorMessage(parsed); + const data = asRecord(asRecord(parsed)?.data); + // GitHub can return usable partial data when a fine-grained token lacks + // access to an optional field such as statusCheckRollup. Keep the list + // available in that case instead of turning the entire workbench blank. + if (error && !data) throw new Error(error); + return { data: data ?? {}, error }; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 30_000); + try { + const response = await fetcher(apiUrl, { + method: "POST", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + }, + body: JSON.stringify({ query, variables }), + signal: controller.signal, + }); + const body = await response.text(); + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + throw new Error( + `GitHub API returned invalid JSON (HTTP ${response.status}).`, + ); + } + const error = graphqlErrorMessage(parsed); + const data = asRecord(asRecord(parsed)?.data); + if (!response.ok || (error && !data)) { + throw new Error( + error ?? `GitHub API request failed with HTTP ${response.status}.`, + ); + } + return { data: data ?? {}, error }; + } finally { + clearTimeout(timeout); + } + } async function getViewerLogin(): Promise { const cached = viewerLogins.get("github.com"); @@ -390,10 +503,12 @@ export function createGitHubResourceIntake( if (running) return running; const request = (async () => { try { - const { stdout } = await run(["api", "user", "--jq", ".login"]); - const login = stdout.trim(); + const result = await graphql(viewerQuery, {}); + const login = asString(asRecord(result.data.viewer)?.login) ?? ""; if (!login) - throw new Error("GitHub CLI returned no authenticated login."); + throw new Error( + result.error ?? "GitHub returned no authenticated login.", + ); viewerLogins.set("github.com", { value: login, expiresAt: Date.now() + CACHE_TTL_MS, @@ -410,76 +525,53 @@ export function createGitHubResourceIntake( async function repositoryResources( repository: string, state: "open" | "merged" | "closed" = "open", - ): Promise { - const prState = - state === "merged" ? "merged" : state === "closed" ? "closed" : "open"; - const issueState = state === "closed" ? "closed" : "open"; - const queries: Array> = [ - run([ - "pr", - "list", - "--repo", - repository, - "--state", - prState, - "--limit", - "100", - "--json", - pullRequestJsonFields, - ]), - ]; - if (state !== "merged") { - queries.push( - run([ - "issue", - "list", - "--repo", - repository, - "--state", - issueState, - "--limit", - "100", - "--json", - issueJsonFields, - ]), + ): Promise { + const [owner, name] = splitRepository(repository); + const result = await graphql(repositoryQuery, { + owner, + name, + pullRequestState: + state === "merged" ? "MERGED" : state === "closed" ? "CLOSED" : "OPEN", + issueState: state === "closed" ? "CLOSED" : "OPEN", + includeIssues: state !== "merged", + }); + const record = asRecord(result.data.repository); + if (!record) + throw new Error( + result.error ?? "GitHub returned no data for this repository.", ); - } - const [pullRequests, issues] = await Promise.all(queries); - const parse = (text: string) => { - const value: unknown = JSON.parse(text); - return Array.isArray(value) - ? value.flatMap((item) => { - const itemRecord = asRecord(item); - return itemRecord ? [itemRecord] : []; + const nodes = (field: "pullRequests" | "issues") => { + const connection = asRecord(record?.[field]); + return Array.isArray(connection?.nodes) + ? connection.nodes.flatMap((item) => { + const resource = asRecord(item); + return resource ? [resource] : []; }) : []; }; - const decorateRepository = (record: Record) => ({ - ...record, - repository: { nameWithOwner: repository }, - }); - const prItems = parse(pullRequests.stdout).flatMap((record) => { - const item = makePullRequest(decorateRepository(record), { + const prItems = nodes("pullRequests").flatMap((item) => { + const resource = makePullRequest(item, { isMine: false, reviewRequestedFromMe: false, }); - return item ? [item] : []; + return resource ? [resource] : []; }); - const issueItems = issues - ? parse(issues.stdout).flatMap((record) => { - const item = makeIssue(decorateRepository(record), { - isMine: false, - isAssignedToMe: false, - }); - return item ? [item] : []; - }) - : []; - return mergeResources([...prItems, ...issueItems]); + const issueItems = nodes("issues").flatMap((item) => { + const resource = makeIssue(item, { + isMine: false, + isAssignedToMe: false, + }); + return resource ? [resource] : []; + }); + return { + resources: mergeResources([...prItems, ...issueItems]), + warnings: result.error ? [partialDataWarning(result.error)] : [], + }; } async function accountResources( state: "open" | "merged" | "closed" = "open", - ): Promise { + ): Promise { const viewer = await getViewerLogin(); const prQualifier = state === "open" @@ -488,25 +580,27 @@ export function createGitHubResourceIntake( ? "is:merged" : "is:closed -is:merged"; const issueQualifier = state === "closed" ? "is:closed" : "is:open"; - const { stdout } = await run([ - "api", - "graphql", - "-f", - `query=${accountQuery}`, - "-f", - `authoredPr=is:pr ${prQualifier} author:${viewer}`, - "-f", - `reviewPr=is:pr ${prQualifier} review-requested:${viewer}`, - "-f", - state === "merged" - ? `authoredIssue=is:issue is:closed author:__none__` - : `authoredIssue=is:issue ${issueQualifier} author:${viewer}`, - "-f", - state === "merged" - ? `assignedIssue=is:issue is:closed assignee:__none__` - : `assignedIssue=is:issue ${issueQualifier} assignee:${viewer}`, - ]); - const root = asRecord(asRecord(JSON.parse(stdout))?.data); + const result = await graphql(accountQuery, { + authoredPr: `is:pr ${prQualifier} author:${viewer}`, + reviewPr: `is:pr ${prQualifier} review-requested:${viewer}`, + authoredIssue: + state === "merged" + ? "is:issue is:closed author:__none__" + : `is:issue ${issueQualifier} author:${viewer}`, + assignedIssue: + state === "merged" + ? "is:issue is:closed assignee:__none__" + : `is:issue ${issueQualifier} assignee:${viewer}`, + }); + const root = result.data; + const connectionNames = [ + "authoredPr", + "reviewPr", + "authoredIssue", + "assignedIssue", + ]; + if (!connectionNames.some((name) => asRecord(root[name]))) + throw new Error(result.error ?? "GitHub returned no account resources."); const nodes = (name: string) => { const connection = asRecord(root?.[name]); return Array.isArray(connection?.nodes) @@ -551,7 +645,10 @@ export function createGitHubResourceIntake( return item ? [item] : []; }), ]; - return mergeResources([...prItems, ...issueItems]); + return { + resources: mergeResources([...prItems, ...issueItems]), + warnings: result.error ? [partialDataWarning(result.error)] : [], + }; } async function listResources( @@ -567,16 +664,16 @@ export function createGitHubResourceIntake( if (running) return running; const request = (async () => { try { - const resources = + const loaded = input.scope === "account" ? await accountResources(state) : repository ? await repositoryResources(repository, state) - : []; + : { resources: [], warnings: [] }; const value = { - resources, + resources: loaded.resources, refreshedAt: new Date().toISOString(), - warnings: [], + warnings: loaded.warnings, }; cache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS }); return value; @@ -597,33 +694,29 @@ export function createGitHubResourceIntake( async function refreshResource( input: z.infer, ): Promise> { - const command = input.kind === "pull-request" ? "pr" : "issue"; - const fields = - input.kind === "pull-request" ? pullRequestJsonFields : issueJsonFields; try { - const { stdout } = await run([ - command, - "view", - String(input.number), - "--repo", - input.repository, - "--json", - fields, - ]); - const record = asRecord(JSON.parse(stdout)); + const [owner, name] = splitRepository(input.repository); + const result = await graphql(resourceQuery(input.kind), { + owner, + name, + number: input.number, + }); + const record = asRecord( + asRecord(result.data.repository)?.[ + input.kind === "pull-request" ? "pullRequest" : "issue" + ], + ); if (!record) - throw new Error("GitHub returned an invalid resource payload."); - const decorated = { - ...record, - repository: { nameWithOwner: input.repository }, - }; + throw new Error( + result.error ?? "GitHub returned an invalid resource payload.", + ); const resource = input.kind === "pull-request" - ? makePullRequest(decorated, { + ? makePullRequest(record, { isMine: false, reviewRequestedFromMe: false, }) - : makeIssue(decorated, { isMine: false, isAssignedToMe: false }); + : makeIssue(record, { isMine: false, isAssignedToMe: false }); if (!resource) throw new Error("GitHub returned an incomplete resource payload."); return { resource }; diff --git a/src/workbench-ui.client.tsx b/src/workbench-ui.client.tsx index d83bdc5..2e2d8ff 100644 --- a/src/workbench-ui.client.tsx +++ b/src/workbench-ui.client.tsx @@ -4,6 +4,7 @@ import { useToast } from "@getpaseo/plugin/react-native"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + FlatList, Linking, Pressable, ScrollView, @@ -39,6 +40,7 @@ type WorkbenchProps = PluginSurfaceProps & { type ContentTab = "all" | "issue" | "pull-request" | "mine" | "review"; type OwnershipFilter = "all" | "mine" | "assigned" | "review"; type StatusFilter = LifecycleState; +const WORKBENCH_STALE_TIME_MS = 5 * 60_000; export function clampWorkbenchListWidth( availableWidth: number, requestedWidth: number, @@ -68,7 +70,7 @@ function usePaseoDirectory(hostId: string) { ); const query = useQuery({ queryKey, - staleTime: 0, + staleTime: WORKBENCH_STALE_TIME_MS, queryFn: async () => { const workspaces: WorkspaceSnapshot[] = []; const agents: PaseoDirectorySnapshot["agents"] = []; @@ -1056,7 +1058,9 @@ export function Workbench({ scope.scope === "repository" ? `repository:${scope.repository}` : "account"; const query = useQuery({ queryKey, - refetchInterval: 60_000, + staleTime: WORKBENCH_STALE_TIME_MS, + refetchInterval: WORKBENCH_STALE_TIME_MS, + refetchIntervalInBackground: false, queryFn: () => listResources( scope.scope === "repository" @@ -1147,24 +1151,19 @@ export function Workbench({ resource.reviewRequestedFromMe), ).length; const refresh = useCallback(() => { - queryClient - .fetchQuery({ - queryKey: [...queryKey, "forced"], - queryFn: () => - listResources( - scope.scope === "repository" - ? { - scope: "repository", - repository: scope.repository, - state: status, - forceRefresh: true, - } - : { scope: "account", state: status, forceRefresh: true }, - ), - }) - .then(() => query.refetch()) + listResources( + scope.scope === "repository" + ? { + scope: "repository", + repository: scope.repository, + state: status, + forceRefresh: true, + } + : { scope: "account", state: status, forceRefresh: true }, + ) + .then((data) => queryClient.setQueryData(queryKey, data)) .catch(() => undefined); - }, [listResources, query, queryClient, queryKey, scope, status]); + }, [listResources, queryClient, queryKey, scope, status]); const refreshItem = useCallback( (resource: GitHubResource) => { setRefreshingKey(resource.key); @@ -1426,7 +1425,7 @@ export function Workbench({ {warning.message} ))} - {query.isLoading || directory.isLoading ? ( + {query.isLoading ? ( ) : null} - - {rows.map(({ resource }) => ( + resource.key} + renderItem={({ item }) => ( setSelectedKey(resource.key)} + resource={item.resource} + selected={selectedKey === item.resource.key} + onPress={() => setSelectedKey(item.resource.key)} theme={theme} /> - ))} - {!query.isLoading && rows.length === 0 ? ( - - {t("workbench.empty")} - - ) : null} - + )} + initialNumToRender={20} + maxToRenderPerBatch={20} + windowSize={7} + contentContainerStyle={{ gap: 2, padding: 8 }} + style={{ flex: 1 }} + ListEmptyComponent={ + !query.isLoading ? ( + + {t("workbench.empty")} + + ) : null + } + /> ); return ( @@ -1605,6 +1609,7 @@ export function useProjectRepositories( const query = useQuery({ queryKey, enabled: Boolean(projectId), + staleTime: WORKBENCH_STALE_TIME_MS, queryFn: async () => { if (!projectId) return []; const repositories = new Set(); diff --git a/test.setup.ts b/test.setup.ts index aa4848d..4966901 100644 --- a/test.setup.ts +++ b/test.setup.ts @@ -22,6 +22,7 @@ mock.module("@getpaseo/plugin/react-native", () => ({ mock.module("react-native", () => ({ Clipboard: { setString: () => {} }, + FlatList: "FlatList", Linking: { openURL: async () => true }, Pressable: "Pressable", ScrollView: "ScrollView", From 81c3c96a6b08cfac2fbf2fbf144ba685529a0c29 Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 00:49:53 +0800 Subject: [PATCH 02/31] perf: seed project workbench from current workspace --- src/project-workbench.client.tsx | 21 +++++++++++++++++++-- src/workbench-ui.client.tsx | 17 ++++++++++++++--- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/project-workbench.client.tsx b/src/project-workbench.client.tsx index fdcd8b0..1b91171 100644 --- a/src/project-workbench.client.tsx +++ b/src/project-workbench.client.tsx @@ -1,17 +1,34 @@ -import { type PluginWorkspacePanelProps, useWorkspace } from "@getpaseo/plugin"; -import { useEffect, useState } from "react"; +import { + type PluginWorkspacePanelProps, + usePaseo, + useWorkspace, +} from "@getpaseo/plugin"; +import { useEffect, useMemo, useState } from "react"; import { Text, View } from "react-native"; +import { normalizeGitHubRepository } from "./github-workbench.shared"; import { I18nProvider, useTranslation } from "./i18n/context"; import { useProjectRepositories, Workbench } from "./workbench-ui.client"; function ProjectGitHubWorkbenchPanelInner(props: PluginWorkspacePanelProps) { const { t } = useTranslation(); + const paseo = usePaseo(); const workspace = useWorkspace(props.workspaceId, (item) => ({ projectId: item.projectId, })); + const workspaceHandle = useMemo( + () => paseo.workspaces.ref(props.workspaceId), + [paseo, props.workspaceId], + ); + // The plugin workspace snapshot intentionally omits git runtime details, but + // the client handle keeps the full descriptor locally after the workspace is + // opened. Use it as an immediate seed while the project-wide query loads. + const currentRepository = normalizeGitHubRepository( + workspaceHandle.current()?.gitRuntime?.remoteUrl, + ); const repositories = useProjectRepositories( workspace?.projectId ?? null, props.host.id, + currentRepository, ); const [repository, setRepository] = useState(null); const selectedRepository = repository ?? repositories[0] ?? null; diff --git a/src/workbench-ui.client.tsx b/src/workbench-ui.client.tsx index 2e2d8ff..9fc9bd2 100644 --- a/src/workbench-ui.client.tsx +++ b/src/workbench-ui.client.tsx @@ -1599,6 +1599,7 @@ export function Workbench({ export function useProjectRepositories( projectId: string | null, hostId: string, + initialRepository: string | null = null, ) { const paseo = usePaseo(); const queryClient = useQueryClient(); @@ -1616,11 +1617,11 @@ export function useProjectRepositories( let cursor: string | undefined; for (let page = 0; page < 10; page += 1) { const response = await paseo.workspaces.list({ + filter: { projectId }, page: { limit: 200, ...(cursor ? { cursor } : {}) }, }); for (const workspace of response.entries) { - if (workspace.projectId !== projectId || workspace.archivingAt) - continue; + if (workspace.archivingAt) continue; const repository = normalizeGitHubRepository( workspace.gitRuntime?.remoteUrl, ); @@ -1636,5 +1637,15 @@ export function useProjectRepositories( const invalidate = () => queryClient.invalidateQueries({ queryKey }); return paseo.workspaces.subscribe(invalidate); }, [paseo, queryClient, queryKey]); - return query.data ?? []; + return useMemo(() => { + const loaded = query.data ?? []; + if (!initialRepository) return loaded; + const initialLower = initialRepository.toLowerCase(); + return [ + initialRepository, + ...loaded.filter( + (repository) => repository.toLowerCase() !== initialLower, + ), + ]; + }, [initialRepository, query.data]); } From 22d55c44fe6091c5c2a3341ee43169ca48b94831 Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 00:58:09 +0800 Subject: [PATCH 03/31] fix: load current workspace remote directly --- src/project-workbench.client.tsx | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/project-workbench.client.tsx b/src/project-workbench.client.tsx index 1b91171..f420d99 100644 --- a/src/project-workbench.client.tsx +++ b/src/project-workbench.client.tsx @@ -3,7 +3,8 @@ import { usePaseo, useWorkspace, } from "@getpaseo/plugin"; -import { useEffect, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; import { Text, View } from "react-native"; import { normalizeGitHubRepository } from "./github-workbench.shared"; import { I18nProvider, useTranslation } from "./i18n/context"; @@ -15,15 +16,22 @@ function ProjectGitHubWorkbenchPanelInner(props: PluginWorkspacePanelProps) { const workspace = useWorkspace(props.workspaceId, (item) => ({ projectId: item.projectId, })); - const workspaceHandle = useMemo( - () => paseo.workspaces.ref(props.workspaceId), - [paseo, props.workspaceId], - ); - // The plugin workspace snapshot intentionally omits git runtime details, but - // the client handle keeps the full descriptor locally after the workspace is - // opened. Use it as an immediate seed while the project-wide query loads. + const currentWorkspaceQuery = useQuery({ + queryKey: [ + "github-workbench", + props.host.id, + "current-workspace", + props.workspaceId, + ], + enabled: Boolean(props.workspaceId), + staleTime: 4 * 60_000, + queryFn: () => paseo.workspaces.ref(props.workspaceId).refresh(), + }); + // The plugin workspace snapshot intentionally omits git runtime details, so + // refresh only this workspace for its remote. This runs in parallel with the + // project-wide repository query and avoids waiting for a global scan. const currentRepository = normalizeGitHubRepository( - workspaceHandle.current()?.gitRuntime?.remoteUrl, + currentWorkspaceQuery.data?.gitRuntime?.remoteUrl, ); const repositories = useProjectRepositories( workspace?.projectId ?? null, From ea2026985c90555397af0a9a7970a75e681e8df5 Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 01:08:25 +0800 Subject: [PATCH 04/31] perf: load GitHub details on demand --- src/github-resource-intake.server.test.ts | 4 ++- src/github-resource-intake.server.ts | 20 +++++++++---- src/workbench-ui.client.tsx | 35 ++++++++++++++++++++--- 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/src/github-resource-intake.server.test.ts b/src/github-resource-intake.server.test.ts index 16622cf..3484b19 100644 --- a/src/github-resource-intake.server.test.ts +++ b/src/github-resource-intake.server.test.ts @@ -6,7 +6,9 @@ describe("GitHubResourceIntake", () => { let calls = 0; const intake = createGitHubResourceIntake(async (args) => { expect(args.slice(0, 2)).toEqual(["api", "graphql"]); - expect(args.join(" ")).toContain("contexts(first: 20)"); + expect(args.join(" ")).not.toContain("body"); + expect(args.join(" ")).not.toContain("contexts(first: 20)"); + expect(args.join(" ")).toContain("statusCheckRollup { state }"); calls += 1; return { stdout: JSON.stringify({ diff --git a/src/github-resource-intake.server.ts b/src/github-resource-intake.server.ts index 7369666..3224287 100644 --- a/src/github-resource-intake.server.ts +++ b/src/github-resource-intake.server.ts @@ -61,12 +61,20 @@ const issueSelection = ` number title body url state closedAt createdAt updatedAt author { login } repository { nameWithOwner } labels(first: 20) { nodes { name } } assignees(first: 20) { nodes { login } } milestone { title } comments { totalCount } `; +const pullRequestSummarySelection = ` +number title url state mergedAt closedAt createdAt updatedAt isDraft headRefName baseRefName mergeable reviewDecision comments { totalCount } author { login } repository { nameWithOwner } labels(first: 20) { nodes { name } } assignees(first: 20) { nodes { login } } statusCheckRollup { state } +`; + +const issueSummarySelection = ` +number title url state closedAt createdAt updatedAt author { login } repository { nameWithOwner } labels(first: 20) { nodes { name } } assignees(first: 20) { nodes { login } } milestone { title } comments { totalCount } +`; + const accountQuery = ` query Workbench($authoredPr: String!, $reviewPr: String!, $authoredIssue: String!, $assignedIssue: String!) { - authoredPr: search(query: $authoredPr, type: ISSUE, first: 100) { nodes { ... on PullRequest { ${pullRequestSelection} } } } - reviewPr: search(query: $reviewPr, type: ISSUE, first: 100) { nodes { ... on PullRequest { ${pullRequestSelection} } } } - authoredIssue: search(query: $authoredIssue, type: ISSUE, first: 100) { nodes { ... on Issue { ${issueSelection} } } } - assignedIssue: search(query: $assignedIssue, type: ISSUE, first: 100) { nodes { ... on Issue { ${issueSelection} } } } + authoredPr: search(query: $authoredPr, type: ISSUE, first: 100) { nodes { ... on PullRequest { ${pullRequestSummarySelection} } } } + reviewPr: search(query: $reviewPr, type: ISSUE, first: 100) { nodes { ... on PullRequest { ${pullRequestSummarySelection} } } } + authoredIssue: search(query: $authoredIssue, type: ISSUE, first: 100) { nodes { ... on Issue { ${issueSummarySelection} } } } + assignedIssue: search(query: $assignedIssue, type: ISSUE, first: 100) { nodes { ... on Issue { ${issueSummarySelection} } } } }`; const viewerQuery = `query WorkbenchViewer { viewer { login } }`; @@ -74,8 +82,8 @@ const viewerQuery = `query WorkbenchViewer { viewer { login } }`; const repositoryQuery = ` query WorkbenchRepository($owner: String!, $name: String!, $pullRequestState: PullRequestState!, $issueState: IssueState!, $includeIssues: Boolean!) { repository(owner: $owner, name: $name) { - pullRequests(states: [$pullRequestState], first: 100, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { ${pullRequestSelection} } } - issues(states: [$issueState], first: 100, orderBy: { field: UPDATED_AT, direction: DESC }) @include(if: $includeIssues) { nodes { ${issueSelection} } } + pullRequests(states: [$pullRequestState], first: 100, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { ${pullRequestSummarySelection} } } + issues(states: [$issueState], first: 100, orderBy: { field: UPDATED_AT, direction: DESC }) @include(if: $includeIssues) { nodes { ${issueSummarySelection} } } } }`; diff --git a/src/workbench-ui.client.tsx b/src/workbench-ui.client.tsx index 9fc9bd2..076b7eb 100644 --- a/src/workbench-ui.client.tsx +++ b/src/workbench-ui.client.tsx @@ -41,6 +41,7 @@ type ContentTab = "all" | "issue" | "pull-request" | "mine" | "review"; type OwnershipFilter = "all" | "mine" | "assigned" | "review"; type StatusFilter = LifecycleState; const WORKBENCH_STALE_TIME_MS = 5 * 60_000; +const RESOURCE_DETAIL_STALE_TIME_MS = 10 * 60_000; export function clampWorkbenchListWidth( availableWidth: number, requestedWidth: number, @@ -1130,6 +1131,28 @@ export function Workbench({ ); const selected = rows.find((item) => item.resource.key === selectedKey)?.resource ?? null; + const selectedDetailQuery = useQuery({ + queryKey: [ + "github-workbench", + host.id, + "resource-detail", + selected?.key ?? null, + ], + enabled: selected !== null, + staleTime: RESOURCE_DETAIL_STALE_TIME_MS, + queryFn: async () => { + if (!selected) throw new Error("No GitHub resource is selected."); + return refreshResource({ + kind: selected.kind, + repository: selected.repository, + number: selected.number, + }); + }, + }); + const selectedForDetail = + selected && selectedDetailQuery.data?.resource + ? mergeRefreshedResource(selected, selectedDetailQuery.data.resource) + : selected; useEffect(() => { if ( selectedKey && @@ -1187,6 +1210,10 @@ export function Workbench({ } : current, ); + queryClient.setQueryData( + ["github-workbench", host.id, "resource-detail", resource.key], + { resource: refreshed }, + ); setRefreshingKey(null); }) .catch(() => { @@ -1194,7 +1221,7 @@ export function Workbench({ toast.error(t("resource.toasts.refreshFailed")); }); }, - [queryClient, queryKey, refreshResource, t, toast], + [host.id, queryClient, queryKey, refreshResource, t, toast], ); const ensure = useCallback( (resource: GitHubResource) => { @@ -1473,7 +1500,7 @@ export function Workbench({ {showingDetail ? ( Date: Thu, 3 Sep 2026 01:21:26 +0800 Subject: [PATCH 05/31] fix: keep selected GitHub details consistent after refresh --- src/workbench-ui.client.tsx | 113 ++++++++++++++++++++++++++++++++---- 1 file changed, 101 insertions(+), 12 deletions(-) diff --git a/src/workbench-ui.client.tsx b/src/workbench-ui.client.tsx index 076b7eb..70443a9 100644 --- a/src/workbench-ui.client.tsx +++ b/src/workbench-ui.client.tsx @@ -42,6 +42,10 @@ type OwnershipFilter = "all" | "mine" | "assigned" | "review"; type StatusFilter = LifecycleState; const WORKBENCH_STALE_TIME_MS = 5 * 60_000; const RESOURCE_DETAIL_STALE_TIME_MS = 10 * 60_000; + +function resourceDetailQueryKey(hostId: string, resourceKey: string | null) { + return ["github-workbench", hostId, "resource-detail", resourceKey] as const; +} export function clampWorkbenchListWidth( availableWidth: number, requestedWidth: number, @@ -397,6 +401,8 @@ function DetailPane({ onEnsure, onRefresh, refreshing, + detailLoading, + detailError, onBack, }: { resource: GitHubResource | null; @@ -406,6 +412,8 @@ function DetailPane({ onEnsure: (resource: GitHubResource) => void; onRefresh: (resource: GitHubResource) => void; refreshing: boolean; + detailLoading?: boolean; + detailError?: string | null; onBack?: () => void; }) { const { t } = useTranslation(); @@ -629,6 +637,16 @@ function DetailPane({ ) : null} + {detailLoading ? ( + + {t("workbench.loading")} + + ) : null} + {detailError ? ( + + {detailError} + + ) : null} {resource.kind === "pull-request" ? ( @@ -1132,12 +1150,7 @@ export function Workbench({ const selected = rows.find((item) => item.resource.key === selectedKey)?.resource ?? null; const selectedDetailQuery = useQuery({ - queryKey: [ - "github-workbench", - host.id, - "resource-detail", - selected?.key ?? null, - ], + queryKey: resourceDetailQueryKey(host.id, selected?.key ?? null), enabled: selected !== null, staleTime: RESOURCE_DETAIL_STALE_TIME_MS, queryFn: async () => { @@ -1153,6 +1166,25 @@ export function Workbench({ selected && selectedDetailQuery.data?.resource ? mergeRefreshedResource(selected, selectedDetailQuery.data.resource) : selected; + const selectedResourceKey = selected?.key ?? null; + const lastListRefreshRef = useRef(null); + useEffect(() => { + const refreshedAt = query.data?.refreshedAt; + if (!refreshedAt) return; + const previousRefreshedAt = lastListRefreshRef.current; + lastListRefreshRef.current = refreshedAt; + if ( + !selectedResourceKey || + previousRefreshedAt === null || + previousRefreshedAt === refreshedAt + ) { + return; + } + void queryClient.invalidateQueries({ + queryKey: resourceDetailQueryKey(host.id, selectedResourceKey), + refetchType: "active", + }); + }, [host.id, query.data?.refreshedAt, queryClient, selectedResourceKey]); useEffect(() => { if ( selectedKey && @@ -1184,12 +1216,31 @@ export function Workbench({ } : { scope: "account", state: status, forceRefresh: true }, ) - .then((data) => queryClient.setQueryData(queryKey, data)) + .then((data) => { + queryClient.setQueryData(queryKey, data); + if (selectedResourceKey) { + void queryClient.invalidateQueries({ + queryKey: resourceDetailQueryKey(host.id, selectedResourceKey), + refetchType: "active", + }); + } + }) .catch(() => undefined); - }, [listResources, queryClient, queryKey, scope, status]); + }, [ + host.id, + listResources, + queryClient, + queryKey, + scope, + selectedResourceKey, + status, + ]); const refreshItem = useCallback( (resource: GitHubResource) => { setRefreshingKey(resource.key); + void queryClient.cancelQueries({ + queryKey: resourceDetailQueryKey(host.id, resource.key), + }); refreshResource({ kind: resource.kind, repository: resource.repository, @@ -1211,7 +1262,7 @@ export function Workbench({ : current, ); queryClient.setQueryData( - ["github-workbench", host.id, "resource-detail", resource.key], + resourceDetailQueryKey(host.id, resource.key), { resource: refreshed }, ); setRefreshingKey(null); @@ -1503,7 +1554,19 @@ export function Workbench({ resource={selectedForDetail} theme={theme} navigation={navigation} - refreshing={refreshingKey === selected?.key} + refreshing={ + refreshingKey === selected?.key || selectedDetailQuery.isFetching + } + detailLoading={ + selectedDetailQuery.isFetching && !selectedDetailQuery.data + } + detailError={ + selectedDetailQuery.error instanceof Error + ? selectedDetailQuery.error.message + : selectedDetailQuery.error + ? t("workbench.unableToLoad") + : null + } onRefresh={refreshItem} ensuring={ selected ? (pendingCounts.get(selected.key) ?? 0) > 0 : false @@ -1525,7 +1588,20 @@ export function Workbench({ resource={selectedForDetail} theme={theme} navigation={navigation} - refreshing={refreshingKey === selected?.key} + refreshing={ + refreshingKey === selected?.key || + selectedDetailQuery.isFetching + } + detailLoading={ + selectedDetailQuery.isFetching && !selectedDetailQuery.data + } + detailError={ + selectedDetailQuery.error instanceof Error + ? selectedDetailQuery.error.message + : selectedDetailQuery.error + ? t("workbench.unableToLoad") + : null + } onRefresh={refreshItem} ensuring={ selected ? (pendingCounts.get(selected.key) ?? 0) > 0 : false @@ -1609,7 +1685,20 @@ export function Workbench({ resource={selectedForDetail} theme={theme} navigation={navigation} - refreshing={refreshingKey === selected?.key} + refreshing={ + refreshingKey === selected?.key || + selectedDetailQuery.isFetching + } + detailLoading={ + selectedDetailQuery.isFetching && !selectedDetailQuery.data + } + detailError={ + selectedDetailQuery.error instanceof Error + ? selectedDetailQuery.error.message + : selectedDetailQuery.error + ? t("workbench.unableToLoad") + : null + } onRefresh={refreshItem} ensuring={ selected ? (pendingCounts.get(selected.key) ?? 0) > 0 : false From 141d734ec24e5d895fab317da70190d09194bfa0 Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 01:28:54 +0800 Subject: [PATCH 06/31] fix: invalidate stale GitHub detail caches --- src/workbench-ui.client.tsx | 39 ++++++++++++++----------------------- 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/src/workbench-ui.client.tsx b/src/workbench-ui.client.tsx index 70443a9..8880663 100644 --- a/src/workbench-ui.client.tsx +++ b/src/workbench-ui.client.tsx @@ -46,6 +46,9 @@ const RESOURCE_DETAIL_STALE_TIME_MS = 10 * 60_000; function resourceDetailQueryKey(hostId: string, resourceKey: string | null) { return ["github-workbench", hostId, "resource-detail", resourceKey] as const; } +function resourceDetailQueryPrefix(hostId: string) { + return ["github-workbench", hostId, "resource-detail"] as const; +} export function clampWorkbenchListWidth( availableWidth: number, requestedWidth: number, @@ -1163,28 +1166,25 @@ export function Workbench({ }, }); const selectedForDetail = - selected && selectedDetailQuery.data?.resource + selected && + selectedDetailQuery.data?.resource && + selectedDetailQuery.dataUpdatedAt >= query.dataUpdatedAt ? mergeRefreshedResource(selected, selectedDetailQuery.data.resource) : selected; - const selectedResourceKey = selected?.key ?? null; const lastListRefreshRef = useRef(null); useEffect(() => { const refreshedAt = query.data?.refreshedAt; if (!refreshedAt) return; const previousRefreshedAt = lastListRefreshRef.current; lastListRefreshRef.current = refreshedAt; - if ( - !selectedResourceKey || - previousRefreshedAt === null || - previousRefreshedAt === refreshedAt - ) { + if (previousRefreshedAt === null || previousRefreshedAt === refreshedAt) { return; } void queryClient.invalidateQueries({ - queryKey: resourceDetailQueryKey(host.id, selectedResourceKey), + queryKey: resourceDetailQueryPrefix(host.id), refetchType: "active", }); - }, [host.id, query.data?.refreshedAt, queryClient, selectedResourceKey]); + }, [host.id, query.data?.refreshedAt, queryClient]); useEffect(() => { if ( selectedKey && @@ -1217,24 +1217,15 @@ export function Workbench({ : { scope: "account", state: status, forceRefresh: true }, ) .then((data) => { + lastListRefreshRef.current = data.refreshedAt; queryClient.setQueryData(queryKey, data); - if (selectedResourceKey) { - void queryClient.invalidateQueries({ - queryKey: resourceDetailQueryKey(host.id, selectedResourceKey), - refetchType: "active", - }); - } + void queryClient.invalidateQueries({ + queryKey: resourceDetailQueryPrefix(host.id), + refetchType: "active", + }); }) .catch(() => undefined); - }, [ - host.id, - listResources, - queryClient, - queryKey, - scope, - selectedResourceKey, - status, - ]); + }, [host.id, listResources, queryClient, queryKey, scope, status]); const refreshItem = useCallback( (resource: GitHubResource) => { setRefreshingKey(resource.key); From 7a7397516dd3afdc5412bb81361be6838b429750 Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 01:34:40 +0800 Subject: [PATCH 07/31] fix: make GitHub detail cache version aware --- src/github-workbench.shared.ts | 20 ++++++++++++++++++ src/github-workbench.test.ts | 26 ++++++++++++++++++++++++ src/workbench-ui.client.tsx | 37 +++++++++++++--------------------- 3 files changed, 60 insertions(+), 23 deletions(-) diff --git a/src/github-workbench.shared.ts b/src/github-workbench.shared.ts index 5ed3671..0072b28 100644 --- a/src/github-workbench.shared.ts +++ b/src/github-workbench.shared.ts @@ -298,6 +298,26 @@ export function mergeRefreshedResource( }; } +/** + * Adds fields that are only requested by the on-demand detail query while + * keeping the list summary authoritative for all shared fields. + */ +export function mergeDetailedResource( + summary: GitHubResource, + detail: GitHubResource, +): GitHubResource { + if (summary.key !== detail.key || summary.kind !== detail.kind) + return summary; + if (summary.kind === "pull-request" && detail.kind === "pull-request") { + return { + ...summary, + body: detail.body, + checkDetails: detail.checkDetails, + }; + } + return { ...summary, body: detail.body }; +} + export function issueBranchSlug(number: number, title: string): string { const slug = title diff --git a/src/github-workbench.test.ts b/src/github-workbench.test.ts index f8bd286..dc5ca01 100644 --- a/src/github-workbench.test.ts +++ b/src/github-workbench.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { adjustPendingResourceCount, issueBranchSlug, + mergeDetailedResource, mergeRefreshedResource, normalizeGitHubRepository, openExternalUrl, @@ -185,6 +186,31 @@ describe("GitHub workbench shared primitives", () => { } }); + test("merges only detail-only fields over the current summary", () => { + const summary = pullRequest({ + title: "Current title", + updatedAt: "2026-02-02T00:00:00Z", + body: "", + checkDetails: [], + labels: ["current"], + }); + const detail = pullRequest({ + title: "Stale title", + updatedAt: "2026-02-01T00:00:00Z", + body: "Loaded body", + checkDetails: [{ name: "CI", status: "success" }], + labels: ["stale"], + }); + + const merged = mergeDetailedResource(summary, detail); + expect(merged.title).toBe("Current title"); + expect(merged.labels).toEqual(["current"]); + expect(merged.body).toBe("Loaded body"); + if (merged.kind === "pull-request") { + expect(merged.checkDetails).toEqual([{ name: "CI", status: "success" }]); + } + }); + test("formats accessibility labels properly", () => { expect( resourceAccessibilityLabel("Pull Request", "getpaseo/paseo", 42, "Title"), diff --git a/src/workbench-ui.client.tsx b/src/workbench-ui.client.tsx index 8880663..722d24a 100644 --- a/src/workbench-ui.client.tsx +++ b/src/workbench-ui.client.tsx @@ -18,6 +18,7 @@ import { type GitHubResource, type LifecycleState, listResourcesRpc, + mergeDetailedResource, mergeRefreshedResource, normalizeGitHubRepository, openExternalUrl, @@ -46,9 +47,6 @@ const RESOURCE_DETAIL_STALE_TIME_MS = 10 * 60_000; function resourceDetailQueryKey(hostId: string, resourceKey: string | null) { return ["github-workbench", hostId, "resource-detail", resourceKey] as const; } -function resourceDetailQueryPrefix(hostId: string) { - return ["github-workbench", hostId, "resource-detail"] as const; -} export function clampWorkbenchListWidth( availableWidth: number, requestedWidth: number, @@ -1165,26 +1163,24 @@ export function Workbench({ }); }, }); + const detailResource = selectedDetailQuery.data?.resource; + const selectedResourceKey = selected?.key ?? null; + const selectedUpdatedAt = selected?.updatedAt ?? null; + const detailUpdatedAt = detailResource?.updatedAt ?? null; + const detailIsBehindSummary = Boolean( + selectedUpdatedAt && detailUpdatedAt && detailUpdatedAt < selectedUpdatedAt, + ); const selectedForDetail = - selected && - selectedDetailQuery.data?.resource && - selectedDetailQuery.dataUpdatedAt >= query.dataUpdatedAt - ? mergeRefreshedResource(selected, selectedDetailQuery.data.resource) + selected && detailResource && !detailIsBehindSummary + ? mergeDetailedResource(selected, detailResource) : selected; - const lastListRefreshRef = useRef(null); useEffect(() => { - const refreshedAt = query.data?.refreshedAt; - if (!refreshedAt) return; - const previousRefreshedAt = lastListRefreshRef.current; - lastListRefreshRef.current = refreshedAt; - if (previousRefreshedAt === null || previousRefreshedAt === refreshedAt) { - return; - } + if (!selectedResourceKey || !detailIsBehindSummary) return; void queryClient.invalidateQueries({ - queryKey: resourceDetailQueryPrefix(host.id), + queryKey: resourceDetailQueryKey(host.id, selectedResourceKey), refetchType: "active", }); - }, [host.id, query.data?.refreshedAt, queryClient]); + }, [detailIsBehindSummary, host.id, queryClient, selectedResourceKey]); useEffect(() => { if ( selectedKey && @@ -1217,15 +1213,10 @@ export function Workbench({ : { scope: "account", state: status, forceRefresh: true }, ) .then((data) => { - lastListRefreshRef.current = data.refreshedAt; queryClient.setQueryData(queryKey, data); - void queryClient.invalidateQueries({ - queryKey: resourceDetailQueryPrefix(host.id), - refetchType: "active", - }); }) .catch(() => undefined); - }, [host.id, listResources, queryClient, queryKey, scope, status]); + }, [listResources, queryClient, queryKey, scope, status]); const refreshItem = useCallback( (resource: GitHubResource) => { setRefreshingKey(resource.key); From d8f8295f7fcd4d9d70a9c5a5c51161bba4adefb7 Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 01:39:49 +0800 Subject: [PATCH 08/31] fix: recheck GitHub details for newer summary versions --- src/workbench-ui.client.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/workbench-ui.client.tsx b/src/workbench-ui.client.tsx index 722d24a..10ba3d4 100644 --- a/src/workbench-ui.client.tsx +++ b/src/workbench-ui.client.tsx @@ -1174,13 +1174,17 @@ export function Workbench({ selected && detailResource && !detailIsBehindSummary ? mergeDetailedResource(selected, detailResource) : selected; + const staleDetailVersion = + detailIsBehindSummary && selectedResourceKey && selectedUpdatedAt + ? `${selectedResourceKey}:${selectedUpdatedAt}` + : null; useEffect(() => { - if (!selectedResourceKey || !detailIsBehindSummary) return; + if (!selectedResourceKey || !staleDetailVersion) return; void queryClient.invalidateQueries({ queryKey: resourceDetailQueryKey(host.id, selectedResourceKey), refetchType: "active", }); - }, [detailIsBehindSummary, host.id, queryClient, selectedResourceKey]); + }, [host.id, queryClient, selectedResourceKey, staleDetailVersion]); useEffect(() => { if ( selectedKey && From f09d6e9b0da1c8f9c30c2d7742db88e185f1b3f7 Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 01:45:53 +0800 Subject: [PATCH 09/31] fix: track GitHub check freshness in detail cache --- src/github-workbench.shared.ts | 11 +++++++++++ src/github-workbench.test.ts | 14 ++++++++++++++ src/workbench-ui.client.tsx | 10 +++++++--- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/github-workbench.shared.ts b/src/github-workbench.shared.ts index 0072b28..390d38c 100644 --- a/src/github-workbench.shared.ts +++ b/src/github-workbench.shared.ts @@ -318,6 +318,17 @@ export function mergeDetailedResource( return { ...summary, body: detail.body }; } +export function isGitHubResourceDetailStale( + summary: GitHubResource, + detail: GitHubResource, +): boolean { + if (summary.key !== detail.key || summary.kind !== detail.kind) return true; + if (detail.updatedAt < summary.updatedAt) return true; + return summary.kind === "pull-request" && detail.kind === "pull-request" + ? detail.checksStatus !== summary.checksStatus + : false; +} + export function issueBranchSlug(number: number, title: string): string { const slug = title diff --git a/src/github-workbench.test.ts b/src/github-workbench.test.ts index dc5ca01..1dc10ad 100644 --- a/src/github-workbench.test.ts +++ b/src/github-workbench.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { adjustPendingResourceCount, + isGitHubResourceDetailStale, issueBranchSlug, mergeDetailedResource, mergeRefreshedResource, @@ -211,6 +212,19 @@ describe("GitHub workbench shared primitives", () => { } }); + test("treats a changed pull request checks status as stale detail", () => { + const summary = pullRequest({ + updatedAt: "2026-02-01T00:00:00Z", + checksStatus: "success", + }); + const detail = pullRequest({ + updatedAt: "2026-02-01T00:00:00Z", + checksStatus: "pending", + }); + + expect(isGitHubResourceDetailStale(summary, detail)).toBe(true); + }); + test("formats accessibility labels properly", () => { expect( resourceAccessibilityLabel("Pull Request", "getpaseo/paseo", 42, "Title"), diff --git a/src/workbench-ui.client.tsx b/src/workbench-ui.client.tsx index 10ba3d4..ee72e8e 100644 --- a/src/workbench-ui.client.tsx +++ b/src/workbench-ui.client.tsx @@ -16,6 +16,7 @@ import { adjustPendingResourceCount, ensureResourceWorkspaceRpc, type GitHubResource, + isGitHubResourceDetailStale, type LifecycleState, listResourcesRpc, mergeDetailedResource, @@ -1166,9 +1167,12 @@ export function Workbench({ const detailResource = selectedDetailQuery.data?.resource; const selectedResourceKey = selected?.key ?? null; const selectedUpdatedAt = selected?.updatedAt ?? null; - const detailUpdatedAt = detailResource?.updatedAt ?? null; + const selectedChecksStatus = + selected?.kind === "pull-request" ? selected.checksStatus : null; const detailIsBehindSummary = Boolean( - selectedUpdatedAt && detailUpdatedAt && detailUpdatedAt < selectedUpdatedAt, + selected && + detailResource && + isGitHubResourceDetailStale(selected, detailResource), ); const selectedForDetail = selected && detailResource && !detailIsBehindSummary @@ -1176,7 +1180,7 @@ export function Workbench({ : selected; const staleDetailVersion = detailIsBehindSummary && selectedResourceKey && selectedUpdatedAt - ? `${selectedResourceKey}:${selectedUpdatedAt}` + ? `${selectedResourceKey}:${selectedUpdatedAt}:${selectedChecksStatus ?? ""}` : null; useEffect(() => { if (!selectedResourceKey || !staleDetailVersion) return; From 24abccfacb781ec3162cc957e484a5a947ccdba1 Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 01:49:10 +0800 Subject: [PATCH 10/31] fix: accept newer GitHub detail snapshots --- src/github-workbench.shared.ts | 1 + src/github-workbench.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/src/github-workbench.shared.ts b/src/github-workbench.shared.ts index 390d38c..3e2152f 100644 --- a/src/github-workbench.shared.ts +++ b/src/github-workbench.shared.ts @@ -324,6 +324,7 @@ export function isGitHubResourceDetailStale( ): boolean { if (summary.key !== detail.key || summary.kind !== detail.kind) return true; if (detail.updatedAt < summary.updatedAt) return true; + if (detail.updatedAt > summary.updatedAt) return false; return summary.kind === "pull-request" && detail.kind === "pull-request" ? detail.checksStatus !== summary.checksStatus : false; diff --git a/src/github-workbench.test.ts b/src/github-workbench.test.ts index 1dc10ad..7b135ba 100644 --- a/src/github-workbench.test.ts +++ b/src/github-workbench.test.ts @@ -223,6 +223,15 @@ describe("GitHub workbench shared primitives", () => { }); expect(isGitHubResourceDetailStale(summary, detail)).toBe(true); + expect( + isGitHubResourceDetailStale( + summary, + pullRequest({ + updatedAt: "2026-02-02T00:00:00Z", + checksStatus: "pending", + }), + ), + ).toBe(false); }); test("formats accessibility labels properly", () => { From 588e55bb5632c5beed052b01afed4709714aed76 Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 01:53:56 +0800 Subject: [PATCH 11/31] fix: order GitHub detail freshness ties --- src/github-workbench.shared.ts | 16 +++++++++++++--- src/github-workbench.test.ts | 2 ++ src/workbench-ui.client.tsx | 7 ++++++- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/github-workbench.shared.ts b/src/github-workbench.shared.ts index 3e2152f..7fb4755 100644 --- a/src/github-workbench.shared.ts +++ b/src/github-workbench.shared.ts @@ -321,13 +321,23 @@ export function mergeDetailedResource( export function isGitHubResourceDetailStale( summary: GitHubResource, detail: GitHubResource, + summaryObservedAt?: number, + detailObservedAt?: number, ): boolean { if (summary.key !== detail.key || summary.kind !== detail.kind) return true; if (detail.updatedAt < summary.updatedAt) return true; if (detail.updatedAt > summary.updatedAt) return false; - return summary.kind === "pull-request" && detail.kind === "pull-request" - ? detail.checksStatus !== summary.checksStatus - : false; + if ( + summary.kind !== "pull-request" || + detail.kind !== "pull-request" || + detail.checksStatus === summary.checksStatus + ) { + return false; + } + if (summaryObservedAt === undefined || detailObservedAt === undefined) { + return true; + } + return detailObservedAt < summaryObservedAt; } export function issueBranchSlug(number: number, title: string): string { diff --git a/src/github-workbench.test.ts b/src/github-workbench.test.ts index 7b135ba..ce2d35b 100644 --- a/src/github-workbench.test.ts +++ b/src/github-workbench.test.ts @@ -232,6 +232,8 @@ describe("GitHub workbench shared primitives", () => { }), ), ).toBe(false); + expect(isGitHubResourceDetailStale(summary, detail, 200, 100)).toBe(true); + expect(isGitHubResourceDetailStale(summary, detail, 100, 200)).toBe(false); }); test("formats accessibility labels properly", () => { diff --git a/src/workbench-ui.client.tsx b/src/workbench-ui.client.tsx index ee72e8e..71d9831 100644 --- a/src/workbench-ui.client.tsx +++ b/src/workbench-ui.client.tsx @@ -1172,7 +1172,12 @@ export function Workbench({ const detailIsBehindSummary = Boolean( selected && detailResource && - isGitHubResourceDetailStale(selected, detailResource), + isGitHubResourceDetailStale( + selected, + detailResource, + query.dataUpdatedAt, + selectedDetailQuery.dataUpdatedAt, + ), ); const selectedForDetail = selected && detailResource && !detailIsBehindSummary From 330bbcd1ceca202e937b4e543654aac091f349f6 Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 02:03:09 +0800 Subject: [PATCH 12/31] fix: guard GitHub detail responses by request version --- src/github-workbench.shared.ts | 13 +++++----- src/github-workbench.test.ts | 6 +++-- src/workbench-ui.client.tsx | 44 ++++++++++++++++++++++------------ 3 files changed, 40 insertions(+), 23 deletions(-) diff --git a/src/github-workbench.shared.ts b/src/github-workbench.shared.ts index 7fb4755..89a23ff 100644 --- a/src/github-workbench.shared.ts +++ b/src/github-workbench.shared.ts @@ -318,11 +318,15 @@ export function mergeDetailedResource( return { ...summary, body: detail.body }; } +export function githubResourceVersion(resource: GitHubResource): string { + return `${resource.key}:${resource.updatedAt}:${ + resource.kind === "pull-request" ? resource.checksStatus : "" + }`; +} + export function isGitHubResourceDetailStale( summary: GitHubResource, detail: GitHubResource, - summaryObservedAt?: number, - detailObservedAt?: number, ): boolean { if (summary.key !== detail.key || summary.kind !== detail.kind) return true; if (detail.updatedAt < summary.updatedAt) return true; @@ -334,10 +338,7 @@ export function isGitHubResourceDetailStale( ) { return false; } - if (summaryObservedAt === undefined || detailObservedAt === undefined) { - return true; - } - return detailObservedAt < summaryObservedAt; + return true; } export function issueBranchSlug(number: number, title: string): string { diff --git a/src/github-workbench.test.ts b/src/github-workbench.test.ts index ce2d35b..8036212 100644 --- a/src/github-workbench.test.ts +++ b/src/github-workbench.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { adjustPendingResourceCount, + githubResourceVersion, isGitHubResourceDetailStale, issueBranchSlug, mergeDetailedResource, @@ -232,8 +233,9 @@ describe("GitHub workbench shared primitives", () => { }), ), ).toBe(false); - expect(isGitHubResourceDetailStale(summary, detail, 200, 100)).toBe(true); - expect(isGitHubResourceDetailStale(summary, detail, 100, 200)).toBe(false); + expect(githubResourceVersion(summary)).not.toBe( + githubResourceVersion(detail), + ); }); test("formats accessibility labels properly", () => { diff --git a/src/workbench-ui.client.tsx b/src/workbench-ui.client.tsx index 71d9831..0f89192 100644 --- a/src/workbench-ui.client.tsx +++ b/src/workbench-ui.client.tsx @@ -16,6 +16,7 @@ import { adjustPendingResourceCount, ensureResourceWorkspaceRpc, type GitHubResource, + githubResourceVersion, isGitHubResourceDetailStale, type LifecycleState, listResourcesRpc, @@ -42,6 +43,10 @@ type WorkbenchProps = PluginSurfaceProps & { type ContentTab = "all" | "issue" | "pull-request" | "mine" | "review"; type OwnershipFilter = "all" | "mine" | "assigned" | "review"; type StatusFilter = LifecycleState; +type ResourceDetailQueryData = { + resource: GitHubResource; + summaryVersion: string; +}; const WORKBENCH_STALE_TIME_MS = 5 * 60_000; const RESOURCE_DETAIL_STALE_TIME_MS = 10 * 60_000; @@ -1157,35 +1162,41 @@ export function Workbench({ staleTime: RESOURCE_DETAIL_STALE_TIME_MS, queryFn: async () => { if (!selected) throw new Error("No GitHub resource is selected."); - return refreshResource({ + const summaryVersion = githubResourceVersion(selected); + const detail = await refreshResource({ kind: selected.kind, repository: selected.repository, number: selected.number, }); + return { ...detail, summaryVersion } satisfies ResourceDetailQueryData; }, }); - const detailResource = selectedDetailQuery.data?.resource; + const detailQueryData = selectedDetailQuery.data; + const detailResource = detailQueryData?.resource; const selectedResourceKey = selected?.key ?? null; - const selectedUpdatedAt = selected?.updatedAt ?? null; - const selectedChecksStatus = - selected?.kind === "pull-request" ? selected.checksStatus : null; + const selectedSummaryVersion = selected + ? githubResourceVersion(selected) + : null; + const detailVersionMismatch = Boolean( + detailQueryData && + detailQueryData.summaryVersion !== selectedSummaryVersion, + ); const detailIsBehindSummary = Boolean( selected && detailResource && - isGitHubResourceDetailStale( - selected, - detailResource, - query.dataUpdatedAt, - selectedDetailQuery.dataUpdatedAt, - ), + (detailVersionMismatch || + isGitHubResourceDetailStale(selected, detailResource)), ); const selectedForDetail = - selected && detailResource && !detailIsBehindSummary + selected && + detailResource && + !detailVersionMismatch && + !detailIsBehindSummary ? mergeDetailedResource(selected, detailResource) : selected; const staleDetailVersion = - detailIsBehindSummary && selectedResourceKey && selectedUpdatedAt - ? `${selectedResourceKey}:${selectedUpdatedAt}:${selectedChecksStatus ?? ""}` + detailIsBehindSummary && selectedSummaryVersion + ? selectedSummaryVersion : null; useEffect(() => { if (!selectedResourceKey || !staleDetailVersion) return; @@ -1258,7 +1269,10 @@ export function Workbench({ ); queryClient.setQueryData( resourceDetailQueryKey(host.id, resource.key), - { resource: refreshed }, + { + resource: refreshed, + summaryVersion: githubResourceVersion(refreshed), + } satisfies ResourceDetailQueryData, ); setRefreshingKey(null); }) From 707bcb44908805b0a3a54ccc576cb1d4c4a90586 Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 02:08:31 +0800 Subject: [PATCH 13/31] fix: parse nested GitHub check rollups --- src/github-resource-intake.server.test.ts | 17 +++++++++++------ src/github-resource-intake.server.ts | 8 +++++++- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/github-resource-intake.server.test.ts b/src/github-resource-intake.server.test.ts index 3484b19..19b917e 100644 --- a/src/github-resource-intake.server.test.ts +++ b/src/github-resource-intake.server.test.ts @@ -329,13 +329,18 @@ describe("GitHubResourceIntake", () => { updatedAt: "2026-09-02T08:50:19Z", createdAt: "2026-09-02T08:45:43Z", reviewDecision: "", - statusCheckRollup: [ - { - name: "TypeScript checks", - status: "COMPLETED", - conclusion: "SUCCESS", + statusCheckRollup: { + state: "SUCCESS", + contexts: { + nodes: [ + { + name: "TypeScript checks", + status: "COMPLETED", + conclusion: "SUCCESS", + }, + ], }, - ], + }, mergeable: "MERGEABLE", comments: [], }, diff --git a/src/github-resource-intake.server.ts b/src/github-resource-intake.server.ts index 3224287..70e2c3f 100644 --- a/src/github-resource-intake.server.ts +++ b/src/github-resource-intake.server.ts @@ -259,7 +259,13 @@ function checkStatusFromGraphql( } function checkDetailsFrom(value: unknown): PullRequestResource["checkDetails"] { - const records = Array.isArray(value) ? value : asRecord(value)?.nodes; + const record = asRecord(value); + const contexts = asRecord(record?.contexts); + const records = Array.isArray(value) + ? value + : Array.isArray(record?.nodes) + ? record.nodes + : contexts?.nodes; if (!Array.isArray(records)) return []; return records .flatMap((item) => { From f3590a0e2d7818e0ffda78ed4da8e6c48e55619a Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 02:13:59 +0800 Subject: [PATCH 14/31] fix: parse GitHub status context states --- src/github-resource-intake.server.test.ts | 19 +++++++++++++------ src/github-resource-intake.server.ts | 15 +++++++++++---- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/github-resource-intake.server.test.ts b/src/github-resource-intake.server.test.ts index 19b917e..4cff4fe 100644 --- a/src/github-resource-intake.server.test.ts +++ b/src/github-resource-intake.server.test.ts @@ -383,13 +383,19 @@ describe("GitHubResourceIntake", () => { updatedAt: "2026-02-05T00:00:00Z", createdAt: "2026-02-01T00:00:00Z", reviewDecision: "CHANGES_REQUESTED", - statusCheckRollup: [ - { - name: "build", - status: "COMPLETED", - conclusion: "FAILURE", + statusCheckRollup: { + state: "FAILURE", + contexts: { + nodes: [ + { + name: "build", + status: "COMPLETED", + conclusion: "FAILURE", + }, + { context: "legacy", state: "PENDING" }, + ], }, - ], + }, mergeable: "CONFLICTING", comments: 5, }, @@ -416,6 +422,7 @@ describe("GitHubResourceIntake", () => { expect(result.resource.checksStatus).toBe("failure"); expect(result.resource.checkDetails).toEqual([ { name: "build", status: "failure" }, + { name: "legacy", status: "pending" }, ]); } }); diff --git a/src/github-resource-intake.server.ts b/src/github-resource-intake.server.ts index 70e2c3f..2c6ba0b 100644 --- a/src/github-resource-intake.server.ts +++ b/src/github-resource-intake.server.ts @@ -273,7 +273,9 @@ function checkDetailsFrom(value: unknown): PullRequestResource["checkDetails"] { if (!record) return []; const name = asString(record.name) ?? asString(record.context) ?? "Unnamed check"; - const status = asString(record.status)?.toUpperCase(); + const status = + asString(record.status)?.toUpperCase() ?? + asString(record.state)?.toUpperCase(); const conclusion = asString(record.conclusion)?.toUpperCase(); const normalized: PullRequestResource["checkDetails"][number]["status"] = conclusion === "FAILURE" || @@ -289,9 +291,14 @@ function checkDetailsFrom(value: unknown): PullRequestResource["checkDetails"] { "ACTION_REQUIRED", ].includes(status ?? "") ? "failure" - : ["IN_PROGRESS", "PENDING", "QUEUED", "EXPECTED"].includes( - status ?? "", - ) + : [ + "IN_PROGRESS", + "PENDING", + "QUEUED", + "EXPECTED", + "REQUESTED", + "WAITING", + ].includes(status ?? "") ? "pending" : conclusion === "SUCCESS" || status === "SUCCESS" ? "success" From 759875e924525f25ef056761e502b2bb132549ce Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 02:19:32 +0800 Subject: [PATCH 15/31] fix: normalize GitHub check status aggregation --- src/github-resource-intake.server.ts | 72 +++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 13 deletions(-) diff --git a/src/github-resource-intake.server.ts b/src/github-resource-intake.server.ts index 2c6ba0b..ac17abf 100644 --- a/src/github-resource-intake.server.ts +++ b/src/github-resource-intake.server.ts @@ -216,27 +216,66 @@ function labelsFrom(value: unknown): string[] { function summarizeChecks(checks: unknown): PullRequestResource["checksStatus"] { if (!Array.isArray(checks) || checks.length === 0) return "none"; - let sawKnown = false; + let sawFailure = false; + let sawPending = false; + let sawSuccess = false; for (const check of checks) { if (!check || typeof check !== "object") continue; const record = check as Record; const status = - typeof record.status === "string" ? record.status.toUpperCase() : ""; - if (status && status !== "COMPLETED") return "pending"; + typeof record.status === "string" + ? record.status.toUpperCase() + : typeof record.state === "string" + ? record.state.toUpperCase() + : ""; const conclusion = typeof record.conclusion === "string" ? record.conclusion.toUpperCase() : ""; if ( - ["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED"].includes( - conclusion, - ) - ) - return "failure"; - if (["SUCCESS", "NEUTRAL", "SKIPPED", "STALE"].includes(conclusion)) - sawKnown = true; + [ + "FAILURE", + "ERROR", + "TIMED_OUT", + "CANCELLED", + "ACTION_REQUIRED", + "STARTUP_FAILURE", + ].includes(conclusion) || + [ + "FAILURE", + "ERROR", + "TIMED_OUT", + "CANCELLED", + "ACTION_REQUIRED", + "STARTUP_FAILURE", + ].includes(status) + ) { + sawFailure = true; + continue; + } + if ( + [ + "IN_PROGRESS", + "PENDING", + "QUEUED", + "EXPECTED", + "REQUESTED", + "WAITING", + ].includes(status) + ) { + sawPending = true; + continue; + } + if ( + ["SUCCESS", "NEUTRAL", "SKIPPED", "STALE"].includes(conclusion) || + ["SUCCESS", "NEUTRAL", "SKIPPED", "STALE"].includes(status) + ) { + sawSuccess = true; + } } - return sawKnown ? "success" : "unknown"; + if (sawFailure) return "failure"; + if (sawPending) return "pending"; + return sawSuccess ? "success" : "unknown"; } function checkStatusFromGraphql( @@ -255,7 +294,7 @@ function checkStatusFromGraphql( return "failure"; if (state === "SUCCESS") return "success"; const contexts = asRecord(rollup.contexts); - return summarizeChecks(contexts?.nodes); + return summarizeChecks(contexts?.nodes ?? rollup.nodes); } function checkDetailsFrom(value: unknown): PullRequestResource["checkDetails"] { @@ -283,12 +322,14 @@ function checkDetailsFrom(value: unknown): PullRequestResource["checkDetails"] { conclusion === "CANCELLED" || conclusion === "TIMED_OUT" || conclusion === "ACTION_REQUIRED" || + conclusion === "STARTUP_FAILURE" || [ "FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", + "STARTUP_FAILURE", ].includes(status ?? "") ? "failure" : [ @@ -300,7 +341,12 @@ function checkDetailsFrom(value: unknown): PullRequestResource["checkDetails"] { "WAITING", ].includes(status ?? "") ? "pending" - : conclusion === "SUCCESS" || status === "SUCCESS" + : ["SUCCESS", "NEUTRAL", "SKIPPED", "STALE"].includes( + conclusion ?? "", + ) || + ["SUCCESS", "NEUTRAL", "SKIPPED", "STALE"].includes( + status ?? "", + ) ? "success" : "unknown"; return [{ name, status: normalized }]; From 3ac0d53c428fcf86e5dd160a951221545c04ef43 Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 02:24:16 +0800 Subject: [PATCH 16/31] fix: preserve unknown GitHub check states --- src/github-resource-intake.server.test.ts | 5 ++++- src/github-resource-intake.server.ts | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/github-resource-intake.server.test.ts b/src/github-resource-intake.server.test.ts index 4cff4fe..bfeacf3 100644 --- a/src/github-resource-intake.server.test.ts +++ b/src/github-resource-intake.server.test.ts @@ -32,7 +32,6 @@ describe("GitHubResourceIntake", () => { updatedAt: "2026-02-02T00:00:00Z", reviewDecision: "APPROVED", statusCheckRollup: { - state: "SUCCESS", contexts: { nodes: [ { @@ -40,6 +39,7 @@ describe("GitHubResourceIntake", () => { status: "COMPLETED", conclusion: "SUCCESS", }, + { name: "mystery", status: "BLOCKED" }, ], }, }, @@ -96,6 +96,9 @@ describe("GitHubResourceIntake", () => { expect(second.resources).toHaveLength(2); expect(first.resources[0].key).toBe("pull-request:getpaseo/paseo#42"); expect(first.resources[1].key).toBe("issue:getpaseo/paseo#99"); + if (first.resources[0].kind === "pull-request") { + expect(first.resources[0].checksStatus).toBe("unknown"); + } const third = await intake.listResources({ scope: "repository", diff --git a/src/github-resource-intake.server.ts b/src/github-resource-intake.server.ts index ac17abf..4299cc6 100644 --- a/src/github-resource-intake.server.ts +++ b/src/github-resource-intake.server.ts @@ -218,6 +218,7 @@ function summarizeChecks(checks: unknown): PullRequestResource["checksStatus"] { if (!Array.isArray(checks) || checks.length === 0) return "none"; let sawFailure = false; let sawPending = false; + let sawUnknown = false; let sawSuccess = false; for (const check of checks) { if (!check || typeof check !== "object") continue; @@ -271,10 +272,13 @@ function summarizeChecks(checks: unknown): PullRequestResource["checksStatus"] { ["SUCCESS", "NEUTRAL", "SKIPPED", "STALE"].includes(status) ) { sawSuccess = true; + } else { + sawUnknown = true; } } if (sawFailure) return "failure"; if (sawPending) return "pending"; + if (sawUnknown) return "unknown"; return sawSuccess ? "success" : "unknown"; } From 682e9003b9542ba8ffa8eb3113e512466fdc3f45 Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 02:31:11 +0800 Subject: [PATCH 17/31] perf: parallelize Paseo directory scans --- src/workbench-ui.client.tsx | 104 ++++++++++++++++++++---------------- 1 file changed, 57 insertions(+), 47 deletions(-) diff --git a/src/workbench-ui.client.tsx b/src/workbench-ui.client.tsx index 0f89192..00183c6 100644 --- a/src/workbench-ui.client.tsx +++ b/src/workbench-ui.client.tsx @@ -84,53 +84,63 @@ function usePaseoDirectory(hostId: string) { queryKey, staleTime: WORKBENCH_STALE_TIME_MS, queryFn: async () => { - const workspaces: WorkspaceSnapshot[] = []; - const agents: PaseoDirectorySnapshot["agents"] = []; - let workspaceCursor: string | undefined; - for (let page = 0; page < 10; page += 1) { - const response = await paseo.workspaces.list({ - page: { - limit: 200, - ...(workspaceCursor ? { cursor: workspaceCursor } : {}), - }, - }); - workspaces.push( - ...response.entries.map((workspace) => ({ - id: workspace.id, - projectId: workspace.projectId, - projectDisplayName: workspace.projectDisplayName, - name: workspace.name, - archivingAt: workspace.archivingAt, - remoteUrl: workspace.gitRuntime?.remoteUrl ?? null, - pullRequestNumber: workspace.githubRuntime?.pullRequest?.number, - worktreeSlug: workspace.worktreeSlug, - activityAt: workspace.activityAt, - })), - ); - workspaceCursor = response.pageInfo.nextCursor ?? undefined; - if (!workspaceCursor) break; - } - let agentCursor: string | undefined; - for (let page = 0; page < 10; page += 1) { - const response = await paseo.agents.list({ - page: { limit: 200, ...(agentCursor ? { cursor: agentCursor } : {}) }, - }); - agents.push( - ...response.entries.map(({ agent }) => ({ - id: agent.id, - workspaceId: agent.workspaceId, - title: agent.title, - status: agent.status, - requiresAttention: agent.requiresAttention ?? false, - attentionReason: agent.attentionReason ?? null, - pendingPermissions: agent.pendingPermissions.length, - updatedAt: agent.updatedAt, - labels: agent.labels, - })), - ); - agentCursor = response.pageInfo.nextCursor ?? undefined; - if (!agentCursor) break; - } + const workspacesPromise = (async () => { + const workspaces: WorkspaceSnapshot[] = []; + let cursor: string | undefined; + for (let page = 0; page < 10; page += 1) { + const response = await paseo.workspaces.list({ + page: { + limit: 200, + ...(cursor ? { cursor } : {}), + }, + }); + workspaces.push( + ...response.entries.map((workspace) => ({ + id: workspace.id, + projectId: workspace.projectId, + projectDisplayName: workspace.projectDisplayName, + name: workspace.name, + archivingAt: workspace.archivingAt, + remoteUrl: workspace.gitRuntime?.remoteUrl ?? null, + pullRequestNumber: workspace.githubRuntime?.pullRequest?.number, + worktreeSlug: workspace.worktreeSlug, + activityAt: workspace.activityAt, + })), + ); + cursor = response.pageInfo.nextCursor ?? undefined; + if (!cursor) break; + } + return workspaces; + })(); + const agentsPromise = (async () => { + const agents: PaseoDirectorySnapshot["agents"] = []; + let cursor: string | undefined; + for (let page = 0; page < 10; page += 1) { + const response = await paseo.agents.list({ + page: { limit: 200, ...(cursor ? { cursor } : {}) }, + }); + agents.push( + ...response.entries.map(({ agent }) => ({ + id: agent.id, + workspaceId: agent.workspaceId, + title: agent.title, + status: agent.status, + requiresAttention: agent.requiresAttention ?? false, + attentionReason: agent.attentionReason ?? null, + pendingPermissions: agent.pendingPermissions.length, + updatedAt: agent.updatedAt, + labels: agent.labels, + })), + ); + cursor = response.pageInfo.nextCursor ?? undefined; + if (!cursor) break; + } + return agents; + })(); + const [workspaces, agents] = await Promise.all([ + workspacesPromise, + agentsPromise, + ]); return { workspaces, agents } satisfies PaseoDirectorySnapshot; }, }); From 6ba8e37db12153c5267e96cb4e8bb6db237c41d0 Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 02:34:11 +0800 Subject: [PATCH 18/31] perf: retain GitHub detail cache through stale window --- src/workbench-ui.client.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/workbench-ui.client.tsx b/src/workbench-ui.client.tsx index 00183c6..aa0563c 100644 --- a/src/workbench-ui.client.tsx +++ b/src/workbench-ui.client.tsx @@ -1170,6 +1170,7 @@ export function Workbench({ queryKey: resourceDetailQueryKey(host.id, selected?.key ?? null), enabled: selected !== null, staleTime: RESOURCE_DETAIL_STALE_TIME_MS, + gcTime: RESOURCE_DETAIL_STALE_TIME_MS, queryFn: async () => { if (!selected) throw new Error("No GitHub resource is selected."); const summaryVersion = githubResourceVersion(selected); From 971adc588355d05f16c7e489d608f230ddab24dd Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 02:38:58 +0800 Subject: [PATCH 19/31] perf: slow polling for stable GitHub states --- src/workbench-ui.client.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/workbench-ui.client.tsx b/src/workbench-ui.client.tsx index aa0563c..1e01f88 100644 --- a/src/workbench-ui.client.tsx +++ b/src/workbench-ui.client.tsx @@ -48,6 +48,7 @@ type ResourceDetailQueryData = { summaryVersion: string; }; const WORKBENCH_STALE_TIME_MS = 5 * 60_000; +const STABLE_WORKBENCH_STALE_TIME_MS = 30 * 60_000; const RESOURCE_DETAIL_STALE_TIME_MS = 10 * 60_000; function resourceDetailQueryKey(hostId: string, resourceKey: string | null) { @@ -1092,10 +1093,14 @@ export function Workbench({ ] as const; const scopeKey = scope.scope === "repository" ? `repository:${scope.repository}` : "account"; + const workbenchRefreshIntervalMs = + status === "open" + ? WORKBENCH_STALE_TIME_MS + : STABLE_WORKBENCH_STALE_TIME_MS; const query = useQuery({ queryKey, - staleTime: WORKBENCH_STALE_TIME_MS, - refetchInterval: WORKBENCH_STALE_TIME_MS, + staleTime: workbenchRefreshIntervalMs, + refetchInterval: workbenchRefreshIntervalMs, refetchIntervalInBackground: false, queryFn: () => listResources( From ed3d2ffd9aa09499b71950e0d2e553739aef9a25 Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 02:43:56 +0800 Subject: [PATCH 20/31] perf: serve stale GitHub data on refresh errors --- src/github-resource-intake.server.test.ts | 50 +++++++++++++++++++++++ src/github-resource-intake.server.ts | 20 ++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/github-resource-intake.server.test.ts b/src/github-resource-intake.server.test.ts index bfeacf3..0c37e63 100644 --- a/src/github-resource-intake.server.test.ts +++ b/src/github-resource-intake.server.test.ts @@ -176,6 +176,56 @@ describe("GitHubResourceIntake", () => { expect(calls).toBe(2); }); + it("serves the last good repository result when a forced refresh fails", async () => { + let calls = 0; + const intake = createGitHubResourceIntake(async () => { + calls += 1; + if (calls > 1) throw new Error("HTTP 503: Service Unavailable"); + return { + stdout: JSON.stringify({ + data: { + repository: { + pullRequests: { nodes: [] }, + issues: { + nodes: [ + { + number: 7, + title: "Cached issue", + url: "https://github.com/owner/repo/issues/7", + state: "OPEN", + repository: { nameWithOwner: "owner/repo" }, + author: { login: "dev" }, + assignees: { nodes: [] }, + labels: { nodes: [] }, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-02T00:00:00Z", + comments: { totalCount: 0 }, + milestone: null, + }, + ], + }, + }, + }, + }), + stderr: "", + }; + }); + + const first = await intake.listResources({ + scope: "repository", + repository: "owner/repo", + }); + const stale = await intake.listResources({ + scope: "repository", + repository: "owner/repo", + forceRefresh: true, + }); + + expect(first.resources[0].title).toBe("Cached issue"); + expect(stale.resources[0].title).toBe("Cached issue"); + expect(stale.warnings.at(-1)?.code).toBe("github-query-failed"); + }); + it("handles account scope, resolves viewer, and merges relationship flags", async () => { const intake = createGitHubResourceIntake(async (args) => { if (args[0] === "api" && args[1] === "graphql") { diff --git a/src/github-resource-intake.server.ts b/src/github-resource-intake.server.ts index 4299cc6..251d840 100644 --- a/src/github-resource-intake.server.ts +++ b/src/github-resource-intake.server.ts @@ -48,6 +48,7 @@ const execFile = promisify(execFileCallback); // Keep this slightly shorter than the five-minute client poll interval so a // scheduled refetch always reaches GitHub rather than extending stale data. const CACHE_TTL_MS = 4 * 60_000; +const STALE_CACHE_GRACE_MS = 60 * 60_000; const GH_COMMAND_TIMEOUT_MS = 60_000; const GH_COMMAND_MAX_BUFFER_BYTES = 8 * 1024 * 1024; const MAX_CHECK_DETAILS_PER_PULL_REQUEST = 20; @@ -500,7 +501,10 @@ export function createGitHubResourceIntake( run: GitHubCommandRunner = defaultCommandRunner, options: GitHubResourceIntakeOptions = {}, ): GitHubResourceIntake { - const cache = new Map(); + const cache = new Map< + string, + { value: CacheValue; expiresAt: number; staleUntil: number } + >(); const inFlight = new Map>(); const viewerLogins = new Map(); const viewerInFlight = new Map>(); @@ -746,9 +750,21 @@ export function createGitHubResourceIntake( refreshedAt: new Date().toISOString(), warnings: loaded.warnings, }; - cache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS }); + const now = Date.now(); + cache.set(key, { + value, + expiresAt: now + CACHE_TTL_MS, + staleUntil: now + CACHE_TTL_MS + STALE_CACHE_GRACE_MS, + }); return value; } catch (error) { + const stale = cache.get(key); + if (stale && stale.staleUntil > Date.now()) { + return { + ...stale.value, + warnings: [...stale.value.warnings, errorWarning(error)], + }; + } return { resources: [], refreshedAt: new Date().toISOString(), From 41ce87140154c6b5327c9002949b63272422da11 Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 02:53:45 +0800 Subject: [PATCH 21/31] perf: patch Paseo directory cache from subscriptions --- src/resource-index.shared.test.ts | 85 ++++++++++++++++++++++++++++++- src/resource-index.shared.ts | 80 +++++++++++++++++++++++++++++ src/workbench-ui.client.tsx | 70 ++++++++++++------------- 3 files changed, 199 insertions(+), 36 deletions(-) diff --git a/src/resource-index.shared.test.ts b/src/resource-index.shared.test.ts index ef9e122..d2e365c 100644 --- a/src/resource-index.shared.test.ts +++ b/src/resource-index.shared.test.ts @@ -3,7 +3,11 @@ import type { IssueResource, PullRequestResource, } from "./github-workbench.shared"; -import { createResourceIndex } from "./resource-index.shared"; +import { + applyAgentUpdate, + applyWorkspaceUpdate, + createResourceIndex, +} from "./resource-index.shared"; describe("ResourceIndex", () => { const pr1: PullRequestResource = { @@ -361,4 +365,83 @@ describe("ResourceIndex", () => { expect(missing.items).toHaveLength(0); expect(missing.summary.total).toBe(0); }); + + it("patches directory snapshots from Paseo updates without a full refetch", () => { + const snapshot = { + workspaces: [ + { + id: "ws-1", + projectId: "project-1", + projectDisplayName: "Paseo", + name: "Fix parser", + archivingAt: null, + remoteUrl: "git@github.com:getpaseo/paseo.git", + pullRequestNumber: 10, + worktreeSlug: "pr-10", + activityAt: "2026-02-03T00:00:00Z", + }, + ], + agents: [ + { + id: "agent-1", + workspaceId: "ws-1", + title: "Reviewer", + status: "idle" as const, + requiresAttention: false, + attentionReason: null, + pendingPermissions: 0, + updatedAt: "2026-02-03T00:00:00Z", + labels: {}, + }, + ], + }; + + const workspaceUpsert = { + kind: "upsert", + workspace: { + id: "ws-1", + projectId: "project-1", + projectDisplayName: "Paseo", + name: "Fix parser (updated)", + archivingAt: null, + gitRuntime: { remoteUrl: "https://github.com/getpaseo/paseo" }, + githubRuntime: { pullRequest: { number: 10 } }, + worktreeSlug: "pr-10", + activityAt: "2026-02-04T00:00:00Z", + }, + } as unknown as Parameters[1]; + const agentUpsert = { + kind: "upsert", + agent: { + id: "agent-1", + workspaceId: "ws-1", + title: "Reviewer (updated)", + status: "running", + requiresAttention: true, + attentionReason: "permission", + pendingPermissions: [{ id: "permission-1" }], + updatedAt: "2026-02-04T00:00:00Z", + labels: { role: "reviewer" }, + }, + } as unknown as Parameters[1]; + + const updated = applyAgentUpdate( + applyWorkspaceUpdate(snapshot, workspaceUpsert), + agentUpsert, + ); + expect(updated.workspaces).toHaveLength(1); + expect(updated.workspaces[0]?.name).toBe("Fix parser (updated)"); + expect(updated.workspaces[0]?.remoteUrl).toBe( + "https://github.com/getpaseo/paseo", + ); + expect(updated.agents[0]?.title).toBe("Reviewer (updated)"); + expect(updated.agents[0]?.pendingPermissions).toBe(1); + + const removed = applyAgentUpdate( + applyWorkspaceUpdate(updated, { kind: "remove", id: "ws-1" }), + { kind: "remove", agentId: "agent-1" }, + ); + expect(removed.workspaces).toHaveLength(0); + expect(removed.agents).toHaveLength(0); + }); }); diff --git a/src/resource-index.shared.ts b/src/resource-index.shared.ts index 50784b6..74fb4ff 100644 --- a/src/resource-index.shared.ts +++ b/src/resource-index.shared.ts @@ -1,3 +1,9 @@ +import type { + PaseoAgent, + PaseoAgentUpdate, + PaseoWorkspace, + PaseoWorkspaceUpdate, +} from "@getpaseo/client"; import type { AgentSummary, GitHubResource, @@ -41,6 +47,80 @@ export type PaseoDirectorySnapshot = { agents: AgentSnapshot[]; }; +export function toWorkspaceSnapshot( + workspace: PaseoWorkspace, +): WorkspaceSnapshot { + return { + id: workspace.id, + projectId: workspace.projectId, + projectDisplayName: workspace.projectDisplayName, + name: workspace.name, + archivingAt: workspace.archivingAt, + remoteUrl: workspace.gitRuntime?.remoteUrl ?? null, + pullRequestNumber: workspace.githubRuntime?.pullRequest?.number, + worktreeSlug: workspace.worktreeSlug, + activityAt: workspace.activityAt, + }; +} + +export function toAgentSnapshot(agent: PaseoAgent): AgentSnapshot { + return { + id: agent.id, + workspaceId: agent.workspaceId, + title: agent.title, + status: agent.status, + requiresAttention: agent.requiresAttention ?? false, + attentionReason: agent.attentionReason ?? null, + pendingPermissions: agent.pendingPermissions.length, + updatedAt: agent.updatedAt, + labels: agent.labels, + }; +} + +export function applyWorkspaceUpdate( + snapshot: PaseoDirectorySnapshot, + update: PaseoWorkspaceUpdate, +): PaseoDirectorySnapshot { + if (update.kind === "remove") { + return { + ...snapshot, + workspaces: snapshot.workspaces.filter( + (workspace) => workspace.id !== update.id, + ), + }; + } + const workspace = toWorkspaceSnapshot(update.workspace); + const index = snapshot.workspaces.findIndex( + (entry) => entry.id === workspace.id, + ); + if (index < 0) { + return { ...snapshot, workspaces: [...snapshot.workspaces, workspace] }; + } + const workspaces = snapshot.workspaces.slice(); + workspaces[index] = workspace; + return { ...snapshot, workspaces }; +} + +export function applyAgentUpdate( + snapshot: PaseoDirectorySnapshot, + update: PaseoAgentUpdate, +): PaseoDirectorySnapshot { + if (update.kind === "remove") { + return { + ...snapshot, + agents: snapshot.agents.filter((agent) => agent.id !== update.agentId), + }; + } + const agent = toAgentSnapshot(update.agent); + const index = snapshot.agents.findIndex((entry) => entry.id === agent.id); + if (index < 0) { + return { ...snapshot, agents: [...snapshot.agents, agent] }; + } + const agents = snapshot.agents.slice(); + agents[index] = agent; + return { ...snapshot, agents }; +} + export type ResourceClassification = { bucket: | "needs-attention" diff --git a/src/workbench-ui.client.tsx b/src/workbench-ui.client.tsx index 1e01f88..95c64b9 100644 --- a/src/workbench-ui.client.tsx +++ b/src/workbench-ui.client.tsx @@ -28,9 +28,13 @@ import { } from "./github-workbench.shared"; import { useTranslation } from "./i18n/context"; import { + applyAgentUpdate, + applyWorkspaceUpdate, createResourceIndex, type PaseoDirectorySnapshot, type ResourceClassification, + toAgentSnapshot, + toWorkspaceSnapshot, type WorkspaceSnapshot, } from "./resource-index.shared"; @@ -50,6 +54,7 @@ type ResourceDetailQueryData = { const WORKBENCH_STALE_TIME_MS = 5 * 60_000; const STABLE_WORKBENCH_STALE_TIME_MS = 30 * 60_000; const RESOURCE_DETAIL_STALE_TIME_MS = 10 * 60_000; +const DIRECTORY_RECONCILE_INTERVAL_MS = 15 * 60_000; function resourceDetailQueryKey(hostId: string, resourceKey: string | null) { return ["github-workbench", hostId, "resource-detail", resourceKey] as const; @@ -78,7 +83,15 @@ function usePaseoDirectory(hostId: string) { const paseo = usePaseo(); const queryClient = useQueryClient(); const queryKey = useMemo( - () => ["github-workbench", hostId, "directory"], + () => ["github-workbench", hostId, "directory"] as const, + [hostId], + ); + const workspaceSubscriptionId = useMemo( + () => `github-workbench:${hostId}:workspaces`, + [hostId], + ); + const agentSubscriptionId = useMemo( + () => `github-workbench:${hostId}:agents`, [hostId], ); const query = useQuery({ @@ -90,24 +103,15 @@ function usePaseoDirectory(hostId: string) { let cursor: string | undefined; for (let page = 0; page < 10; page += 1) { const response = await paseo.workspaces.list({ + ...(!cursor + ? { subscribe: { subscriptionId: workspaceSubscriptionId } } + : {}), page: { limit: 200, ...(cursor ? { cursor } : {}), }, }); - workspaces.push( - ...response.entries.map((workspace) => ({ - id: workspace.id, - projectId: workspace.projectId, - projectDisplayName: workspace.projectDisplayName, - name: workspace.name, - archivingAt: workspace.archivingAt, - remoteUrl: workspace.gitRuntime?.remoteUrl ?? null, - pullRequestNumber: workspace.githubRuntime?.pullRequest?.number, - worktreeSlug: workspace.worktreeSlug, - activityAt: workspace.activityAt, - })), - ); + workspaces.push(...response.entries.map(toWorkspaceSnapshot)); cursor = response.pageInfo.nextCursor ?? undefined; if (!cursor) break; } @@ -118,20 +122,13 @@ function usePaseoDirectory(hostId: string) { let cursor: string | undefined; for (let page = 0; page < 10; page += 1) { const response = await paseo.agents.list({ + ...(!cursor + ? { subscribe: { subscriptionId: agentSubscriptionId } } + : {}), page: { limit: 200, ...(cursor ? { cursor } : {}) }, }); agents.push( - ...response.entries.map(({ agent }) => ({ - id: agent.id, - workspaceId: agent.workspaceId, - title: agent.title, - status: agent.status, - requiresAttention: agent.requiresAttention ?? false, - attentionReason: agent.attentionReason ?? null, - pendingPermissions: agent.pendingPermissions.length, - updatedAt: agent.updatedAt, - labels: agent.labels, - })), + ...response.entries.map(({ agent }) => toAgentSnapshot(agent)), ); cursor = response.pageInfo.nextCursor ?? undefined; if (!cursor) break; @@ -146,20 +143,23 @@ function usePaseoDirectory(hostId: string) { }, }); useEffect(() => { - let timer: ReturnType | undefined; - const invalidate = () => { - if (timer) clearTimeout(timer); - timer = setTimeout( - () => queryClient.invalidateQueries({ queryKey }), - 500, + const stopWorkspaces = paseo.workspaces.subscribe((update) => { + queryClient.setQueryData(queryKey, (snapshot) => + snapshot ? applyWorkspaceUpdate(snapshot, update) : snapshot, ); - }; - const stopWorkspaces = paseo.workspaces.subscribe(invalidate); - const stopAgents = paseo.agents.subscribe(invalidate); + }); + const stopAgents = paseo.agents.subscribe((update) => { + queryClient.setQueryData(queryKey, (snapshot) => + snapshot ? applyAgentUpdate(snapshot, update) : snapshot, + ); + }); + const reconcile = setInterval(() => { + queryClient.invalidateQueries({ queryKey, refetchType: "active" }); + }, DIRECTORY_RECONCILE_INTERVAL_MS); return () => { - if (timer) clearTimeout(timer); stopWorkspaces(); stopAgents(); + clearInterval(reconcile); }; }, [paseo, queryClient, queryKey]); return query; From fd333d040abf897804873c0240e97e2d7b6b511b Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 03:02:33 +0800 Subject: [PATCH 22/31] fix: replay Paseo directory deltas after fetch --- src/workbench-ui.client.tsx | 114 ++++++++++++++++++++++-------------- 1 file changed, 70 insertions(+), 44 deletions(-) diff --git a/src/workbench-ui.client.tsx b/src/workbench-ui.client.tsx index 95c64b9..cf3ff85 100644 --- a/src/workbench-ui.client.tsx +++ b/src/workbench-ui.client.tsx @@ -1,3 +1,4 @@ +import type { PaseoAgentUpdate, PaseoWorkspaceUpdate } from "@getpaseo/client"; import type { PluginHostProps, PluginSurfaceProps } from "@getpaseo/plugin"; import { usePaseo, useRpc } from "@getpaseo/plugin"; import { useToast } from "@getpaseo/plugin/react-native"; @@ -51,6 +52,10 @@ type ResourceDetailQueryData = { resource: GitHubResource; summaryVersion: string; }; +type DirectoryFetchTransaction = { + workspaceUpdates: PaseoWorkspaceUpdate[]; + agentUpdates: PaseoAgentUpdate[]; +}; const WORKBENCH_STALE_TIME_MS = 5 * 60_000; const STABLE_WORKBENCH_STALE_TIME_MS = 30 * 60_000; const RESOURCE_DETAIL_STALE_TIME_MS = 10 * 60_000; @@ -94,72 +99,93 @@ function usePaseoDirectory(hostId: string) { () => `github-workbench:${hostId}:agents`, [hostId], ); + const fetchTransactions = useRef>(new Set()); const query = useQuery({ queryKey, staleTime: WORKBENCH_STALE_TIME_MS, + refetchInterval: DIRECTORY_RECONCILE_INTERVAL_MS, + refetchIntervalInBackground: false, queryFn: async () => { - const workspacesPromise = (async () => { - const workspaces: WorkspaceSnapshot[] = []; - let cursor: string | undefined; - for (let page = 0; page < 10; page += 1) { - const response = await paseo.workspaces.list({ - ...(!cursor - ? { subscribe: { subscriptionId: workspaceSubscriptionId } } - : {}), - page: { - limit: 200, - ...(cursor ? { cursor } : {}), - }, - }); - workspaces.push(...response.entries.map(toWorkspaceSnapshot)); - cursor = response.pageInfo.nextCursor ?? undefined; - if (!cursor) break; + const transaction: DirectoryFetchTransaction = { + workspaceUpdates: [], + agentUpdates: [], + }; + fetchTransactions.current.add(transaction); + try { + const workspacesPromise = (async () => { + const workspaces: WorkspaceSnapshot[] = []; + let cursor: string | undefined; + for (let page = 0; page < 10; page += 1) { + const response = await paseo.workspaces.list({ + ...(!cursor + ? { subscribe: { subscriptionId: workspaceSubscriptionId } } + : {}), + page: { + limit: 200, + ...(cursor ? { cursor } : {}), + }, + }); + workspaces.push(...response.entries.map(toWorkspaceSnapshot)); + cursor = response.pageInfo.nextCursor ?? undefined; + if (!cursor) break; + } + return workspaces; + })(); + const agentsPromise = (async () => { + const agents: PaseoDirectorySnapshot["agents"] = []; + let cursor: string | undefined; + for (let page = 0; page < 10; page += 1) { + const response = await paseo.agents.list({ + ...(!cursor + ? { subscribe: { subscriptionId: agentSubscriptionId } } + : {}), + page: { limit: 200, ...(cursor ? { cursor } : {}) }, + }); + agents.push( + ...response.entries.map(({ agent }) => toAgentSnapshot(agent)), + ); + cursor = response.pageInfo.nextCursor ?? undefined; + if (!cursor) break; + } + return agents; + })(); + const [workspaces, agents] = await Promise.all([ + workspacesPromise, + agentsPromise, + ]); + let snapshot = { workspaces, agents } satisfies PaseoDirectorySnapshot; + for (const update of transaction.workspaceUpdates) { + snapshot = applyWorkspaceUpdate(snapshot, update); } - return workspaces; - })(); - const agentsPromise = (async () => { - const agents: PaseoDirectorySnapshot["agents"] = []; - let cursor: string | undefined; - for (let page = 0; page < 10; page += 1) { - const response = await paseo.agents.list({ - ...(!cursor - ? { subscribe: { subscriptionId: agentSubscriptionId } } - : {}), - page: { limit: 200, ...(cursor ? { cursor } : {}) }, - }); - agents.push( - ...response.entries.map(({ agent }) => toAgentSnapshot(agent)), - ); - cursor = response.pageInfo.nextCursor ?? undefined; - if (!cursor) break; + for (const update of transaction.agentUpdates) { + snapshot = applyAgentUpdate(snapshot, update); } - return agents; - })(); - const [workspaces, agents] = await Promise.all([ - workspacesPromise, - agentsPromise, - ]); - return { workspaces, agents } satisfies PaseoDirectorySnapshot; + return snapshot; + } finally { + fetchTransactions.current.delete(transaction); + } }, }); useEffect(() => { const stopWorkspaces = paseo.workspaces.subscribe((update) => { + for (const transaction of fetchTransactions.current) { + transaction.workspaceUpdates.push(update); + } queryClient.setQueryData(queryKey, (snapshot) => snapshot ? applyWorkspaceUpdate(snapshot, update) : snapshot, ); }); const stopAgents = paseo.agents.subscribe((update) => { + for (const transaction of fetchTransactions.current) { + transaction.agentUpdates.push(update); + } queryClient.setQueryData(queryKey, (snapshot) => snapshot ? applyAgentUpdate(snapshot, update) : snapshot, ); }); - const reconcile = setInterval(() => { - queryClient.invalidateQueries({ queryKey, refetchType: "active" }); - }, DIRECTORY_RECONCILE_INTERVAL_MS); return () => { stopWorkspaces(); stopAgents(); - clearInterval(reconcile); }; }, [paseo, queryClient, queryKey]); return query; From e9f5e22fcdf7ad1a1187be70e39e55c4b59dd84b Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 03:14:56 +0800 Subject: [PATCH 23/31] fix: make directory refresh transactional --- src/resource-index.shared.ts | 10 +++++---- src/workbench-ui.client.tsx | 43 +++++++++++++++++++++++++++--------- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/resource-index.shared.ts b/src/resource-index.shared.ts index 74fb4ff..71bfee7 100644 --- a/src/resource-index.shared.ts +++ b/src/resource-index.shared.ts @@ -96,8 +96,10 @@ export function applyWorkspaceUpdate( if (index < 0) { return { ...snapshot, workspaces: [...snapshot.workspaces, workspace] }; } - const workspaces = snapshot.workspaces.slice(); - workspaces[index] = workspace; + const workspaces = snapshot.workspaces.filter( + (entry) => entry.id !== workspace.id, + ); + workspaces.splice(Math.min(index, workspaces.length), 0, workspace); return { ...snapshot, workspaces }; } @@ -116,8 +118,8 @@ export function applyAgentUpdate( if (index < 0) { return { ...snapshot, agents: [...snapshot.agents, agent] }; } - const agents = snapshot.agents.slice(); - agents[index] = agent; + const agents = snapshot.agents.filter((entry) => entry.id !== agent.id); + agents.splice(Math.min(index, agents.length), 0, agent); return { ...snapshot, agents }; } diff --git a/src/workbench-ui.client.tsx b/src/workbench-ui.client.tsx index cf3ff85..4cc6769 100644 --- a/src/workbench-ui.client.tsx +++ b/src/workbench-ui.client.tsx @@ -99,9 +99,13 @@ function usePaseoDirectory(hostId: string) { () => `github-workbench:${hostId}:agents`, [hostId], ); + const [subscriptionsReadyHost, setSubscriptionsReadyHost] = useState< + string | null + >(null); const fetchTransactions = useRef>(new Set()); const query = useQuery({ queryKey, + enabled: subscriptionsReadyHost === hostId, staleTime: WORKBENCH_STALE_TIME_MS, refetchInterval: DIRECTORY_RECONCILE_INTERVAL_MS, refetchIntervalInBackground: false, @@ -113,7 +117,7 @@ function usePaseoDirectory(hostId: string) { fetchTransactions.current.add(transaction); try { const workspacesPromise = (async () => { - const workspaces: WorkspaceSnapshot[] = []; + const workspaces = new Map(); let cursor: string | undefined; for (let page = 0; page < 10; page += 1) { const response = await paseo.workspaces.list({ @@ -125,14 +129,20 @@ function usePaseoDirectory(hostId: string) { ...(cursor ? { cursor } : {}), }, }); - workspaces.push(...response.entries.map(toWorkspaceSnapshot)); + for (const workspace of response.entries) { + const snapshot = toWorkspaceSnapshot(workspace); + workspaces.set(snapshot.id, snapshot); + } cursor = response.pageInfo.nextCursor ?? undefined; if (!cursor) break; } - return workspaces; + return [...workspaces.values()]; })(); const agentsPromise = (async () => { - const agents: PaseoDirectorySnapshot["agents"] = []; + const agents = new Map< + string, + PaseoDirectorySnapshot["agents"][number] + >(); let cursor: string | undefined; for (let page = 0; page < 10; page += 1) { const response = await paseo.agents.list({ @@ -141,18 +151,27 @@ function usePaseoDirectory(hostId: string) { : {}), page: { limit: 200, ...(cursor ? { cursor } : {}) }, }); - agents.push( - ...response.entries.map(({ agent }) => toAgentSnapshot(agent)), - ); + for (const { agent } of response.entries) { + const snapshot = toAgentSnapshot(agent); + agents.set(snapshot.id, snapshot); + } cursor = response.pageInfo.nextCursor ?? undefined; if (!cursor) break; } - return agents; + return [...agents.values()]; })(); - const [workspaces, agents] = await Promise.all([ + const [workspacesResult, agentsResult] = await Promise.allSettled([ workspacesPromise, agentsPromise, ]); + if (workspacesResult.status === "rejected") { + throw workspacesResult.reason; + } + if (agentsResult.status === "rejected") { + throw agentsResult.reason; + } + const { value: workspaces } = workspacesResult; + const { value: agents } = agentsResult; let snapshot = { workspaces, agents } satisfies PaseoDirectorySnapshot; for (const update of transaction.workspaceUpdates) { snapshot = applyWorkspaceUpdate(snapshot, update); @@ -183,11 +202,15 @@ function usePaseoDirectory(hostId: string) { snapshot ? applyAgentUpdate(snapshot, update) : snapshot, ); }); + setSubscriptionsReadyHost(hostId); return () => { stopWorkspaces(); stopAgents(); + setSubscriptionsReadyHost((current) => + current === hostId ? null : current, + ); }; - }, [paseo, queryClient, queryKey]); + }, [hostId, paseo, queryClient, queryKey]); return query; } From 80f50d73c2bbe7c2ef266fba8ccb083944e5c0c2 Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 03:19:17 +0800 Subject: [PATCH 24/31] perf: query current workspace by id prefix --- src/project-workbench.client.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/project-workbench.client.tsx b/src/project-workbench.client.tsx index f420d99..5e98858 100644 --- a/src/project-workbench.client.tsx +++ b/src/project-workbench.client.tsx @@ -25,7 +25,17 @@ function ProjectGitHubWorkbenchPanelInner(props: PluginWorkspacePanelProps) { ], enabled: Boolean(props.workspaceId), staleTime: 4 * 60_000, - queryFn: () => paseo.workspaces.ref(props.workspaceId).refresh(), + queryFn: async () => { + const response = await paseo.workspaces.list({ + filter: { idPrefix: props.workspaceId }, + page: { limit: 20 }, + }); + return ( + response.entries.find( + (workspace) => workspace.id === props.workspaceId, + ) ?? null + ); + }, }); // The plugin workspace snapshot intentionally omits git runtime details, so // refresh only this workspace for its remote. This runs in parallel with the From 88ae715534b94fb7c24218fe90b983d24bcc417f Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 03:22:33 +0800 Subject: [PATCH 25/31] fix: fall back when id prefix is unsupported --- src/project-workbench.client.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/project-workbench.client.tsx b/src/project-workbench.client.tsx index 5e98858..d0df78a 100644 --- a/src/project-workbench.client.tsx +++ b/src/project-workbench.client.tsx @@ -30,11 +30,13 @@ function ProjectGitHubWorkbenchPanelInner(props: PluginWorkspacePanelProps) { filter: { idPrefix: props.workspaceId }, page: { limit: 20 }, }); - return ( - response.entries.find( - (workspace) => workspace.id === props.workspaceId, - ) ?? null + const match = response.entries.find( + (workspace) => workspace.id === props.workspaceId, ); + // Older daemons accept idPrefix as a compatibility field but may not + // apply it server-side. Preserve correctness with the legacy scan when + // the fast path does not return an exact workspace. + return match ?? paseo.workspaces.ref(props.workspaceId).refresh(); }, }); // The plugin workspace snapshot intentionally omits git runtime details, so From 2c75de1e2d6a85ba3269db61cb894f2ad32ad54f Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 03:29:29 +0800 Subject: [PATCH 26/31] revert: avoid unsupported workspace id prefix query --- src/project-workbench.client.tsx | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/project-workbench.client.tsx b/src/project-workbench.client.tsx index d0df78a..f420d99 100644 --- a/src/project-workbench.client.tsx +++ b/src/project-workbench.client.tsx @@ -25,19 +25,7 @@ function ProjectGitHubWorkbenchPanelInner(props: PluginWorkspacePanelProps) { ], enabled: Boolean(props.workspaceId), staleTime: 4 * 60_000, - queryFn: async () => { - const response = await paseo.workspaces.list({ - filter: { idPrefix: props.workspaceId }, - page: { limit: 20 }, - }); - const match = response.entries.find( - (workspace) => workspace.id === props.workspaceId, - ); - // Older daemons accept idPrefix as a compatibility field but may not - // apply it server-side. Preserve correctness with the legacy scan when - // the fast path does not return an exact workspace. - return match ?? paseo.workspaces.ref(props.workspaceId).refresh(); - }, + queryFn: () => paseo.workspaces.ref(props.workspaceId).refresh(), }); // The plugin workspace snapshot intentionally omits git runtime details, so // refresh only this workspace for its remote. This runs in parallel with the From 45d0fa6adf88e06294ff57c012c3cd163b6fbe10 Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 03:33:09 +0800 Subject: [PATCH 27/31] perf: skip project repository scan for current workspace --- src/project-workbench.client.tsx | 7 ++++++- src/workbench-ui.client.tsx | 15 +++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/project-workbench.client.tsx b/src/project-workbench.client.tsx index f420d99..f1330d6 100644 --- a/src/project-workbench.client.tsx +++ b/src/project-workbench.client.tsx @@ -33,8 +33,13 @@ function ProjectGitHubWorkbenchPanelInner(props: PluginWorkspacePanelProps) { const currentRepository = normalizeGitHubRepository( currentWorkspaceQuery.data?.gitRuntime?.remoteUrl, ); + const repositoriesProjectId = currentWorkspaceQuery.isPending + ? null + : currentRepository + ? null + : (workspace?.projectId ?? null); const repositories = useProjectRepositories( - workspace?.projectId ?? null, + repositoriesProjectId, props.host.id, currentRepository, ); diff --git a/src/workbench-ui.client.tsx b/src/workbench-ui.client.tsx index 4cc6769..154356d 100644 --- a/src/workbench-ui.client.tsx +++ b/src/workbench-ui.client.tsx @@ -1799,7 +1799,7 @@ export function useProjectRepositories( ); const query = useQuery({ queryKey, - enabled: Boolean(projectId), + enabled: Boolean(projectId) && !initialRepository, staleTime: WORKBENCH_STALE_TIME_MS, queryFn: async () => { if (!projectId) return []; @@ -1824,18 +1824,13 @@ export function useProjectRepositories( }, }); useEffect(() => { + if (initialRepository) return; const invalidate = () => queryClient.invalidateQueries({ queryKey }); return paseo.workspaces.subscribe(invalidate); - }, [paseo, queryClient, queryKey]); + }, [initialRepository, paseo, queryClient, queryKey]); return useMemo(() => { + if (initialRepository) return [initialRepository]; const loaded = query.data ?? []; - if (!initialRepository) return loaded; - const initialLower = initialRepository.toLowerCase(); - return [ - initialRepository, - ...loaded.filter( - (repository) => repository.toLowerCase() !== initialLower, - ), - ]; + return loaded; }, [initialRepository, query.data]); } From 0b84725c7270dbf7bc167c9a9fed85fa7da9f543 Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 03:38:20 +0800 Subject: [PATCH 28/31] perf: index directory agents by workspace --- src/resource-index.shared.test.ts | 17 ++++++++++-- src/resource-index.shared.ts | 43 ++++++++++++++++++++++--------- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/src/resource-index.shared.test.ts b/src/resource-index.shared.test.ts index d2e365c..7f44cee 100644 --- a/src/resource-index.shared.test.ts +++ b/src/resource-index.shared.test.ts @@ -240,6 +240,16 @@ describe("ResourceIndex", () => { updatedAt: "2026-02-05T00:00:00Z", labels: { "github-workbench.resource": pr1.key }, // matched by BOTH label and workspace }, + { + id: "agent-2", + title: "Direct resource agent", + status: "idle" as const, + requiresAttention: false, + attentionReason: null, + pendingPermissions: 0, + updatedAt: "2026-02-05T00:00:00Z", + labels: { "github-workbench.resource": pr1.key }, + }, ], }; @@ -247,8 +257,11 @@ describe("ResourceIndex", () => { const item = index.get(pr1.key); expect(item?.workspaceIds).toEqual(["ws-1"]); expect(item?.workspaceNames).toEqual(["pr-10"]); - expect(item?.agents).toHaveLength(1); - expect(item?.agents[0].id).toBe("agent-1"); + expect(item?.agents).toHaveLength(2); + expect(item?.agents.map((agent) => agent.id)).toEqual([ + "agent-1", + "agent-2", + ]); const queryResult = index.query({ focusKey: null, diff --git a/src/resource-index.shared.ts b/src/resource-index.shared.ts index 71bfee7..87a268d 100644 --- a/src/resource-index.shared.ts +++ b/src/resource-index.shared.ts @@ -342,6 +342,8 @@ export function createResourceIndex( ): ResourceIndex { // 1. Enrich resources with directory data if available const workspacesByRepo = new Map(); + const agentsByWorkspaceId = new Map(); + const agentsByResourceId = new Map(); if (directory) { for (const ws of directory.workspaces) { if (ws.archivingAt) continue; @@ -354,6 +356,25 @@ export function createResourceIndex( } list.push(ws); } + for (const agent of directory.agents) { + if (agent.workspaceId) { + let agents = agentsByWorkspaceId.get(agent.workspaceId); + if (!agents) { + agents = []; + agentsByWorkspaceId.set(agent.workspaceId, agents); + } + agents.push(agent); + } + const resourceId = agent.labels["github-workbench.resource"]; + if (resourceId) { + let agents = agentsByResourceId.get(resourceId); + if (!agents) { + agents = []; + agentsByResourceId.set(resourceId, agents); + } + agents.push(agent); + } + } } const enrichedResources: GitHubResource[] = inputResources.map((resource) => { @@ -373,20 +394,10 @@ export function createResourceIndex( resource.repository, resource.number, ); - const matchingWorkspaceIdSet = new Set( - matchingWorkspaces.map((ws) => ws.id), - ); - - const matchedAgents = directory.agents.filter( - (agent) => - agent.labels["github-workbench.resource"] === resourceId || - (agent.workspaceId && matchingWorkspaceIdSet.has(agent.workspaceId)), - ); - const agentSummaries: AgentSummary[] = []; const seenAgentIds = new Set(); - for (const agent of matchedAgents) { - if (seenAgentIds.has(agent.id)) continue; + const appendAgent = (agent: AgentSnapshot) => { + if (seenAgentIds.has(agent.id)) return; seenAgentIds.add(agent.id); agentSummaries.push({ id: agent.id, @@ -397,6 +408,14 @@ export function createResourceIndex( pendingPermissions: agent.pendingPermissions, updatedAt: agent.updatedAt, }); + }; + for (const agent of agentsByResourceId.get(resourceId) ?? []) { + appendAgent(agent); + } + for (const workspace of matchingWorkspaces) { + for (const agent of agentsByWorkspaceId.get(workspace.id) ?? []) { + appendAgent(agent); + } } return { From c619e0a495f54c182c108fb25c291240f19a2075 Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 03:42:01 +0800 Subject: [PATCH 29/31] fix: preserve directory agent order in index --- src/resource-index.shared.test.ts | 17 +++++++++++++++-- src/resource-index.shared.ts | 20 +++++++++++++------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/resource-index.shared.test.ts b/src/resource-index.shared.test.ts index 7f44cee..d732d47 100644 --- a/src/resource-index.shared.test.ts +++ b/src/resource-index.shared.test.ts @@ -7,6 +7,7 @@ import { applyAgentUpdate, applyWorkspaceUpdate, createResourceIndex, + type PaseoDirectorySnapshot, } from "./resource-index.shared"; describe("ResourceIndex", () => { @@ -214,7 +215,7 @@ describe("ResourceIndex", () => { }); }); it("enriches resources with workspaces and deduplicates directory agents", () => { - const directory = { + const directory: PaseoDirectorySnapshot = { workspaces: [ { id: "ws-1", @@ -229,6 +230,17 @@ describe("ResourceIndex", () => { }, ], agents: [ + { + id: "agent-0", + workspaceId: "ws-1", + title: "Workspace agent first", + status: "idle" as const, + requiresAttention: false, + attentionReason: null, + pendingPermissions: 0, + updatedAt: "2026-02-05T00:00:00Z", + labels: {}, + }, { id: "agent-1", workspaceId: "ws-1", @@ -257,8 +269,9 @@ describe("ResourceIndex", () => { const item = index.get(pr1.key); expect(item?.workspaceIds).toEqual(["ws-1"]); expect(item?.workspaceNames).toEqual(["pr-10"]); - expect(item?.agents).toHaveLength(2); + expect(item?.agents).toHaveLength(3); expect(item?.agents.map((agent) => agent.id)).toEqual([ + "agent-0", "agent-1", "agent-2", ]); diff --git a/src/resource-index.shared.ts b/src/resource-index.shared.ts index 87a268d..7037752 100644 --- a/src/resource-index.shared.ts +++ b/src/resource-index.shared.ts @@ -344,6 +344,7 @@ export function createResourceIndex( const workspacesByRepo = new Map(); const agentsByWorkspaceId = new Map(); const agentsByResourceId = new Map(); + const agentDirectoryOrder = new Map(); if (directory) { for (const ws of directory.workspaces) { if (ws.archivingAt) continue; @@ -356,7 +357,8 @@ export function createResourceIndex( } list.push(ws); } - for (const agent of directory.agents) { + for (const [index, agent] of directory.agents.entries()) { + agentDirectoryOrder.set(agent.id, index); if (agent.workspaceId) { let agents = agentsByWorkspaceId.get(agent.workspaceId); if (!agents) { @@ -409,13 +411,17 @@ export function createResourceIndex( updatedAt: agent.updatedAt, }); }; - for (const agent of agentsByResourceId.get(resourceId) ?? []) { - appendAgent(agent); - } + const candidateAgents = [...(agentsByResourceId.get(resourceId) ?? [])]; for (const workspace of matchingWorkspaces) { - for (const agent of agentsByWorkspaceId.get(workspace.id) ?? []) { - appendAgent(agent); - } + candidateAgents.push(...(agentsByWorkspaceId.get(workspace.id) ?? [])); + } + candidateAgents.sort( + (left, right) => + (agentDirectoryOrder.get(left.id) ?? Number.MAX_SAFE_INTEGER) - + (agentDirectoryOrder.get(right.id) ?? Number.MAX_SAFE_INTEGER), + ); + for (const agent of candidateAgents) { + appendAgent(agent); } return { From 546846b9b48d0f7951164896c6a11dd77dfd2483 Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 03:44:44 +0800 Subject: [PATCH 30/31] perf: use @me qualifiers for account resources --- src/github-resource-intake.server.test.ts | 13 ++++---- src/github-resource-intake.server.ts | 39 +++-------------------- 2 files changed, 10 insertions(+), 42 deletions(-) diff --git a/src/github-resource-intake.server.test.ts b/src/github-resource-intake.server.test.ts index 0c37e63..0659313 100644 --- a/src/github-resource-intake.server.test.ts +++ b/src/github-resource-intake.server.test.ts @@ -226,7 +226,7 @@ describe("GitHubResourceIntake", () => { expect(stale.warnings.at(-1)?.code).toBe("github-query-failed"); }); - it("handles account scope, resolves viewer, and merges relationship flags", async () => { + it("handles account scope with @me qualifiers and merges relationship flags", async () => { const intake = createGitHubResourceIntake(async (args) => { if (args[0] === "api" && args[1] === "graphql") { const queryArg = @@ -254,12 +254,11 @@ describe("GitHubResourceIntake", () => { expect(hadOpenBrace).toBe(true); expect(braceDepth).toBe(0); - if (rawQuery.includes("WorkbenchViewer")) { - return { - stdout: JSON.stringify({ data: { viewer: { login: "octocat" } } }), - stderr: "", - }; - } + expect(rawQuery).not.toContain("WorkbenchViewer"); + expect(args).toContain("authoredPr=is:pr is:open author:@me"); + expect(args).toContain("reviewPr=is:pr is:open review-requested:@me"); + expect(args).toContain("authoredIssue=is:issue is:open author:@me"); + expect(args).toContain("assignedIssue=is:issue is:open assignee:@me"); return { stdout: JSON.stringify({ diff --git a/src/github-resource-intake.server.ts b/src/github-resource-intake.server.ts index 251d840..c9a6c3e 100644 --- a/src/github-resource-intake.server.ts +++ b/src/github-resource-intake.server.ts @@ -78,8 +78,6 @@ query Workbench($authoredPr: String!, $reviewPr: String!, $authoredIssue: String assignedIssue: search(query: $assignedIssue, type: ISSUE, first: 100) { nodes { ... on Issue { ${issueSummarySelection} } } } }`; -const viewerQuery = `query WorkbenchViewer { viewer { login } }`; - const repositoryQuery = ` query WorkbenchRepository($owner: String!, $name: String!, $pullRequestState: PullRequestState!, $issueState: IssueState!, $includeIssues: Boolean!) { repository(owner: $owner, name: $name) { @@ -506,8 +504,6 @@ export function createGitHubResourceIntake( { value: CacheValue; expiresAt: number; staleUntil: number } >(); const inFlight = new Map>(); - const viewerLogins = new Map(); - const viewerInFlight = new Map>(); const token = options.token === undefined ? environmentToken() @@ -571,32 +567,6 @@ export function createGitHubResourceIntake( } } - async function getViewerLogin(): Promise { - const cached = viewerLogins.get("github.com"); - if (cached && cached.expiresAt > Date.now()) return cached.value; - const running = viewerInFlight.get("github.com"); - if (running) return running; - const request = (async () => { - try { - const result = await graphql(viewerQuery, {}); - const login = asString(asRecord(result.data.viewer)?.login) ?? ""; - if (!login) - throw new Error( - result.error ?? "GitHub returned no authenticated login.", - ); - viewerLogins.set("github.com", { - value: login, - expiresAt: Date.now() + CACHE_TTL_MS, - }); - return login; - } finally { - viewerInFlight.delete("github.com"); - } - })(); - viewerInFlight.set("github.com", request); - return request; - } - async function repositoryResources( repository: string, state: "open" | "merged" | "closed" = "open", @@ -647,7 +617,6 @@ export function createGitHubResourceIntake( async function accountResources( state: "open" | "merged" | "closed" = "open", ): Promise { - const viewer = await getViewerLogin(); const prQualifier = state === "open" ? "is:open" @@ -656,16 +625,16 @@ export function createGitHubResourceIntake( : "is:closed -is:merged"; const issueQualifier = state === "closed" ? "is:closed" : "is:open"; const result = await graphql(accountQuery, { - authoredPr: `is:pr ${prQualifier} author:${viewer}`, - reviewPr: `is:pr ${prQualifier} review-requested:${viewer}`, + authoredPr: `is:pr ${prQualifier} author:@me`, + reviewPr: `is:pr ${prQualifier} review-requested:@me`, authoredIssue: state === "merged" ? "is:issue is:closed author:__none__" - : `is:issue ${issueQualifier} author:${viewer}`, + : `is:issue ${issueQualifier} author:@me`, assignedIssue: state === "merged" ? "is:issue is:closed assignee:__none__" - : `is:issue ${issueQualifier} assignee:${viewer}`, + : `is:issue ${issueQualifier} assignee:@me`, }); const root = result.data; const connectionNames = [ From d2e1e5cffd088722a098e6610722f44cb79639e7 Mon Sep 17 00:00:00 2001 From: AllenReder Date: Thu, 3 Sep 2026 11:04:27 +0800 Subject: [PATCH 31/31] fix: accept newer checks in matching detail fetch --- src/github-workbench.shared.ts | 4 +++- src/github-workbench.test.ts | 22 ++++++++++++++++++---- src/workbench-ui.client.tsx | 6 +++++- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/github-workbench.shared.ts b/src/github-workbench.shared.ts index 89a23ff..c6457e7 100644 --- a/src/github-workbench.shared.ts +++ b/src/github-workbench.shared.ts @@ -327,6 +327,7 @@ export function githubResourceVersion(resource: GitHubResource): string { export function isGitHubResourceDetailStale( summary: GitHubResource, detail: GitHubResource, + fetchedForSummaryVersion?: string, ): boolean { if (summary.key !== detail.key || summary.kind !== detail.kind) return true; if (detail.updatedAt < summary.updatedAt) return true; @@ -334,7 +335,8 @@ export function isGitHubResourceDetailStale( if ( summary.kind !== "pull-request" || detail.kind !== "pull-request" || - detail.checksStatus === summary.checksStatus + detail.checksStatus === summary.checksStatus || + fetchedForSummaryVersion === githubResourceVersion(summary) ) { return false; } diff --git a/src/github-workbench.test.ts b/src/github-workbench.test.ts index 8036212..2577f71 100644 --- a/src/github-workbench.test.ts +++ b/src/github-workbench.test.ts @@ -213,23 +213,37 @@ describe("GitHub workbench shared primitives", () => { } }); - test("treats a changed pull request checks status as stale detail", () => { + test("accepts a check status advance fetched for the current summary", () => { const summary = pullRequest({ updatedAt: "2026-02-01T00:00:00Z", - checksStatus: "success", + checksStatus: "pending", }); const detail = pullRequest({ updatedAt: "2026-02-01T00:00:00Z", - checksStatus: "pending", + checksStatus: "success", }); expect(isGitHubResourceDetailStale(summary, detail)).toBe(true); + expect( + isGitHubResourceDetailStale( + summary, + detail, + githubResourceVersion(summary), + ), + ).toBe(false); + expect( + isGitHubResourceDetailStale( + summary, + detail, + githubResourceVersion(detail), + ), + ).toBe(true); expect( isGitHubResourceDetailStale( summary, pullRequest({ updatedAt: "2026-02-02T00:00:00Z", - checksStatus: "pending", + checksStatus: "success", }), ), ).toBe(false); diff --git a/src/workbench-ui.client.tsx b/src/workbench-ui.client.tsx index 154356d..04fb613 100644 --- a/src/workbench-ui.client.tsx +++ b/src/workbench-ui.client.tsx @@ -1250,7 +1250,11 @@ export function Workbench({ selected && detailResource && (detailVersionMismatch || - isGitHubResourceDetailStale(selected, detailResource)), + isGitHubResourceDetailStale( + selected, + detailResource, + detailQueryData?.summaryVersion, + )), ); const selectedForDetail = selected &&