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
This commit is contained in:
Jinjing
2026-11-12 16:21:30 -08:00
committed by GitHub
parent 1a90544178
commit 7420dfa8f8
13 changed files with 863 additions and 29 deletions
@@ -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 (
<GitHubPeekContent
key={key}
lookup={peekLookup(source, link)}
link={link}
otherRepoFallback={otherRepoFallback}
/>
)
}
function GitHubPeekContent({
lookup,
link,
otherRepoFallback
}: {
lookup: ReturnType<typeof peekLookup>
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
}
@@ -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<LinkActionRequest | null>(null)
@@ -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 (
<NativeChatGitHubPeekBody
key={href}
repo={githubRepo.repo}
key={`repo:${githubRepo.repo.id}:${href}`}
source={{ kind: 'repo', repo: githubRepo.repo }}
link={github.link}
otherRepoFallback={github.ownRepo ? undefined : webBody}
/>
)
}
// 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 (
<NativeChatGitHubPeekBody
key={`routed:${githubRoutingAuthorityKey}:${href}`}
source={{ kind: 'routed', authorityKey: githubRoutingAuthorityKey }}
link={routed}
otherRepoFallback={webBody}
/>
)
}
return webBody
}, [githubRepo, href, webPreviews])
}, [githubRepo, githubRoutingAuthorityKey, href, webPreviews])
if (!body) {
return children
}
@@ -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<GitHubWorkItem>({ 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<GitHubWorkItem | null> {
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<GitHubWorkItem | null> {
const item = await lookup(repo, link, link.type)
async function fetchPreview(
repo: Repo,
link: GitHubIssueOrPRLink,
accepts?: () => boolean
): Promise<GitHubWorkItem | null> {
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<GitHubWorkItem | null> {
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<GitHubWorkItem | null> {
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<Repo | null>,
read: (repos: readonly Repo[]) => Repo | null
): Promise<GitHubWorkItem | null> {
const repos = useAppStore.getState().repos
const captured = new Set(
githubPreviewRouteCandidates(repos, routeAuthority).map(githubPreviewRepoIdentity)
)
const retired = new Set<string>()
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()
}
}
@@ -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<void>(),
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<void>()
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<void>()
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<void>()
const sent = Promise.withResolvers<void>()
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<void>()
const sent = Promise.withResolvers<void>()
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<void>()
const sent = Promise.withResolvers<void>()
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'
})
})
@@ -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
@@ -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<string, string | null>(),
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> = {}): 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"
})
})
})
@@ -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<Repo | null> {
// 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)
}
@@ -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<string>({ 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<string>({ 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<string>({ freshMs: 60_000, maxEntries: 100 })
const response = Promise.withResolvers<string>()
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<string>({ freshMs: 60_000, maxEntries: 100 })
await cache.load('pr', async () => 'closed')
@@ -5,6 +5,7 @@ type Entry<T> = {
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<T>({
budget?: { maxBytes: number; sizeOf: (value: T) => number }
}): {
read: (key: string) => T | null
load: (key: string, fetch: () => Promise<T | null>) => Promise<T | null>
load: (key: string, fetch: () => Promise<T | null>, accepts?: () => boolean) => Promise<T | null>
/** 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<T>({
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<T>({
const entry: Entry<T> = {
promise: Promise.resolve(null),
settledAt: null,
accepts,
value: cached?.value,
bytes: cached?.value === undefined ? 0 : cached.bytes
}
@@ -78,6 +81,10 @@ export function createHoverPreviewCache<T>({
}
entry.promise = fetch().then(
(value) => {
if (accepts?.() === false) {
forget()
return null
}
if (!value) {
forget()
} else if (entries.get(key) === entry) {
@@ -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(
<NativeChatLinkPeekProvider fileLinkContext={context}>
<RoutingHost />
</NativeChatLinkPeekProvider>
)
).toBe(expectedHost)
}
)
@@ -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<NativeChatLinkPeek>({
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 (
<NativeChatLinkPeekContext.Provider value={value}>
@@ -141,6 +141,9 @@ export async function lookupGitHubWorkItemByOwnerRepoForSource(
args.sourceContext
))
: null
if (args.accepts?.() === false) {
return null
}
const item =
target.kind === 'environment'
? await callRuntimeRpc<Omit<GitHubWorkItem, 'repoId'> | null>(