From 7420dfa8f891df4e154db939fa04a0125cb5d2c0 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:21:30 -0800 Subject: [PATCH] fix(native-chat): preview GitHub PR links in chats without a GitHub repo (#41359) * fix(native-chat): preview GitHub PR links in chats without a GitHub repo A chat whose workspace has no GitHub repo (a plain folder) sent every GitHub link to the anonymous web page fetch. Private repos answer that with a 404, so the card said "No preview for this page." Route the lookup through another registered repo on the chat's host whose remote is on the link's GitHub host; main already lets a preview read any repo on such a host with that sign-in. * fix(native-chat): prefer exact clones and repair attachment test import * fix(native-chat): retain direct SSH ownership for GitHub previews * fix(native-chat): invalidate routed previews when repo accounts change * fix(native-chat): resolve current preview routes before cache reuse * fix(native-chat): preserve preview authority across async lookups * fix(native-chat): retain valid preview lookups across catalog updates * Avoid coalescing retired preview requests --- .../previews/NativeChatGitHubLinkPeek.tsx | 60 +++++- .../previews/NativeChatLinkPeek.fixture.tsx | 6 +- .../links/previews/NativeChatLinkPeek.tsx | 23 +- .../native-chat-github-link-preview.ts | 120 ++++++++++- ...ve-chat-github-preview-source-race.test.ts | 197 ++++++++++++++++++ .../links/previews/native-chat-github-repo.ts | 16 +- .../native-chat-github-route-repo.test.ts | 196 +++++++++++++++++ .../previews/native-chat-github-route-repo.ts | 111 ++++++++++ .../native-chat-hover-preview-cache.test.ts | 54 +++++ .../native-chat-hover-preview-cache.ts | 11 +- .../native-chat-link-peek-context.test.tsx | 80 +++++++ .../native-chat-link-peek-context.tsx | 15 +- .../github/github-work-item-source-lookup.ts | 3 + 13 files changed, 863 insertions(+), 29 deletions(-) create mode 100644 src/renderer/src/components/native-chat/links/previews/native-chat-github-preview-source-race.test.ts create mode 100644 src/renderer/src/components/native-chat/links/previews/native-chat-github-route-repo.test.ts create mode 100644 src/renderer/src/components/native-chat/links/previews/native-chat-github-route-repo.ts create mode 100644 src/renderer/src/components/native-chat/links/previews/native-chat-link-peek-context.test.tsx diff --git a/src/renderer/src/components/native-chat/links/previews/NativeChatGitHubLinkPeek.tsx b/src/renderer/src/components/native-chat/links/previews/NativeChatGitHubLinkPeek.tsx index b5819928b62..e90c95efad8 100644 --- a/src/renderer/src/components/native-chat/links/previews/NativeChatGitHubLinkPeek.tsx +++ b/src/renderer/src/components/native-chat/links/previews/NativeChatGitHubLinkPeek.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from 'react' +import { useAppStore } from '@/store/index' import { Button } from '@/components/ui/button' import { GitHubIcon } from '@/components/icons/GitHubIcon' import { GitHubUserAvatar } from '@/components/github/github-user-avatar' @@ -12,10 +13,14 @@ import { } from '../../../../../../shared/github/repositories/links' import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' import type { Repo } from '../../../../../../shared/repo-types' +import type { HostAuthorityKey } from '../../../../../../shared/host-authority' import { githubLinkPreviewKey, loadGitHubLinkPreview, - readCachedGitHubLinkPreview + loadRoutedGitHubLinkPreview, + readCachedGitHubLinkPreview, + readCachedRoutedGitHubLinkPreview, + routedGitHubLinkPreviewKey } from './native-chat-github-link-preview' import { githubLinkBelongsToRepo, @@ -69,24 +74,63 @@ function PeekFooter({ item }: { item: GitHubWorkItem }): React.JSX.Element { ) } +/** Which lookup answers a card: the chat repo's, or one routed through another repo on the chat's host. */ +export type GitHubPeekSource = + | { kind: 'repo'; repo: Repo } + | { kind: 'routed'; authorityKey: HostAuthorityKey } + +function peekLookup(source: GitHubPeekSource, link: GitHubIssueOrPRLink) { + return source.kind === 'repo' + ? { + key: githubLinkPreviewKey(source.repo, link), + read: () => readCachedGitHubLinkPreview(source.repo, link), + load: () => loadGitHubLinkPreview(source.repo, link) + } + : { + key: routedGitHubLinkPreviewKey(source.authorityKey, link), + read: () => readCachedRoutedGitHubLinkPreview(source.authorityKey, link), + load: () => loadRoutedGitHubLinkPreview(source.authorityKey, link) + } +} + export function NativeChatGitHubPeekBody({ - repo, + source, link, otherRepoFallback }: { - repo: Repo + source: GitHubPeekSource link: GitHubIssueOrPRLink /** For a link to another repo, which an older host or a repo the sign-in can't read leaves * unanswered: what such a link showed before, kept until its details arrive, and never a failure. * Null shows just the name until then. Undefined for the chat repo's own links. */ otherRepoFallback?: ReactNode }): ReactNode { - const t = useTranslate() - const { state, retry } = useNativeChatHoverPreview( - githubLinkPreviewKey(repo, link), - () => readCachedGitHubLinkPreview(repo, link), - () => loadGitHubLinkPreview(repo, link) + const key = useAppStore((state) => + source.kind === 'repo' + ? githubLinkPreviewKey(source.repo, link) + : routedGitHubLinkPreviewKey(source.authorityKey, link, state.repos) ) + return ( + + ) +} + +function GitHubPeekContent({ + lookup, + link, + otherRepoFallback +}: { + lookup: ReturnType + link: GitHubIssueOrPRLink + otherRepoFallback?: ReactNode +}): ReactNode { + const t = useTranslate() + const { state, retry } = useNativeChatHoverPreview(lookup.key, lookup.read, lookup.load) if (otherRepoFallback && state.status !== 'ready') { return otherRepoFallback } diff --git a/src/renderer/src/components/native-chat/links/previews/NativeChatLinkPeek.fixture.tsx b/src/renderer/src/components/native-chat/links/previews/NativeChatLinkPeek.fixture.tsx index d56bb0f99cb..3edc45f4eab 100644 --- a/src/renderer/src/components/native-chat/links/previews/NativeChatLinkPeek.fixture.tsx +++ b/src/renderer/src/components/native-chat/links/previews/NativeChatLinkPeek.fixture.tsx @@ -5,7 +5,11 @@ import type { LinkActionRequest } from '@/components/link-actions/link-action-re import { NativeChatLinkPeek } from './NativeChatLinkPeek' import { NativeChatLinkPeekContext } from './native-chat-link-peek-context' -const previewContext = { githubRepo: null, webPreviews: { runtimeEnvironmentId: null } } +const previewContext = { + githubRepo: null, + githubRoutingAuthorityKey: null, + webPreviews: { runtimeEnvironmentId: null } +} function LinkPreview() { const [request, setRequest] = useState(null) diff --git a/src/renderer/src/components/native-chat/links/previews/NativeChatLinkPeek.tsx b/src/renderer/src/components/native-chat/links/previews/NativeChatLinkPeek.tsx index 76855d2f651..05160ef8954 100644 --- a/src/renderer/src/components/native-chat/links/previews/NativeChatLinkPeek.tsx +++ b/src/renderer/src/components/native-chat/links/previews/NativeChatLinkPeek.tsx @@ -4,6 +4,7 @@ import { NativeChatGitHubPeekBody, resolveGitHubPeekLink } from './NativeChatGit import { NativeChatWebPeekBody } from './NativeChatWebLinkPeek' import { NativeChatLinkPeekContext } from './native-chat-link-peek-context' import { previewableWebUrl } from './native-chat-web-link-preview' +import { parseGitHubIssueOrPRLink } from '../../../../../../shared/github/repositories/links' /** Hover or focus a link to preview it: PRs and issues on its repo's GitHub host, or what a web page says about itself. */ export function NativeChatLinkPeek({ @@ -13,7 +14,8 @@ export function NativeChatLinkPeek({ href: string | undefined children: ReactNode }): ReactNode { - const { githubRepo, webPreviews } = useContext(NativeChatLinkPeekContext) + const { githubRepo, githubRoutingAuthorityKey, webPreviews } = + useContext(NativeChatLinkPeekContext) const body = useMemo(() => { const webUrl = webPreviews ? previewableWebUrl(href) : null const webBody = webUrl ? ( @@ -27,15 +29,28 @@ export function NativeChatLinkPeek({ if (githubRepo && github) { return ( ) } + // No chat repo on the link's host, as in a folder workspace: another repo on the chat's host + // can still answer with its GitHub sign-in. A web page fetch can't read private repos. + const routed = href && githubRoutingAuthorityKey ? parseGitHubIssueOrPRLink(href) : null + if (routed && githubRoutingAuthorityKey) { + return ( + + ) + } return webBody - }, [githubRepo, href, webPreviews]) + }, [githubRepo, githubRoutingAuthorityKey, href, webPreviews]) if (!body) { return children } diff --git a/src/renderer/src/components/native-chat/links/previews/native-chat-github-link-preview.ts b/src/renderer/src/components/native-chat/links/previews/native-chat-github-link-preview.ts index 281b40f8a83..d3e1e3fbfa6 100644 --- a/src/renderer/src/components/native-chat/links/previews/native-chat-github-link-preview.ts +++ b/src/renderer/src/components/native-chat/links/previews/native-chat-github-link-preview.ts @@ -1,23 +1,32 @@ import { lookupGitHubWorkItemByOwnerRepoForSource } from '@/lib/work-items/github/github-work-item-source-lookup' +import { useAppStore } from '@/store/index' import type { GitHubIssueOrPRLink } from '../../../../../../shared/github/repositories/links' import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' import type { Repo } from '../../../../../../shared/repo-types' import { githubRepoIdentityKey } from '../../../../../../shared/github/repositories/repository-identity-key' import { buildTaskSourceContextFromRepo } from '../../../../../../shared/work-items/task-source-context' -import { getRepoExecutionHostId } from '../../../../../../shared/execution-host' +import type { HostAuthorityKey } from '../../../../../../shared/host-authority' import { createHoverPreviewCache } from './native-chat-hover-preview-cache' +import { + findGitHubPreviewRouteRepo, + githubPreviewRepoAuthorityKey, + githubPreviewRepoIdentity, + githubPreviewRouteCandidates, + readGitHubPreviewRouteRepo +} from './native-chat-github-route-repo' const previews = createHoverPreviewCache({ freshMs: 60_000, maxEntries: 200 }) /** Repo ids repeat across hosts, and each host answers with its own GitHub account. */ export function githubLinkPreviewKey(repo: Repo, link: GitHubIssueOrPRLink): string { - return `${getRepoExecutionHostId(repo)}:${repo.id}:${githubRepoIdentityKey(link.slug)}#${link.number}:${link.type}` + return `${githubPreviewRepoIdentity(repo)}:${githubRepoIdentityKey(link.slug)}#${link.number}:${link.type}` } async function lookup( repo: Repo, link: GitHubIssueOrPRLink, - type: 'issue' | 'pr' + type: 'issue' | 'pr', + accepts?: () => boolean ): Promise { return lookupGitHubWorkItemByOwnerRepoForSource({ repoPath: repo.path, @@ -32,14 +41,19 @@ async function lookup( host: link.slug.host, number: link.number, type, - preview: true + preview: true, + accepts }) } -async function fetchPreview(repo: Repo, link: GitHubIssueOrPRLink): Promise { - const item = await lookup(repo, link, link.type) +async function fetchPreview( + repo: Repo, + link: GitHubIssueOrPRLink, + accepts?: () => boolean +): Promise { + const item = await lookup(repo, link, link.type, accepts) // Hosts without the preview lookup answer null for a PR asked for as an issue (bare #N links). - return item || link.type !== 'issue' ? item : lookup(repo, link, 'pr') + return item || link.type !== 'issue' ? item : lookup(repo, link, 'pr', accepts) } /** Lets a reopened card paint its last answer without a loading frame. */ @@ -54,7 +68,14 @@ export function loadGitHubLinkPreview( repo: Repo, link: GitHubIssueOrPRLink ): Promise { - return previews.load(githubLinkPreviewKey(repo, link), () => fetchPreview(repo, link)) + const authority = githubPreviewRepoAuthorityKey(repo) + if (!authority) { + return Promise.resolve(null) + } + const identity = githubPreviewRepoIdentity(repo) + const read = (repos: readonly Repo[]) => + repos.find((row) => githubPreviewRepoIdentity(row) === identity) ?? null + return loadPreviewFromCatalog(authority, link, async (repos) => read(repos), read) } /** Asks again even while the last answer is fresh: the pull request may have just changed. */ @@ -65,3 +86,86 @@ export function reloadGitHubLinkPreview( previews.expire(githubLinkPreviewKey(repo, link)) return loadGitHubLinkPreview(repo, link) } + +/** The same selected route identity names synchronous reads, requests and cache commits. */ +export function routedGitHubLinkPreviewKey( + routeAuthority: HostAuthorityKey, + link: GitHubIssueOrPRLink, + repos: readonly Repo[] = useAppStore.getState().repos +): string { + const repo = readGitHubPreviewRouteRepo(repos, routeAuthority, link) + return repo + ? githubLinkPreviewKey(repo, link) + : `${routeAuthority}:unrouted:${githubRepoIdentityKey(link.slug)}#${link.number}:${link.type}` +} + +export function readCachedRoutedGitHubLinkPreview( + routeAuthority: HostAuthorityKey, + link: GitHubIssueOrPRLink +): GitHubWorkItem | null { + return previews.read(routedGitHubLinkPreviewKey(routeAuthority, link)) +} + +export function loadRoutedGitHubLinkPreview( + routeAuthority: HostAuthorityKey, + link: GitHubIssueOrPRLink +): Promise { + return loadPreviewFromCatalog( + routeAuthority, + link, + (repos) => findGitHubPreviewRouteRepo(repos, routeAuthority, link), + (repos) => readGitHubPreviewRouteRepo(repos, routeAuthority, link) + ) +} + +async function loadPreviewFromCatalog( + routeAuthority: HostAuthorityKey, + link: GitHubIssueOrPRLink, + select: (repos: readonly Repo[]) => Promise, + read: (repos: readonly Repo[]) => Repo | null +): Promise { + const repos = useAppStore.getState().repos + const captured = new Set( + githubPreviewRouteCandidates(repos, routeAuthority).map(githubPreviewRepoIdentity) + ) + const retired = new Set() + const stop = useAppStore.subscribe((state) => { + const live = new Set( + githubPreviewRouteCandidates(state.repos, routeAuthority).map(githubPreviewRepoIdentity) + ) + // Restoring a retired binding cannot revive its old request. + for (const identity of captured) { + if (!live.has(identity)) { + retired.add(identity) + } + } + }) + try { + const repo = await select(repos) + if (!repo) { + return null + } + const identity = githubPreviewRepoIdentity(repo) + const accepts = () => { + const currentRepos = useAppStore.getState().repos + const currentRepo = read(currentRepos) + return ( + captured.has(identity) && + !retired.has(identity) && + currentRepo !== null && + githubPreviewRepoIdentity(currentRepo) === identity + ) + } + if (!accepts()) { + return null + } + const item = await previews.load( + githubLinkPreviewKey(repo, link), + () => fetchPreview(repo, link, accepts), + accepts + ) + return accepts() ? item : null + } finally { + stop() + } +} diff --git a/src/renderer/src/components/native-chat/links/previews/native-chat-github-preview-source-race.test.ts b/src/renderer/src/components/native-chat/links/previews/native-chat-github-preview-source-race.test.ts new file mode 100644 index 00000000000..6dd0ba59200 --- /dev/null +++ b/src/renderer/src/components/native-chat/links/previews/native-chat-github-preview-source-race.test.ts @@ -0,0 +1,197 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../../../shared/repo-types' +import { parseGitHubIssueOrPRLink } from '../../../../../../shared/github/repositories/links' +import { clearRepoSlugCacheValues } from '@/lib/workspace/catalog/repo-slug-cache' + +const mocks = await vi.hoisted(async () => { + const { createStore } = await import('zustand/vanilla') + return { + store: createStore<{ repos: Repo[] }>(() => ({ repos: [] })), + slugGate: Promise.resolve(), + slugStarted: Promise.withResolvers(), + endpoint: vi.fn(), + rpc: vi.fn() + } +}) +vi.mock('@/store/index', () => ({ useAppStore: mocks.store })) +vi.mock('@/runtime/rpc/calls/runtime-rpc-call', () => ({ callRuntimeRpc: mocks.rpc })) +vi.mock('@/lib/workspace/catalog/repo-slug-index', async () => { + const { rememberRepoSlug, repoSlugCacheKey } = + await import('@/lib/workspace/catalog/repo-slug-cache') + return { + resolveRepoSlug: async (repo: Repo) => { + mocks.slugStarted.resolve() + await mocks.slugGate + const key = repoSlugCacheKey(repo) + const slug = repo.id === 'nested-u' ? 'acme/private' : 'acme/router' + if (key) { + rememberRepoSlug(key, slug) + } + return slug + } + } +}) + +const { loadRoutedGitHubLinkPreview, readCachedRoutedGitHubLinkPreview } = + await import('./native-chat-github-link-preview') + +const alice: Repo = { + id: 'router', + path: '/router', + displayName: 'Router', + badgeColor: '', + addedAt: 0, + ghAccount: { host: 'github.com', user: 'alice' } +} +const bob: Repo = { ...alice, ghAccount: { host: 'github.com', user: 'bob' } } + +beforeEach(() => { + clearRepoSlugCacheValues() + mocks.store.setState({ repos: [alice] }) + mocks.slugGate = Promise.resolve() + mocks.slugStarted = Promise.withResolvers() + mocks.endpoint.mockReset() + mocks.rpc.mockReset() + mocks.endpoint.mockImplementation(async ({ number }: { number: number }) => ({ + type: 'pr', + number, + title: `${mocks.store.getState().repos[0].ghAccount?.user} response`, + url: `https://github.com/acme/private/pull/${number}` + })) + vi.stubGlobal('window', { api: { gh: { workItemByOwnerRepo: mocks.endpoint } } }) +}) +afterEach(() => vi.unstubAllGlobals()) + +it('discards a lookup whose binding changes while its slug is resolving', async () => { + const gate = Promise.withResolvers() + mocks.slugGate = gate.promise + const link = parseGitHubIssueOrPRLink('https://github.com/acme/private/pull/801')! + const pending = loadRoutedGitHubLinkPreview('local', link) + await mocks.slugStarted.promise + mocks.store.setState({ repos: [bob] }) + gate.resolve() + await expect(pending).resolves.toBeNull() + + mocks.store.setState({ repos: [alice] }) + expect(readCachedRoutedGitHubLinkPreview('local', link)).toBeNull() + await expect(loadRoutedGitHubLinkPreview('local', link)).resolves.toMatchObject({ + title: 'alice response' + }) +}) + +it('does not commit a response if the binding changes during the GitHub request', async () => { + const gate = Promise.withResolvers() + const sent = Promise.withResolvers() + mocks.endpoint.mockImplementationOnce(async ({ number }: { number: number }) => { + sent.resolve() + await gate.promise + return { + type: 'pr', + number, + title: 'bob response', + url: `https://github.com/acme/private/pull/${number}` + } + }) + const link = parseGitHubIssueOrPRLink('https://github.com/acme/private/pull/802')! + const pending = loadRoutedGitHubLinkPreview('local', link) + await sent.promise + mocks.store.setState({ repos: [bob] }) + gate.resolve() + await expect(pending).resolves.toBeNull() + + mocks.store.setState({ repos: [alice] }) + expect(readCachedRoutedGitHubLinkPreview('local', link)).toBeNull() + await expect(loadRoutedGitHubLinkPreview('local', link)).resolves.toMatchObject({ + title: 'alice response' + }) +}) + +it('rejects an in-flight response even if Alice is restored before it settles', async () => { + const gate = Promise.withResolvers() + const sent = Promise.withResolvers() + mocks.endpoint.mockImplementationOnce(async ({ number }: { number: number }) => { + sent.resolve() + await gate.promise + return { + type: 'pr', + number, + title: 'bob response', + url: `https://github.com/acme/private/pull/${number}` + } + }) + const link = parseGitHubIssueOrPRLink('https://github.com/acme/private/pull/803')! + const pending = loadRoutedGitHubLinkPreview('local', link) + await sent.promise + mocks.store.setState({ repos: [bob] }) + mocks.store.setState({ repos: [alice] }) + gate.resolve() + await expect(pending).resolves.toBeNull() + expect(readCachedRoutedGitHubLinkPreview('local', link)).toBeNull() + await expect(loadRoutedGitHubLinkPreview('local', link)).resolves.toMatchObject({ + title: 'alice response' + }) +}) + +it('keeps a valid request alive when an unrelated candidate is removed', async () => { + mocks.store.setState({ repos: [alice, { ...bob, id: 'unrelated' }] }) + const gate = Promise.withResolvers() + const sent = Promise.withResolvers() + mocks.endpoint.mockImplementationOnce(async ({ number }: { number: number }) => { + sent.resolve() + await gate.promise + return { + type: 'pr', + number, + title: 'alice response', + url: `https://github.com/acme/private/pull/${number}` + } + }) + const link = parseGitHubIssueOrPRLink('https://github.com/acme/private/pull/805')! + const pending = loadRoutedGitHubLinkPreview('local', link) + await sent.promise + mocks.store.setState({ repos: [alice] }) + gate.resolve() + await expect(pending).resolves.toMatchObject({ title: 'alice response' }) + expect(readCachedRoutedGitHubLinkPreview('local', link)).toMatchObject({ + title: 'alice response' + }) +}) + +it('keeps endpoint-local, nested T and nested U lookups on their own repo/account', async () => { + const endpoint: Repo = { ...alice, id: 'endpoint', executionHostId: 'runtime:env' } + const targetT: Repo = { + ...alice, + id: 'nested-t', + executionHostId: 'runtime:env', + connectionId: 'T', + ghAccount: { host: 'github.com', user: 't' } + } + const targetU: Repo = { + ...alice, + id: 'nested-u', + executionHostId: 'runtime:env', + connectionId: 'U', + ghAccount: { host: 'github.com', user: 'u' } + } + mocks.store.setState({ repos: [endpoint, targetT, targetU] }) + mocks.rpc.mockImplementation(async (_target, _method, params, options) => { + options?.beforeSend?.() + const repo = mocks.store.getState().repos.find((row) => row.id === params.repo) + return { + type: 'pr', + number: params.number, + title: `${repo?.ghAccount?.user} response`, + url: `https://github.com/acme/private/pull/${params.number}` + } + }) + const link = parseGitHubIssueOrPRLink('https://github.com/acme/private/pull/804')! + await expect(loadRoutedGitHubLinkPreview('runtime:env', link)).resolves.toMatchObject({ + title: 'alice response' + }) + await expect(loadRoutedGitHubLinkPreview('nested:env/T', link)).resolves.toMatchObject({ + title: 't response' + }) + await expect(loadRoutedGitHubLinkPreview('nested:env/U', link)).resolves.toMatchObject({ + title: 'u response' + }) +}) diff --git a/src/renderer/src/components/native-chat/links/previews/native-chat-github-repo.ts b/src/renderer/src/components/native-chat/links/previews/native-chat-github-repo.ts index da9e2b6bc6f..e6c2309422a 100644 --- a/src/renderer/src/components/native-chat/links/previews/native-chat-github-repo.ts +++ b/src/renderer/src/components/native-chat/links/previews/native-chat-github-repo.ts @@ -11,11 +11,11 @@ import { getKnownExecutionHostIdForWorktree } from '@/lib/workspace/ownership/wo import type { AppState } from '@/store/types' import type { WorktreeRuntimeOwnerState } from '@/lib/workspace/ownership/worktree-runtime-owner-state' import { FILE_LINK_CONTEXT_INPUT_KEYS } from '../actions/native-chat-file-link-context' +import { resolveOwner } from '@/lib/workspace/ownership/resolve-owner' +import { authorityKey } from '../../../../../../shared/host-authority' +import { githubPreviewRepoAuthorityKey } from './native-chat-github-route-repo' import type { Repo } from '../../../../../../shared/repo-types' -import { - getRepoExecutionHostId, - isUnresolvedOwnerHostId -} from '../../../../../../shared/execution-host' +import { isUnresolvedOwnerHostId } from '../../../../../../shared/execution-host' import { githubHostFromIdentityKey, githubRepoIdentityKey, @@ -63,8 +63,14 @@ function resolveNativeChatRepo( if (!worktree) { return null } + const owner = resolveOwner(state, { workspaceId: worktreeId }) + if (owner.kind !== 'resolved') { + return null + } const owners = state.repos.filter( - (candidate) => candidate.id === worktree.repoId && getRepoExecutionHostId(candidate) === hostId + (candidate) => + candidate.id === worktree.repoId && + githubPreviewRepoAuthorityKey(candidate) === authorityKey(owner.owner) ) const found = owners.length === 1 ? owners[0] : null return found && (!found.kind || found.kind === 'git') ? found : null diff --git a/src/renderer/src/components/native-chat/links/previews/native-chat-github-route-repo.test.ts b/src/renderer/src/components/native-chat/links/previews/native-chat-github-route-repo.test.ts new file mode 100644 index 00000000000..b5c81a6fa66 --- /dev/null +++ b/src/renderer/src/components/native-chat/links/previews/native-chat-github-route-repo.test.ts @@ -0,0 +1,196 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../../../shared/repo-types' +import { parseGitHubIssueOrPRLink } from '../../../../../../shared/github/repositories/links' +import { clearRepoSlugCacheValues } from '@/lib/workspace/catalog/repo-slug-cache' + +const mocks = await vi.hoisted(async () => { + const { createStore } = await import('zustand/vanilla') + const store = createStore<{ repos: Repo[] }>(() => ({ repos: [] })) + return { + store, + get repos() { + return store.getState().repos + }, + set repos(repos: Repo[]) { + store.setState({ repos }) + }, + slugs: new Map(), + lookup: vi.fn() + } +}) + +vi.mock('@/store/index', () => ({ + useAppStore: mocks.store +})) +vi.mock('@/lib/workspace/catalog/repo-slug-index', async () => { + const { rememberRepoSlug, repoSlugCacheKey } = + await import('@/lib/workspace/catalog/repo-slug-cache') + return { + resolveRepoSlug: async (repo: Repo) => { + const slug = mocks.slugs.get(repo.id) ?? null + const key = repoSlugCacheKey(repo) + if (key) { + rememberRepoSlug(key, slug) + } + return slug + } + } +}) +vi.mock('@/lib/work-items/github/github-work-item-source-lookup', () => ({ + lookupGitHubWorkItemByOwnerRepoForSource: mocks.lookup +})) + +const { githubPreviewRouteCandidates, pickGitHubPreviewRouteRepo } = + await import('./native-chat-github-route-repo') +const { loadRoutedGitHubLinkPreview, readCachedRoutedGitHubLinkPreview } = + await import('./native-chat-github-link-preview') + +function repo(id: string, extra: Partial = {}): Repo { + return { id, path: `/src/${id}`, displayName: id, badgeColor: '', addedAt: 0, ...extra } +} + +const privatePr = parseGitHubIssueOrPRLink('https://github.com/stablyai/orca-private/pull/685')! + +describe('GitHub preview routing for a chat without a repo of its own', () => { + beforeEach(() => { + mocks.repos = [] + mocks.slugs.clear() + clearRepoSlugCacheValues() + mocks.lookup.mockReset() + }) + + it('keeps git repos on the chat host only', () => { + const local = repo('local') + const remote = repo('ssh', { connectionId: 'box' }) + const runtime = repo('rt', { executionHostId: 'runtime:env' }) + const folder = repo('folder', { kind: 'folder' }) + const all = [local, remote, runtime, folder] + expect(githubPreviewRouteCandidates(all, 'local')).toEqual([local]) + expect(githubPreviewRouteCandidates(all, 'runtime:env')).toEqual([runtime]) + }) + + it('separates endpoint-local repos from each nested SSH target on the same runtime', () => { + const local = repo('local') + const endpoint = repo('endpoint', { executionHostId: 'runtime:env' }) + const targetT = repo('target-t', { executionHostId: 'runtime:env', connectionId: 'T' }) + const targetU = repo('target-u', { executionHostId: 'runtime:env', connectionId: 'U' }) + const directT = repo('direct-t', { connectionId: 'T' }) + const candidates = [local, endpoint, targetT, targetU, directT] + expect(githubPreviewRouteCandidates(candidates, 'local')).toEqual([local]) + expect(githubPreviewRouteCandidates(candidates, 'runtime:env')).toEqual([endpoint]) + expect(githubPreviewRouteCandidates(candidates, 'nested:env/T')).toEqual([targetT]) + expect(githubPreviewRouteCandidates(candidates, 'nested:env/U')).toEqual([targetU]) + expect(githubPreviewRouteCandidates(candidates, 'ssh:T')).toEqual([directT]) + }) + + it('prefers an exact clone before a fork upstream, then any repo on the GitHub host', () => { + const orca = repo('orca') + const fork = repo('fork', { upstream: { owner: 'stablyai', repo: 'orca-private' } }) + const clone = repo('clone') + const ghes = repo('ghes') + const rows = [ + { repo: ghes, originKey: 'git.corp.example/team/app' }, + { repo: orca, originKey: 'stablyai/orca' }, + { repo: fork, originKey: 'me/orca-private' }, + { repo: clone, originKey: 'stablyai/orca-private' } + ] + expect(pickGitHubPreviewRouteRepo(rows, privatePr)).toBe(clone) + expect(pickGitHubPreviewRouteRepo(rows.slice(0, 3), privatePr)).toBe(fork) + expect(pickGitHubPreviewRouteRepo(rows.slice(0, 2), privatePr)).toBe(orca) + expect(pickGitHubPreviewRouteRepo(rows.slice(0, 1), privatePr)).toBeNull() + expect(pickGitHubPreviewRouteRepo([{ repo: orca, originKey: null }], privatePr)).toBeNull() + }) + + it('looks a private PR up through a local repo on github.com, as a preview', async () => { + const orca = repo('orca') + mocks.repos = [repo('folder', { kind: 'folder' }), orca] + mocks.slugs.set('orca', 'stablyai/orca') + const item = { type: 'pr', number: 685, title: 'perf: cacheable projections', state: 'draft' } + mocks.lookup.mockResolvedValue(item) + + await expect(loadRoutedGitHubLinkPreview('local', privatePr)).resolves.toBe(item) + expect(mocks.lookup.mock.calls[0][0]).toMatchObject({ + repoPath: '/src/orca', + repoId: 'orca', + owner: 'stablyai', + repo: 'orca-private', + number: 685, + type: 'pr', + preview: true + }) + }) + + it('answers nothing, leaving the web card, when no repo on the chat host uses GitHub', async () => { + mocks.repos = [repo('ghes')] + mocks.slugs.set('ghes', 'git.corp.example/team/app') + const link = parseGitHubIssueOrPRLink('https://github.com/stablyai/orca-private/pull/686')! + await expect(loadRoutedGitHubLinkPreview('local', link)).resolves.toBeNull() + expect(mocks.lookup).not.toHaveBeenCalled() + }) + + it('uses the remaining repo after the repo that answered is removed', async () => { + const link = parseGitHubIssueOrPRLink('https://github.com/acme/private/pull/701')! + const alice = repo('alice', { ghAccount: { host: 'github.com', user: 'alice' } }) + const bob = repo('bob', { ghAccount: { host: 'github.com', user: 'bob' } }) + mocks.repos = [alice, bob] + mocks.slugs.set('alice', 'acme/alice') + mocks.slugs.set('bob', 'acme/bob') + mocks.lookup.mockImplementation(async ({ repoId }) => ({ title: `${repoId}'s preview` })) + + await expect(loadRoutedGitHubLinkPreview('local', link)).resolves.toEqual({ + title: "alice's preview" + }) + mocks.repos = [bob] + expect(readCachedRoutedGitHubLinkPreview('local', link)).toBeNull() + await expect(loadRoutedGitHubLinkPreview('local', link)).resolves.toEqual({ + title: "bob's preview" + }) + }) + + it('leaves no cached preview when the last eligible repo is removed', async () => { + const link = parseGitHubIssueOrPRLink('https://github.com/acme/private/pull/702')! + mocks.repos = [repo('alice')] + mocks.slugs.set('alice', 'acme/alice') + mocks.lookup.mockResolvedValue({ title: "alice's preview" }) + + await expect(loadRoutedGitHubLinkPreview('local', link)).resolves.toEqual({ + title: "alice's preview" + }) + mocks.repos = [] + expect(readCachedRoutedGitHubLinkPreview('local', link)).toBeNull() + await expect(loadRoutedGitHubLinkPreview('local', link)).resolves.toBeNull() + }) + + it('reloads a preview when the answering repo changes its account binding', async () => { + const link = parseGitHubIssueOrPRLink('https://github.com/acme/private/pull/703')! + const alice = repo('bound', { ghAccount: { host: 'github.com', user: 'alice' } }) + mocks.repos = [alice] + mocks.slugs.set('bound', 'acme/bound') + mocks.lookup.mockResolvedValueOnce({ title: "alice's preview" }).mockResolvedValueOnce(null) + + await expect(loadRoutedGitHubLinkPreview('local', link)).resolves.toEqual({ + title: "alice's preview" + }) + mocks.repos = [{ ...alice, ghAccount: { host: 'github.com', user: 'bob' } }] + expect(readCachedRoutedGitHubLinkPreview('local', link)).toBeNull() + await expect(loadRoutedGitHubLinkPreview('local', link)).resolves.toBeNull() + }) + + it('uses a newly resolved exact clone instead of the previously cached fallback', async () => { + const link = parseGitHubIssueOrPRLink('https://github.com/acme/private/pull/704')! + mocks.repos = [repo('alice'), repo('bob')] + mocks.slugs.set('bob', 'acme/bob') + mocks.lookup.mockImplementation(async ({ repoId }) => ({ title: `${repoId}'s preview` })) + + await expect(loadRoutedGitHubLinkPreview('local', link)).resolves.toEqual({ + title: "bob's preview" + }) + mocks.slugs.set('alice', 'acme/private') + await expect(loadRoutedGitHubLinkPreview('local', link)).resolves.toEqual({ + title: "alice's preview" + }) + expect(readCachedRoutedGitHubLinkPreview('local', link)).toEqual({ + title: "alice's preview" + }) + }) +}) diff --git a/src/renderer/src/components/native-chat/links/previews/native-chat-github-route-repo.ts b/src/renderer/src/components/native-chat/links/previews/native-chat-github-route-repo.ts new file mode 100644 index 00000000000..4d605a5a814 --- /dev/null +++ b/src/renderer/src/components/native-chat/links/previews/native-chat-github-route-repo.ts @@ -0,0 +1,111 @@ +import { resolveRepoSlug } from '@/lib/workspace/catalog/repo-slug-index' +import { readRepoSlugCache, repoSlugCacheKey } from '@/lib/workspace/catalog/repo-slug-cache' +import type { GitHubIssueOrPRLink } from '../../../../../../shared/github/repositories/links' +import type { Repo } from '../../../../../../shared/repo-types' +import { + getRepoExecutionHostId, + getRepoSshConnectionId, + toSshExecutionHostId +} from '../../../../../../shared/execution-host' +import { + authorityKey, + parseHostAuthorityKey, + type HostAuthorityKey +} from '../../../../../../shared/host-authority' +import { + githubHostFromIdentityKey, + githubRepoIdentityKey, + repoUpstreamIdentityKey +} from '../../../../../../shared/github/repositories/repository-identity-key' + +export type RepoWithOriginKey = { repo: Repo; originKey: string | null } + +export function githubPreviewRepoAuthorityKey(repo: Repo): HostAuthorityKey | null { + const authority = parseHostAuthorityKey(getRepoExecutionHostId(repo)) + if (!authority) { + return null + } + const connectionId = getRepoSshConnectionId(repo) + return authorityKey( + authority.endpoint.kind === 'environment' && connectionId + ? { ...authority, at: toSshExecutionHostId(connectionId) } + : authority + ) +} + +/** One registered route, including its endpoint, nested target and account binding. */ +export function githubPreviewRepoIdentity(repo: Repo): string { + return JSON.stringify([ + githubPreviewRepoAuthorityKey(repo), + repo.id, + repo.path, + repo.kind ?? 'git', + repo.connectionId ?? null, + repo.ghAccount?.host ?? null, + repo.ghAccount?.user ?? null, + repo.upstream ?? null, + repo.gitRemoteIdentity ?? null + ]) +} + +/** Git repos on the chat's host; their remotes decide which GitHub sign-in a lookup may use. */ +export function githubPreviewRouteCandidates( + repos: readonly Repo[], + routeAuthority: HostAuthorityKey +): Repo[] { + return repos.filter( + (repo) => + (!repo.kind || repo.kind === 'git') && githubPreviewRepoAuthorityKey(repo) === routeAuthority + ) +} + +export function readGitHubPreviewRouteRepo( + repos: readonly Repo[], + routeAuthority: HostAuthorityKey, + link: GitHubIssueOrPRLink +): Repo | null { + return pickGitHubPreviewRouteRepo( + githubPreviewRouteCandidates(repos, routeAuthority).map((repo) => { + const key = repoSlugCacheKey(repo) + const slug = key ? readRepoSlugCache(key) : null + return { repo, originKey: slug?.hit ? slug.value : null } + }), + link + ) +} + +/** The repo to ask about a link when the chat has no repo on its GitHub host: the linked repo's own + * clone first, else any repo with a remote on that host, whose sign-in main lets a preview use. */ +export function pickGitHubPreviewRouteRepo( + candidates: readonly RepoWithOriginKey[], + link: GitHubIssueOrPRLink +): Repo | null { + const linkKey = githubRepoIdentityKey(link.slug) + const linkHost = githubHostFromIdentityKey(linkKey) + const resolved = candidates.filter((row): row is { repo: Repo; originKey: string } => + Boolean(row.originKey) + ) + const clone = + resolved.find(({ originKey }) => originKey === linkKey) ?? + resolved.find(({ repo, originKey }) => repoUpstreamIdentityKey(repo, originKey) === linkKey) + return ( + clone?.repo ?? + resolved.find(({ originKey }) => githubHostFromIdentityKey(originKey) === linkHost)?.repo ?? + null + ) +} + +export async function findGitHubPreviewRouteRepo( + repos: readonly Repo[], + routeAuthority: HostAuthorityKey, + link: GitHubIssueOrPRLink +): Promise { + // Slugs are cached per repo, and the sidebar's slug index has usually resolved them already. + const candidates = await Promise.all( + githubPreviewRouteCandidates(repos, routeAuthority).map(async (repo) => ({ + repo, + originKey: await resolveRepoSlug(repo) + })) + ) + return pickGitHubPreviewRouteRepo(candidates, link) +} diff --git a/src/renderer/src/components/native-chat/links/previews/native-chat-hover-preview-cache.test.ts b/src/renderer/src/components/native-chat/links/previews/native-chat-hover-preview-cache.test.ts index a6c2ff5284f..30d1de67d08 100644 --- a/src/renderer/src/components/native-chat/links/previews/native-chat-hover-preview-cache.test.ts +++ b/src/renderer/src/components/native-chat/links/previews/native-chat-hover-preview-cache.test.ts @@ -2,6 +2,60 @@ import { describe, expect, it } from 'vitest' import { createHoverPreviewCache } from './native-chat-hover-preview-cache' describe('createHoverPreviewCache', () => { + it('starts a fresh request after the pending binding retires and preserves its replacement', async () => { + const cache = createHoverPreviewCache({ freshMs: 60_000, maxEntries: 100 }) + let live = true + let finish: (value: string) => void = () => {} + const old = cache.load( + 'a', + () => new Promise((resolve) => (finish = resolve)), + () => live + ) + live = false + let finishNew: (value: string) => void = () => {} + const current = cache.load('a', () => new Promise((resolve) => (finishNew = resolve))) + finish('old') + expect(await old).toBeNull() + const joined = cache.load('a', async () => 'unexpected') + finishNew('new') + expect(await current).toBe('new') + expect(await joined).toBe('new') + expect(cache.read('a')).toBe('new') + }) + + it('coalesces pending requests while their binding remains live', async () => { + const cache = createHoverPreviewCache({ freshMs: 60_000, maxEntries: 100 }) + let finish: (value: string) => void = () => {} + const first = cache.load( + 'a', + () => new Promise((resolve) => (finish = resolve)), + () => true + ) + const second = cache.load( + 'a', + async () => 'unexpected', + () => true + ) + finish('result') + expect(await first).toBe('result') + expect(await second).toBe('result') + }) + + it('does not publish a response whose owner rejects it at cache commit', async () => { + const cache = createHoverPreviewCache({ freshMs: 60_000, maxEntries: 100 }) + const response = Promise.withResolvers() + let current = true + const pending = cache.load( + 'alice', + () => response.promise, + () => current + ) + response.resolve('bob response') + current = false + await expect(pending).resolves.toBeNull() + expect(cache.read('alice')).toBeNull() + }) + it('keeps the last answer through a reread that fails, and rereads past one started earlier', async () => { const cache = createHoverPreviewCache({ freshMs: 60_000, maxEntries: 100 }) await cache.load('pr', async () => 'closed') diff --git a/src/renderer/src/components/native-chat/links/previews/native-chat-hover-preview-cache.ts b/src/renderer/src/components/native-chat/links/previews/native-chat-hover-preview-cache.ts index 50c3f20f5c7..97007e86214 100644 --- a/src/renderer/src/components/native-chat/links/previews/native-chat-hover-preview-cache.ts +++ b/src/renderer/src/components/native-chat/links/previews/native-chat-hover-preview-cache.ts @@ -5,6 +5,7 @@ type Entry = { bytes: number /** Started before an `expire`, so its answer may predate the change. */ expired?: boolean + accepts?: () => boolean } /** One request per key at a time; answers stay fresh for `freshMs`, and empty answers retry on the next open. @@ -19,7 +20,7 @@ export function createHoverPreviewCache({ budget?: { maxBytes: number; sizeOf: (value: T) => number } }): { read: (key: string) => T | null - load: (key: string, fetch: () => Promise) => Promise + load: (key: string, fetch: () => Promise, accepts?: () => boolean) => Promise /** The next load asks again, even past one already under way; `read` keeps the old answer. */ expire: (key: string) => void } { @@ -48,11 +49,12 @@ export function createHoverPreviewCache({ entry.expired = true } }, - load: (key, fetch) => { + load: (key, fetch, accepts) => { const cached = entries.get(key) if ( cached && !cached.expired && + (cached.settledAt !== null || cached.accepts?.() !== false) && (cached.settledAt === null || Date.now() - cached.settledAt < freshMs) ) { return cached.promise @@ -62,6 +64,7 @@ export function createHoverPreviewCache({ const entry: Entry = { promise: Promise.resolve(null), settledAt: null, + accepts, value: cached?.value, bytes: cached?.value === undefined ? 0 : cached.bytes } @@ -78,6 +81,10 @@ export function createHoverPreviewCache({ } entry.promise = fetch().then( (value) => { + if (accepts?.() === false) { + forget() + return null + } if (!value) { forget() } else if (entries.get(key) === entry) { diff --git a/src/renderer/src/components/native-chat/links/previews/native-chat-link-peek-context.test.tsx b/src/renderer/src/components/native-chat/links/previews/native-chat-link-peek-context.test.tsx new file mode 100644 index 00000000000..b5972eab6a1 --- /dev/null +++ b/src/renderer/src/components/native-chat/links/previews/native-chat-link-peek-context.test.tsx @@ -0,0 +1,80 @@ +import { useContext } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { expect, it, vi } from 'vitest' +import type { FolderWorkspace } from '../../../../../../shared/folder-workspaces/folder-workspace-types' +import { + resolveNativeChatFileLinkContextForOwner, + type NativeChatFileLinkState +} from '../actions/native-chat-file-link-context' +import { + NativeChatLinkPeekContext, + NativeChatLinkPeekProvider +} from './native-chat-link-peek-context' + +const mocks = vi.hoisted((): { state: NativeChatFileLinkState | null } => ({ state: null })) +vi.mock('@/store', () => ({ + useAppStore: (select: (state: NativeChatFileLinkState) => unknown) => select(mocks.state!) +})) + +function RoutingHost() { + return useContext(NativeChatLinkPeekContext).githubRoutingAuthorityKey +} + +it.each([ + { owner: {}, runtimeEnvironmentId: null, expectedHost: 'local' }, + { owner: { connectionId: 'box' }, runtimeEnvironmentId: null, expectedHost: 'ssh:box' }, + { + owner: { executionHostId: 'runtime:env' as const }, + runtimeEnvironmentId: 'env', + expectedHost: 'runtime:env' + }, + { + owner: { executionHostId: 'runtime:env' as const, connectionId: 'T' }, + runtimeEnvironmentId: 'env', + expectedHost: 'nested:env/T' + }, + { + owner: { executionHostId: 'runtime:env' as const, connectionId: 'U' }, + runtimeEnvironmentId: 'env', + expectedHost: 'nested:env/U' + } +])( + 'routes a folder chat preview through $expectedHost', + ({ owner, runtimeEnvironmentId, expectedHost }) => { + const folder: FolderWorkspace = { + id: 'chat', + projectGroupId: 'group', + name: 'Chat', + folderPath: '/workspace', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0, + ...owner + } + mocks.state = { + repos: [], + projectGroups: [], + folderWorkspaces: [folder], + settings: null, + worktreesByRepo: {}, + detectedWorktreesByRepo: {}, + tabsByWorktree: {}, + floatingWorkspacePath: null + } + const context = resolveNativeChatFileLinkContextForOwner(mocks.state, 'tab', 'folder:chat') + expect(context?.runtimeEnvironmentId).toBe(runtimeEnvironmentId) + expect( + renderToStaticMarkup( + + + + ) + ).toBe(expectedHost) + } +) diff --git a/src/renderer/src/components/native-chat/links/previews/native-chat-link-peek-context.tsx b/src/renderer/src/components/native-chat/links/previews/native-chat-link-peek-context.tsx index 0425948cb18..f03e59863c3 100644 --- a/src/renderer/src/components/native-chat/links/previews/native-chat-link-peek-context.tsx +++ b/src/renderer/src/components/native-chat/links/previews/native-chat-link-peek-context.tsx @@ -1,16 +1,22 @@ import { createContext, useMemo, type ReactNode } from 'react' import { useAppStore } from '@/store' +import { resolveOwnerOrLocal } from '@/lib/workspace/ownership/worktree-runtime-owner' import type { NativeChatFileLinkContext } from '../actions/native-chat-file-link-context' import { useNativeChatGitHubRepo, type NativeChatGitHubRepo } from './native-chat-github-repo' +import { authorityKey, type HostAuthorityKey } from '../../../../../../shared/host-authority' export type NativeChatLinkPeek = { githubRepo: NativeChatGitHubRepo | null + /** The chat's host, where GitHub links off `githubRepo`'s hosts find a repo to answer them; null + * leaves those links to the web preview. */ + githubRoutingAuthorityKey: HostAuthorityKey | null /** Null while web link previews are turned off. */ webPreviews: { runtimeEnvironmentId: string | null } | null } export const NativeChatLinkPeekContext = createContext({ githubRepo: null, + githubRoutingAuthorityKey: null, webPreviews: null }) @@ -27,12 +33,19 @@ export function NativeChatLinkPeekProvider({ (state) => state.settings?.nativeChatLinkPreviews !== false ) const runtimeEnvironmentId = fileLinkContext?.runtimeEnvironmentId ?? null + const githubRoutingAuthorityKey = useAppStore((state) => { + const match = fileLinkContext + ? resolveOwnerOrLocal(state, { workspaceId: fileLinkContext.worktreeId }) + : null + return match?.kind === 'resolved' ? authorityKey(match.owner) : null + }) const value = useMemo( () => ({ githubRepo, + githubRoutingAuthorityKey, webPreviews: webPreviewsEnabled ? { runtimeEnvironmentId } : null }), - [githubRepo, runtimeEnvironmentId, webPreviewsEnabled] + [githubRepo, githubRoutingAuthorityKey, runtimeEnvironmentId, webPreviewsEnabled] ) return ( diff --git a/src/renderer/src/lib/work-items/github/github-work-item-source-lookup.ts b/src/renderer/src/lib/work-items/github/github-work-item-source-lookup.ts index c97455da1e6..095d9fbde43 100644 --- a/src/renderer/src/lib/work-items/github/github-work-item-source-lookup.ts +++ b/src/renderer/src/lib/work-items/github/github-work-item-source-lookup.ts @@ -141,6 +141,9 @@ export async function lookupGitHubWorkItemByOwnerRepoForSource( args.sourceContext )) : null + if (args.accepts?.() === false) { + return null + } const item = target.kind === 'environment' ? await callRuntimeRpc | null>(