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>(