Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/renderer/src/components/task-page-cache-selectors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,38 @@ describe('task page cache selectors', () => {
).toBe(origin)
})

it('opens the copy the clicked twin host returned, not the other host cached copy', () => {
const local = { ...workItem('pr:7', 'twin'), repoHostId: 'local' as const }
const remote = { ...local, repoHostId: 'ssh:builder' as const }
const cache = {
local: entry<GitHubWorkItem[]>([local]),
remote: entry<GitHubWorkItem[]>([remote])
}

expect(
findTaskPageDialogWorkItem(cache, {
id: remote.id,
repoId: remote.repoId,
url: remote.url,
repoHostId: remote.repoHostId
})
).toBe(remote)
})

it('keeps each twin host row on its own copy when the cache syncs the table', () => {
const local = { ...workItem('pr:7', 'twin'), repoHostId: 'local' as const }
const remote = { ...local, repoHostId: 'ssh:builder' as const }
const pages = [[local, { ...remote }]]

const nextPages = reconcileTaskPagePagesWithWorkItemsCache(pages, [
entry<GitHubWorkItem[]>([local]),
entry<GitHubWorkItem[]>([remote])
])

expect(nextPages[0]?.map((item) => item.repoHostId)).toEqual(['local', 'ssh:builder'])
expect(nextPages[0]?.[1]).toBe(remote)
})

it('reconciles paged table rows with patched work-item cache entries', () => {
const stale = {
...workItem('pr-1', 'repo-1'),
Expand Down
13 changes: 6 additions & 7 deletions src/renderer/src/components/task-page-cache-selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export type TaskPageDialogWorkItemKey = {
id: string
repoId: string
url: string
repoHostId?: GitHubWorkItem['repoHostId']
} | null

export type TaskPageRepoSourceState = {
Expand Down Expand Up @@ -121,18 +122,14 @@ export function selectTaskPageUnresolvedSourceRepos(
return unresolved
}

function taskPageWorkItemCacheKey(item: GitHubWorkItem): string {
return `${item.repoId}\u0000${item.id}`
}

export function reconcileTaskPagePagesWithWorkItemsCache(
pages: TaskPageWorkItemPages,
entries: readonly (CacheEntry<readonly GitHubWorkItem[]> | undefined)[]
): (GitHubWorkItem[] | null)[] {
const cachedItems = new Map<string, GitHubWorkItem>()
for (const entry of entries) {
for (const item of entry?.data ?? []) {
cachedItems.set(taskPageWorkItemCacheKey(item), item)
cachedItems.set(taskPageWorkItemKey(item), item)
}
}

Expand All @@ -143,7 +140,7 @@ export function reconcileTaskPagePagesWithWorkItemsCache(
}
let pageChanged = false
const nextPage = page.map((item) => {
const cached = cachedItems.get(taskPageWorkItemCacheKey(item))
const cached = cachedItems.get(taskPageWorkItemKey(item))
if (!cached || cached === item) {
return item
}
Expand Down Expand Up @@ -244,7 +241,9 @@ export function findTaskPageDialogWorkItem(
(wi) =>
wi.id === dialogWorkItemKey.id &&
wi.repoId === dialogWorkItemKey.repoId &&
wi.url === dialogWorkItemKey.url
wi.url === dialogWorkItemKey.url &&
// Why: a twin's other host caches the same PR; its copy routes edits to that host.
(!dialogWorkItemKey.repoHostId || wi.repoHostId === dialogWorkItemKey.repoHostId)
)
if (found) {
return found
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { GitHubWorkItem } from '../../../shared/github/work-item-types'

// Why the host: a twin's two hosts return the same PR under one repo id.
export function taskPageWorkItemKey(item: GitHubWorkItem): string {
return `${item.repoId}\u0000${item.id}`
return `${item.repoId}\u0000${item.repoHostId ?? ''}\u0000${item.id}`
}

export function sortedStrings(values: readonly string[] | undefined): string {
Expand Down
7 changes: 4 additions & 3 deletions src/renderer/src/components/task-page/github/Rows.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions'
import { getGitHubWorkItemRepo } from '@/lib/github-work-item-repo-row'
import { findGithubWorkItemWorkspaceAttachment } from '@/lib/github-work-item-workspace-attachment'
import { getWorktreeAttachmentLabel } from '@/lib/worktree-attachment-label'
import { formatPRDelta } from '@/components/task-page-pr-delta-summary'
Expand Down Expand Up @@ -48,7 +49,7 @@ export function TaskPageGitHubRows({
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const {
repoMap,
repos,
allWorktrees,
selectedRepos,
openGitHubDetailPage,
Expand All @@ -65,7 +66,7 @@ export function TaskPageGitHubRows({
<div className="divide-y divide-border/40">
{!showGitHubTaskSkeletons &&
filteredWorkItems.map((item) => {
const itemRepo = repoMap.get(item.repoId) ?? null
const itemRepo = getGitHubWorkItemRepo(repos, item) ?? null
const attachedWorkspace = findGithubWorkItemWorkspaceAttachment(
allWorktrees,
item.repoId,
Expand Down Expand Up @@ -106,7 +107,7 @@ export function TaskPageGitHubRows({
// Why: clickable div not a <button> — it nests buttons, and button-in-button is invalid HTML that breaks hydration.
<div
// Why: key on repoId+item.id — repos sharing an upstream reuse item.id, so a bare key collides and React silently drops rows.
key={`${item.repoId}:${item.id}`}
key={`${item.repoId}:${item.repoHostId ?? ''}:${item.id}`}
role="button"
tabIndex={0}
onClick={() => openGitHubDetailPage(item)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
import type { Repo } from '../../../../../shared/repo-types'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions'
import { TaskPageGitHubRows } from './Rows'

type CellProps = { repo: Repo | null; sourceContext: TaskSourceContext | null }
const cellProps = vi.hoisted(() => new Map<string, CellProps>())
vi.mock('./ReviewCell', () => ({
PRReviewCell: (props: CellProps) => (cellProps.set('review', props), null)
}))
vi.mock('./MergeCell', () => ({
PRMergeCell: (props: CellProps) => (cellProps.set('merge', props), null)
}))
vi.mock('./StatusCell', () => ({
GHStatusCell: (props: CellProps) => (cellProps.set('status', props), null)
}))
vi.mock('./AssigneesCell', () => ({
GHAssigneesCell: (props: CellProps) => (cellProps.set('assignees', props), null)
}))
vi.mock('./ChecksCell', () => ({ PRChecksCell: () => null }))

const sshRow: Repo = {
id: 'twin',
path: '/remote/twin',
displayName: 'Twin',
badgeColor: '#000',
addedAt: 1,
connectionId: 'builder'
}
const localRow: Repo = {
id: 'twin',
path: '/laptop/twin',
displayName: 'Twin',
badgeColor: '#000',
addedAt: 2,
executionHostId: 'local'
}
const pr: GitHubWorkItem = {
id: 'pr-7',
type: 'pr',
number: 7,
title: 'Fix it',
state: 'open',
url: 'https://github.com/o/r/pull/7',
labels: [],
updatedAt: '2026-10-20T00:00:00Z',
author: 'octo',
repoId: 'twin',
repoHostId: 'ssh:builder'
}

function renderRows(item: GitHubWorkItem, showPRManagementColumns: boolean) {
const repos = [sshRow, localRow]
const model = {
repos,
// By id a map keeps the last twin, which is the row that did not return the item.
repoMap: new Map(repos.map((repo) => [repo.id, repo])),
allWorktrees: [],
selectedRepos: repos,
openGitHubDetailPage: vi.fn(),
githubWorkItemMutation: vi.fn(),
filteredWorkItems: [item],
showGitHubTaskSkeletons: false,
showPRManagementColumns,
githubTaskGridClass: '',
ensurePRChecksLoaded: vi.fn(),
handleUseWorkItem: vi.fn(),
handleOpenOrUseGitHubWorkItem: vi.fn()
}
render(
<TooltipProvider>
{/* oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the rows read only the fields above. */}
<TaskPageGitHubRows model={model as unknown as TaskPageComposerActionsModel} />
</TooltipProvider>
)
}

afterEach(() => {
cleanup()
cellProps.clear()
})

describe('GitHub task rows on a repo id two hosts share', () => {
it.each(['review', 'merge'])('%s runs on the host that returned the PR', (cell) => {
renderRows(pr, true)

expect(cellProps.get(cell)?.repo).toBe(sshRow)
expect(cellProps.get(cell)?.sourceContext?.hostId).toBe('ssh:builder')
})

it.each(['status', 'assignees'])('%s edits run on the host that returned the item', (cell) => {
renderRows({ ...pr, type: 'issue', id: 'issue-7' }, false)

expect(cellProps.get(cell)?.repo).toBe(sshRow)
expect(cellProps.get(cell)?.sourceContext?.hostId).toBe('ssh:builder')
})
})
22 changes: 15 additions & 7 deletions src/renderer/src/components/use-task-page-github-detail.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { TaskPageGitHubListStateModel } from './use-task-page-github-list-state'
import { useAppStore } from '@/store'
import { getGitHubWorkItemRepo } from '@/lib/github-work-item-repo-row'
import { useState, useMemo, useLayoutEffect, useCallback, useEffect } from 'react'
import type { ItemDialogTab } from '@/components/GitHubItemDialog'
import { stripRepoQualifiers } from '../../../shared/task-query'
Expand All @@ -24,6 +25,7 @@ export function useTaskPageGitHubDetail(model: TaskPageGitHubListStateModel) {
const {
pageData,
openTaskPage,
repos,
repoMap,
selectedRepos,
primaryRepo,
Expand All @@ -50,7 +52,8 @@ export function useTaskPageGitHubDetail(model: TaskPageGitHubListStateModel) {
? {
id: githubTaskDrawerWorkItem.id,
repoId: githubTaskDrawerWorkItem.repoId,
url: githubTaskDrawerWorkItem.url
url: githubTaskDrawerWorkItem.url,
repoHostId: githubTaskDrawerWorkItem.repoHostId
}
: null
const appliedWorkItemsCacheQuery = useMemo(
Expand Down Expand Up @@ -105,20 +108,22 @@ export function useTaskPageGitHubDetail(model: TaskPageGitHubListStateModel) {
pendingGithubScrollRestoreRef,
taskListPositionRef
])
const dialogRepoPath = dialogWorkItem ? (repoMap.get(dialogWorkItem.repoId)?.path ?? null) : null
const dialogRepo = dialogWorkItem ? getGitHubWorkItemRepo(repos, dialogWorkItem) : undefined
const dialogRepoPath = dialogRepo?.path ?? null
const dialogSourceContext = useMemo(() => {
if (!dialogWorkItem) {
return null
}
if (
pageData.openGitHubSourceContext?.provider === 'github' &&
pageData.openGitHubWorkItem?.id === dialogWorkItem.id &&
pageData.openGitHubWorkItem.repoId === dialogWorkItem.repoId
pageData.openGitHubWorkItem.repoId === dialogWorkItem.repoId &&
pageData.openGitHubWorkItem.repoHostId === dialogWorkItem.repoHostId
) {
return pageData.openGitHubSourceContext
}
return getTaskPageRepoSourceContext(repoMap.get(dialogWorkItem.repoId), 'github')
}, [dialogWorkItem, pageData.openGitHubSourceContext, pageData.openGitHubWorkItem, repoMap])
return getTaskPageRepoSourceContext(dialogRepo, 'github')
}, [dialogRepo, dialogWorkItem, pageData.openGitHubSourceContext, pageData.openGitHubWorkItem])
const gitlabDialogRepo = useMemo(
() =>
gitlabDialogItem
Expand Down Expand Up @@ -178,7 +183,10 @@ export function useTaskPageGitHubDetail(model: TaskPageGitHubListStateModel) {
taskSource: 'github',
preselectedRepoId: item.repoId,
openGitHubWorkItem: item,
openGitHubSourceContext: getTaskPageRepoSourceContext(repoMap.get(item.repoId), 'github'),
openGitHubSourceContext: getTaskPageRepoSourceContext(
getGitHubWorkItemRepo(repos, item),
'github'
),
openGitHubInitialTab: initialTab
},
{
Expand All @@ -193,7 +201,7 @@ export function useTaskPageGitHubDetail(model: TaskPageGitHubListStateModel) {
githubResumeContextKey,
openTaskPage,
pendingGithubScrollRestoreRef,
repoMap,
repos,
taskListPositionRef
]
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { TaskPageJiraCreationMetadataModel } from './use-task-page-jira-creation-metadata'
import { getGitHubWorkItemRepo } from '@/lib/github-work-item-repo-row'
import { useCallback, useMemo, useEffect } from 'react'
import type { GitHubWorkItem } from '../../../shared/github/work-item-types'
import { taskPageGitHubItemKey } from '@/components/task-page-github-work-item-mutation-registry'
Expand All @@ -13,7 +14,7 @@ import {
import { sameOptionalGitHubOwnerRepo } from './task-page-github-review-model'
export function useTaskPageGitHubListProjection(model: TaskPageJiraCreationMetadataModel) {
const {
repoMap,
repos,
fetchPRChecks,
taskSource,
githubMode,
Expand Down Expand Up @@ -102,7 +103,7 @@ export function useTaskPageGitHubListProjection(model: TaskPageJiraCreationMetad
if (item.type !== 'pr' || item.checksSummary) {
return
}
const repo = repoMap.get(item.repoId)
const repo = getGitHubWorkItemRepo(repos, item)
if (!repo) {
return
}
Expand Down Expand Up @@ -134,7 +135,7 @@ export function useTaskPageGitHubListProjection(model: TaskPageJiraCreationMetad
)
})
},
[fetchPRChecks, patchTaskPageWorkItemRows, repoMap]
[fetchPRChecks, patchTaskPageWorkItemRows, repos]
)
useEffect(() => {
if (taskSource !== 'github' || githubMode !== 'items' || !showPRManagementColumns) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import type { GitHubWorkItem } from '../../../shared/github/work-item-types'
import type { Repo } from '../../../shared/repo-types'
import type { TaskPageSearchActionsModel } from './use-task-page-search-actions'
import { useTaskPageWorkspaceActions } from './use-task-page-workspace-actions'

vi.mock('@/store', () => ({
useAppStore: { getState: () => ({ recordFeatureInteraction: vi.fn() }) }
}))

const sshRow: Repo = {
id: 'twin',
path: '/remote/twin',
displayName: 'Twin',
badgeColor: '#000',
addedAt: 1,
connectionId: 'builder'
}
const localRow: Repo = { ...sshRow, path: '/laptop/twin', addedAt: 2, connectionId: undefined }
const pr: GitHubWorkItem = {
id: 'pr-7',
type: 'pr',
number: 7,
title: 'Fix it',
state: 'open',
url: 'https://github.com/o/r/pull/7',
labels: [],
updatedAt: '2026-10-20T00:00:00Z',
author: 'octo',
repoId: 'twin',
repoHostId: 'ssh:builder'
}

describe('starting work from a PR card on a repo id two hosts share', () => {
it('opens the composer for the host that returned the PR', () => {
const openModal = vi.fn()
const repos = [sshRow, localRow]
// By id a map keeps the last twin, which is the row that did not return the PR.
const model = { repos, repoMap: new Map(repos.map((repo) => [repo.id, repo])), openModal }
const { result } = renderHook(() =>
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook reads only these fields.
useTaskPageWorkspaceActions(model as unknown as TaskPageSearchActionsModel)
)

result.current.handleUseWorkItem(pr)

expect(openModal).toHaveBeenCalledWith(
'new-workspace-composer',
expect.objectContaining({
taskSourceContext: expect.objectContaining({ hostId: 'ssh:builder', repoId: 'twin' })
})
)
})
})
Loading
Loading