(null)
+
+ useEffect(() => {
+ if (!editor || editor.isDestroyed) {
+ lastNavigationRef.current = null
+ return
+ }
+ // A newer editor transaction can arrive between render and this effect.
+ if (editor.state.doc !== searchDocument) {
+ return
+ }
+ const previous = lastNavigationRef.current
+ const searchChanged =
+ previous?.editor !== editor ||
+ previous.query !== query ||
+ previous.matchCase !== matchCase ||
+ previous.wholeWord !== wholeWord
+ const navigationRequested =
+ previous?.editor === editor && previous.navigationRequest !== navigationRequest
+ lastNavigationRef.current = { editor, query, matchCase, wholeWord, navigationRequest }
+
+ // Refreshing matches after an edit must preserve the user's caret and viewport.
+ const activeElement = rootRef.current?.ownerDocument.activeElement
+ const searchOwnsFocus =
+ activeElement &&
+ rootRef.current?.contains(activeElement) &&
+ activeElement.closest('.rich-markdown-search')
+ const shouldNavigate = navigationRequested
+ ? navigationRequest.selectMatch
+ : searchChanged && searchOwnsFocus
+ const activeMatch =
+ shouldNavigate && query && activeMatchIndex >= 0 ? matches[activeMatchIndex] : null
+ const tr = editor.state.tr.setMeta(richMarkdownSearchPluginKey, {
+ activeIndex: activeMatchIndex,
+ matches,
+ query
+ })
+ if (activeMatch) {
+ tr.setSelection(TextSelection.create(tr.doc, activeMatch.from, activeMatch.to))
+ }
+ editor.view.dispatch(tr)
+ if (editor.isDestroyed || editor.state.doc !== tr.doc) {
+ return
+ }
+
+ // The editor's scrollIntoView does not reliably reach the outer flex viewport.
+ const container = scrollContainerRef.current
+ if (activeMatch && container) {
+ const coords = editor.view.coordsAtPos(activeMatch.from)
+ const containerRect = container.getBoundingClientRect()
+ const relativeTop = coords.top - containerRect.top
+ const targetScroll = container.scrollTop + relativeTop - containerRect.height / 2
+ container.scrollTo({ top: targetScroll, behavior: 'instant' })
+ }
+ }, [
+ activeMatchIndex,
+ editor,
+ matchCase,
+ matches,
+ navigationRequest,
+ query,
+ rootRef,
+ searchDocument,
+ scrollContainerRef,
+ wholeWord
+ ])
+}
diff --git a/tests/e2e/markdown-find-adversarial.spec.ts b/tests/e2e/markdown-find-adversarial.spec.ts
new file mode 100644
index 00000000000..99ebe2282cc
--- /dev/null
+++ b/tests/e2e/markdown-find-adversarial.spec.ts
@@ -0,0 +1,307 @@
+import type { Locator, Page } from '@stablyai/playwright-test'
+import { expect, test } from './helpers/orca-app'
+import { pressShortcut } from './helpers/shortcuts'
+import {
+ cleanupMarkdownFixture,
+ createMarkdownFixture,
+ getActiveWorktreeContext,
+ openMarkdownFixture,
+ waitForRichMarkdownEditor
+} from './helpers/markdown-editor-fixture'
+
+const FIRST = 'First alpha paragraph for pointer selection.'
+const SECOND = 'Second beta paragraph for pointer selection.'
+const SOURCE = [
+ '# Adversarial Find interactions',
+ 'Needle first match.',
+ ...Array.from({ length: 65 }, (_, index) => `Spacer ${index} keeps the search match away.`),
+ FIRST,
+ SECOND,
+ '| Left | Right |\n| --- | --- |\n| Cell alpha | Cell beta |\n| Cell gamma | Cell delta |',
+ '- [ ] Task marker',
+ '\nDetails marker
\n\nDetails body marker\n\n ',
+ '```javascript\nconst codeMarker = "clean";\n```',
+ 'Late target paragraph for a pending query.',
+ ...Array.from({ length: 30 }, (_, index) => `Tail spacer ${index} keeps the second match away.`),
+ 'Needle final match.'
+].join('\n\n')
+
+async function textPoint(target: Locator, offset: number) {
+ return target.evaluate((element, offset) => {
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
+ let remaining = offset
+ let node = walker.nextNode()
+ while (node) {
+ if (node instanceof Text && remaining <= node.length) {
+ const range = document.createRange()
+ range.setStart(node, remaining)
+ range.collapse(true)
+ const bounds = range.getBoundingClientRect()
+ return { x: bounds.left, y: bounds.top + bounds.height / 2 }
+ }
+ remaining -= node.textContent?.length ?? 0
+ node = walker.nextNode()
+ }
+ throw new Error('Text offset was not found')
+ }, offset)
+}
+
+async function selectedText(page: Page) {
+ return page.evaluate(() => window.getSelection()?.toString() ?? '')
+}
+
+async function centered(target: Locator) {
+ await target.evaluate((element) => element.scrollIntoView({ block: 'center' }))
+}
+
+async function find(page: Page, query = 'Needle', status = '1/2') {
+ await pressShortcut(page, 'f')
+ const input = page.getByRole('textbox', { name: 'Find in rich markdown editor' })
+ await expect(input).toBeFocused()
+ await input.fill(query)
+ await expect(page.locator('.rich-markdown-search-status')).toHaveText(status)
+ return input
+}
+
+async function drag(page: Page, start: Locator, end: Locator, from: number, to: number) {
+ const firstPoint = await textPoint(start, from)
+ const lastPoint = await textPoint(end, to)
+ await page.mouse.move(firstPoint.x, firstPoint.y)
+ await page.mouse.down()
+ await page.mouse.move(lastPoint.x, lastPoint.y, { steps: 15 })
+ await page.mouse.up()
+}
+
+test.beforeEach(async ({ orcaPage, registerPostElectronShutdownCleanup }, testInfo) => {
+ const context = await getActiveWorktreeContext(orcaPage)
+ const filePath = await createMarkdownFixture(
+ context,
+ '.orca-e2e-markdown-adversarial',
+ 'find-interactions',
+ testInfo.workerIndex,
+ SOURCE
+ )
+ registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath))
+ await openMarkdownFixture(orcaPage, context, filePath)
+ const editor = await waitForRichMarkdownEditor(orcaPage)
+ await editor.locator('p').first().click()
+})
+
+test('Find preserves Shift-click, double-click, and multi-paragraph copy selection', async ({
+ orcaPage
+}, testInfo) => {
+ const editor = orcaPage.locator('.rich-markdown-editor')
+ const first = editor.getByText(FIRST, { exact: true })
+ const second = editor.getByText(SECOND, { exact: true })
+ const search = await find(orcaPage)
+ await centered(first)
+ const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto')
+ const scroll = await viewport.evaluate((element) => element.scrollTop)
+ const start = await textPoint(first, 6)
+ const end = await textPoint(second, 11)
+ await orcaPage.mouse.click(start.x, start.y)
+ await orcaPage.keyboard.down('Shift')
+ await orcaPage.mouse.click(end.x, end.y)
+ await orcaPage.keyboard.up('Shift')
+ expect(await selectedText(orcaPage)).toBe(`${FIRST.slice(6)}\n\n${SECOND.slice(0, 11)}`)
+ await expect(editor).toBeFocused()
+ await expect(
+ orcaPage
+ .locator('[data-tab-id]')
+ .filter({ hasText: 'find-interactions' })
+ .last()
+ .locator('span.rounded-full')
+ ).toHaveCount(0)
+ await orcaPage.mouse.click(start.x, start.y)
+ await search.focus()
+ await orcaPage.keyboard.down('Shift')
+ await orcaPage.mouse.click(end.x, end.y)
+ await orcaPage.keyboard.up('Shift')
+ expect(await selectedText(orcaPage)).toBe(`${FIRST.slice(6)}\n\n${SECOND.slice(0, 11)}`)
+ const copied = await editor.evaluate((element) => {
+ const clipboard = new DataTransfer()
+ element.dispatchEvent(
+ new ClipboardEvent('copy', { bubbles: true, cancelable: true, clipboardData: clipboard })
+ )
+ return { plain: clipboard.getData('text/plain'), html: clipboard.getData('text/html') }
+ })
+ expect(copied.plain).toContain(FIRST.slice(6))
+ expect(copied.plain).toContain(SECOND.slice(0, 11))
+ expect(copied.html).toContain('')
+ await expect(editor.getByText('Needle first match.', { exact: true })).toBeVisible()
+ await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeCloseTo(scroll, 0)
+ await orcaPage.screenshot({ path: testInfo.outputPath('shift-click-copy-selection.png') })
+ await search.focus()
+ const word = await textPoint(first, 9)
+ await orcaPage.mouse.dblclick(word.x, word.y)
+ expect(await selectedText(orcaPage)).toBe('alpha')
+ await orcaPage.keyboard.type('xy', { delay: 100 })
+ await expect(
+ editor.getByText('First xy paragraph for pointer selection.', { exact: true })
+ ).toBeVisible()
+ await expect(search).toHaveValue('Needle')
+ await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2')
+})
+
+test('Replace input returns to a multi-paragraph drag and explicit next-match navigation', async ({
+ orcaPage
+}, testInfo) => {
+ const editor = orcaPage.locator('.rich-markdown-editor')
+ const search = await find(orcaPage)
+ await orcaPage.getByRole('button', { name: 'Toggle replace', exact: true }).click()
+ const replace = orcaPage.getByRole('textbox', { name: 'Replace in rich markdown editor' })
+ await replace.fill('Thread')
+ const first = editor.getByText(FIRST, { exact: true })
+ const second = editor.getByText(SECOND, { exact: true })
+ await centered(first)
+ await drag(orcaPage, first, second, 6, 11)
+ expect(await selectedText(orcaPage)).toBe(`${FIRST.slice(6)}\n\n${SECOND.slice(0, 11)}`)
+ await orcaPage.keyboard.type('xy', { delay: 100 })
+ await expect(
+ editor.getByText(`${FIRST.slice(0, 6)}xy${SECOND.slice(11)}`, { exact: true })
+ ).toBeVisible()
+ await orcaPage.getByRole('button', { name: 'Next match', exact: true }).click()
+ await expect.poll(() => selectedText(orcaPage)).toBe('Needle')
+ await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('2/2')
+ await replace.focus()
+ await orcaPage.getByRole('button', { name: 'Replace', exact: true }).click()
+ await expect(editor.getByText('Thread final match.', { exact: true })).toBeVisible()
+ await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/1')
+ await expect(replace).toBeFocused()
+ await orcaPage.keyboard.press('Escape')
+ await expect(search).toHaveCount(0)
+ await pressShortcut(orcaPage, 'f')
+ await expect(search).toBeFocused()
+ await expect(search).toHaveValue('')
+ await orcaPage.screenshot({ path: testInfo.outputPath('replace-next-reopen.png') })
+})
+
+test('Find returns focus and copies the intended cells after Shift-clicking a table', async ({
+ orcaPage
+}, testInfo) => {
+ const editor = orcaPage.locator('.rich-markdown-editor')
+ const search = await find(orcaPage, 'Cell alpha', '1/1')
+ const firstCell = editor.getByText('Cell alpha', { exact: true })
+ const lastCell = editor.getByText('Cell delta', { exact: true })
+ await centered(firstCell)
+ const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto')
+ const scroll = await viewport.evaluate((element) => element.scrollTop)
+ const point = await textPoint(lastCell, 6)
+ await orcaPage.keyboard.down('Shift')
+ await orcaPage.mouse.move(point.x, point.y)
+ await orcaPage.mouse.down()
+ await orcaPage.evaluate(
+ () =>
+ new Promise((resolve) => {
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
+ })
+ )
+ await orcaPage.mouse.up()
+ await orcaPage.keyboard.up('Shift')
+ await expect(editor.locator('td.selectedCell')).toHaveCount(4)
+ await orcaPage.screenshot({ path: testInfo.outputPath('shift-cell-selection.png') })
+ await expect(editor).toBeFocused()
+ await expect(search).toHaveValue('Cell alpha')
+ await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeCloseTo(scroll, 0)
+ const tab = orcaPage.locator('[data-tab-id]').filter({ hasText: 'find-interactions' }).last()
+ await expect(tab.locator('span.rounded-full')).toHaveCount(0)
+ const copied = await editor.evaluate((element) => {
+ const clipboard = new DataTransfer()
+ element.dispatchEvent(
+ new ClipboardEvent('copy', {
+ bubbles: true,
+ cancelable: true,
+ clipboardData: clipboard
+ })
+ )
+ return clipboard.getData('text/plain')
+ })
+ expect(copied).toBe('Cell alpha\n\nCell beta\n\nCell gamma\n\nCell delta')
+})
+
+test('Find preserves embedded task, details, and code editing', async ({ orcaPage }, testInfo) => {
+ const editor = orcaPage.locator('.rich-markdown-editor')
+ const search = await find(orcaPage)
+ await centered(editor.getByRole('checkbox'))
+ await search.focus()
+ const checkbox = editor.getByRole('checkbox')
+ await checkbox.check()
+ await expect(checkbox).toBeChecked()
+ await expect(search).toHaveValue('Needle')
+ await search.focus()
+ const details = editor.locator('[data-type="details"]')
+ await details.getByRole('button').click()
+ await expect(details.locator('[data-type="detailsContent"]')).toBeHidden()
+ await search.focus()
+ await details.getByRole('button').click()
+ await expect(details.locator('[data-type="detailsContent"]')).toBeVisible()
+ const body = editor.getByText('Details body marker', { exact: true })
+ const bodyPoint = await textPoint(body, 7)
+ await search.focus()
+ await orcaPage.mouse.click(bodyPoint.x, bodyPoint.y)
+ await orcaPage.keyboard.type('xy', { delay: 100 })
+ await expect(editor.getByText('Detailsxy body marker', { exact: true })).toBeVisible()
+ const code = editor.getByText('const codeMarker = "clean";', { exact: true })
+ await centered(code)
+ const codePoint = await textPoint(code, 6)
+ await search.focus()
+ await orcaPage.mouse.click(codePoint.x, codePoint.y)
+ await orcaPage.keyboard.type('xy', { delay: 100 })
+ await expect(editor.getByText('const xycodeMarker = "clean";', { exact: true })).toBeVisible()
+ await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2')
+ await orcaPage.screenshot({ path: testInfo.outputPath('embedded-control-editing.png') })
+})
+
+test('a pending Find query cannot claim focus after document and checkbox interaction', async ({
+ orcaPage
+}, testInfo) => {
+ const editor = orcaPage.locator('.rich-markdown-editor')
+ const search = await find(orcaPage)
+ const target = editor.getByText('Late target paragraph for a pending query.', { exact: true })
+ await centered(target)
+ const point = await textPoint(target, 5)
+ const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto')
+ const scroll = await viewport.evaluate((element) => element.scrollTop)
+ const checkbox = editor.getByRole('checkbox')
+ const checkboxPoint = await checkbox.evaluate((element) => {
+ const bounds = element.getBoundingClientRect()
+ return { x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2 }
+ })
+ await search.fill('Cell alpha')
+ await orcaPage.mouse.click(point.x, point.y)
+ await expect(editor).toBeFocused()
+ await orcaPage.mouse.click(checkboxPoint.x, checkboxPoint.y)
+ await expect(checkbox).toBeChecked()
+ await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/1')
+ await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeCloseTo(scroll, 0)
+ await expect(target).toHaveText('Late target paragraph for a pending query.')
+ await orcaPage.mouse.click(point.x, point.y)
+ await orcaPage.keyboard.type('xy', { delay: 100 })
+ await expect(
+ editor.getByText('Late xytarget paragraph for a pending query.', { exact: true })
+ ).toBeVisible()
+ await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/1')
+ await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeCloseTo(scroll, 0)
+ await expect(search).toHaveValue('Cell alpha')
+ await orcaPage.screenshot({ path: testInfo.outputPath('pending-query-editor-caret.png') })
+})
+
+test('Replace advances when its replacement still contains the search query', async ({
+ orcaPage
+}, testInfo) => {
+ const editor = orcaPage.locator('.rich-markdown-editor')
+ await find(orcaPage)
+ await orcaPage.getByRole('button', { name: 'Toggle replace', exact: true }).click()
+ await orcaPage.getByRole('textbox', { name: 'Replace in rich markdown editor' }).fill('NeedleX')
+ await orcaPage.getByRole('button', { name: 'Replace', exact: true }).click()
+ await expect(editor.getByText('NeedleX first match.', { exact: true })).toBeVisible()
+ await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('2/2')
+ await expect(
+ editor.getByText('Needle final match.', { exact: true }).locator('[data-active="true"]')
+ ).toHaveText('Needle')
+ await orcaPage.getByRole('button', { name: 'Replace', exact: true }).click()
+ await expect(editor.getByText('NeedleX final match.', { exact: true })).toBeVisible()
+ await expect(editor.getByText('NeedleX first match.', { exact: true })).toBeVisible()
+ await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2')
+ await orcaPage.screenshot({ path: testInfo.outputPath('replacement-retains-query.png') })
+})
diff --git a/tests/e2e/markdown-find-editing.spec.ts b/tests/e2e/markdown-find-editing.spec.ts
new file mode 100644
index 00000000000..b227ba81390
--- /dev/null
+++ b/tests/e2e/markdown-find-editing.spec.ts
@@ -0,0 +1,215 @@
+import type { Locator, Page } from '@stablyai/playwright-test'
+import path from 'node:path'
+import { test, expect } from './helpers/orca-app'
+import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
+import { pressShortcut } from './helpers/shortcuts'
+import {
+ cleanupMarkdownFixture,
+ createMarkdownFixture,
+ getActiveWorktreeContext,
+ openMarkdownFixture,
+ waitForRichMarkdownEditor
+} from './helpers/markdown-editor-fixture'
+
+const TARGET_TEXT = 'Editing target paragraph keeps the pointer position and viewport.'
+const MARKDOWN = [
+ '# Find and pointer editing',
+ 'Needle remains at the beginning.',
+ ...Array.from({ length: 100 }, (_, index) =>
+ index === 70 ? TARGET_TEXT : `Paragraph ${index} provides room to scroll through the document.`
+ ),
+ 'Needle remains at the end.'
+].join('\n\n')
+const TABLE_MARKDOWN = MARKDOWN.replace(
+ TARGET_TEXT,
+ `| Name | Value |\n| --- | --- |\n| Target | ${TARGET_TEXT} |`
+)
+
+async function textPoint(paragraph: Locator, offset: number) {
+ return paragraph.evaluate((element, offset) => {
+ const text = element.firstChild
+ if (!(text instanceof Text)) {
+ throw new Error('Expected a plain-text paragraph')
+ }
+ const range = document.createRange()
+ range.setStart(text, offset)
+ range.collapse(true)
+ const bounds = range.getBoundingClientRect()
+ return { x: bounds.left, y: bounds.top + bounds.height / 2 }
+ }, offset)
+}
+
+async function readParagraphSelection(paragraph: Locator) {
+ return paragraph.evaluate((element) => {
+ const selection = window.getSelection()
+ if (!selection?.anchorNode || !selection.focusNode) {
+ return null
+ }
+ if (!element.contains(selection.anchorNode) || !element.contains(selection.focusNode)) {
+ return null
+ }
+ const start = document.createRange()
+ start.selectNodeContents(element)
+ start.setEnd(selection.anchorNode, selection.anchorOffset)
+ const end = document.createRange()
+ end.selectNodeContents(element)
+ end.setEnd(selection.focusNode, selection.focusOffset)
+ return {
+ from: Math.min(start.toString().length, end.toString().length),
+ to: Math.max(start.toString().length, end.toString().length),
+ text: selection.toString()
+ }
+ })
+}
+
+async function centerParagraph(paragraph: Locator): Promise {
+ await paragraph.evaluate((element) => element.scrollIntoView({ block: 'center' }))
+}
+
+async function pointAtParagraph(page: Page, paragraph: Locator, drag: boolean) {
+ const start = await textPoint(paragraph, 8)
+ await page.mouse.move(start.x, start.y)
+ await page.mouse.down()
+ if (drag) {
+ const end = await textPoint(paragraph, 24)
+ await page.mouse.move(end.x, end.y, { steps: 12 })
+ }
+ // Tiptap focus commands can schedule a stale-selection scroll for the next frame.
+ await page.evaluate(
+ () =>
+ new Promise((resolve) => {
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
+ })
+ )
+ await page.mouse.up()
+ const selection = await readParagraphSelection(paragraph)
+ expect(selection).not.toBeNull()
+ if (!selection) {
+ throw new Error('Pointer selection left the intended paragraph')
+ }
+ if (drag) {
+ expect(selection.text).toBe('target paragraph')
+ } else {
+ expect(selection.from).toBe(selection.to)
+ }
+ return selection
+}
+
+test.beforeEach(async ({ orcaPage }) => {
+ await waitForSessionReady(orcaPage)
+ await waitForActiveWorktree(orcaPage)
+})
+
+for (const interaction of ['click', 'drag'] as const) {
+ test(`keeps Find open while a document ${interaction} edits at the pointer position`, async ({
+ orcaPage,
+ registerPostElectronShutdownCleanup
+ }, testInfo) => {
+ const context = await getActiveWorktreeContext(orcaPage)
+ const filePath = await createMarkdownFixture(
+ context,
+ '.orca-e2e-markdown-find-editing',
+ `find-${interaction}`,
+ testInfo.workerIndex,
+ MARKDOWN
+ )
+ registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath))
+ await openMarkdownFixture(orcaPage, context, filePath)
+ const editor = await waitForRichMarkdownEditor(orcaPage)
+ const paragraph = editor.locator('p').filter({ hasText: 'Editing' })
+ const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto')
+ await editor.locator('p').first().click()
+ await pressShortcut(orcaPage, 'f')
+ const search = orcaPage.getByRole('textbox', { name: 'Find in rich markdown editor' })
+ await expect(search).toBeFocused()
+ await search.fill('Needle')
+ await orcaPage.getByRole('button', { name: 'Match case', exact: true }).click()
+ await orcaPage.getByRole('button', { name: 'Match whole word', exact: true }).click()
+ await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2')
+ await centerParagraph(paragraph)
+ const originalScroll = await viewport.evaluate((element) => element.scrollTop)
+ const selection = await pointAtParagraph(orcaPage, paragraph, interaction === 'drag')
+
+ // Separate key events allow document updates to expose a caret reset between characters.
+ await orcaPage.keyboard.type('xy', { delay: 100 })
+ await orcaPage.screenshot({ path: testInfo.outputPath('find-document-edit.png') })
+ await expect(paragraph).toHaveText(
+ `${TARGET_TEXT.slice(0, selection.from)}xy${TARGET_TEXT.slice(selection.to)}`
+ )
+ await expect(editor.locator('p').first()).toHaveText('Needle remains at the beginning.')
+ await expect
+ .poll(() => readParagraphSelection(paragraph))
+ .toEqual({
+ from: selection.from + 2,
+ to: selection.from + 2,
+ text: ''
+ })
+ await expect
+ .poll(() => viewport.evaluate((element) => element.scrollTop))
+ .toBeCloseTo(originalScroll, 0)
+ await expect(search).toBeVisible()
+ await expect(search).toHaveValue('Needle')
+ await expect(orcaPage.getByRole('button', { name: 'Match case', exact: true })).toHaveAttribute(
+ 'aria-pressed',
+ 'true'
+ )
+ await expect(
+ orcaPage.getByRole('button', { name: 'Match whole word', exact: true })
+ ).toHaveAttribute('aria-pressed', 'true')
+ await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2')
+ })
+
+ test(`keeps the scrolled table and ${interaction} selection after switching Source to Rich`, async ({
+ orcaPage,
+ registerPostElectronShutdownCleanup
+ }, testInfo) => {
+ const context = await getActiveWorktreeContext(orcaPage)
+ const filePath = await createMarkdownFixture(
+ context,
+ '.orca-e2e-markdown-find-editing',
+ `source-${interaction}`,
+ testInfo.workerIndex,
+ TABLE_MARKDOWN
+ )
+ registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath))
+ await openMarkdownFixture(orcaPage, context, filePath)
+ await waitForRichMarkdownEditor(orcaPage)
+ await orcaPage.getByRole('radio', { name: 'Source', exact: true }).click()
+ await expect(orcaPage.locator('.monaco-editor')).toBeVisible()
+ await orcaPage.getByRole('radio', { name: 'Rich Editor', exact: true }).click()
+ const editor = await waitForRichMarkdownEditor(orcaPage)
+ await expect(editor).not.toBeFocused()
+ const paragraph = editor.locator('td p').filter({ hasText: 'Editing' })
+ const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto')
+ await centerParagraph(paragraph)
+ const originalScroll = await viewport.evaluate((element) => element.scrollTop)
+ const selection = await pointAtParagraph(orcaPage, paragraph, interaction === 'drag')
+ await expect(editor).toBeFocused()
+ await expect(paragraph).toHaveText(TARGET_TEXT)
+ await expect
+ .poll(() => viewport.evaluate((element) => element.scrollTop))
+ .toBeCloseTo(originalScroll, 0)
+ const tab = orcaPage
+ .locator('[data-tab-id]')
+ .filter({ hasText: path.basename(filePath) })
+ .last()
+ await expect(tab.locator('span.rounded-full')).toHaveCount(0)
+ await expect(tab.getByRole('button', { name: 'Close tab' })).toBeVisible()
+ await orcaPage.screenshot({ path: testInfo.outputPath('source-rich-table-selection.png') })
+ await orcaPage.keyboard.type('xy', { delay: 100 })
+ await orcaPage.screenshot({ path: testInfo.outputPath('source-rich-pointer-edit.png') })
+ await expect(paragraph).toHaveText(
+ `${TARGET_TEXT.slice(0, selection.from)}xy${TARGET_TEXT.slice(selection.to)}`
+ )
+ await expect
+ .poll(() => readParagraphSelection(paragraph))
+ .toEqual({
+ from: selection.from + 2,
+ to: selection.from + 2,
+ text: ''
+ })
+ await expect
+ .poll(() => viewport.evaluate((element) => element.scrollTop))
+ .toBeCloseTo(originalScroll, 0)
+ })
+}
From ce0778626618bb9a5197209f5189190f2d51545c Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Sat, 3 Oct 2026 23:19:55 -0700
Subject: [PATCH 18/23] Keep opened issue details and edits in the selected
repository (#24729)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Keeps a fork issue’s details, metadata and edits bound to the repository the user opened, including same-number issues in fork and upstream. Repairs selected-assignee leakage and delayed failed edits repainting another issue.
Fixes #24378
Incorporates and cross-reviews contributor PR #24379, including its source-resolver correction and regression material. Covers the contributor PR’s Project-row identity and retained-dialog mutation findings. The final published head passes focused tests, hidden macOS rendering and current CI; the callback-timing bot thread has an evidence-based response.
Co-authored-by: Katsuma Takehisa
---
.../github-opened-issue-repository.test.ts | 440 ++++++++++++++++++
.../github-pr-assignee-repository.test.ts | 204 ++++++++
src/main/github/client.ts | 6 +-
src/main/github/client/fetch/get-work-item.ts | 69 ++-
.../github-api-repository-validation.test.ts | 21 +
.../github-api-repository-validation.ts | 15 +-
src/main/github/issue-comment.ts | 32 +-
src/main/github/issue-field-options.ts | 46 +-
src/main/github/issue-mutation-source.test.ts | 178 +++++++
src/main/github/issue-update.ts | 36 +-
.../work-item-details-concurrency.test.ts | 6 +-
.../work-item-details-config-change.test.ts | 207 ++++++++
.../work-item-details-enterprise-host.test.ts | 6 +-
.../work-item-details-file-viewed.test.ts | 6 +-
.../work-item-details-issue-source.test.ts | 239 ++++++++++
.../github/work-item-details-pr-files.test.ts | 6 +-
.../github/work-item-details-test-lookup.ts | 17 +
src/main/github/work-item-details.test.ts | 6 +-
src/main/github/work-item-details.ts | 62 ++-
.../ipc/github-issue-mutation-handlers.ts | 76 ++-
.../github-issue-source-preference.test.ts | 120 +++++
src/main/ipc/github-work-item-handlers.ts | 23 +-
.../ipc/github-wsl-runtime-routing.test.ts | 29 +-
.../hooks-and-hosted-review.spec.ts | 6 +-
.../rpc/methods/github-issue-methods.ts | 15 +-
src/main/runtime/rpc/methods/github.test.ts | 11 +-
.../runtime-github-issue-comment-commands.ts | 10 +-
src/preload/api/github-work-item-api.ts | 4 +
.../gh-edit-section-mutations.ts | 73 ++-
...gh-edit-section.assignee-identity.test.tsx | 364 +++++++++++++++
.../edit-item-fields/gh-edit-section.tsx | 71 +--
.../github-item-dialog-types.ts | 20 +
.../use-github-item-dialog-details.ts | 31 +-
.../work-item-details-cache.ts | 20 +-
.../work-item-details-issue-target.test.ts | 81 ++++
.../components/github/PRAssigneesPanel.tsx | 14 +-
.../github-work-item-comment-mutations.ts | 1 +
.../github/github-work-item-edit-mutations.ts | 36 +-
.../github-work-item-issue-target.test.ts | 172 +++++++
.../task-page-cache-selectors.test.ts | 31 +-
.../components/task-page-cache-selectors.ts | 6 +-
...ask-page-github-confirmed-client-values.ts | 61 +++
...page-github-dialog-state-authority.test.ts | 65 +++
...task-page-github-dialog-state-authority.ts | 44 +-
...e-github-work-item-mutation-composition.ts | 9 +-
...sk-page-github-work-item-mutation-pages.ts | 6 +-
...page-github-work-item-mutation-registry.ts | 49 +-
...sk-page-github-work-item-mutation-types.ts | 3 +-
.../task-page-github-work-item-quiet-adopt.ts | 30 +-
.../components/use-task-page-github-detail.ts | 5 +-
.../src/hooks/useGitHubRepoMetadata.ts | 95 ++++
.../src/hooks/useIssueMetadata.test.tsx | 75 ++-
src/renderer/src/hooks/useIssueMetadata.ts | 77 +--
.../github-work-item-source-lookup.test.ts | 21 +-
.../src/lib/github-work-item-source-lookup.ts | 5 +-
src/renderer/src/store/github/cache-model.ts | 1 +
.../github/work-item-mutation-actions.ts | 18 +-
.../github-work-item-cache-identity.test.ts | 37 ++
58 files changed, 3045 insertions(+), 372 deletions(-)
create mode 100644 config/scripts/github-opened-issue-repository.test.ts
create mode 100644 config/scripts/github-pr-assignee-repository.test.ts
create mode 100644 src/main/github/issue-mutation-source.test.ts
create mode 100644 src/main/github/work-item-details-config-change.test.ts
create mode 100644 src/main/github/work-item-details-issue-source.test.ts
create mode 100644 src/main/github/work-item-details-test-lookup.ts
create mode 100644 src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.assignee-identity.test.tsx
create mode 100644 src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-issue-target.test.ts
create mode 100644 src/renderer/src/components/github/github-work-item-issue-target.test.ts
create mode 100644 src/renderer/src/components/task-page-github-confirmed-client-values.ts
create mode 100644 src/renderer/src/hooks/useGitHubRepoMetadata.ts
diff --git a/config/scripts/github-opened-issue-repository.test.ts b/config/scripts/github-opened-issue-repository.test.ts
new file mode 100644
index 00000000000..b2d0519c5cc
--- /dev/null
+++ b/config/scripts/github-opened-issue-repository.test.ts
@@ -0,0 +1,440 @@
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import { createElement } from 'react'
+import { renderToStaticMarkup } from 'react-dom/server'
+import type * as ReactModule from 'react'
+import type * as GhUtils from '../../src/main/github/gh-utils'
+import type * as IssueMetadata from '../../src/renderer/src/hooks/useIssueMetadata'
+import type { GitHubWorkItem } from '../../src/shared/github/work-item-types'
+import type { GitHubOwnerRepo } from '../../src/shared/github/pull-request-types'
+import type { TaskSourceContext } from '../../src/shared/task-source-context'
+import type { Repo } from '../../src/shared/repo-types'
+
+const fixture = vi.hoisted(() => {
+ const state: {
+ loads: { key: string | null; load: () => Promise }[]
+ requests: { args: string[]; host?: string }[]
+ gh: ReturnType
+ apiUpdate: ReturnType
+ preference: 'origin' | 'upstream'
+ } = { loads: [], requests: [], gh: vi.fn(), apiUpdate: vi.fn(), preference: 'upstream' }
+ return state
+})
+
+vi.mock('react', async (original) => ({
+ ...(await original()),
+ useState: (initial: unknown) => [typeof initial === 'function' ? initial() : initial, vi.fn()],
+ useMemo: (value: () => T) => value(),
+ useCallback: (value: T) => value,
+ useRef: (initial: T) => ({ current: initial }),
+ useEffect: vi.fn()
+}))
+vi.mock('zustand/react/shallow', () => ({ useShallow: (value: T) => value }))
+vi.mock('@/store', () => ({
+ useAppStore: Object.assign(
+ (selector: (state: unknown) => unknown) =>
+ selector({ patchWorkItem: vi.fn(), patchProjectRowContent: vi.fn() }),
+ { getState: () => ({ recordFeatureInteraction: vi.fn() }) }
+ )
+}))
+vi.mock('@/lib/repo-runtime-owner', () => ({
+ getSettingsForRepoRuntimeOwner: () => ({ activeRuntimeEnvironmentId: null })
+}))
+vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
+vi.mock('@/components/github/github-duplicate-issue-candidates', () => ({
+ useGitHubDuplicateIssueCandidates: () => []
+}))
+vi.mock('@/components/github/github-work-item-comment-mutations', () => ({
+ notifyWorkItemDetailsMutation: vi.fn()
+}))
+vi.mock('@/hooks/useIssueMetadata', async (original) => ({
+ ...(await original()),
+ useImmediateMutation: () => ({ isPending: () => false, run: vi.fn() })
+}))
+vi.mock('@/hooks/useMetadataListRequest', () => ({
+ useMetadataListRequest: (args: { cacheKey: string | null; load: () => Promise }) => {
+ fixture.loads.push({ key: args.cacheKey, load: args.load })
+ return { data: [], loading: false, error: null }
+ }
+}))
+vi.mock('../../src/main/github/gh-utils', async (original) => ({
+ ...(await original()),
+ ghExecFileAsync: fixture.gh,
+ acquire: vi.fn(),
+ release: vi.fn(),
+ getOwnerRepoForRemote: async (_path: string, remote: string) => ({
+ owner: remote === 'upstream' ? 'upstream-owner' : 'fork-owner',
+ repo: 'widgets'
+ })
+}))
+vi.mock('../../src/main/git/remote-name-listing', () => ({
+ shouldProbeGitRemote: async () => true
+}))
+
+import { GHEditSection } from '../../src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section'
+import {
+ runGHEditLabelToggle,
+ runGHEditStateChange
+} from '../../src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations'
+import { findTaskPageDialogWorkItem } from '../../src/renderer/src/components/task-page-cache-selectors'
+import { getTaskPageRepoSourceContext } from '../../src/renderer/src/components/task-page-source-context'
+import { workItemsCacheKey } from '../../src/renderer/src/store/github/cache-identity'
+import { createTestStore } from '../../src/renderer/src/store/slices/github-slice-test-harness'
+import { getTaskSourceCacheScope } from '../../src/shared/task-source-context'
+import { listLabels, listAssignableUsers } from '../../src/main/github/issue-field-options'
+import { useRepoLabels, useRepoAssignees } from '../../src/renderer/src/hooks/useIssueMetadata'
+import { updateIssue } from '../../src/main/github/issue-update'
+import { materializeTaskPageItemList } from '../../src/renderer/src/components/task-page-github-work-item-mutations'
+import {
+ resetTaskPageGitHubMutationRegistryForTests,
+ setTaskPageGitHubMutationQueryKey
+} from '../../src/renderer/src/components/task-page-github-work-item-mutation-registry'
+
+function renderEditSection(props: Parameters[0]): void {
+ renderToStaticMarkup(createElement(GHEditSection, props))
+}
+
+const registeredRepo: Repo = {
+ id: 'repo-1',
+ path: join(tmpdir(), 'orca-opened-issue-repository-fixture'),
+ displayName: 'widgets',
+ badgeColor: 'primary',
+ addedAt: 1,
+ upstream: { owner: 'upstream-owner', repo: 'widgets', host: 'github.com' }
+}
+
+function sourceFor(preference: 'origin' | 'upstream'): TaskSourceContext {
+ const source = getTaskPageRepoSourceContext(
+ { ...registeredRepo, issueSourcePreference: preference },
+ 'github'
+ )
+ if (!source) {
+ throw new Error('Registered fixture must produce a source context')
+ }
+ return source
+}
+
+const sourceContext = sourceFor('origin')
+const fork: GitHubWorkItem = {
+ id: 'issue:5',
+ type: 'issue',
+ number: 5,
+ title: 'FORK title',
+ state: 'open',
+ url: 'https://github.com/fork-owner/widgets/issues/5',
+ labels: [],
+ updatedAt: '',
+ author: null,
+ repoId: 'repo-1'
+}
+const upstream: GitHubWorkItem = {
+ ...fork,
+ title: 'UPSTREAM title',
+ url: 'https://github.com/upstream-owner/widgets/issues/5'
+}
+const issueRepo = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' }
+
+type MetadataArgs = { repoPath: string; ownerRepo?: GitHubOwnerRepo }
+
+beforeEach(() => {
+ fixture.preference = 'upstream'
+ fixture.loads = []
+ fixture.requests = []
+ resetTaskPageGitHubMutationRegistryForTests()
+ setTaskPageGitHubMutationQueryKey('current-upstream-list')
+ fixture.gh.mockReset()
+ fixture.gh.mockImplementation(async (args: string[], options: { host?: string }) => {
+ fixture.requests.push({ args, host: options.host })
+ return { stdout: '', stderr: '' }
+ })
+ fixture.apiUpdate = vi.fn((args: Parameters[0]) =>
+ updateIssue(
+ args.repoPath,
+ args.number,
+ args.updates,
+ null,
+ {},
+ fixture.preference,
+ args.ownerRepo
+ )
+ )
+ vi.stubGlobal('window', {
+ api: {
+ gh: {
+ updateIssue: fixture.apiUpdate,
+ listLabels: (args: MetadataArgs) =>
+ listLabels(args.repoPath, fixture.preference, null, {}, args.ownerRepo),
+ listAssignableUsers: (args: MetadataArgs) =>
+ listAssignableUsers(args.repoPath, fixture.preference, null, {}, args.ownerRepo)
+ }
+ }
+ })
+})
+
+afterEach(() => {
+ resetTaskPageGitHubMutationRegistryForTests()
+ vi.unstubAllGlobals()
+})
+
+it.each([
+ { openedItem: fork, listItem: upstream, preference: 'upstream' },
+ { openedItem: upstream, listItem: fork, preference: 'origin' },
+ { openedItem: fork, listItem: fork, preference: 'origin' }
+] as const)(
+ 'scopes $openedItem.title labels under $preference to its canonical list row',
+ async ({ openedItem, listItem, preference }) => {
+ fixture.preference = preference
+ expect(sourceFor('upstream')).toEqual(sourceContext)
+ const store = createTestStore()
+ const key = workItemsCacheKey(
+ registeredRepo.id,
+ 36,
+ '',
+ getTaskSourceCacheScope(sourceFor('upstream'))
+ )
+ store.setState({
+ workItemsCache: { [key]: { data: [listItem], fetchedAt: Date.now() } }
+ })
+ const opened =
+ findTaskPageDialogWorkItem(store.getState().workItemsCache, {
+ id: openedItem.id,
+ repoId: openedItem.repoId,
+ url: openedItem.url
+ }) ?? openedItem
+ expect(opened.url).toBe(openedItem.url)
+ const target = { ...issueRepo, owner: openedItem === fork ? 'fork-owner' : 'upstream-owner' }
+ let mutation: Promise = Promise.resolve()
+ runGHEditLabelToggle({
+ itemId: opened.id,
+ itemNumber: opened.number,
+ itemRepoId: opened.repoId,
+ repoPath: registeredRepo.path,
+ sourceContext,
+ projectOrigin: undefined,
+ issueRepo: target,
+ label: 'fork-only-label',
+ localLabels: [],
+ run: async (_key, options) => {
+ options.onOptimistic?.()
+ mutation = options.mutate()
+ await mutation
+ options.onSuccess?.()
+ return true
+ },
+ onLabelsChange: vi.fn(),
+ patchWorkItem: store.getState().patchWorkItem,
+ patchProjectRowIfNeeded: vi.fn(),
+ onMutated: vi.fn()
+ })
+ await mutation
+ expect(fixture.requests[0].args).toContain(`${target.owner}/widgets`)
+ expect(fixture.apiUpdate).toHaveBeenCalledWith(expect.objectContaining({ ownerRepo: target }))
+ expect(store.getState().workItemsCache[key]?.data?.[0].labels).toEqual(
+ listItem.url === openedItem.url ? ['fork-only-label'] : []
+ )
+ }
+)
+
+it.each([
+ { openedItem: fork, owner: 'fork-owner', preference: 'origin' },
+ { openedItem: fork, owner: 'fork-owner', preference: 'upstream' },
+ { openedItem: upstream, owner: 'upstream-owner', preference: 'origin' },
+ { openedItem: upstream, owner: 'upstream-owner', preference: 'upstream' }
+] as const)(
+ 'loads $owner picker candidates while preference=$preference',
+ async ({ preference, openedItem, owner }) => {
+ fixture.preference = preference
+ renderEditSection({
+ item: openedItem,
+ repoPath: registeredRepo.path,
+ repoId: fork.repoId,
+ sourceContext,
+ projectOrigin: undefined,
+ localState: 'open',
+ localLabels: [],
+ assignees: [],
+ onStateChange: vi.fn(),
+ onLabelsChange: vi.fn(),
+ onMutated: vi.fn(),
+ onUse: vi.fn()
+ })
+ for (const request of fixture.loads.filter((load) => load.key !== null)) {
+ await request.load()
+ }
+ expect(
+ fixture.requests.map((request) => request.args.find((arg) => arg.startsWith('repos/')))
+ ).toEqual([`repos/${owner}/widgets/labels`, `repos/${owner}/widgets/assignees?per_page=100`])
+ }
+)
+
+it.each([
+ { openedItem: fork, listItem: fork },
+ { openedItem: fork, listItem: upstream },
+ { openedItem: upstream, listItem: fork },
+ { openedItem: upstream, listItem: upstream }
+])(
+ 'a $openedItem.title close only controls its own row while search lags (list=$listItem.title)',
+ async ({ openedItem, listItem }) => {
+ const target = { ...issueRepo, owner: openedItem === fork ? 'fork-owner' : 'upstream-owner' }
+ let pending = Promise.resolve()
+ runGHEditStateChange({
+ newState: 'closed',
+ localState: 'open',
+ itemId: fork.id,
+ itemNumber: fork.number,
+ itemRepoId: fork.repoId,
+ repoPath: registeredRepo.path,
+ sourceContext,
+ projectOrigin: undefined,
+ issueRepo: target,
+ run: (_key, options) => {
+ pending = (async () => {
+ options.onOptimistic?.()
+ await options.mutate()
+ options.onSuccess?.()
+ })()
+ return pending
+ },
+ onStateChange: vi.fn(),
+ patchWorkItem: vi.fn(),
+ patchProjectRowIfNeeded: vi.fn(),
+ onMutated: vi.fn()
+ })
+ await pending
+ expect(fixture.apiUpdate).toHaveBeenCalledWith(expect.objectContaining({ ownerRepo: target }))
+ const displayed = materializeTaskPageItemList({
+ networkItems: [listItem],
+ previousItems: [listItem],
+ queryKey: 'current-upstream-list'
+ })
+ expect(displayed[0]?.state).toBe(listItem.url === openedItem.url ? 'closed' : 'open')
+ }
+)
+
+it('keeps ordinary metadata caches distinct by canonical repository and host', () => {
+ const identities = [
+ issueRepo,
+ { ...issueRepo, owner: 'upstream-owner' },
+ { ...issueRepo, host: 'ghe.example:8443' }
+ ]
+ for (const ownerRepo of identities) {
+ useRepoLabels(registeredRepo.path, registeredRepo.id, { ownerRepo })
+ useRepoAssignees(registeredRepo.path, registeredRepo.id, { ownerRepo })
+ }
+ expect(new Set(fixture.loads.filter((_, i) => i % 2 === 0).map((load) => load.key)).size).toBe(3)
+ expect(new Set(fixture.loads.filter((_, i) => i % 2 === 1).map((load) => load.key)).size).toBe(3)
+})
+
+it('keeps metadata requests without an explicit target compatible', async () => {
+ useRepoLabels(registeredRepo.path, registeredRepo.id)
+ useRepoAssignees(registeredRepo.path, registeredRepo.id)
+ for (const request of fixture.loads) {
+ await request.load()
+ }
+ expect(fixture.loads.map((load) => load.key)).toEqual([registeredRepo.id, registeredRepo.id])
+ expect(
+ fixture.requests.map((request) => request.args.find((arg) => arg.startsWith('repos/')))
+ ).toEqual([
+ 'repos/upstream-owner/widgets/labels',
+ 'repos/upstream-owner/widgets/assignees?per_page=100'
+ ])
+})
+
+it('keeps Project row metadata on the existing slug route', async () => {
+ const labels = vi.fn().mockResolvedValue({ ok: true, labels: [] })
+ const users = vi.fn().mockResolvedValue({ ok: true, users: [] })
+ vi.stubGlobal('window', {
+ api: { gh: { listLabelsBySlug: labels, listAssignableUsersBySlug: users } }
+ })
+ renderEditSection({
+ item: fork,
+ repoPath: registeredRepo.path,
+ repoId: fork.repoId,
+ sourceContext,
+ projectOrigin: {
+ owner: 'project-owner',
+ repo: 'outside',
+ host: 'ghe.example',
+ number: fork.number,
+ type: 'issue',
+ projectId: 'project-1',
+ projectItemId: 'row-1',
+ cacheKey: 'project-key'
+ },
+ localState: 'open',
+ localLabels: [],
+ assignees: [],
+ onStateChange: vi.fn(),
+ onLabelsChange: vi.fn(),
+ onMutated: vi.fn(),
+ onUse: vi.fn()
+ })
+ for (const request of fixture.loads.filter((load) => load.key !== null)) {
+ await request.load()
+ }
+ expect(labels).toHaveBeenCalledWith({
+ owner: 'project-owner',
+ repo: 'outside',
+ host: 'ghe.example'
+ })
+ expect(users).toHaveBeenCalledWith({
+ owner: 'project-owner',
+ repo: 'outside',
+ host: 'ghe.example'
+ })
+ expect(fixture.requests).toEqual([])
+})
+
+it('rolls back a rejected fork label without changing the upstream row', async () => {
+ const store = createTestStore()
+ const key = workItemsCacheKey(registeredRepo.id, 36, '', getTaskSourceCacheScope(sourceContext))
+ store.setState({ workItemsCache: { [key]: { data: [upstream, fork], fetchedAt: 1 } } })
+ fixture.gh.mockRejectedValueOnce(new Error('Fixture rejects label'))
+ let pending = Promise.resolve(false)
+ const observed: string[][][] = []
+ runGHEditLabelToggle({
+ itemId: fork.id,
+ itemNumber: fork.number,
+ itemRepoId: fork.repoId,
+ repoPath: registeredRepo.path,
+ sourceContext,
+ projectOrigin: undefined,
+ issueRepo,
+ label: 'fork-only-label',
+ localLabels: [],
+ run: (_key, options) => {
+ pending = (async () => {
+ options.onOptimistic?.()
+ observed.push(store.getState().workItemsCache[key]?.data?.map((row) => row.labels) ?? [])
+ try {
+ await options.mutate()
+ return true
+ } catch {
+ options.onRevert?.()
+ observed.push(store.getState().workItemsCache[key]?.data?.map((row) => row.labels) ?? [])
+ return false
+ }
+ })()
+ return pending
+ },
+ onLabelsChange: vi.fn(),
+ patchWorkItem: store.getState().patchWorkItem,
+ patchProjectRowIfNeeded: vi.fn(),
+ onMutated: vi.fn()
+ })
+ expect(await pending).toBe(false)
+ expect(observed).toEqual([
+ [[], ['fork-only-label']],
+ [[], []]
+ ])
+})
+
+it('does not fall back to upstream metadata for an invalid explicit repository', async () => {
+ const invalid = { owner: '../escape', repo: 'widgets', host: 'github.com' }
+ await expect(listLabels(registeredRepo.path, 'upstream', null, {}, invalid)).resolves.toEqual([])
+ await expect(
+ listAssignableUsers(registeredRepo.path, 'upstream', null, {}, invalid)
+ ).resolves.toEqual([])
+ expect(fixture.requests).toEqual([])
+})
diff --git a/config/scripts/github-pr-assignee-repository.test.ts b/config/scripts/github-pr-assignee-repository.test.ts
new file mode 100644
index 00000000000..0265aa92c41
--- /dev/null
+++ b/config/scripts/github-pr-assignee-repository.test.ts
@@ -0,0 +1,204 @@
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import type * as GhUtils from '../../src/main/github/gh-utils'
+import type * as ReactModule from 'react'
+import type { GitHubOwnerRepo } from '../../src/shared/github/pull-request-types'
+import type { GitHubWorkItem } from '../../src/shared/github/work-item-types'
+import type { GitHubIssueUpdate } from '../../src/shared/issue-mutation-types'
+
+const fixture = vi.hoisted(() => {
+ const state: {
+ callbacks: unknown[]
+ mutation: Promise | null
+ gh: ReturnType
+ apiUpdate: ReturnType
+ patch: ReturnType
+ preference: 'origin' | 'upstream'
+ localGitOptions: { wslDistro?: string }
+ requests: { args: string[]; host?: string; cwd?: string; wslDistro?: string }[]
+ } = {
+ callbacks: [],
+ mutation: null,
+ gh: vi.fn(),
+ apiUpdate: vi.fn(),
+ patch: vi.fn(),
+ preference: 'origin',
+ localGitOptions: {},
+ requests: []
+ }
+ return state
+})
+
+vi.mock('react', async (original) => ({
+ ...(await original()),
+ useState: (value: unknown) => [typeof value === 'function' ? value() : value, vi.fn()],
+ useMemo: (getValue: () => T) => getValue(),
+ useCallback: (callback: T) => {
+ fixture.callbacks.push(callback)
+ return callback
+ }
+}))
+vi.mock('zustand/react/shallow', () => ({ useShallow: (selector: T) => selector }))
+vi.mock('@/store', () => ({
+ useAppStore: Object.assign(
+ (selector: (state: unknown) => unknown) =>
+ selector({
+ patchWorkItem: fixture.patch,
+ patchProjectRowContent: fixture.patch,
+ repos: [],
+ settings: {}
+ }),
+ { getState: () => ({ recordFeatureInteraction: vi.fn() }) }
+ )
+}))
+vi.mock('@/lib/repo-runtime-owner', () => ({
+ getSettingsForRepoRuntimeOwner: () => ({ activeRuntimeEnvironmentId: null })
+}))
+vi.mock('@/components/ui/popover', () => ({
+ Popover: vi.fn(),
+ PopoverContent: vi.fn(),
+ PopoverTrigger: vi.fn()
+}))
+vi.mock('@/hooks/useIssueMetadata', () => ({
+ useRepoAssignees: () => ({ data: [], loading: false, error: null }),
+ useImmediateMutation: () => ({
+ isPending: () => false,
+ run: (_key: string, spec: { mutate: () => Promise }) => {
+ fixture.mutation = spec.mutate()
+ }
+ })
+}))
+vi.mock('@/hooks/useGitHubSlugMetadata', () => ({
+ useRepoAssigneesBySlug: () => ({
+ data: [{ login: 'octo', name: null, avatarUrl: '' }],
+ loading: false,
+ error: null
+ })
+}))
+vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
+vi.mock('@/components/github/work-item-state-presentation', () => ({ ReviewerAvatar: vi.fn() }))
+vi.mock('../../src/main/github/gh-utils', async (original) => ({
+ ...(await original()),
+ ghExecFileAsync: fixture.gh,
+ acquire: vi.fn(),
+ release: vi.fn(),
+ getOwnerRepoForRemote: async (_path: string, remote: string) => ({
+ owner: remote === 'upstream' ? 'upstream-owner' : 'fork-owner',
+ repo: 'widgets'
+ })
+}))
+vi.mock('../../src/main/git/remote-name-listing', () => ({
+ shouldProbeGitRemote: async () => true
+}))
+
+import { PRAssigneesPanel } from '../../src/renderer/src/components/github/PRAssigneesPanel'
+import { updateIssue } from '../../src/main/github/issue-update'
+import { _resetOriginGitHubApiRepositoryCache } from '../../src/main/github/github-api-repository'
+
+const repoPath = join(tmpdir(), 'orca-pr-assignee-repository-fixture')
+
+beforeEach(() => {
+ fixture.callbacks = []
+ fixture.mutation = null
+ fixture.requests = []
+ fixture.localGitOptions = {}
+ fixture.gh.mockReset()
+ fixture.gh.mockImplementation(
+ async (
+ args: string[],
+ options: {
+ host?: string
+ cwd?: string
+ wslDistro?: string
+ }
+ ) => {
+ fixture.requests.push({
+ args,
+ host: options.host,
+ cwd: options.cwd,
+ wslDistro: options.wslDistro
+ })
+ return { stdout: '', stderr: '' }
+ }
+ )
+ _resetOriginGitHubApiRepositoryCache()
+ fixture.apiUpdate = vi.fn(
+ (args: {
+ repoPath: string
+ number: number
+ updates: GitHubIssueUpdate
+ ownerRepo?: GitHubOwnerRepo
+ }) =>
+ updateIssue(
+ args.repoPath,
+ args.number,
+ args.updates,
+ null,
+ fixture.localGitOptions,
+ fixture.preference,
+ args.ownerRepo
+ )
+ )
+ vi.stubGlobal('window', { api: { gh: { updateIssue: fixture.apiUpdate } } })
+})
+
+afterEach(() => vi.unstubAllGlobals())
+
+it.each([
+ { owner: 'upstream-owner', preference: 'origin', assigned: false, legacy: false },
+ { owner: 'upstream-owner', preference: 'origin', assigned: true, legacy: false },
+ { owner: 'fork-owner', preference: 'upstream', assigned: false, legacy: false },
+ { owner: 'fork-owner', preference: 'upstream', assigned: true, legacy: false },
+ { owner: 'upstream-owner', preference: 'origin', assigned: false, legacy: true }
+] as const)(
+ 'keeps $owner PR assignees under $preference (remove=$assigned, legacy=$legacy)',
+ async ({ owner, preference, assigned, legacy }) => {
+ fixture.preference = preference
+ fixture.localGitOptions = owner === 'fork-owner' ? { wslDistro: 'Ubuntu' } : {}
+ const item: GitHubWorkItem = {
+ id: 'pr:5',
+ type: 'pr',
+ number: 5,
+ title: 'Opened PR',
+ state: 'open',
+ url: `https://github.com/${owner}/widgets/pull/5`,
+ prRepo: legacy ? undefined : { owner, repo: 'widgets', host: 'github.com' },
+ labels: [],
+ updatedAt: '',
+ author: null,
+ repoId: 'repo-1',
+ assignees: assigned ? [{ login: 'octo', name: null, avatarUrl: '' }] : []
+ }
+ PRAssigneesPanel({ item, repoPath, projectOrigin: undefined, onMutated: vi.fn() })
+ const toggleAssignee = fixture.callbacks.at(-1)
+ if (typeof toggleAssignee !== 'function') {
+ throw new Error('PR panel did not create an assignee handler')
+ }
+ toggleAssignee('octo')
+ await fixture.mutation
+ expect(fixture.requests).toEqual([
+ {
+ args: [
+ 'issue',
+ 'edit',
+ '5',
+ '--repo',
+ `${owner}/widgets`,
+ assigned ? '--remove-assignee' : '--add-assignee',
+ 'octo'
+ ],
+ host: 'github.com',
+ cwd: repoPath,
+ wslDistro: fixture.localGitOptions.wslDistro
+ }
+ ])
+ expect(fixture.apiUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ repoPath,
+ repoId: item.repoId,
+ ownerRepo: { owner, repo: 'widgets', host: 'github.com' }
+ })
+ )
+ }
+)
diff --git a/src/main/github/client.ts b/src/main/github/client.ts
index fa331749c8b..ed72bc12232 100644
--- a/src/main/github/client.ts
+++ b/src/main/github/client.ts
@@ -23,7 +23,11 @@ export {
type PullRequestPushTarget
} from './client/lookup/pull-request-push-target'
export { getRepoSlug, getRepoUpstream } from './client/fetch/repo-slug-upstream'
-export { getWorkItem, getWorkItemByOwnerRepo } from './client/fetch/get-work-item'
+export {
+ getWorkItem,
+ getWorkItemByOwnerRepo,
+ getWorkItemWithRepository
+} from './client/fetch/get-work-item'
export { listWorkItems } from './client/list/list-work-items'
export { mergePR } from './client/merge/merge-pr'
export { removePRReviewers, requestPRReviewers } from './client/update/pr-reviewers'
diff --git a/src/main/github/client/fetch/get-work-item.ts b/src/main/github/client/fetch/get-work-item.ts
index 3178a4c921a..5224061dfa3 100644
--- a/src/main/github/client/fetch/get-work-item.ts
+++ b/src/main/github/client/fetch/get-work-item.ts
@@ -21,6 +21,30 @@ export async function getWorkItem(
localGitOptions: LocalGitExecOptions = {},
preference?: IssueSourcePreference
): Promise {
+ const { item } = await getWorkItemWithRepository(
+ repoPath,
+ number,
+ type,
+ connectionId,
+ localGitOptions,
+ preference
+ )
+ return item
+}
+
+export type WorkItemRepositoryLookup = {
+ item: MainWorkItem | null
+ repository: GitHubApiRepository | null
+}
+
+export async function getWorkItemWithRepository(
+ repoPath: string,
+ number: number,
+ type?: 'issue' | 'pr',
+ connectionId?: string | null,
+ localGitOptions: LocalGitExecOptions = {},
+ preference?: IssueSourcePreference
+): Promise {
await acquire()
try {
// Why: listWorkItems uses resolveIssueGitHubApiRepositorySource; open-by-number
@@ -35,18 +59,24 @@ export async function getWorkItem(
// Why: explicit origin with no origin identity must not bare-lookup ambient gh
// (same fail-closed rule as origin-pinned PR candidate resolution).
if (!source && preference === 'origin') {
- return null
+ return { item: null, repository: null }
+ }
+ return {
+ item: await fetchIssueWorkItem(repoPath, source, number, connectionId, localGitOptions),
+ repository: source
}
- return await fetchIssueWorkItem(repoPath, source, number, connectionId, localGitOptions)
}
if (type === 'pr') {
- return await fetchPullRequestWorkItemFromCandidates(
- repoPath,
- number,
- connectionId,
- localGitOptions,
- preference
- )
+ return {
+ item: await fetchPullRequestWorkItemFromCandidates(
+ repoPath,
+ number,
+ connectionId,
+ localGitOptions,
+ preference
+ ),
+ repository: null
+ }
}
try {
@@ -65,7 +95,7 @@ export async function getWorkItem(
localGitOptions
)
if (issue) {
- return issue
+ return { item: issue, repository: source }
}
}
} catch (err) {
@@ -75,15 +105,18 @@ export async function getWorkItem(
throw err
}
}
- return await fetchPullRequestWorkItemFromCandidates(
- repoPath,
- number,
- connectionId,
- localGitOptions,
- preference
- )
+ return {
+ item: await fetchPullRequestWorkItemFromCandidates(
+ repoPath,
+ number,
+ connectionId,
+ localGitOptions,
+ preference
+ ),
+ repository: null
+ }
} catch {
- return null
+ return { item: null, repository: null }
} finally {
release()
}
diff --git a/src/main/github/github-api-repository-validation.test.ts b/src/main/github/github-api-repository-validation.test.ts
index 81e395a8bdb..7f8eec06602 100644
--- a/src/main/github/github-api-repository-validation.test.ts
+++ b/src/main/github/github-api-repository-validation.test.ts
@@ -22,4 +22,25 @@ describe('isValidGitHubApiRepository', () => {
expect(isValidGitHubApiRepository({ owner: 'acme', repo: '..' })).toBe(false)
expect(isValidGitHubApiRepository({ owner: 'acme', repo: 'a/b' })).toBe(false)
})
+
+ it.each([42, false, null, {}])('rejects a non-string repository host: %j', (host) => {
+ expect(isValidGitHubApiRepository({ owner: 'acme', repo: 'orca', host })).toBe(false)
+ })
+
+ it.each([undefined, 'github.com', 'github.enterprise.test:8443'])(
+ 'accepts supported legacy or explicit repository host types: %j',
+ (host) => {
+ expect(isValidGitHubApiRepository({ owner: 'acme', repo: 'orca', host })).toBe(true)
+ }
+ )
+
+ it.each([
+ null,
+ 'acme/orca',
+ { owner: 42, repo: 'orca' },
+ { owner: 'acme', repo: 42 },
+ { owner: 'acme' }
+ ])('rejects malformed runtime repository identities: %j', (repository) => {
+ expect(isValidGitHubApiRepository(repository)).toBe(false)
+ })
})
diff --git a/src/main/github/github-api-repository-validation.ts b/src/main/github/github-api-repository-validation.ts
index b687a0ebdee..4031e64db13 100644
--- a/src/main/github/github-api-repository-validation.ts
+++ b/src/main/github/github-api-repository-validation.ts
@@ -11,8 +11,21 @@ export type GitHubApiRepositoryResolution =
const OWNER_SLUG_RE = GITHUB_OWNER_SLUG_RE
const REPOSITORY_SLUG_RE = /^[A-Za-z0-9._-]+$/
-export function isValidGitHubApiRepository(repository: GitHubOwnerRepo): boolean {
+export function isValidGitHubApiRepository(repository: unknown): repository is GitHubOwnerRepo {
+ if (
+ !repository ||
+ typeof repository !== 'object' ||
+ !('owner' in repository) ||
+ !('repo' in repository)
+ ) {
+ return false
+ }
return (
+ typeof repository.owner === 'string' &&
+ typeof repository.repo === 'string' &&
+ (!('host' in repository) ||
+ repository.host === undefined ||
+ typeof repository.host === 'string') &&
OWNER_SLUG_RE.test(repository.owner) &&
REPOSITORY_SLUG_RE.test(repository.repo) &&
repository.repo !== '.' &&
diff --git a/src/main/github/issue-comment.ts b/src/main/github/issue-comment.ts
index ac3f6a205de..478ac1ce68c 100644
--- a/src/main/github/issue-comment.ts
+++ b/src/main/github/issue-comment.ts
@@ -1,32 +1,34 @@
import type { GitHubCommentResult, PRComment } from '../../shared/github/comment-types'
+import type { IssueSourcePreference } from '../../shared/repo-types'
import type { LocalGitExecOptions, OwnerRepo } from './gh-utils'
-import { getIssueGitHubApiRepository, resolveGitHubRepoExecution } from './github-api-repository'
+import {
+ resolveGitHubRepoExecution,
+ resolveIssueGitHubApiRepositorySource
+} from './github-api-repository'
import { acquire, classifyGhError, ghExecFileAsync, release } from './gh-utils'
-/**
- * Add a comment to an existing GitHub issue.
- *
- * Why this path doesn't take a preference (mirrors `getIssue` / `updateIssue`):
- * a comment is posted against an issue number already bound to a worktree or
- * surfaced from a prior read. Routing through the live per-repo preference
- * would let a user read upstream#N, toggle the selector to origin, and have
- * their reply silently post on origin#N — a different issue entirely. That
- * is the same silent-source-switch class of wrongness #1186 / the parent
- * design doc guard against. List and create paths honor preference;
- * mutations stay on the heuristic `getIssueOwnerRepo`.
- */
+// An explicit target keeps replies bound to the conversation; otherwise use the selected source.
export async function addIssueComment(
repoPath: string,
issueNumber: number,
body: string,
connectionId?: string | null,
ownerRepoOverride?: OwnerRepo | null,
- localGitOptions: LocalGitExecOptions = {}
+ localGitOptions: LocalGitExecOptions = {},
+ preference?: IssueSourcePreference
): Promise {
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
ownerRepoOverride ??
- (() => getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions)),
+ (async () =>
+ (
+ await resolveIssueGitHubApiRepositorySource(
+ repoPath,
+ preference,
+ connectionId,
+ localGitOptions
+ )
+ ).source),
connectionId,
localGitOptions
)
diff --git a/src/main/github/issue-field-options.ts b/src/main/github/issue-field-options.ts
index a4d2aae32f4..77e2337ae8a 100644
--- a/src/main/github/issue-field-options.ts
+++ b/src/main/github/issue-field-options.ts
@@ -1,4 +1,4 @@
-import type { GitHubAssignableUser } from '../../shared/github/pull-request-types'
+import type { GitHubAssignableUser, GitHubOwnerRepo } from '../../shared/github/pull-request-types'
import type { IssueSourcePreference } from '../../shared/repo-types'
import type { LocalGitExecOptions } from './gh-utils'
import {
@@ -11,19 +11,21 @@ export async function listLabels(
repoPath: string,
preference?: IssueSourcePreference,
connectionId?: string | null,
- localGitOptions: LocalGitExecOptions = {}
+ localGitOptions: LocalGitExecOptions = {},
+ repositoryOverride?: GitHubOwnerRepo | null
): Promise {
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
- async () =>
- (
- await resolveIssueGitHubApiRepositorySource(
- repoPath,
- preference,
- connectionId,
- localGitOptions
- )
- ).source,
+ repositoryOverride ??
+ (async () =>
+ (
+ await resolveIssueGitHubApiRepositorySource(
+ repoPath,
+ preference,
+ connectionId,
+ localGitOptions
+ )
+ ).source),
connectionId,
localGitOptions
)
@@ -57,19 +59,21 @@ export async function listAssignableUsers(
repoPath: string,
preference?: IssueSourcePreference,
connectionId?: string | null,
- localGitOptions: LocalGitExecOptions = {}
+ localGitOptions: LocalGitExecOptions = {},
+ repositoryOverride?: GitHubOwnerRepo | null
): Promise {
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
- async () =>
- (
- await resolveIssueGitHubApiRepositorySource(
- repoPath,
- preference,
- connectionId,
- localGitOptions
- )
- ).source,
+ repositoryOverride ??
+ (async () =>
+ (
+ await resolveIssueGitHubApiRepositorySource(
+ repoPath,
+ preference,
+ connectionId,
+ localGitOptions
+ )
+ ).source),
connectionId,
localGitOptions
)
diff --git a/src/main/github/issue-mutation-source.test.ts b/src/main/github/issue-mutation-source.test.ts
new file mode 100644
index 00000000000..48967198fd1
--- /dev/null
+++ b/src/main/github/issue-mutation-source.test.ts
@@ -0,0 +1,178 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type * as GithubApiRepositoryModule from './github-api-repository'
+import type * as GhUtils from './gh-utils'
+
+const { ghExecFileAsyncMock, resolveIssueSourceMock, acquireMock, releaseMock } = vi.hoisted(
+ () => ({
+ ghExecFileAsyncMock: vi.fn(),
+ resolveIssueSourceMock: vi.fn(),
+ acquireMock: vi.fn(),
+ releaseMock: vi.fn()
+ })
+)
+
+vi.mock('./gh-utils', async () => {
+ const actual = await vi.importActual('./gh-utils')
+ return {
+ ...actual,
+ ghExecFileAsync: ghExecFileAsyncMock,
+ acquire: acquireMock,
+ release: releaseMock
+ }
+})
+
+vi.mock('./github-api-repository', async (importOriginal) => ({
+ ...(await importOriginal()),
+ resolveIssueGitHubApiRepositorySource: resolveIssueSourceMock
+}))
+
+import { addIssueComment } from './issue-comment'
+import { updateIssue } from './issue-update'
+
+const ORIGIN = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' }
+const UPSTREAM = { owner: 'upstream-owner', repo: 'widgets', host: 'github.com' }
+
+describe('issue mutations follow the issue source preference', () => {
+ beforeEach(() => {
+ ghExecFileAsyncMock.mockReset()
+ acquireMock.mockReset()
+ acquireMock.mockResolvedValue(undefined)
+ releaseMock.mockReset()
+ resolveIssueSourceMock.mockReset()
+ resolveIssueSourceMock.mockImplementation(async (_repoPath: string, preference: unknown) => ({
+ source: preference === 'origin' ? ORIGIN : UPSTREAM,
+ fellBack: false
+ }))
+ })
+
+ it('posts issue comments to origin when origin is selected', async () => {
+ ghExecFileAsyncMock.mockResolvedValueOnce({
+ stdout: JSON.stringify({ id: 1, user: { login: 'octo', avatar_url: '' }, body: 'hi' })
+ })
+
+ await expect(
+ addIssueComment('/repo-root', 5, 'hi', null, null, {}, 'origin')
+ ).resolves.toMatchObject({ ok: true })
+
+ expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'origin', null, {})
+ expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain(
+ 'repos/fork-owner/widgets/issues/5/comments'
+ )
+ })
+
+ it('keeps an explicit owner/repo override ahead of the preference', async () => {
+ ghExecFileAsyncMock.mockResolvedValueOnce({
+ stdout: JSON.stringify({ id: 2, user: { login: 'octo', avatar_url: '' }, body: 'hi' })
+ })
+
+ await addIssueComment('/repo-root', 7, 'hi', null, UPSTREAM, {}, 'origin')
+
+ expect(resolveIssueSourceMock).not.toHaveBeenCalled()
+ expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain(
+ 'repos/upstream-owner/widgets/issues/7/comments'
+ )
+ })
+
+ it('edits issues on origin when origin is selected', async () => {
+ ghExecFileAsyncMock.mockResolvedValue({ stdout: '' })
+
+ await expect(
+ updateIssue('/repo-root', 5, { body: 'Updated', addAssignees: ['octo'] }, null, {}, 'origin')
+ ).resolves.toEqual({ ok: true })
+
+ expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'origin', null, {})
+ expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain('repos/fork-owner/widgets/issues/5')
+ expect(ghExecFileAsyncMock.mock.calls[1][0]).toEqual([
+ 'issue',
+ 'edit',
+ '5',
+ '--repo',
+ 'fork-owner/widgets',
+ '--add-assignee',
+ 'octo'
+ ])
+ })
+
+ it('keeps the upstream-first default when no preference is set', async () => {
+ ghExecFileAsyncMock.mockResolvedValue({ stdout: '' })
+
+ await updateIssue('/repo-root', 5, { body: 'Updated' })
+
+ expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', undefined, undefined, {})
+ expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain('repos/upstream-owner/widgets/issues/5')
+ })
+
+ it('keeps edits on the opened issue when the saved preference changes', async () => {
+ ghExecFileAsyncMock.mockResolvedValue({ stdout: '' })
+
+ await updateIssue(
+ '/repo-root',
+ 5,
+ { body: 'Origin edit', state: 'closed', addLabels: ['bug'], addAssignees: ['octo'] },
+ null,
+ {},
+ 'upstream',
+ ORIGIN
+ )
+
+ expect(resolveIssueSourceMock).not.toHaveBeenCalled()
+ expect(ghExecFileAsyncMock.mock.calls.map(([args]) => args)).toEqual([
+ ['issue', 'close', '5', '--repo', 'fork-owner/widgets'],
+ [
+ 'api',
+ '-X',
+ 'PATCH',
+ 'repos/fork-owner/widgets/issues/5',
+ '--raw-field',
+ 'body=Origin edit'
+ ],
+ [
+ 'issue',
+ 'edit',
+ '5',
+ '--repo',
+ 'fork-owner/widgets',
+ '--add-label',
+ 'bug',
+ '--add-assignee',
+ 'octo'
+ ]
+ ])
+ })
+
+ it('preserves local WSL execution with an explicit issue repository', async () => {
+ ghExecFileAsyncMock.mockResolvedValue({ stdout: '' })
+
+ await updateIssue(
+ '/home/fixture/widgets',
+ 5,
+ { body: 'Origin edit' },
+ null,
+ { wslDistro: 'Ubuntu' },
+ 'upstream',
+ ORIGIN
+ )
+
+ expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
+ expect.arrayContaining(['repos/fork-owner/widgets/issues/5']),
+ expect.objectContaining({
+ cwd: '/home/fixture/widgets',
+ wslDistro: 'Ubuntu',
+ host: 'github.com'
+ })
+ )
+ })
+
+ it('refuses unresolved Origin mutations instead of using ambient gh', async () => {
+ resolveIssueSourceMock.mockResolvedValue({ source: null, fellBack: false })
+
+ await expect(
+ updateIssue('/repo-root', 5, { body: 'Origin edit' }, null, {}, 'origin')
+ ).resolves.toMatchObject({ ok: false })
+ await expect(
+ addIssueComment('/repo-root', 5, 'Origin reply', null, null, {}, 'origin')
+ ).resolves.toMatchObject({ ok: false })
+
+ expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/main/github/issue-update.ts b/src/main/github/issue-update.ts
index 801c12b79c5..8b00b24a148 100644
--- a/src/main/github/issue-update.ts
+++ b/src/main/github/issue-update.ts
@@ -1,31 +1,35 @@
import type { GitHubIssueUpdate } from '../../shared/issue-mutation-types'
+import type { IssueSourcePreference } from '../../shared/repo-types'
import type { LocalGitExecOptions } from './gh-utils'
-import { getIssueGitHubApiRepository, resolveGitHubRepoExecution } from './github-api-repository'
+import {
+ resolveGitHubRepoExecution,
+ resolveIssueGitHubApiRepositorySource,
+ type GitHubApiRepository
+} from './github-api-repository'
import { acquire, classifyGhError, ghExecFileAsync, release } from './gh-utils'
-/**
- * Update an existing GitHub issue. Fans out to separate gh commands for
- * state changes vs field edits since `gh issue edit` does not support state.
- *
- * Why this path doesn't take a preference (mirrors `getIssue`): mutations
- * target an issue number already bound to a worktree / linked elsewhere in
- * the UI. Routing an update through the live per-repo preference would let
- * a user open upstream#N, toggle the selector to origin, save, and silently
- * write to origin#N — a different issue (or 404). That is the exact
- * silent-source-switch class of wrongness #1186 / the parent design doc
- * guard against. List and create paths honor preference; mutations stay on
- * the heuristic `getIssueOwnerRepo`.
- */
+// Use the selected source for issue-number edits.
export async function updateIssue(
repoPath: string,
issueNumber: number,
updates: GitHubIssueUpdate,
connectionId?: string | null,
- localGitOptions: LocalGitExecOptions = {}
+ localGitOptions: LocalGitExecOptions = {},
+ preference?: IssueSourcePreference,
+ ownerRepoOverride?: GitHubApiRepository | null
): Promise<{ ok: true } | { ok: false; error: string }> {
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
- () => getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions),
+ ownerRepoOverride ??
+ (async () =>
+ (
+ await resolveIssueGitHubApiRepositorySource(
+ repoPath,
+ preference,
+ connectionId,
+ localGitOptions
+ )
+ ).source),
connectionId,
localGitOptions
)
diff --git a/src/main/github/work-item-details-concurrency.test.ts b/src/main/github/work-item-details-concurrency.test.ts
index 3fdbacb6491..d7a0671470e 100644
--- a/src/main/github/work-item-details-concurrency.test.ts
+++ b/src/main/github/work-item-details-concurrency.test.ts
@@ -1,3 +1,4 @@
+import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
@@ -27,8 +28,11 @@ vi.mock('./gh-utils', () => ({
})
}))
-vi.mock('./client', () => ({
+vi.mock('./client', async () => ({
getWorkItem: getWorkItemMock,
+ getWorkItemWithRepository: (
+ await vi.importActual('./work-item-details-test-lookup')
+ ).makeWorkItemDetailsLookupMock(getWorkItemMock),
getPRComments: getPRCommentsMock,
getPRChecks: getPRChecksMock
}))
diff --git a/src/main/github/work-item-details-config-change.test.ts b/src/main/github/work-item-details-config-change.test.ts
new file mode 100644
index 00000000000..f10ecfe0046
--- /dev/null
+++ b/src/main/github/work-item-details-config-change.test.ts
@@ -0,0 +1,207 @@
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { mkdir, mkdtemp, rm } from 'node:fs/promises'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import type * as NodeOs from 'node:os'
+import type * as GitExecError from '../git/exec-error'
+import type * as WorkItemLookup from './client/fetch/get-work-item'
+import { runProcess } from '../../shared/child-process/run-process'
+
+const { ghMock, fixture } = vi.hoisted(() => {
+ const fixture: {
+ path: string
+ initialHost: string
+ replacementUrl: string
+ requests: { args: string[]; host?: string }[]
+ } = { path: '', initialHost: '', replacementUrl: '', requests: [] }
+ return { ghMock: vi.fn(), fixture }
+})
+
+vi.mock('node:os', async (original) => ({
+ ...(await original()),
+ homedir: () => join(fixture.path, 'isolated-home')
+}))
+
+vi.mock('../git/runner', async () => {
+ const errors = await vi.importActual('../git/exec-error')
+ return {
+ ...errors,
+ ghExecFileAsync: ghMock,
+ gitExecFileAsync: async (args: string[], options: { cwd?: string }) => {
+ const result = await runProcess({
+ program: 'git',
+ args,
+ cwd: options.cwd,
+ env: {
+ ...process.env,
+ GIT_CONFIG_NOSYSTEM: '1',
+ GIT_CONFIG_GLOBAL: join(fixture.path, 'isolated-home', '.gitconfig')
+ }
+ })
+ if (result.code !== 0) {
+ throw new Error(result.stderr)
+ }
+ return { stdout: result.stdout, stderr: result.stderr }
+ }
+ }
+})
+
+vi.mock('./client', async () => ({
+ ...(await vi.importActual('./client/fetch/get-work-item')),
+ getPRChecks: vi.fn(),
+ getPRComments: vi.fn()
+}))
+
+vi.mock('./rate-limit', () => ({
+ repositoryRateLimitGuard: () => ({ blocked: false }),
+ noteRepositoryRateLimitSpend: vi.fn()
+}))
+
+import { getWorkItemDetails } from './work-item-details'
+import { _resetOwnerRepoCache } from './github-repository-identity'
+import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository'
+import { _resetGitHubHostAuthCache } from './github-enterprise-repository'
+
+async function fixtureGit(args: string[]): Promise {
+ const result = await runProcess({
+ program: 'git',
+ args,
+ cwd: fixture.path,
+ env: {
+ ...process.env,
+ GIT_CONFIG_NOSYSTEM: '1',
+ GIT_CONFIG_GLOBAL: join(fixture.path, 'isolated-home', '.gitconfig')
+ }
+ })
+ expect(result.code, result.stderr).toBe(0)
+}
+
+beforeEach(async () => {
+ fixture.path = await mkdtemp(join(tmpdir(), 'orca-issue-config-change-'))
+ await mkdir(join(fixture.path, 'isolated-home'))
+ await fixtureGit(['init', '--quiet'])
+ await fixtureGit(['remote', 'add', 'origin', 'https://github.com/fork-owner/widgets.git'])
+ _resetOwnerRepoCache()
+ _resetOriginGitHubApiRepositoryCache()
+ _resetGitHubHostAuthCache()
+ fixture.initialHost = 'github.com'
+ fixture.replacementUrl = 'https://github.com/replacement-owner/widgets.git'
+ fixture.requests = []
+ ghMock.mockReset()
+ ghMock.mockImplementation(async (args: string[], options: { host?: string }) => {
+ fixture.requests.push({ args, host: options.host })
+ if (args[0] === 'auth' && args[1] === 'status') {
+ return {
+ stdout: ['github.com', 'github.enterprise.test']
+ .map((host) => `${host}\n Logged in to ${host} account fixture (keyring)`)
+ .join('\n'),
+ stderr: ''
+ }
+ }
+ if (args.includes('repos/fork-owner/widgets/issues/5')) {
+ await fixtureGit(['remote', 'set-url', 'origin', fixture.replacementUrl])
+ return {
+ stdout: JSON.stringify({
+ number: 5,
+ title: 'ORIGIN title',
+ state: 'open',
+ html_url: `https://${fixture.initialHost}/fork-owner/widgets/issues/5`,
+ labels: [],
+ updated_at: '2026-10-02T00:00:00Z',
+ user: { login: 'fork-author' }
+ }),
+ stderr: ''
+ }
+ }
+ if (args.includes('graphql')) {
+ const original = args.includes('owner=fork-owner') && options.host === fixture.initialHost
+ const marker = original ? 'ORIGIN' : 'REPLACEMENT'
+ return {
+ stdout: JSON.stringify({
+ data: {
+ repository: {
+ issue: {
+ body: `${marker} body`,
+ assignees: { nodes: [{ login: `${marker}-assignee` }] },
+ participants: { nodes: [] },
+ comments: {
+ nodes: [
+ {
+ databaseId: 1,
+ body: `${marker} comment`,
+ createdAt: '2026-10-02T00:00:00Z',
+ url: `https://${options.host}/fork-owner/widgets/issues/5#issuecomment-1`,
+ author: { login: `${marker}-author`, avatarUrl: '' }
+ }
+ ]
+ }
+ }
+ }
+ }
+ }),
+ stderr: ''
+ }
+ }
+ if (args.some((arg) => arg.includes('/timeline?'))) {
+ return { stdout: '[]', stderr: '' }
+ }
+ throw new Error(`Unexpected fixture gh request: ${args.join(' ')}`)
+ })
+})
+
+afterEach(async () => {
+ await rm(fixture.path, { recursive: true, force: true })
+})
+
+it.each(['issue', undefined] as const)(
+ 'keeps implicit %s conversation on the repository used for the item fetch',
+ async (type) => {
+ const details = await getWorkItemDetails(fixture.path, 5, type, null, {}, 'origin')
+ expect(details?.item.title).toBe('ORIGIN title')
+ expect(details?.body).toBe('ORIGIN body')
+ expect(details?.assignees).toEqual(['ORIGIN-assignee'])
+ expect(details?.comments.map((comment) => comment.body)).toEqual(['ORIGIN comment'])
+ expect(fixture.requests[1].args).toContain('owner=fork-owner')
+ }
+)
+
+it('keeps an explicit local Tasks identity through the same real config change', async () => {
+ const details = await getWorkItemDetails(fixture.path, 5, 'issue', null, {}, 'origin', {
+ owner: 'fork-owner',
+ repo: 'widgets',
+ host: 'github.com'
+ })
+ expect(details?.item.title).toBe('ORIGIN title')
+ expect(details?.body).toBe('ORIGIN body')
+})
+
+it.each([
+ { from: 'github.com', to: 'github.enterprise.test', qualified: false },
+ { from: 'github.enterprise.test', to: 'github.com', qualified: false },
+ { from: 'github.com', to: 'github.enterprise.test', qualified: true },
+ { from: 'github.enterprise.test', to: 'github.com', qualified: true }
+])(
+ 'keeps explicit host from $from to $to (qualified=$qualified)',
+ async ({ from, to, qualified }) => {
+ fixture.initialHost = from
+ fixture.replacementUrl = `https://${to}/fork-owner/widgets.git`
+ await fixtureGit(['remote', 'set-url', 'origin', `https://${from}/fork-owner/widgets.git`])
+ const details = await getWorkItemDetails(fixture.path, 5, 'issue', null, {}, 'origin', {
+ owner: 'fork-owner',
+ repo: 'widgets',
+ ...(qualified ? { host: from } : {})
+ })
+ expect(details?.item.url).toBe(`https://${from}/fork-owner/widgets/issues/5`)
+ expect(details?.body).toBe('ORIGIN body')
+ expect(details?.assignees).toEqual(['ORIGIN-assignee'])
+ expect(details?.comments.map((comment) => comment.body)).toEqual(['ORIGIN comment'])
+ const conversationRequests = fixture.requests.filter(
+ ({ args }) => args.includes('graphql') || args.some((arg) => arg.includes('/timeline?'))
+ )
+ expect(conversationRequests.some(({ args }) => args.includes('graphql'))).toBe(true)
+ expect(
+ conversationRequests.some(({ args }) => args.some((arg) => arg.includes('/timeline?')))
+ ).toBe(true)
+ expect(conversationRequests.map(({ host }) => host)).toEqual([from, from])
+ }
+)
diff --git a/src/main/github/work-item-details-enterprise-host.test.ts b/src/main/github/work-item-details-enterprise-host.test.ts
index be2576051b5..b615f4e566b 100644
--- a/src/main/github/work-item-details-enterprise-host.test.ts
+++ b/src/main/github/work-item-details-enterprise-host.test.ts
@@ -1,3 +1,4 @@
+import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type RateLimitGuardResult =
@@ -47,8 +48,11 @@ vi.mock('./gh-utils', () => ({
release: vi.fn()
}))
-vi.mock('./client', () => ({
+vi.mock('./client', async () => ({
getWorkItem: getWorkItemMock,
+ getWorkItemWithRepository: (
+ await vi.importActual('./work-item-details-test-lookup')
+ ).makeWorkItemDetailsLookupMock(getWorkItemMock),
getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock,
getPRChecks: getPRChecksMock,
getPRComments: getPRCommentsMock
diff --git a/src/main/github/work-item-details-file-viewed.test.ts b/src/main/github/work-item-details-file-viewed.test.ts
index 6075803493c..7cbfa041608 100644
--- a/src/main/github/work-item-details-file-viewed.test.ts
+++ b/src/main/github/work-item-details-file-viewed.test.ts
@@ -1,3 +1,4 @@
+import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type RateLimitGuardResult =
@@ -59,8 +60,11 @@ vi.mock('./gh-utils', () => ({
release: releaseMock
}))
-vi.mock('./client', () => ({
+vi.mock('./client', async () => ({
getWorkItem: getWorkItemMock,
+ getWorkItemWithRepository: (
+ await vi.importActual('./work-item-details-test-lookup')
+ ).makeWorkItemDetailsLookupMock(getWorkItemMock),
getWorkItemByOwnerRepo: vi.fn(),
getPRChecks: getPRChecksMock,
getPRComments: getPRCommentsMock
diff --git a/src/main/github/work-item-details-issue-source.test.ts b/src/main/github/work-item-details-issue-source.test.ts
new file mode 100644
index 00000000000..e52de876d18
--- /dev/null
+++ b/src/main/github/work-item-details-issue-source.test.ts
@@ -0,0 +1,239 @@
+import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ ghExecFileAsyncMock,
+ getOwnerRepoMock,
+ getOwnerRepoForRemoteMock,
+ getWorkItemMock,
+ getWorkItemByOwnerRepoMock,
+ acquireMock,
+ releaseMock
+} = vi.hoisted(() => ({
+ ghExecFileAsyncMock: vi.fn(),
+ getOwnerRepoMock: vi.fn(),
+ getOwnerRepoForRemoteMock: vi.fn(),
+ getWorkItemMock: vi.fn(),
+ getWorkItemByOwnerRepoMock: vi.fn(),
+ acquireMock: vi.fn(),
+ releaseMock: vi.fn()
+}))
+
+vi.mock('./gh-utils', () => ({
+ ghExecFileAsync: ghExecFileAsyncMock,
+ getOwnerRepo: getOwnerRepoMock,
+ getOwnerRepoForRemote: getOwnerRepoForRemoteMock,
+ ghRepoExecOptions: vi.fn((context) => ({ cwd: context.repoPath })),
+ githubRepoContext: vi.fn((repoPath, connectionId, localGitOptions) => ({
+ repoPath,
+ connectionId: connectionId ?? null,
+ ...localGitOptions
+ })),
+ acquire: acquireMock,
+ release: releaseMock
+}))
+
+vi.mock('./client', async () => ({
+ getWorkItem: getWorkItemMock,
+ getWorkItemWithRepository: (
+ await vi.importActual('./work-item-details-test-lookup')
+ ).makeWorkItemDetailsLookupMock(getWorkItemMock),
+ getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock,
+ getPRChecks: vi.fn(),
+ getPRComments: vi.fn()
+}))
+
+vi.mock('./github-enterprise-repository', () => ({
+ getEnterpriseGitHubRepoSlug: vi.fn().mockResolvedValue(null),
+ getEnterpriseGitHubRepoSlugForRemote: vi.fn().mockResolvedValue(null),
+ isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true)
+}))
+
+vi.mock('./rate-limit', () => ({
+ repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })),
+ noteRepositoryRateLimitSpend: vi.fn()
+}))
+
+vi.mock('../git/remote-name-listing', () => ({
+ shouldProbeGitRemote: vi.fn().mockResolvedValue(true)
+}))
+
+import { getWorkItemDetails } from './work-item-details'
+import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository'
+
+const REMOTES: Record = {
+ origin: { owner: 'fork-owner', repo: 'widgets' },
+ upstream: { owner: 'upstream-owner', repo: 'widgets' }
+}
+
+function graphQLIssueResponse(assignee: string): { stdout: string } {
+ return {
+ stdout: JSON.stringify({
+ data: {
+ repository: {
+ issue: {
+ body: `${assignee} body`,
+ assignees: { nodes: [{ login: assignee }] },
+ participants: { nodes: [] },
+ comments: { nodes: [] }
+ }
+ }
+ }
+ })
+ }
+}
+
+function graphQLOwnerArg(): string | undefined {
+ const args: string[] = ghExecFileAsyncMock.mock.calls[0][0]
+ return args.find((arg) => arg.startsWith('owner='))
+}
+
+describe('getWorkItemDetails issue source', () => {
+ beforeEach(() => {
+ _resetOriginGitHubApiRepositoryCache()
+ ghExecFileAsyncMock.mockReset()
+ getOwnerRepoMock.mockReset()
+ getOwnerRepoMock.mockResolvedValue(REMOTES.origin)
+ getOwnerRepoForRemoteMock.mockReset()
+ getOwnerRepoForRemoteMock.mockImplementation(
+ async (_repoPath: string, remoteName: string) => REMOTES[remoteName] ?? null
+ )
+ getWorkItemMock.mockReset()
+ getWorkItemByOwnerRepoMock.mockReset()
+ getWorkItemMock.mockResolvedValue({
+ id: 'issue:5',
+ type: 'issue',
+ number: 5,
+ title: 'Origin issue',
+ state: 'open',
+ url: 'https://github.com/fork-owner/widgets/issues/5',
+ labels: [],
+ updatedAt: '2026-04-01T00:00:00Z',
+ author: 'fork-author'
+ })
+ acquireMock.mockReset()
+ acquireMock.mockResolvedValue(undefined)
+ releaseMock.mockReset()
+ })
+
+ it('reads body, assignees and comments from origin when origin is selected', async () => {
+ ghExecFileAsyncMock
+ .mockResolvedValueOnce(graphQLIssueResponse('fork-assignee'))
+ .mockResolvedValueOnce({ stdout: '' })
+
+ const details = await getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'origin')
+
+ expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 5, 'issue', null, {}, 'origin')
+ expect(graphQLOwnerArg()).toBe('owner=fork-owner')
+ expect(ghExecFileAsyncMock.mock.calls[1][0]).toContain(
+ 'repos/fork-owner/widgets/issues/5/timeline?per_page=100&page=1'
+ )
+ expect(details?.assignees).toEqual(['fork-assignee'])
+ })
+
+ it('keeps the upstream-first default when no preference is set', async () => {
+ ghExecFileAsyncMock
+ .mockResolvedValueOnce(graphQLIssueResponse('upstream-assignee'))
+ .mockResolvedValueOnce({ stdout: '' })
+
+ const details = await getWorkItemDetails('/repo-root', 5, 'issue', null, {})
+
+ expect(graphQLOwnerArg()).toBe('owner=upstream-owner')
+ expect(details?.assignees).toEqual(['upstream-assignee'])
+ })
+
+ it('keeps a Project row on upstream even when Origin is selected', async () => {
+ const upstream = { ...REMOTES.upstream, host: 'github.com' }
+ getWorkItemByOwnerRepoMock.mockResolvedValue({
+ id: 'issue:5',
+ type: 'issue',
+ number: 5,
+ title: 'Upstream project issue',
+ url: 'https://github.com/upstream-owner/widgets/issues/5',
+ state: 'open',
+ labels: [],
+ updatedAt: '',
+ author: 'upstream-author'
+ })
+ ghExecFileAsyncMock
+ .mockResolvedValueOnce(graphQLIssueResponse('upstream-assignee'))
+ .mockResolvedValueOnce({ stdout: '' })
+
+ const details = await getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'origin', upstream)
+
+ expect(getWorkItemMock).not.toHaveBeenCalled()
+ expect(getWorkItemByOwnerRepoMock).toHaveBeenCalledWith(
+ '/repo-root',
+ upstream,
+ 5,
+ 'issue',
+ null,
+ {}
+ )
+ expect(graphQLOwnerArg()).toBe('owner=upstream-owner')
+ expect(details?.item.title).toBe('Upstream project issue')
+ expect(details?.assignees).toEqual(['upstream-assignee'])
+ })
+
+ it('does not fall back when the selected issue repository is unavailable', async () => {
+ getWorkItemByOwnerRepoMock.mockResolvedValue(null)
+
+ await expect(
+ getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'upstream', {
+ ...REMOTES.origin,
+ host: 'github.com'
+ })
+ ).resolves.toBeNull()
+
+ expect(getWorkItemMock).not.toHaveBeenCalled()
+ expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
+ })
+
+ it('rejects an invalid runtime issue host before calling the repository lookup', async () => {
+ await expect(
+ getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'upstream', {
+ ...REMOTES.origin,
+ host: 42
+ })
+ ).resolves.toBeNull()
+
+ expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled()
+ expect(getWorkItemMock).not.toHaveBeenCalled()
+ expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
+ })
+
+ it('ignores an issue repository override on an untyped lookup', async () => {
+ ghExecFileAsyncMock
+ .mockResolvedValueOnce(graphQLIssueResponse('fork-assignee'))
+ .mockResolvedValueOnce({ stdout: '' })
+
+ await getWorkItemDetails('/repo-root', 5, undefined, null, {}, 'origin', {
+ owner: 'untrusted',
+ repo: 'other',
+ host: 'untrusted.example'
+ })
+
+ expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled()
+ expect(graphQLOwnerArg()).toBe('owner=fork-owner')
+ expect(ghExecFileAsyncMock.mock.calls[0][1]).toMatchObject({ host: 'github.com' })
+ })
+
+ it('normalizes host-less issue targets before loading the conversation', async () => {
+ getWorkItemByOwnerRepoMock.mockResolvedValue({
+ type: 'issue',
+ number: 5,
+ title: 'Origin issue',
+ url: 'https://github.com/fork-owner/widgets/issues/5',
+ author: null
+ })
+ ghExecFileAsyncMock
+ .mockResolvedValueOnce(graphQLIssueResponse('fork-assignee'))
+ .mockResolvedValueOnce({ stdout: '' })
+
+ await getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'upstream', REMOTES.origin)
+
+ expect(graphQLOwnerArg()).toBe('owner=fork-owner')
+ expect(ghExecFileAsyncMock.mock.calls[0][1]).toMatchObject({ host: 'github.com' })
+ expect(ghExecFileAsyncMock.mock.calls[1][1]).toMatchObject({ host: 'github.com' })
+ })
+})
diff --git a/src/main/github/work-item-details-pr-files.test.ts b/src/main/github/work-item-details-pr-files.test.ts
index d0205332bd5..c7af8abac85 100644
--- a/src/main/github/work-item-details-pr-files.test.ts
+++ b/src/main/github/work-item-details-pr-files.test.ts
@@ -1,3 +1,4 @@
+import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type RateLimitGuardResult =
@@ -52,8 +53,11 @@ vi.mock('./gh-utils', () => ({
release: vi.fn()
}))
-vi.mock('./client', () => ({
+vi.mock('./client', async () => ({
getWorkItem: getWorkItemMock,
+ getWorkItemWithRepository: (
+ await vi.importActual('./work-item-details-test-lookup')
+ ).makeWorkItemDetailsLookupMock(getWorkItemMock),
getWorkItemByOwnerRepo: vi.fn(),
getPRChecks: getPRChecksMock,
getPRComments: getPRCommentsMock
diff --git a/src/main/github/work-item-details-test-lookup.ts b/src/main/github/work-item-details-test-lookup.ts
new file mode 100644
index 00000000000..69c1daf0777
--- /dev/null
+++ b/src/main/github/work-item-details-test-lookup.ts
@@ -0,0 +1,17 @@
+import { resolveIssueGitHubApiRepositorySource } from './github-api-repository'
+import type { getWorkItem } from './client'
+import type { WorkItemRepositoryLookup } from './client/fetch/get-work-item'
+
+// Why: detail tests replace item lookup; retain its selected-source metadata.
+export function makeWorkItemDetailsLookupMock(lookup: typeof getWorkItem) {
+ return async (...args: Parameters): Promise => {
+ const item = await lookup(...args)
+ return {
+ item,
+ repository:
+ item?.type === 'issue'
+ ? (await resolveIssueGitHubApiRepositorySource(args[0], args[5], args[3], args[4])).source
+ : null
+ }
+ }
+}
diff --git a/src/main/github/work-item-details.test.ts b/src/main/github/work-item-details.test.ts
index 3ec1a5a4d9f..ef2d3649818 100644
--- a/src/main/github/work-item-details.test.ts
+++ b/src/main/github/work-item-details.test.ts
@@ -1,3 +1,4 @@
+import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type RateLimitGuardResult =
@@ -62,8 +63,11 @@ vi.mock('./gh-utils', () => ({
release: releaseMock
}))
-vi.mock('./client', () => ({
+vi.mock('./client', async () => ({
getWorkItem: getWorkItemMock,
+ getWorkItemWithRepository: (
+ await vi.importActual('./work-item-details-test-lookup')
+ ).makeWorkItemDetailsLookupMock(getWorkItemMock),
getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock,
getPRChecks: getPRChecksMock,
getPRComments: getPRCommentsMock
diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts
index dafc4f514d6..385d66c9515 100644
--- a/src/main/github/work-item-details.ts
+++ b/src/main/github/work-item-details.ts
@@ -1,14 +1,16 @@
import type { PRCheckDetail } from '../../shared/github/check-types'
import type { GitHubPRFile, GitHubPRFileContents } from '../../shared/github/pull-request-types'
-import type { GitHubWorkItem, GitHubWorkItemDetails } from '../../shared/github/work-item-types'
+import type { GitHubWorkItemDetails } from '../../shared/github/work-item-types'
import type { IssueSourcePreference } from '../../shared/repo-types'
-import { getPRChecks, getPRComments, getWorkItem } from './client'
-import { acquire, release, type LocalGitExecOptions } from './gh-utils'
import {
- getIssueGitHubApiRepository,
- resolveGitHubRepoExecution,
- type GitHubApiRepository
-} from './github-api-repository'
+ getPRChecks,
+ getPRComments,
+ getWorkItemWithRepository,
+ getWorkItemByOwnerRepo
+} from './client'
+import { acquire, release, type LocalGitExecOptions } from './gh-utils'
+import { resolveGitHubRepoExecution, type GitHubApiRepository } from './github-api-repository'
+import { isValidGitHubApiRepository } from './github-api-repository-validation'
import { getIssueBodyAndComments, getIssueDetailsViaGraphQL } from './issue-work-item-details'
import {
getPRFiles,
@@ -69,23 +71,49 @@ export async function getWorkItemDetails(
type?: 'issue' | 'pr',
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {},
- preference?: IssueSourcePreference
+ preference?: IssueSourcePreference,
+ repositoryOverride?: unknown
): Promise {
- const item: Omit | null = await getWorkItem(
- repoPath,
- number,
- type,
- connectionId,
- localGitOptions,
- preference
- )
+ let issueRepositoryOverride: GitHubApiRepository | null = null
+ if (type === 'issue' && repositoryOverride != null) {
+ if (!isValidGitHubApiRepository(repositoryOverride)) {
+ return null
+ }
+ issueRepositoryOverride = (
+ await resolveGitHubRepoExecution(repoPath, repositoryOverride, connectionId, localGitOptions)
+ ).ownerRepo
+ if (!issueRepositoryOverride) {
+ return null
+ }
+ }
+ const { item, repository: selectedRepository } = issueRepositoryOverride
+ ? {
+ item: await getWorkItemByOwnerRepo(
+ repoPath,
+ issueRepositoryOverride,
+ number,
+ 'issue',
+ connectionId,
+ localGitOptions
+ ),
+ repository: issueRepositoryOverride
+ }
+ : await getWorkItemWithRepository(
+ repoPath,
+ number,
+ type,
+ connectionId,
+ localGitOptions,
+ preference
+ )
if (!item) {
return null
}
+ // Keep the conversation in the same repository as the selected issue.
const resolvedRepository =
item.type === 'issue'
- ? await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions)
+ ? selectedRepository
: (await resolveGitHubRepoExecution(repoPath, item.prRepo, connectionId, localGitOptions))
.ownerRepo
diff --git a/src/main/ipc/github-issue-mutation-handlers.ts b/src/main/ipc/github-issue-mutation-handlers.ts
index 992f0c6e11f..8936e2eb15a 100644
--- a/src/main/ipc/github-issue-mutation-handlers.ts
+++ b/src/main/ipc/github-issue-mutation-handlers.ts
@@ -15,7 +15,14 @@ import { broadcastGitHubWorkItemMutation } from './github-work-item-mutation-eve
export function registerGitHubIssueMutationHandlers(store: Store): void {
ipcMain.handle(
'gh:updateIssue',
- async (event, args: GitHubRepoScopedArgs & { number: number; updates: GitHubIssueUpdate }) => {
+ async (
+ event,
+ args: GitHubRepoScopedArgs & {
+ number: number
+ updates: GitHubIssueUpdate
+ ownerRepo?: GitHubOwnerRepo
+ }
+ ) => {
const repo = assertRegisteredGitHubRepo(args, store)
if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) {
return { ok: false, error: 'Invalid issue number' }
@@ -28,7 +35,9 @@ export function registerGitHubIssueMutationHandlers(store: Store): void {
args.number,
args.updates,
getGitHubRepoConnectionId(repo),
- ...getGitHubLocalGitOptionArgs(store, repo)
+ getGitHubLocalGitOptionArgs(store, repo)[0],
+ repo.issueSourcePreference,
+ ...(args.ownerRepo ? [args.ownerRepo] : [])
)
if (result.ok) {
broadcastGitHubWorkItemMutation(
@@ -67,7 +76,9 @@ export function registerGitHubIssueMutationHandlers(store: Store): void {
args.body.trim(),
getGitHubRepoConnectionId(repo),
args.prRepo ?? null,
- ...getGitHubLocalGitOptionArgs(store, repo)
+ getGitHubLocalGitOptionArgs(store, repo)[0],
+ // Why: the issue source selector only scopes issues; PR comments keep their resolution.
+ args.type === 'pr' ? undefined : repo.issueSourcePreference
)
if (result.ok) {
broadcastGitHubWorkItemMutation(
@@ -84,23 +95,46 @@ export function registerGitHubIssueMutationHandlers(store: Store): void {
}
)
- ipcMain.handle('gh:listLabels', (_event, args: GitHubRepoScopedArgs) => {
- const repo = assertRegisteredGitHubRepo(args, store)
- return listLabels(
- repo.path,
- repo.issueSourcePreference,
- getGitHubRepoConnectionId(repo),
- ...getGitHubLocalGitOptionArgs(store, repo)
- )
- })
+ ipcMain.handle(
+ 'gh:listLabels',
+ (_event, args: GitHubRepoScopedArgs & { ownerRepo?: GitHubOwnerRepo }) => {
+ const repo = assertRegisteredGitHubRepo(args, store)
+ const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)
+ const connectionId = getGitHubRepoConnectionId(repo)
+ if (args.ownerRepo) {
+ return listLabels(
+ repo.path,
+ repo.issueSourcePreference,
+ connectionId,
+ localGitOptions[0],
+ args.ownerRepo
+ )
+ }
+ return listLabels(repo.path, repo.issueSourcePreference, connectionId, ...localGitOptions)
+ }
+ )
- ipcMain.handle('gh:listAssignableUsers', (_event, args: GitHubRepoScopedArgs) => {
- const repo = assertRegisteredGitHubRepo(args, store)
- return listAssignableUsers(
- repo.path,
- repo.issueSourcePreference,
- getGitHubRepoConnectionId(repo),
- ...getGitHubLocalGitOptionArgs(store, repo)
- )
- })
+ ipcMain.handle(
+ 'gh:listAssignableUsers',
+ (_event, args: GitHubRepoScopedArgs & { ownerRepo?: GitHubOwnerRepo }) => {
+ const repo = assertRegisteredGitHubRepo(args, store)
+ const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)
+ const connectionId = getGitHubRepoConnectionId(repo)
+ if (args.ownerRepo) {
+ return listAssignableUsers(
+ repo.path,
+ repo.issueSourcePreference,
+ connectionId,
+ localGitOptions[0],
+ args.ownerRepo
+ )
+ }
+ return listAssignableUsers(
+ repo.path,
+ repo.issueSourcePreference,
+ connectionId,
+ ...localGitOptions
+ )
+ }
+ )
}
diff --git a/src/main/ipc/github-issue-source-preference.test.ts b/src/main/ipc/github-issue-source-preference.test.ts
index ffef0da244b..eb51af7773e 100644
--- a/src/main/ipc/github-issue-source-preference.test.ts
+++ b/src/main/ipc/github-issue-source-preference.test.ts
@@ -143,4 +143,124 @@ describe('registerGitHubHandlers', () => {
'origin'
)
})
+
+ it('passes the displayed repository ahead of the current source preference', async () => {
+ harness.repos[0].issueSourcePreference = 'upstream'
+ const ownerRepo = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' }
+ mocks.client.updateIssue.mockResolvedValue({ ok: false, error: 'stub' })
+ getWorkItemDetailsMock.mockResolvedValue(null)
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These fixtures implement the repo methods used by the handlers; unrelated handlers are only registered.
+ registerGitHubHandlers(store as never, stats as never)
+
+ await handlers['gh:workItemDetails'](null, {
+ repoPath: '/workspace/repo',
+ number: 12,
+ type: 'issue',
+ ownerRepo
+ })
+ await handlers['gh:updateIssue'](null, {
+ repoPath: '/workspace/repo',
+ number: 12,
+ updates: { body: 'Origin edit' },
+ ownerRepo
+ })
+ await handlers['gh:listLabels'](null, { repoPath: '/workspace/repo', ownerRepo })
+ await handlers['gh:listAssignableUsers'](null, { repoPath: '/workspace/repo', ownerRepo })
+
+ expect(getWorkItemDetailsMock).toHaveBeenCalledWith(
+ '/workspace/repo',
+ 12,
+ 'issue',
+ null,
+ undefined,
+ 'upstream',
+ ownerRepo
+ )
+ expect(mocks.client.updateIssue).toHaveBeenCalledWith(
+ '/workspace/repo',
+ 12,
+ { body: 'Origin edit' },
+ null,
+ undefined,
+ 'upstream',
+ ownerRepo
+ )
+ expect(mocks.client.listLabels).toHaveBeenCalledWith(
+ '/workspace/repo',
+ 'upstream',
+ null,
+ undefined,
+ ownerRepo
+ )
+ expect(mocks.client.listAssignableUsers).toHaveBeenCalledWith(
+ '/workspace/repo',
+ 'upstream',
+ null,
+ undefined,
+ ownerRepo
+ )
+ })
+
+ it('pins the repo origin source preference on issue update and comment IPC', async () => {
+ harness.repos = [
+ {
+ id: 'repo-1',
+ path: '/workspace/repo',
+ displayName: 'repo',
+ badgeColor: '#000',
+ addedAt: 0,
+ issueSourcePreference: 'origin'
+ }
+ ]
+ mocks.client.updateIssue.mockResolvedValue({ ok: false, error: 'stub' })
+ mocks.client.addIssueComment.mockResolvedValue({ ok: false, error: 'stub' })
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These fixtures implement the repo methods used by the handlers; unrelated handlers are only registered.
+ registerGitHubHandlers(store as never, stats as never)
+
+ await handlers['gh:updateIssue'](null, {
+ repoPath: '/workspace/repo',
+ number: 5,
+ updates: { body: 'Updated' }
+ })
+ await handlers['gh:addIssueComment'](null, {
+ repoPath: '/workspace/repo',
+ number: 5,
+ body: 'Issue comment'
+ })
+ await handlers['gh:addIssueComment'](null, {
+ repoPath: '/workspace/repo',
+ number: 6,
+ body: 'PR comment',
+ type: 'pr'
+ })
+
+ expect(mocks.client.updateIssue).toHaveBeenCalledWith(
+ '/workspace/repo',
+ 5,
+ { body: 'Updated' },
+ null,
+ undefined,
+ 'origin'
+ )
+ expect(mocks.client.addIssueComment).toHaveBeenNthCalledWith(
+ 1,
+ '/workspace/repo',
+ 5,
+ 'Issue comment',
+ null,
+ null,
+ undefined,
+ 'origin'
+ )
+ expect(mocks.client.addIssueComment).toHaveBeenNthCalledWith(
+ 2,
+ '/workspace/repo',
+ 6,
+ 'PR comment',
+ null,
+ null,
+ undefined,
+ undefined
+ )
+ })
})
diff --git a/src/main/ipc/github-work-item-handlers.ts b/src/main/ipc/github-work-item-handlers.ts
index 26c21b3db81..94e982d60dc 100644
--- a/src/main/ipc/github-work-item-handlers.ts
+++ b/src/main/ipc/github-work-item-handlers.ts
@@ -146,15 +146,20 @@ export function registerGitHubWorkItemHandlers(store: Store): void {
}
)
- ipcMain.handle('gh:workItemDetails', (_event, args: WorkItemArgs) => {
- const repo = assertRegisteredGitHubRepo(args, store)
- return dispatchWorkItem(
- args,
- repo,
- getWorkItemDetails,
- getGitHubLocalGitOptionArgs(store, repo)[0]
- )
- })
+ ipcMain.handle(
+ 'gh:workItemDetails',
+ (_event, args: WorkItemArgs & { ownerRepo?: GitHubOwnerRepo }) => {
+ const repo = assertRegisteredGitHubRepo(args, store)
+ return dispatchWorkItem(
+ args,
+ repo,
+ args.ownerRepo
+ ? (...params) => getWorkItemDetails(...params, args.ownerRepo)
+ : getWorkItemDetails,
+ getGitHubLocalGitOptionArgs(store, repo)[0]
+ )
+ }
+ )
ipcMain.handle(
'gh:notifyWorkItemMutated',
diff --git a/src/main/ipc/github-wsl-runtime-routing.test.ts b/src/main/ipc/github-wsl-runtime-routing.test.ts
index e2f30c18eba..977422cb68e 100644
--- a/src/main/ipc/github-wsl-runtime-routing.test.ts
+++ b/src/main/ipc/github-wsl-runtime-routing.test.ts
@@ -138,6 +138,15 @@ describe('registerGitHubHandlers', () => {
)
await handlers['gh:listLabels'](null, { repoPath: '/workspace/repo' })
await handlers['gh:listAssignableUsers'](null, { repoPath: '/workspace/repo' })
+ const metadataRepo = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' }
+ await handlers['gh:listLabels'](null, {
+ repoPath: '/workspace/repo',
+ ownerRepo: metadataRepo
+ })
+ await handlers['gh:listAssignableUsers'](null, {
+ repoPath: '/workspace/repo',
+ ownerRepo: metadataRepo
+ })
expect(getPRForBranchMock).toHaveBeenCalledWith(
'/workspace/repo',
@@ -197,7 +206,8 @@ describe('registerGitHubHandlers', () => {
7,
{ body: 'Updated' },
null,
- localGitOptions
+ localGitOptions,
+ undefined
)
expect(addIssueCommentMock).toHaveBeenCalledWith(
'/workspace/repo',
@@ -205,9 +215,24 @@ describe('registerGitHubHandlers', () => {
'Comment',
null,
null,
- localGitOptions
+ localGitOptions,
+ undefined
)
expect(listLabelsMock).toHaveBeenCalledWith('/workspace/repo', undefined, null, localGitOptions)
+ expect(listLabelsMock).toHaveBeenLastCalledWith(
+ '/workspace/repo',
+ undefined,
+ null,
+ localGitOptions,
+ metadataRepo
+ )
+ expect(listAssignableUsersMock).toHaveBeenLastCalledWith(
+ '/workspace/repo',
+ undefined,
+ null,
+ localGitOptions,
+ metadataRepo
+ )
expect(listAssignableUsersMock).toHaveBeenCalledWith(
'/workspace/repo',
undefined,
diff --git a/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review.spec.ts b/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review.spec.ts
index 69878468ccb..139e018025a 100644
--- a/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review.spec.ts
+++ b/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review.spec.ts
@@ -319,7 +319,8 @@ describe('OrcaRuntimeService', () => {
12,
issueUpdates,
null,
- localGitOptions
+ localGitOptions,
+ undefined
)
expect(addGitHubIssueCommentMock).toHaveBeenCalledWith(
TEST_REPO_PATH,
@@ -327,7 +328,8 @@ describe('OrcaRuntimeService', () => {
'Comment',
null,
null,
- localGitOptions
+ localGitOptions,
+ undefined
)
expect(listGitHubLabelsMock).toHaveBeenCalledWith(
TEST_REPO_PATH,
diff --git a/src/main/runtime/rpc/methods/github-issue-methods.ts b/src/main/runtime/rpc/methods/github-issue-methods.ts
index 86300017741..825fe573d2f 100644
--- a/src/main/runtime/rpc/methods/github-issue-methods.ts
+++ b/src/main/runtime/rpc/methods/github-issue-methods.ts
@@ -35,6 +35,19 @@ export const GITHUB_ISSUE_METHODS = [
name: 'github.addIssueComment',
params: IssueComment,
handler: async (params, { runtime }) =>
- runtime.addRepoIssueComment(params.repo, params.number, params.body, params.prRepo ?? null)
+ params.type
+ ? runtime.addRepoIssueComment(
+ params.repo,
+ params.number,
+ params.body,
+ params.prRepo ?? null,
+ params.type
+ )
+ : runtime.addRepoIssueComment(
+ params.repo,
+ params.number,
+ params.body,
+ params.prRepo ?? null
+ )
})
]
diff --git a/src/main/runtime/rpc/methods/github.test.ts b/src/main/runtime/rpc/methods/github.test.ts
index d1bf9946806..1ed202d2f57 100644
--- a/src/main/runtime/rpc/methods/github.test.ts
+++ b/src/main/runtime/rpc/methods/github.test.ts
@@ -626,10 +626,13 @@ describe('github RPC methods', () => {
})
)
- expect(runtime.addRepoIssueComment).toHaveBeenCalledWith('repo-1', 3, 'Looks good', {
- owner: 'acme',
- repo: 'widgets'
- })
+ expect(runtime.addRepoIssueComment).toHaveBeenCalledWith(
+ 'repo-1',
+ 3,
+ 'Looks good',
+ { owner: 'acme', repo: 'widgets' },
+ 'pr'
+ )
expect(response).toMatchObject({ ok: true, result: { ok: true, comment: { id: 1 } } })
})
diff --git a/src/main/runtime/runtime-github-issue-comment-commands.ts b/src/main/runtime/runtime-github-issue-comment-commands.ts
index fcef9602405..e3f19c36d76 100644
--- a/src/main/runtime/runtime-github-issue-comment-commands.ts
+++ b/src/main/runtime/runtime-github-issue-comment-commands.ts
@@ -50,7 +50,8 @@ export class RuntimeGitHubIssueCommentCommands {
number,
updates,
repo.connectionId ?? null,
- ...this.deps.getLocalGitArgs(repo)
+ this.deps.getLocalGitArgs(repo)[0],
+ repo.issueSourcePreference
)
}
@@ -58,7 +59,8 @@ export class RuntimeGitHubIssueCommentCommands {
repoSelector: string,
number: number,
body: string,
- prRepo?: GitHubOwnerRepo | null
+ prRepo?: GitHubOwnerRepo | null,
+ type?: 'issue' | 'pr'
): Promise>> {
const repo = await this.deps.resolveRepo(repoSelector)
return addIssueComment(
@@ -67,7 +69,9 @@ export class RuntimeGitHubIssueCommentCommands {
body,
repo.connectionId ?? null,
prRepo ?? null,
- ...this.deps.getLocalGitArgs(repo)
+ this.deps.getLocalGitArgs(repo)[0],
+ // Why: the issue source selector only scopes issues; PR comments keep their resolution.
+ type === 'pr' ? undefined : repo.issueSourcePreference
)
}
diff --git a/src/preload/api/github-work-item-api.ts b/src/preload/api/github-work-item-api.ts
index ae95a908fb6..db5842eb2cf 100644
--- a/src/preload/api/github-work-item-api.ts
+++ b/src/preload/api/github-work-item-api.ts
@@ -75,6 +75,7 @@ export type GithubWorkItemApi = {
args: GitHubRepoSelectorArgs & {
number: number
type?: 'issue' | 'pr'
+ ownerRepo?: GitHubOwnerRepo
}
) => Promise
notifyWorkItemMutated: (args: {
@@ -106,6 +107,7 @@ export type GithubWorkItemApi = {
args: GitHubRepoSelectorArgs & {
number: number
updates: GitHubIssueUpdate
+ ownerRepo?: GitHubOwnerRepo
}
) => Promise<{ ok: true } | { ok: false; error: string }>
addIssueComment: (
@@ -121,11 +123,13 @@ export type GithubWorkItemApi = {
repoPath: string
repoId?: string
sourceContext?: TaskSourceContext | null
+ ownerRepo?: GitHubOwnerRepo
}) => Promise
listAssignableUsers: (args: {
repoPath: string
repoId?: string
sourceContext?: TaskSourceContext | null
+ ownerRepo?: GitHubOwnerRepo
}) => Promise
/** Subscribe to local-mutation broadcasts so the work-item-drawer cache can invalidate across windows. Returns an unsubscribe. */
onWorkItemMutated: (
diff --git a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts
index 42fba184a2e..12d6831f18c 100644
--- a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts
+++ b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts
@@ -10,7 +10,9 @@ import {
import { assertTaskPageGitHubDialogStateAuthority } from '@/components/task-page-github-dialog-state-authority'
import { runIssueUpdate } from '@/components/github/github-work-item-edit-mutations'
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
+import type { GitHubOwnerRepo } from '../../../../../shared/github/pull-request-types'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
+import type { GitHubPatchWorkItemOptions } from '@/store/github/cache-model'
import { translate } from '@/i18n/i18n'
import type { GitHubItemDialogProjectOrigin } from '../load-item-details/github-item-dialog-types'
@@ -28,6 +30,7 @@ type GHEditMutationBase = {
repoPath: string | null
sourceContext?: TaskSourceContext | null
projectOrigin: GitHubItemDialogProjectOrigin | undefined
+ issueRepo?: GitHubOwnerRepo | null
run: GHEditMutationRun
patchProjectRowIfNeeded: (patch: GHEditProjectRowPatch) => void
onMutated: () => void
@@ -43,6 +46,7 @@ export function runGHEditStateChange({
repoPath,
sourceContext,
projectOrigin,
+ issueRepo,
run,
onStateChange,
patchWorkItem,
@@ -58,7 +62,7 @@ export function runGHEditStateChange({
id: string,
patch: { state: GitHubWorkItem['state'] },
repoId: string | undefined,
- options: { sourceContext?: TaskSourceContext | null }
+ options: GitHubPatchWorkItemOptions
) => void
}): void {
// Why: a close reason still has to reach GitHub even when the item already reads as closed locally.
@@ -76,6 +80,7 @@ export function runGHEditStateChange({
repoPath,
sourceContext,
projectOrigin,
+ issueRepo,
number: itemNumber,
updates:
newState === 'closed' && closeAction
@@ -87,22 +92,32 @@ export function runGHEditStateChange({
repoId: itemRepoId,
itemId,
state: newState,
- sourceContext
+ sourceContext,
+ ownerRepo: projectOrigin ?? issueRepo
})
onStateChange(newState)
- patchWorkItem(itemId, { state: newState }, itemRepoId, { sourceContext })
+ patchWorkItem(itemId, { state: newState }, itemRepoId, {
+ sourceContext,
+ ownerRepo: projectOrigin ?? issueRepo
+ })
patchProjectRowIfNeeded({ state: newState })
},
onRevert: () => {
if (authority?.revert()) {
onStateChange(prevState)
- patchWorkItem(itemId, { state: prevState }, itemRepoId, { sourceContext })
+ patchWorkItem(itemId, { state: prevState }, itemRepoId, {
+ sourceContext,
+ ownerRepo: projectOrigin ?? issueRepo
+ })
patchProjectRowIfNeeded({ state: prevState })
}
},
onSuccess: () => {
useAppStore.getState().recordFeatureInteraction('github-tasks')
- patchWorkItem(itemId, { state: newState }, itemRepoId, { sourceContext })
+ patchWorkItem(itemId, { state: newState }, itemRepoId, {
+ sourceContext,
+ ownerRepo: projectOrigin ?? issueRepo
+ })
patchProjectRowIfNeeded({ state: newState })
onMutated()
},
@@ -145,6 +160,7 @@ export function runGHEditLabelToggle({
repoPath,
sourceContext,
projectOrigin,
+ issueRepo,
run,
onLabelsChange,
patchWorkItem,
@@ -159,42 +175,13 @@ export function runGHEditLabelToggle({
id: string,
patch: { labels: string[] },
repoId: string | undefined,
- options: { sourceContext?: TaskSourceContext | null }
+ options: GitHubPatchWorkItemOptions
) => void
}): void {
const isAdding = !localLabels.includes(label)
const prevLabels = localLabels
const newLabels = isAdding ? [...prevLabels, label] : prevLabels.filter((l) => l !== label)
- if (isAdding) {
- void run('labels', {
- mutate: () =>
- runIssueUpdate({
- repoId: itemRepoId,
- repoPath,
- sourceContext,
- projectOrigin,
- number: itemNumber,
- updates: { addLabels: [label] }
- }),
- onOptimistic: () => {
- onLabelsChange(newLabels)
- patchWorkItem(itemId, { labels: newLabels }, itemRepoId, { sourceContext })
- patchProjectRowIfNeeded({ labels: newLabels })
- },
- onSuccess: () => {
- useAppStore.getState().recordFeatureInteraction('github-tasks')
- onMutated()
- },
- onRevert: () => {
- onLabelsChange(prevLabels)
- patchWorkItem(itemId, { labels: prevLabels }, itemRepoId, { sourceContext })
- patchProjectRowIfNeeded({ labels: prevLabels })
- },
- onError: (err) => toast.error(err)
- })
- return
- }
void run('labels', {
mutate: () =>
runIssueUpdate({
@@ -202,17 +189,24 @@ export function runGHEditLabelToggle({
repoPath,
sourceContext,
projectOrigin,
+ issueRepo,
number: itemNumber,
- updates: { removeLabels: [label] }
+ updates: isAdding ? { addLabels: [label] } : { removeLabels: [label] }
}),
onOptimistic: () => {
onLabelsChange(newLabels)
- patchWorkItem(itemId, { labels: newLabels }, itemRepoId, { sourceContext })
+ patchWorkItem(itemId, { labels: newLabels }, itemRepoId, {
+ sourceContext,
+ ownerRepo: projectOrigin ?? issueRepo
+ })
patchProjectRowIfNeeded({ labels: newLabels })
},
onRevert: () => {
onLabelsChange(prevLabels)
- patchWorkItem(itemId, { labels: prevLabels }, itemRepoId, { sourceContext })
+ patchWorkItem(itemId, { labels: prevLabels }, itemRepoId, {
+ sourceContext,
+ ownerRepo: projectOrigin ?? issueRepo
+ })
patchProjectRowIfNeeded({ labels: prevLabels })
},
onSuccess: () => {
@@ -233,6 +227,7 @@ export function runGHEditAssigneeToggle({
repoPath,
sourceContext,
projectOrigin,
+ issueRepo,
run,
setLocalAssignees,
patchProjectRowIfNeeded,
@@ -260,6 +255,7 @@ export function runGHEditAssigneeToggle({
repoPath,
sourceContext,
projectOrigin,
+ issueRepo,
number: itemNumber,
updates: { removeAssignees: [login] }
}),
@@ -288,6 +284,7 @@ export function runGHEditAssigneeToggle({
repoPath,
sourceContext,
projectOrigin,
+ issueRepo,
number: itemNumber,
updates: { addAssignees: [login] }
}),
diff --git a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.assignee-identity.test.tsx b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.assignee-identity.test.tsx
new file mode 100644
index 00000000000..be5ee88f54d
--- /dev/null
+++ b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.assignee-identity.test.tsx
@@ -0,0 +1,364 @@
+// @vitest-environment happy-dom
+
+import { act, useState } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
+import type { GitHubItemDialogProjectOrigin } from '../load-item-details/github-item-dialog-types'
+import { GHEditSection } from './gh-edit-section'
+import { resetTaskPageGitHubMutationRegistryForTests } from '@/components/task-page-github-work-item-mutation-registry'
+
+const mocks = vi.hoisted(() => ({
+ update: vi.fn(),
+ patchWorkItem: vi.fn(),
+ patchProjectRowContent: vi.fn(),
+ recordFeatureInteraction: vi.fn(),
+ onMutated: vi.fn()
+}))
+
+vi.mock('@/store', () => {
+ const state = {
+ patchWorkItem: mocks.patchWorkItem,
+ patchProjectRowContent: mocks.patchProjectRowContent,
+ recordFeatureInteraction: mocks.recordFeatureInteraction,
+ repos: [],
+ settings: null
+ }
+ return {
+ useAppStore: Object.assign((selector: (value: typeof state) => unknown) => selector(state), {
+ getState: () => state
+ })
+ }
+})
+
+vi.mock('@/hooks/useIssueMetadata', async () => {
+ const { useImmediateMutation } = await import('@/hooks/useImmediateMutation')
+ return {
+ useImmediateMutation,
+ useRepoLabels: () => ({ data: [] }),
+ useRepoAssignees: () => ({ data: [] })
+ }
+})
+vi.mock('@/hooks/useGitHubSlugMetadata', () => ({
+ useRepoLabelsBySlug: () => ({ data: [] }),
+ useRepoAssigneesBySlug: () => ({ data: [] })
+}))
+vi.mock('@/components/github/github-duplicate-issue-candidates', () => ({
+ useGitHubDuplicateIssueCandidates: () => []
+}))
+vi.mock('@/components/github/github-work-item-edit-mutations', () => ({
+ runIssueUpdate: mocks.update
+}))
+vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
+vi.mock('./gh-edit-section-horizontal', () => ({ GHEditSectionHorizontal: () => null }))
+vi.mock('./gh-edit-section-top-columns', () => ({
+ GHEditSectionTopColumns: ({
+ localAssignees,
+ onAssigneeToggle,
+ localState,
+ localLabels,
+ onStateChange,
+ onLabelToggle
+ }: {
+ localAssignees: string[]
+ onAssigneeToggle: (login: string) => void
+ localState: GitHubWorkItem['state']
+ localLabels: string[]
+ onStateChange: (state: 'open' | 'closed') => void
+ onLabelToggle: (label: string) => void
+ }) => (
+ <>
+
+
+
+
+
+
+
+ >
+ )
+}))
+
+const item: GitHubWorkItem = {
+ id: 'issue:5',
+ repoId: 'registered-repo',
+ type: 'issue',
+ number: 5,
+ title: 'Issue five',
+ state: 'open',
+ url: 'https://github.com/upstream/widgets/issues/5',
+ labels: [],
+ updatedAt: '',
+ author: null
+}
+let root: Root
+let container: HTMLDivElement
+
+async function render(
+ nextItem = item,
+ assignees: string[] = [],
+ projectOrigin?: GitHubItemDialogProjectOrigin
+): Promise {
+ await act(async () => {
+ root.render(
+ {}}
+ onLabelsChange={() => {}}
+ onMutated={mocks.onMutated}
+ assignees={assignees}
+ onUse={() => {}}
+ layout="top-columns"
+ />
+ )
+ })
+}
+
+async function click(label = 'Assign candidate'): Promise {
+ const button = [...container.querySelectorAll('button')].find(
+ (candidate) => candidate.textContent === label
+ )
+ expect(button).toBeDefined()
+ await act(async () => button?.click())
+}
+
+function selected(): string | null {
+ return container.querySelector('[data-field="assignees"]')?.textContent ?? null
+}
+
+let updateParentSelection: (nextItem: GitHubWorkItem) => void = () => {}
+
+function ParentSelection({ item }: { item: GitHubWorkItem }): React.JSX.Element {
+ const [localState, setLocalState] = useState(item.state)
+ const [localLabels, setLocalLabels] = useState(item.labels)
+ updateParentSelection = (nextItem) => {
+ setLocalState(nextItem.state)
+ setLocalLabels(nextItem.labels)
+ }
+ return (
+ {}}
+ layout="top-columns"
+ />
+ )
+}
+
+async function renderParent(nextItem: GitHubWorkItem): Promise {
+ await act(async () => {
+ updateParentSelection(nextItem)
+ root.render()
+ })
+}
+
+describe('opened issue assignee ownership', () => {
+ beforeEach(() => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ vi.clearAllMocks()
+ resetTaskPageGitHubMutationRegistryForTests()
+ updateParentSelection = () => {}
+ mocks.update.mockResolvedValue({ ok: true })
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ vi.unstubAllGlobals()
+ resetTaskPageGitHubMutationRegistryForTests()
+ })
+
+ it('retires an optimistic selection across fork and return navigation', async () => {
+ await render()
+ await click()
+ expect(selected()).toBe('candidate-user')
+ await render(item, [])
+ expect(selected()).toBe('candidate-user')
+
+ await render({ ...item, url: 'https://github.com/fork/widgets/issues/5' }, ['fork-user'])
+ expect(selected()).toBe('fork-user')
+ await render(item, ['authoritative-upstream-user'])
+ expect(selected()).toBe('authoritative-upstream-user')
+ })
+
+ it('does not revive an edited guard when returning from a different issue number', async () => {
+ await render()
+ await click()
+ await render({ ...item, id: 'issue:6', number: 6 }, ['other-issue-user'])
+ expect(selected()).toBe('other-issue-user')
+ await render(item, ['authoritative-upstream-user'])
+ expect(selected()).toBe('authoritative-upstream-user')
+ })
+
+ it.each(['Assign candidate', 'Toggle original'])(
+ 'does not roll a late failed %s edit into another opened issue',
+ async (button) => {
+ let rejectUpdate: (reason: Error) => void = () => {}
+ mocks.update.mockImplementation(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectUpdate = reject
+ })
+ )
+ await render(item, ['original-user'])
+ await click(button)
+ await render({ ...item, id: 'issue:6', number: 6 }, ['other-issue-user'])
+ expect(selected()).toBe('other-issue-user')
+ await act(async () => rejectUpdate(new Error('held edit failed')))
+ expect(selected()).toBe('other-issue-user')
+ }
+ )
+
+ it('treats another GitHub host as a distinct same-number issue', async () => {
+ await render()
+ await click()
+ await render({ ...item, url: 'https://github.example.test/upstream/widgets/issues/5' }, [])
+ expect(selected()).toBe('')
+ })
+
+ it('retires the issue edit when the same legacy id is used by a pull request', async () => {
+ await render()
+ await click()
+ await render({ ...item, type: 'pr' }, [])
+ await render(item, ['authoritative-upstream-user'])
+ expect(selected()).toBe('authoritative-upstream-user')
+ })
+
+ it('preserves optimistic edits across canonical URL casing and trailing paths', async () => {
+ await render()
+ await click()
+ await render({ ...item, url: 'https://GITHUB.COM/Upstream/Widgets/issues/5#activity' }, [])
+ expect(selected()).toBe('candidate-user')
+ })
+
+ it('uses the Project row repository before an unrelated item URL', async () => {
+ const projectOrigin: GitHubItemDialogProjectOrigin = {
+ owner: 'upstream',
+ repo: 'widgets',
+ number: 5,
+ type: 'issue',
+ projectId: 'project',
+ projectItemId: 'row',
+ cacheKey: 'project-cache'
+ }
+ await render(item, [], projectOrigin)
+ await click()
+ await render(
+ { ...item, url: 'https://github.com/unrelated/widgets/issues/5' },
+ [],
+ projectOrigin
+ )
+ expect(selected()).toBe('candidate-user')
+ await render(item, ['fork-user'], { ...projectOrigin, owner: 'fork' })
+ expect(selected()).toBe('fork-user')
+ })
+
+ it('still rolls back a failed edit while its issue remains open', async () => {
+ mocks.update.mockRejectedValue(new Error('edit failed'))
+ await render(item, ['original-user'])
+ await click()
+ expect(selected()).toBe('original-user')
+ await render(item, ['refetched-user'])
+ expect(selected()).toBe('refetched-user')
+ })
+
+ it('keeps a late Project-row rollback scoped to the captured row', async () => {
+ const projectOrigin: GitHubItemDialogProjectOrigin = {
+ owner: 'upstream',
+ repo: 'widgets',
+ number: 5,
+ type: 'issue',
+ projectId: 'project',
+ projectItemId: 'upstream-row',
+ cacheKey: 'upstream-cache'
+ }
+ let rejectUpdate: (reason: Error) => void = () => {}
+ mocks.update.mockImplementation(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectUpdate = reject
+ })
+ )
+ await render(item, ['original-user'], projectOrigin)
+ await click()
+ await render(item, ['fork-user'], {
+ ...projectOrigin,
+ owner: 'fork',
+ projectItemId: 'fork-row',
+ cacheKey: 'fork-cache'
+ })
+ expect(selected()).toBe('fork-user')
+ await act(async () => rejectUpdate(new Error('held Project edit failed')))
+ expect(selected()).toBe('fork-user')
+ expect(mocks.patchProjectRowContent).toHaveBeenLastCalledWith(
+ 'upstream-cache',
+ 'upstream-row',
+ { assignees: ['original-user'] }
+ )
+ })
+
+ it.each([
+ { button: 'Toggle label', field: 'labels', expected: 'fork-only-label' },
+ { button: 'Close issue', field: 'state', expected: 'closed' }
+ ])(
+ 'a late failed $field edit cannot overwrite the new parent selection',
+ async ({ button, field, expected }) => {
+ let rejectUpdate: (reason: Error) => void = () => {}
+ mocks.update.mockImplementation(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectUpdate = reject
+ })
+ )
+ await renderParent({ ...item, labels: ['upstream-original-label'] })
+ await click(button)
+ await renderParent({
+ ...item,
+ state: 'closed',
+ url: 'https://github.com/fork/widgets/issues/5',
+ labels: ['fork-only-label']
+ })
+ expect(container.querySelector(`[data-field="${field}"]`)?.textContent).toBe(expected)
+ await act(async () => rejectUpdate(new Error('held edit failed')))
+ expect(container.querySelector(`[data-field="${field}"]`)?.textContent).toBe(expected)
+ expect(mocks.patchWorkItem).toHaveBeenLastCalledWith(
+ item.id,
+ field === 'labels' ? { labels: ['upstream-original-label'] } : { state: 'open' },
+ item.repoId,
+ {
+ sourceContext: undefined,
+ ownerRepo: { owner: 'upstream', repo: 'widgets', host: 'github.com' }
+ }
+ )
+ }
+ )
+
+ it.each([
+ { button: 'Toggle label', field: 'labels', expected: 'upstream-original-label' },
+ { button: 'Close issue', field: 'state', expected: 'open' }
+ ])(
+ 'a same-item failed $field edit still restores its prior parent value',
+ async ({ button, field, expected }) => {
+ mocks.update.mockRejectedValue(new Error('same issue edit failed'))
+ await renderParent({ ...item, labels: ['upstream-original-label'] })
+ await click(button)
+ expect(container.querySelector(`[data-field="${field}"]`)?.textContent).toBe(expected)
+ }
+ )
+})
diff --git a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.tsx b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.tsx
index f92c99b2a09..a50f41fc960 100644
--- a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.tsx
+++ b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.tsx
@@ -3,11 +3,8 @@ import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '@/store'
import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata'
import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata'
-import {
- getTaskSourceRuntimeSettings,
- type TaskSourceContext
-} from '../../../../../shared/task-source-context'
-import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
+import { getTaskSourceRuntimeSettings } from '../../../../../shared/task-source-context'
+import { githubRepoIdentityKey } from '../../../../../shared/github/repository-identity-key'
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
import {
getTaskPageGitHubDuplicateCandidates,
@@ -17,7 +14,8 @@ import {
} from '@/components/task-page-github-status-actions'
import { parseOwnerRepoFromItemUrl } from '@/components/github/github-work-item-identity'
import { translate } from '@/i18n/i18n'
-import type { GitHubItemDialogProjectOrigin } from '../load-item-details/github-item-dialog-types'
+import type { GitHubItemDialogEditSectionProps } from '../load-item-details/github-item-dialog-types'
+import { useMountedRef } from '@/hooks/useMountedRef'
import { getGitHubRepositoryLabelsUrl } from './repository-labels-url'
import {
closeGHEditAsDuplicate,
@@ -29,7 +27,14 @@ import { GHEditSectionTopColumns } from './gh-edit-section-top-columns'
import { GHEditSectionHorizontal } from './gh-edit-section-horizontal'
import { useGitHubDuplicateIssueCandidates } from '@/components/github/github-duplicate-issue-candidates'
-export function GHEditSection({
+export function GHEditSection(props: GitHubItemDialogEditSectionProps): React.JSX.Element | null {
+ const { item, projectOrigin } = props
+ const repository = projectOrigin ?? parseOwnerRepoFromItemUrl(item.url)
+ const itemKey = `${item.repoId}\0${repository ? githubRepoIdentityKey(repository) : item.url}\0${item.type}\0${item.id}`
+ return
+}
+
+function GHEditSectionItem({
item,
repoPath,
repoId,
@@ -45,25 +50,7 @@ export function GHEditSection({
onOpenOrUse,
attachedWorkspaceLabel,
layout = 'horizontal'
-}: {
- item: GitHubWorkItem
- repoPath: string | null
- repoId: string | null
- sourceContext?: TaskSourceContext | null
- projectOrigin: GitHubItemDialogProjectOrigin | undefined
- localState: GitHubWorkItem['state']
- localLabels: string[]
- onStateChange: (state: GitHubWorkItem['state']) => void
- onLabelsChange: (labels: string[]) => void
- /** Why: lets the parent invalidate its details cache after a mutation, else a reopen within FRESH_MS paints pre-mutation data. */
- onMutated: () => void
- assignees: string[]
- onUse: (item: GitHubWorkItem) => void
- onOpenOrUse?: (item: GitHubWorkItem) => void
- attachedWorkspaceLabel?: string | null
- /** `horizontal`: compact pill strip for the non-issue drawer/header; `top-columns`: labeled columns above the issue page body. */
- layout?: 'horizontal' | 'top-columns'
-}): React.JSX.Element | null {
+}: GitHubItemDialogEditSectionProps): React.JSX.Element | null {
const [labelPopoverOpen, setLabelPopoverOpen] = useState(false)
const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false)
const [statusPopoverOpen, setStatusPopoverOpen] = useState(false)
@@ -72,6 +59,7 @@ export function GHEditSection({
const [duplicateError, setDuplicateError] = useState(null)
const [localAssignees, setLocalAssignees] = useState(assignees)
const editedAssigneesItemKeyRef = useRef(null)
+ const mountedRef = useMountedRef()
const assigneesItemKey = `${item.repoId}\0${item.id}`
const patchWorkItem = useAppStore((s) => s.patchWorkItem)
const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent)
@@ -101,13 +89,18 @@ export function GHEditSection({
[projectOrigin, patchProjectRowContent]
)
- // Why: with projectOrigin set, read labels/assignees from the row's repo, not the workspace path, or popovers list a different repo than writes target.
+ const issueRepo = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url])
+ const metadataOptions = useMemo(
+ () => ({ ...sourceSettings, ownerRepo: issueRepo }),
+ [sourceSettings, issueRepo]
+ )
+ // Project metadata comes from the row repository.
const slugOwner = projectOrigin?.owner ?? null
const slugRepo = projectOrigin?.repo ?? null
const repoLabelsByPath = useRepoLabels(
projectOrigin ? null : repoPath,
projectOrigin ? null : repoId,
- sourceSettings
+ metadataOptions
)
const repoLabelsBySlug = useRepoLabelsBySlug(
slugOwner,
@@ -120,7 +113,7 @@ export function GHEditSection({
const repoAssigneesByPath = useRepoAssignees(
projectOrigin ? null : repoPath,
projectOrigin ? null : repoId,
- sourceSettings
+ metadataOptions
)
const repoAssigneesBySlug = useRepoAssigneesBySlug(
slugOwner,
@@ -187,8 +180,13 @@ export function GHEditSection({
repoPath,
sourceContext,
projectOrigin,
+ issueRepo,
run,
- onStateChange,
+ onStateChange: (state) => {
+ if (mountedRef.current) {
+ onStateChange(state)
+ }
+ },
patchWorkItem,
patchProjectRowIfNeeded,
onMutated
@@ -202,9 +200,11 @@ export function GHEditSection({
repoPath,
sourceContext,
projectOrigin,
+ issueRepo,
patchWorkItem,
patchProjectRowIfNeeded,
run,
+ mountedRef,
onStateChange,
onMutated
]
@@ -253,8 +253,13 @@ export function GHEditSection({
repoPath,
sourceContext,
projectOrigin,
+ issueRepo,
run,
- onLabelsChange,
+ onLabelsChange: (labels) => {
+ if (mountedRef.current) {
+ onLabelsChange(labels)
+ }
+ },
patchWorkItem,
patchProjectRowIfNeeded,
onMutated
@@ -268,9 +273,11 @@ export function GHEditSection({
repoPath,
sourceContext,
projectOrigin,
+ issueRepo,
patchWorkItem,
patchProjectRowIfNeeded,
run,
+ mountedRef,
onLabelsChange,
onMutated
]
@@ -288,6 +295,7 @@ export function GHEditSection({
repoPath,
sourceContext,
projectOrigin,
+ issueRepo,
run,
setLocalAssignees,
patchProjectRowIfNeeded,
@@ -301,6 +309,7 @@ export function GHEditSection({
repoPath,
sourceContext,
projectOrigin,
+ issueRepo,
localAssignees,
patchProjectRowIfNeeded,
run,
diff --git a/src/renderer/src/components/github-item-dialog/load-item-details/github-item-dialog-types.ts b/src/renderer/src/components/github-item-dialog/load-item-details/github-item-dialog-types.ts
index c0a6fc7a3b8..9c8e81953b2 100644
--- a/src/renderer/src/components/github-item-dialog/load-item-details/github-item-dialog-types.ts
+++ b/src/renderer/src/components/github-item-dialog/load-item-details/github-item-dialog-types.ts
@@ -26,3 +26,23 @@ export type GitHubItemDialogProps = {
/** Optional Project-origin context; when set, edits route via slug-addressed IPCs against the row's repo (slug routing wins for writes). */
projectOrigin?: GitHubItemDialogProjectOrigin
}
+
+export type GitHubItemDialogEditSectionProps = {
+ item: GitHubWorkItem
+ repoPath: string | null
+ repoId: string | null
+ sourceContext?: TaskSourceContext | null
+ projectOrigin: GitHubItemDialogProjectOrigin | undefined
+ localState: GitHubWorkItem['state']
+ localLabels: string[]
+ onStateChange: (state: GitHubWorkItem['state']) => void
+ onLabelsChange: (labels: string[]) => void
+ /** Why: lets the parent invalidate its details cache after a mutation, else a reopen within FRESH_MS paints pre-mutation data. */
+ onMutated: () => void
+ assignees: string[]
+ onUse: (item: GitHubWorkItem) => void
+ onOpenOrUse?: (item: GitHubWorkItem) => void
+ attachedWorkspaceLabel?: string | null
+ /** `horizontal`: compact pill strip for the non-issue drawer/header; `top-columns`: labeled columns above the issue page body. */
+ layout?: 'horizontal' | 'top-columns'
+}
diff --git a/src/renderer/src/components/github-item-dialog/load-item-details/use-github-item-dialog-details.ts b/src/renderer/src/components/github-item-dialog/load-item-details/use-github-item-dialog-details.ts
index 0d685da6020..c9bd69d9bb1 100644
--- a/src/renderer/src/components/github-item-dialog/load-item-details/use-github-item-dialog-details.ts
+++ b/src/renderer/src/components/github-item-dialog/load-item-details/use-github-item-dialog-details.ts
@@ -4,6 +4,7 @@ import { lookupGitHubWorkItemDetailsForSource } from '@/lib/github-work-item-sou
import { canUseGitHubRepoContext } from '@/lib/github-source-runtime-context'
import {
normalizeItemDialogTab,
+ parseOwnerRepoFromItemUrl,
type ItemDialogTab
} from '@/components/github/github-work-item-identity'
import type { PRComment } from '../../../../../shared/github/comment-types'
@@ -61,6 +62,13 @@ export function useGitHubItemDialogDetails({
?.issueSourcePreference
})
const canUseDetailsRepoContext = canUseGitHubRepoContext(repoPath, sourceContext)
+ const issueRepository = useMemo(
+ () =>
+ workItem?.type === 'issue'
+ ? (projectOrigin ?? parseOwnerRepoFromItemUrl(workItem.url))
+ : null,
+ [projectOrigin, workItem]
+ )
const detailsCacheKey = useMemo(() => {
if (!workItem || !effectiveRepoId || !canUseDetailsRepoContext) {
return null
@@ -69,10 +77,12 @@ export function useGitHubItemDialogDetails({
repoPath: repoPath ?? '',
repoId: effectiveRepoId,
issueSourcePreference,
+ sourceContext,
sourceCacheScope:
sourceContext?.provider === 'github' ? getTaskSourceCacheScope(sourceContext) : null,
type: workItem.type,
- number: workItem.number
+ number: workItem.number,
+ ownerRepo: issueRepository
})
}, [
canUseDetailsRepoContext,
@@ -80,7 +90,8 @@ export function useGitHubItemDialogDetails({
effectiveRepoId,
sourceContext,
workItem,
- issueSourcePreference
+ issueSourcePreference,
+ issueRepository
])
// Why: reset during render so an item switch never paints the previous item's tab.
@@ -97,7 +108,7 @@ export function useGitHubItemDialogDetails({
}
// Why: hold comments added before the detail fetch resolves so they merge into the result instead of being overwritten.
- const optimisticCommentsRef = useRef([])
+ const optimisticCommentsRef = useRef(new Map())
// Why: distinguish "reopen same item" from "switch item" — reopen must keep optimistic comments since gh's 60s cache omits the just-posted one.
const prevItemIdRef = useRef(null)
@@ -116,7 +127,7 @@ export function useGitHubItemDialogDetails({
// Why: key off cachedEntry identity (stable), not the optimistic ref array (fresh each render), to avoid needless recompute.
const details = useMemo(() => {
const cachedDetails = cachedEntry?.details ?? null
- const opt = optimisticCommentsRef.current
+ const opt = optimisticCommentsRef.current.get(detailsCacheKey ?? '') ?? []
if (!cachedDetails) {
// Why: on cold open, details may still be loading — surface optimistic comments via a minimal shell so a pre-fetch comment isn't invisible.
if (opt.length > 0 && workItem) {
@@ -138,7 +149,7 @@ export function useGitHubItemDialogDetails({
}
// Why: optimisticTick forces this ref-reading memo to re-run on cold-open writes; lint can't see the dependency.
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [cachedEntry, workItem, optimisticTick])
+ }, [cachedEntry, workItem, detailsCacheKey, optimisticTick])
const loading = !!cachedEntry?.pending && !cachedEntry?.details
const error = cachedEntry?.error && !cachedEntry?.details ? cachedEntry.error : null
@@ -158,7 +169,7 @@ export function useGitHubItemDialogDetails({
}
// Why: clear optimistic comments only on item switch — on reopen, gh's 60s cache omits the just-posted comment, so keep the ref to re-merge.
if (workItem.id !== prevItemIdRef.current) {
- optimisticCommentsRef.current = []
+ optimisticCommentsRef.current.clear()
}
prevItemIdRef.current = workItem.id
@@ -178,7 +189,8 @@ export function useGitHubItemDialogDetails({
repoId: effectiveRepoId,
sourceContext,
number: workItem.number,
- type: workItem.type
+ type: workItem.type,
+ ownerRepo: issueRepository
})
// Why: snapshot the invalidation generation; if it advances before resolve, a mid-flight mutation invalidated the entry — don't write back.
@@ -201,6 +213,7 @@ export function useGitHubItemDialogDetails({
sourceContext,
workItem,
detailsCacheKey,
+ issueRepository,
refetchTick
])
@@ -231,7 +244,9 @@ export function useGitHubItemDialogDetails({
(comment: PRComment) => {
useAppStore.getState().recordFeatureInteraction('github-tasks')
// Why: skip refreshDetails() — gh's 60s cache would overwrite the optimistic comment; next open picks up the server version.
- optimisticCommentsRef.current.push(comment)
+ const optimisticKey = detailsCacheKey ?? ''
+ const optimisticComments = optimisticCommentsRef.current.get(optimisticKey) ?? []
+ optimisticCommentsRef.current.set(optimisticKey, [...optimisticComments, comment])
// Why: write through the module cache so concurrent drawers re-render; mark fetchedAt stale (0) so next open refetches server fields.
if (detailsCacheKey) {
const prev = workItemDetailsCache.get(detailsCacheKey)
diff --git a/src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-cache.ts b/src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-cache.ts
index 36087ef00b2..38811153a28 100644
--- a/src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-cache.ts
+++ b/src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-cache.ts
@@ -1,9 +1,13 @@
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
import type {
GitHubAssignableUser,
+ GitHubOwnerRepo,
GitHubPRFileViewedState
} from '../../../../../shared/github/pull-request-types'
+import { githubRepoIdentityKey } from '../../../../../shared/github/repository-identity-key'
import type { GitHubWorkItemDetails } from '../../../../../shared/github/work-item-types'
+import type { TaskSourceContext } from '../../../../../shared/task-source-context'
+import { getGitHubSourceRuntimeHost } from '@/lib/github-source-runtime-context'
import { onGitHubWorkItemDetailsCacheMutation } from '@/lib/github-work-item-details-cache-events'
// Why: SWR cache for work-item details so reopening paints instantly instead of paying IPC + `gh` startup; keyed to avoid source/type collisions, LRU-bounded, FRESH_MS refetch on open. See docs/gh-work-item-drawer-cache.md.
@@ -37,20 +41,20 @@ export function getWorkItemDetailsCacheKey(args: {
repoId: string
issueSourcePreference: string | undefined
sourceCacheScope?: string | null
+ sourceContext?: TaskSourceContext | null
type: 'issue' | 'pr'
number: number
+ ownerRepo?: GitHubOwnerRepo | null
}): string {
// Why: key on every axis that changes which (repo, item) the IPC resolves to; `\0` separator avoids ambiguity with fields containing `:` or `/`.
// Why: repoPath is the second part so match-based invalidation can find entries from a cross-window event that carries only the path.
+ const sourceKey =
+ args.ownerRepo && !getGitHubSourceRuntimeHost(args.sourceContext)
+ ? githubRepoIdentityKey(args.ownerRepo)
+ : (args.issueSourcePreference ?? 'auto')
const keyParts = args.sourceCacheScope
- ? [
- args.repoId,
- args.repoPath,
- args.sourceCacheScope,
- args.issueSourcePreference ?? 'auto',
- args.type
- ]
- : [args.repoId, args.repoPath, args.issueSourcePreference ?? 'auto', args.type]
+ ? [args.repoId, args.repoPath, args.sourceCacheScope, sourceKey, args.type]
+ : [args.repoId, args.repoPath, sourceKey, args.type]
return [...keyParts, args.number].join('\0')
}
diff --git a/src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-issue-target.test.ts b/src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-issue-target.test.ts
new file mode 100644
index 00000000000..a5c7ab456c8
--- /dev/null
+++ b/src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-issue-target.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { TaskSourceContext } from '../../../../../shared/task-source-context'
+import {
+ getWorkItemDetailsCacheKey,
+ invalidateWorkItemDetailsCacheByMatch,
+ touchWorkItemDetailsCache,
+ workItemDetailsCache
+} from './work-item-details-cache'
+
+vi.mock('@/lib/github-work-item-details-cache-events', () => ({
+ onGitHubWorkItemDetailsCacheMutation: vi.fn()
+}))
+
+const keyArgs = {
+ repoId: 'repo-1',
+ repoPath: '/home/fixture/widgets',
+ type: 'issue' as const,
+ number: 12,
+ issueSourcePreference: 'origin'
+}
+const ORIGIN = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' }
+const UPSTREAM = { owner: 'upstream-owner', repo: 'widgets', host: 'github.com' }
+const LOCAL_SOURCE: TaskSourceContext = {
+ kind: 'task-source',
+ provider: 'github',
+ projectId: 'project-1',
+ hostId: 'local',
+ repoId: 'repo-1'
+}
+
+describe('issue detail cache repository identity', () => {
+ it('separates equal issue numbers across origin and upstream', () => {
+ expect(getWorkItemDetailsCacheKey({ ...keyArgs, ownerRepo: ORIGIN })).not.toBe(
+ getWorkItemDetailsCacheKey({ ...keyArgs, ownerRepo: UPSTREAM })
+ )
+ })
+
+ it.each([undefined, LOCAL_SOURCE])(
+ 'keeps a local opened issue key stable when another window changes the selector: %j',
+ (sourceContext) => {
+ expect(getWorkItemDetailsCacheKey({ ...keyArgs, ownerRepo: ORIGIN, sourceContext })).toBe(
+ getWorkItemDetailsCacheKey({
+ ...keyArgs,
+ issueSourcePreference: 'upstream',
+ ownerRepo: ORIGIN,
+ sourceContext
+ })
+ )
+ }
+ )
+
+ it('keeps RPC detail caches scoped to the preference used by their existing lookup', () => {
+ const sourceContext: TaskSourceContext = {
+ kind: 'task-source',
+ provider: 'github',
+ projectId: 'project-1',
+ hostId: 'runtime:env-1',
+ repoId: 'runtime-repo'
+ }
+ const args = { ...keyArgs, ownerRepo: ORIGIN, sourceContext }
+
+ expect(getWorkItemDetailsCacheKey(args)).not.toBe(
+ getWorkItemDetailsCacheKey({ ...args, issueSourcePreference: 'upstream' })
+ )
+ expect(getWorkItemDetailsCacheKey(args)).toBe(
+ getWorkItemDetailsCacheKey({ ...args, ownerRepo: null })
+ )
+ })
+
+ it('invalidates both repository variants after a mutation', () => {
+ const originKey = getWorkItemDetailsCacheKey({ ...keyArgs, ownerRepo: ORIGIN })
+ const upstreamKey = getWorkItemDetailsCacheKey({ ...keyArgs, ownerRepo: UPSTREAM })
+ touchWorkItemDetailsCache(originKey, { details: null, fetchedAt: 0 })
+ touchWorkItemDetailsCache(upstreamKey, { details: null, fetchedAt: 0 })
+
+ invalidateWorkItemDetailsCacheByMatch(keyArgs)
+
+ expect(workItemDetailsCache.has(originKey)).toBe(false)
+ expect(workItemDetailsCache.has(upstreamKey)).toBe(false)
+ })
+})
diff --git a/src/renderer/src/components/github/PRAssigneesPanel.tsx b/src/renderer/src/components/github/PRAssigneesPanel.tsx
index d759d93880a..916c9f5d413 100644
--- a/src/renderer/src/components/github/PRAssigneesPanel.tsx
+++ b/src/renderer/src/components/github/PRAssigneesPanel.tsx
@@ -11,6 +11,7 @@ import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata'
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
import {
parseOwnerRepoFromItemUrl,
+ resolvePullRequestRepo,
type GitHubWorkItemProjectOrigin
} from '@/components/github/github-work-item-identity'
import { runIssueUpdate } from '@/components/github/github-work-item-edit-mutations'
@@ -82,6 +83,7 @@ export function PRAssigneesPanel({
)
const assigneeLogins = useMemo(() => localAssignees.map((user) => user.login), [localAssignees])
const assigneeSlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url])
+ const prRepo = useMemo(() => resolvePullRequestRepo(item, projectOrigin), [item, projectOrigin])
const slugOwner = projectOrigin?.owner ?? assigneeSlug?.owner ?? null
const slugRepo = projectOrigin?.repo ?? assigneeSlug?.repo ?? null
const repoAssigneesBySlug = useRepoAssigneesBySlug(
@@ -118,17 +120,24 @@ export function PRAssigneesPanel({
repoPath,
sourceContext,
projectOrigin,
+ issueRepo: prRepo,
number: item.number,
updates: isAssigned ? { removeAssignees: [login] } : { addAssignees: [login] }
}),
onOptimistic: () => {
setLocalAssignees(nextAssignees)
- patchWorkItem(item.id, { assignees: nextAssignees }, item.repoId, { sourceContext })
+ patchWorkItem(item.id, { assignees: nextAssignees }, item.repoId, {
+ sourceContext,
+ ownerRepo: prRepo
+ })
patchProjectRowIfNeeded(nextLogins)
},
onRevert: () => {
setLocalAssignees(prevAssignees)
- patchWorkItem(item.id, { assignees: prevAssignees }, item.repoId, { sourceContext })
+ patchWorkItem(item.id, { assignees: prevAssignees }, item.repoId, {
+ sourceContext,
+ ownerRepo: prRepo
+ })
patchProjectRowIfNeeded(prevLogins)
},
onSuccess: () => {
@@ -147,6 +156,7 @@ export function PRAssigneesPanel({
onMutated,
patchProjectRowIfNeeded,
patchWorkItem,
+ prRepo,
projectOrigin,
repoPath,
run,
diff --git a/src/renderer/src/components/github/github-work-item-comment-mutations.ts b/src/renderer/src/components/github/github-work-item-comment-mutations.ts
index f0f6fda3b82..6c7fc7e99ac 100644
--- a/src/renderer/src/components/github/github-work-item-comment-mutations.ts
+++ b/src/renderer/src/components/github/github-work-item-comment-mutations.ts
@@ -25,6 +25,7 @@ export function addIssueCommentForRepo(args: {
repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId),
number: args.number,
body: args.body,
+ ...(args.type ? { type: args.type } : {}),
prRepo: args.prRepo ?? null
},
{ timeoutMs: 30_000 }
diff --git a/src/renderer/src/components/github/github-work-item-edit-mutations.ts b/src/renderer/src/components/github/github-work-item-edit-mutations.ts
index 5c40aeb2328..543fb2395e8 100644
--- a/src/renderer/src/components/github/github-work-item-edit-mutations.ts
+++ b/src/renderer/src/components/github/github-work-item-edit-mutations.ts
@@ -29,19 +29,21 @@ export async function runIssueUpdate(args: {
repoId?: string | null
sourceContext?: TaskSourceContext | null
projectOrigin: GitHubWorkItemProjectOrigin | undefined
+ issueRepo?: GitHubOwnerRepo | null
number: number
updates: Parameters[0]['updates']
}): Promise {
- if (args.projectOrigin) {
+ const issueRepo = args.projectOrigin
+ if (issueRepo) {
const targetSettings =
args.sourceContext?.provider === 'github'
? getTaskSourceRuntimeSettings(args.sourceContext)
: getGitHubMutationSettings(args.repoId)
const target = getActiveRuntimeTarget(targetSettings)
const updateArgs = {
- owner: args.projectOrigin.owner,
- repo: args.projectOrigin.repo,
- host: githubProjectHost(args.projectOrigin.host),
+ owner: issueRepo.owner,
+ repo: issueRepo.repo,
+ host: githubProjectHost(issueRepo.host),
number: args.number,
updates: args.updates
}
@@ -59,18 +61,16 @@ export async function runIssueUpdate(args: {
if (!res.ok) {
throw new Error(res.error.message)
}
- if (target.kind === 'environment') {
- notifyWorkItemDetailsMutation(
- {
- repoPath: args.repoPath ?? '',
- repoId: args.repoId ?? undefined,
- sourceContext: args.sourceContext,
- type: 'issue',
- number: args.number
- },
- { local: false }
- )
- }
+ notifyWorkItemDetailsMutation(
+ {
+ repoPath: args.repoPath ?? '',
+ repoId: args.repoId ?? undefined,
+ sourceContext: args.sourceContext,
+ type: 'issue',
+ number: args.number
+ },
+ { local: target.kind !== 'environment' }
+ )
return
}
const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext)
@@ -93,7 +93,8 @@ export async function runIssueUpdate(args: {
repoId: args.repoId ?? undefined,
sourceContext: args.sourceContext,
number: args.number,
- updates: args.updates
+ updates: args.updates,
+ ...(args.issueRepo ? { ownerRepo: args.issueRepo } : {})
})
if (!res.ok) {
throw new Error(res.error)
@@ -177,6 +178,7 @@ export async function runWorkItemBodyUpdate(args: {
repoId: args.item.repoId,
sourceContext: args.sourceContext,
projectOrigin: args.projectOrigin,
+ issueRepo: args.parsedSlug,
number: args.item.number,
updates: { body: args.body }
})
diff --git a/src/renderer/src/components/github/github-work-item-issue-target.test.ts b/src/renderer/src/components/github/github-work-item-issue-target.test.ts
new file mode 100644
index 00000000000..88056a25cbb
--- /dev/null
+++ b/src/renderer/src/components/github/github-work-item-issue-target.test.ts
@@ -0,0 +1,172 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { runIssueUpdate, runWorkItemBodyUpdate } from './github-work-item-edit-mutations'
+import type { GitHubWorkItem } from '../../../../shared/github/work-item-types'
+import type { TaskSourceContext } from '../../../../shared/task-source-context'
+import { runGHEditLabelToggle } from '../github-item-dialog/edit-item-fields/gh-edit-section-mutations'
+
+vi.mock('@/store', () => ({ useAppStore: { getState: vi.fn() } }))
+vi.mock('@/components/github/github-work-item-comment-mutations', () => ({
+ notifyWorkItemDetailsMutation: vi.fn()
+}))
+
+const ORIGIN = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' }
+const UPSTREAM = { owner: 'upstream-owner', repo: 'widgets', host: 'github.com' }
+const projectOrigin = {
+ ...UPSTREAM,
+ number: 12,
+ type: 'issue' as const,
+ projectId: 'project-1',
+ projectItemId: 'project-item-12',
+ cacheKey: 'project-cache'
+}
+const localSource: TaskSourceContext = {
+ kind: 'task-source',
+ provider: 'github',
+ projectId: 'project-1',
+ hostId: 'local',
+ repoId: 'repo-1'
+}
+const item: GitHubWorkItem = {
+ id: 'issue:12',
+ type: 'issue',
+ number: 12,
+ title: 'Origin issue',
+ state: 'open',
+ url: 'https://github.com/fork-owner/widgets/issues/12',
+ labels: [],
+ updatedAt: '',
+ author: null,
+ repoId: 'repo-1'
+}
+
+describe('issue edits retain the displayed repository', () => {
+ beforeEach(() => {
+ vi.stubGlobal('window', {
+ api: {
+ gh: { updateIssue: vi.fn().mockResolvedValue({ ok: true }), updateIssueBySlug: vi.fn() }
+ }
+ })
+ })
+
+ it('keeps body edits on the registered repo path and passes the opened issue target', async () => {
+ await runWorkItemBodyUpdate({
+ item,
+ repoPath: '/home/fixture/widgets',
+ projectOrigin: undefined,
+ body: 'Origin edit',
+ parsedSlug: ORIGIN
+ })
+
+ expect(window.api.gh.updateIssue).toHaveBeenCalledWith({
+ repoId: 'repo-1',
+ repoPath: '/home/fixture/widgets',
+ sourceContext: undefined,
+ number: 12,
+ updates: { body: 'Origin edit' },
+ ownerRepo: ORIGIN
+ })
+ expect(window.api.gh.updateIssueBySlug).not.toHaveBeenCalled()
+ })
+
+ it('saves a Project row body in its upstream repository instead of the fork workspace', async () => {
+ vi.mocked(window.api.gh.updateIssueBySlug).mockResolvedValue({ ok: true })
+
+ await runWorkItemBodyUpdate({
+ item: { ...item, url: 'https://github.com/upstream-owner/widgets/issues/12' },
+ repoPath: '/home/fixture/fork-widgets',
+ sourceContext: localSource,
+ projectOrigin,
+ body: 'Project edit',
+ parsedSlug: UPSTREAM
+ })
+
+ expect(window.api.gh.updateIssueBySlug).toHaveBeenCalledWith({
+ ...UPSTREAM,
+ number: 12,
+ updates: { body: 'Project edit' }
+ })
+ expect(window.api.gh.updateIssue).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ { state: 'closed' as const },
+ { addLabels: ['bug'] },
+ { addAssignees: ['upstream-assignee'] }
+ ])('keeps Project field edits in the row repository: %j', async (updates) => {
+ vi.mocked(window.api.gh.updateIssueBySlug).mockResolvedValue({ ok: true })
+
+ await runIssueUpdate({
+ repoPath: '/home/fixture/fork-widgets',
+ repoId: item.repoId,
+ sourceContext: localSource,
+ projectOrigin,
+ issueRepo: UPSTREAM,
+ number: 12,
+ updates
+ })
+
+ expect(window.api.gh.updateIssueBySlug).toHaveBeenCalledWith({
+ ...UPSTREAM,
+ number: 12,
+ updates
+ })
+ expect(window.api.gh.updateIssue).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ { state: 'closed' as const },
+ { addLabels: ['bug'] },
+ { addAssignees: ['fork-assignee'] }
+ ])('pins field edits to the opened issue repository: %j', async (updates) => {
+ await runIssueUpdate({
+ repoPath: 'C:\\workspace\\widgets',
+ repoId: 'repo-1',
+ projectOrigin: undefined,
+ number: 12,
+ issueRepo: ORIGIN,
+ updates
+ })
+
+ expect(window.api.gh.updateIssue).toHaveBeenCalledWith(
+ expect.objectContaining({
+ repoPath: 'C:\\workspace\\widgets',
+ ownerRepo: ORIGIN,
+ updates
+ })
+ )
+ expect(window.api.gh.updateIssueBySlug).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ { localLabels: [], updates: { addLabels: ['bug'] } },
+ { localLabels: ['bug'], updates: { removeLabels: ['bug'] } }
+ ])(
+ 'retains the target for both label toggle directions: %j',
+ async ({ localLabels, updates }) => {
+ let mutation: Promise = Promise.resolve()
+ runGHEditLabelToggle({
+ itemId: item.id,
+ itemNumber: item.number,
+ itemRepoId: item.repoId,
+ repoPath: 'C:\\workspace\\widgets',
+ projectOrigin: undefined,
+ issueRepo: ORIGIN,
+ label: 'bug',
+ localLabels,
+ run: async (_key, options) => {
+ mutation = options.mutate()
+ await mutation
+ },
+ onLabelsChange: vi.fn(),
+ patchWorkItem: vi.fn(),
+ patchProjectRowIfNeeded: vi.fn(),
+ onMutated: vi.fn()
+ })
+ await mutation
+
+ expect(window.api.gh.updateIssue).toHaveBeenCalledWith(
+ expect.objectContaining({ ownerRepo: ORIGIN, updates })
+ )
+ }
+ )
+})
diff --git a/src/renderer/src/components/task-page-cache-selectors.test.ts b/src/renderer/src/components/task-page-cache-selectors.test.ts
index 44777236862..ef1a0e20efe 100644
--- a/src/renderer/src/components/task-page-cache-selectors.test.ts
+++ b/src/renderer/src/components/task-page-cache-selectors.test.ts
@@ -204,8 +204,35 @@ describe('task page cache selectors', () => {
}
expect(findTaskPageDialogWorkItem(cache, null)).toBeNull()
- expect(findTaskPageDialogWorkItem(cache, { id: 'issue-1', repoId: 'repo-1' })).toBe(item)
- expect(findTaskPageDialogWorkItem(cache, { id: 'issue-1', repoId: 'repo-2' })).toBeNull()
+ expect(
+ findTaskPageDialogWorkItem(cache, { id: 'issue-1', repoId: 'repo-1', url: item.url })
+ ).toBe(item)
+ expect(
+ findTaskPageDialogWorkItem(cache, { id: 'issue-1', repoId: 'repo-2', url: item.url })
+ ).toBeNull()
+ })
+
+ it('keeps the clicked repository when a source change refreshes the same issue number', () => {
+ const origin = {
+ ...workItem('issue:12', 'repo-1'),
+ url: 'https://github.com/fork-owner/widgets/issues/12'
+ }
+ const upstream = {
+ ...origin,
+ url: 'https://github.com/upstream-owner/widgets/issues/12'
+ }
+ const clickedIssue = { id: origin.id, repoId: origin.repoId, url: origin.url }
+ const upstreamCache = {
+ upstream: entry([upstream])
+ }
+
+ expect(findTaskPageDialogWorkItem(upstreamCache, clickedIssue)).toBeNull()
+ expect(
+ findTaskPageDialogWorkItem(
+ { ...upstreamCache, origin: entry([origin]) },
+ clickedIssue
+ )
+ ).toBe(origin)
})
it('reconciles paged table rows with patched work-item cache entries', () => {
diff --git a/src/renderer/src/components/task-page-cache-selectors.ts b/src/renderer/src/components/task-page-cache-selectors.ts
index a563dcae7b1..7cab1b34ece 100644
--- a/src/renderer/src/components/task-page-cache-selectors.ts
+++ b/src/renderer/src/components/task-page-cache-selectors.ts
@@ -28,6 +28,7 @@ export type TaskPageRepoCacheInput = {
export type TaskPageDialogWorkItemKey = {
id: string
repoId: string
+ url: string
} | null
export type TaskPageRepoSourceState = {
@@ -240,7 +241,10 @@ export function findTaskPageDialogWorkItem(
for (const entry of Object.values(workItemsCache)) {
const found = entry?.data?.find(
- (wi) => wi.id === dialogWorkItemKey.id && wi.repoId === dialogWorkItemKey.repoId
+ (wi) =>
+ wi.id === dialogWorkItemKey.id &&
+ wi.repoId === dialogWorkItemKey.repoId &&
+ wi.url === dialogWorkItemKey.url
)
if (found) {
return found
diff --git a/src/renderer/src/components/task-page-github-confirmed-client-values.ts b/src/renderer/src/components/task-page-github-confirmed-client-values.ts
new file mode 100644
index 00000000000..8b67b25d260
--- /dev/null
+++ b/src/renderer/src/components/task-page-github-confirmed-client-values.ts
@@ -0,0 +1,61 @@
+import type { GitHubOwnerRepo } from '../../../shared/github/pull-request-types'
+import { githubRepoIdentityKey } from '../../../shared/github/repository-identity-key'
+import { taskPageGitHubLastConfirmedKey } from './task-page-github-work-item-mutation-keys'
+
+const lastConfirmedClientValues = new Map<
+ string,
+ { value: unknown; ownerRepo?: GitHubOwnerRepo | null }
+>()
+
+export function getLastConfirmedClientValue(
+ sourceScope: string | null,
+ repoId: string,
+ itemId: string,
+ family: string,
+ ownerRepo?: GitHubOwnerRepo | null
+): unknown {
+ const entry = lastConfirmedClientValues.get(
+ taskPageGitHubLastConfirmedKey(sourceScope, repoId, itemId, family)
+ )
+ if (
+ ownerRepo !== undefined &&
+ entry?.ownerRepo &&
+ (!ownerRepo || githubRepoIdentityKey(ownerRepo) !== githubRepoIdentityKey(entry.ownerRepo))
+ ) {
+ return undefined
+ }
+ return entry?.value
+}
+export function setLastConfirmedClientValue(
+ sourceScope: string | null,
+ repoId: string,
+ itemId: string,
+ family: string,
+ value: unknown,
+ ownerRepo?: GitHubOwnerRepo | null
+): void {
+ lastConfirmedClientValues.set(
+ taskPageGitHubLastConfirmedKey(sourceScope, repoId, itemId, family),
+ { value, ownerRepo }
+ )
+}
+export function deleteLastConfirmedClientValue(
+ sourceScope: string | null,
+ repoId: string,
+ itemId: string,
+ family: string,
+ ownerRepo?: GitHubOwnerRepo | null
+): void {
+ if (
+ ownerRepo !== undefined &&
+ getLastConfirmedClientValue(sourceScope, repoId, itemId, family, ownerRepo) === undefined
+ ) {
+ return
+ }
+ lastConfirmedClientValues.delete(
+ taskPageGitHubLastConfirmedKey(sourceScope, repoId, itemId, family)
+ )
+}
+export function clearLastConfirmedClientValues(): void {
+ lastConfirmedClientValues.clear()
+}
diff --git a/src/renderer/src/components/task-page-github-dialog-state-authority.test.ts b/src/renderer/src/components/task-page-github-dialog-state-authority.test.ts
index b0bde81eb82..664b8bf7409 100644
--- a/src/renderer/src/components/task-page-github-dialog-state-authority.test.ts
+++ b/src/renderer/src/components/task-page-github-dialog-state-authority.test.ts
@@ -184,4 +184,69 @@ describe('dialog state authority (STA-3343)', () => {
expect(superseded.revert()).toBe(false)
expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state')).toBe('merged')
})
+
+ it('holds qualified state only on its canonical repository and releases it on matching search', () => {
+ const ownerRepo = { owner: 'o', repo: 'r', host: 'github.com' }
+ setTaskPageGitHubMutationQueryKey('q')
+ assertTaskPageGitHubDialogStateAuthority({
+ repoId: 'repo-1',
+ itemId: 'issue:1',
+ state: 'closed',
+ ownerRepo
+ })
+ const other = item({ url: 'https://github.com/upstream/r/issues/1' })
+ const enterprise = item({ url: 'https://ghe.example/o/r/issues/1' })
+ expect(
+ applyPendingTaskPageGitHubMutationsToItems([item(), other, enterprise]).map(
+ (row) => row.state
+ )
+ ).toEqual(['closed', 'open', 'open'])
+ adoptQuietSearchFieldsForItem({
+ item: other,
+ serverItem: { ...other, state: 'closed' },
+ sourceScope: null,
+ queryKey: 'q',
+ fetchStartedAtGeneration: getOrCreateQuietRevalidateState('q').dirtyGeneration,
+ patchWorkItem: () => {}
+ })
+ expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state', ownerRepo)).toBe(
+ 'closed'
+ )
+ adoptQuietSearchFieldsForItem({
+ item: item(),
+ serverItem: item({ state: 'closed' }),
+ sourceScope: null,
+ queryKey: 'q',
+ fetchStartedAtGeneration: getOrCreateQuietRevalidateState('q').dirtyGeneration,
+ patchWorkItem: () => {}
+ })
+ expect(
+ getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state', ownerRepo)
+ ).toBeUndefined()
+ expect(applyPendingTaskPageGitHubMutationsToItems([item()])[0]?.state).toBe('open')
+ })
+
+ it('keeps another repository confirmation when an older qualified edit rolls back', () => {
+ const forkRepo = { owner: 'o', repo: 'r', host: 'github.com' }
+ const upstreamRepo = { ...forkRepo, owner: 'upstream' }
+ const forkAuthority = assertTaskPageGitHubDialogStateAuthority({
+ repoId: 'repo-1',
+ itemId: 'issue:1',
+ state: 'closed',
+ ownerRepo: forkRepo
+ })
+ assertTaskPageGitHubDialogStateAuthority({
+ repoId: 'repo-1',
+ itemId: 'issue:1',
+ state: 'closed',
+ ownerRepo: upstreamRepo
+ })
+ expect(forkAuthority.revert()).toBe(false)
+ expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state', upstreamRepo)).toBe(
+ 'closed'
+ )
+ expect(
+ getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state', forkRepo)
+ ).toBeUndefined()
+ })
})
diff --git a/src/renderer/src/components/task-page-github-dialog-state-authority.ts b/src/renderer/src/components/task-page-github-dialog-state-authority.ts
index 2918e1eb4d0..55bb62aedf1 100644
--- a/src/renderer/src/components/task-page-github-dialog-state-authority.ts
+++ b/src/renderer/src/components/task-page-github-dialog-state-authority.ts
@@ -1,4 +1,5 @@
import type { GitHubWorkItem } from '../../../shared/github/work-item-types'
+import type { GitHubOwnerRepo } from '../../../shared/github/pull-request-types'
import {
getTaskSourceCacheScope,
type TaskSourceContext
@@ -32,24 +33,57 @@ export function assertTaskPageGitHubDialogStateAuthority(args: {
itemId: string
state: GitHubWorkItem['state']
sourceContext?: TaskSourceContext | null
+ ownerRepo?: GitHubOwnerRepo | null
}): { revert: () => boolean } {
const sourceScope =
args.sourceContext?.provider === 'github' ? getTaskSourceCacheScope(args.sourceContext) : null
- const previous = getLastConfirmedClientValue(sourceScope, args.repoId, args.itemId, 'state')
- setLastConfirmedClientValue(sourceScope, args.repoId, args.itemId, 'state', args.state)
+ const previous = getLastConfirmedClientValue(
+ sourceScope,
+ args.repoId,
+ args.itemId,
+ 'state',
+ args.ownerRepo
+ )
+ setLastConfirmedClientValue(
+ sourceScope,
+ args.repoId,
+ args.itemId,
+ 'state',
+ args.state,
+ args.ownerRepo
+ )
markStateFamilyDirty(args.repoId, args.itemId)
notifyTaskPageGitHubMutationRegistry()
return {
revert: () => {
- const current = getLastConfirmedClientValue(sourceScope, args.repoId, args.itemId, 'state')
+ const current = getLastConfirmedClientValue(
+ sourceScope,
+ args.repoId,
+ args.itemId,
+ 'state',
+ args.ownerRepo
+ )
// A matching search adopt or newer mutation owns the state now.
if (current !== args.state) {
return false
}
if (previous === undefined) {
- deleteLastConfirmedClientValue(sourceScope, args.repoId, args.itemId, 'state')
+ deleteLastConfirmedClientValue(
+ sourceScope,
+ args.repoId,
+ args.itemId,
+ 'state',
+ args.ownerRepo
+ )
} else {
- setLastConfirmedClientValue(sourceScope, args.repoId, args.itemId, 'state', previous)
+ setLastConfirmedClientValue(
+ sourceScope,
+ args.repoId,
+ args.itemId,
+ 'state',
+ previous,
+ args.ownerRepo
+ )
}
markStateFamilyDirty(args.repoId, args.itemId)
notifyTaskPageGitHubMutationRegistry()
diff --git a/src/renderer/src/components/task-page-github-work-item-mutation-composition.ts b/src/renderer/src/components/task-page-github-work-item-mutation-composition.ts
index 6e98c29cb9b..d7387e6cfe4 100644
--- a/src/renderer/src/components/task-page-github-work-item-mutation-composition.ts
+++ b/src/renderer/src/components/task-page-github-work-item-mutation-composition.ts
@@ -1,6 +1,7 @@
import type { ParsedTaskQuery } from '../../../shared/task-query'
import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types'
import type { GitHubWorkItem } from '../../../shared/github/work-item-types'
+import { parseGitHubIssueOrPRLink } from '../../../shared/github/links'
import {
recomputeTaskPageGitHubItemSoftHide,
shouldSoftHideTaskPageGitHubWorkItem
@@ -98,7 +99,13 @@ export function getRegistryMergedTaskPageGitHubWorkItem(
// Why: after confirm, pending is cleared but search may still lag — hold the
// last confirmed whole-field values until a matching adopt or newer pending.
- const lastState = getLastConfirmedClientValue(sourceScope, item.repoId, item.id, 'state')
+ const lastState = getLastConfirmedClientValue(
+ sourceScope,
+ item.repoId,
+ item.id,
+ 'state',
+ parseGitHubIssueOrPRLink(item.url)?.slug ?? null
+ )
if (typeof lastState === 'string') {
merged = { ...merged, state: lastState as GitHubWorkItem['state'] }
}
diff --git a/src/renderer/src/components/task-page-github-work-item-mutation-pages.ts b/src/renderer/src/components/task-page-github-work-item-mutation-pages.ts
index b8e0ff01c83..1c50e9c547e 100644
--- a/src/renderer/src/components/task-page-github-work-item-mutation-pages.ts
+++ b/src/renderer/src/components/task-page-github-work-item-mutation-pages.ts
@@ -1,5 +1,6 @@
import type { GitHubWorkItem } from '../../../shared/github/work-item-types'
import type { TaskSourceContext } from '../../../shared/task-source-context'
+import { parseGitHubIssueOrPRLink } from '../../../shared/github/links'
import { getRegistryMergedTaskPageGitHubWorkItem } from './task-page-github-work-item-mutation-composition'
import {
getStickyHideEntry,
@@ -74,7 +75,10 @@ export function reapplyPendingTaskPageGitHubMutationsToCache(args: {
autoMergeEnabled: merged.autoMergeEnabled
},
item.repoId,
- { sourceContext: args.sourceContextByRepoId?.get(item.repoId) }
+ {
+ sourceContext: args.sourceContextByRepoId?.get(item.repoId),
+ ownerRepo: parseGitHubIssueOrPRLink(item.url)?.slug
+ }
)
}
}
diff --git a/src/renderer/src/components/task-page-github-work-item-mutation-registry.ts b/src/renderer/src/components/task-page-github-work-item-mutation-registry.ts
index 6988ff7f7b3..9fdf7760e19 100644
--- a/src/renderer/src/components/task-page-github-work-item-mutation-registry.ts
+++ b/src/renderer/src/components/task-page-github-work-item-mutation-registry.ts
@@ -1,4 +1,17 @@
-import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types'
+import {
+ clearLastConfirmedClientValues,
+ deleteLastConfirmedClientValue,
+ getLastConfirmedClientValue,
+ setLastConfirmedClientValue as storeLastConfirmedClientValue
+} from './task-page-github-confirmed-client-values'
+export {
+ deleteLastConfirmedClientValue,
+ getLastConfirmedClientValue
+} from './task-page-github-confirmed-client-values'
+import type {
+ GitHubAssignableUser,
+ GitHubOwnerRepo
+} from '../../../shared/github/pull-request-types'
import type {
PendingOp,
StickyHideEntry,
@@ -15,7 +28,6 @@ export type {
import {
serializeTaskPageGitHubMutationKey,
taskPageGitHubItemKey,
- taskPageGitHubLastConfirmedKey,
taskPageGitHubSnapshotKey
} from './task-page-github-work-item-mutation-keys'
import { clearTaskPageGitHubQuietStates } from './task-page-github-work-item-quiet-state'
@@ -37,7 +49,6 @@ const listeners = new Set()
const pendingByKey = new Map()
const generations = new Map()
const confirmedSnapshots = new Map()
-const lastConfirmedClientValues = new Map()
/**
* Why: after confirm, pending ops are gone but lastConfirmed/snapshots stay keyed
* by sourceScope. Overlay must still resolve the same scope or authority is lost.
@@ -79,7 +90,7 @@ export function getTaskPageGitHubConfirmedAuthorityItemKeys(): ReadonlySet,
repoId?: string,
- options?: { sourceContext?: TaskSourceContext | null }
+ options?: GitHubPatchWorkItemOptions
) => void
export type BeginTaskPageGitHubWorkItemMutationArgs = {
diff --git a/src/renderer/src/components/task-page-github-work-item-quiet-adopt.ts b/src/renderer/src/components/task-page-github-work-item-quiet-adopt.ts
index 5547986ab2e..769948311d0 100644
--- a/src/renderer/src/components/task-page-github-work-item-quiet-adopt.ts
+++ b/src/renderer/src/components/task-page-github-work-item-quiet-adopt.ts
@@ -1,5 +1,6 @@
import type { GitHubWorkItem } from '../../../shared/github/work-item-types'
import type { TaskSourceContext } from '../../../shared/task-source-context'
+import { parseGitHubIssueOrPRLink } from '../../../shared/github/links'
import { loginSetOfUsers, loginSetsEqual } from './task-page-github-work-item-mutation-patches'
import {
familiesFromPendingOp,
@@ -52,6 +53,7 @@ export function adoptQuietSearchFieldsForItem(args: {
}): { needTrailing: boolean } {
const state = getOrCreateQuietRevalidateState(args.queryKey)
const itemKey = taskPageGitHubItemKey(args.item.repoId, args.item.id)
+ const ownerRepo = parseGitHubIssueOrPRLink(args.item.url)?.slug ?? null
let needTrailing = false
const G0 = args.fetchStartedAtGeneration
const tryFamily = (
@@ -91,7 +93,8 @@ export function adoptQuietSearchFieldsForItem(args: {
'state',
() => {
args.patchWorkItem(args.item.id, { state: args.serverItem.state }, args.item.repoId, {
- sourceContext: args.sourceContext
+ sourceContext: args.sourceContext,
+ ownerRepo
})
},
() => {
@@ -99,14 +102,27 @@ export function adoptQuietSearchFieldsForItem(args: {
args.sourceScope,
args.item.repoId,
args.item.id,
- 'state'
+ 'state',
+ ownerRepo
)
return last === undefined || args.serverItem.state === last
},
() =>
- getLastConfirmedClientValue(args.sourceScope, args.item.repoId, args.item.id, 'state') !==
- undefined,
- () => deleteLastConfirmedClientValue(args.sourceScope, args.item.repoId, args.item.id, 'state')
+ getLastConfirmedClientValue(
+ args.sourceScope,
+ args.item.repoId,
+ args.item.id,
+ 'state',
+ ownerRepo
+ ) !== undefined,
+ () =>
+ deleteLastConfirmedClientValue(
+ args.sourceScope,
+ args.item.repoId,
+ args.item.id,
+ 'state',
+ ownerRepo
+ )
)
tryFamily(
'autoMerge',
@@ -115,7 +131,7 @@ export function adoptQuietSearchFieldsForItem(args: {
args.item.id,
{ autoMergeEnabled: args.serverItem.autoMergeEnabled },
args.item.repoId,
- { sourceContext: args.sourceContext }
+ { sourceContext: args.sourceContext, ownerRepo }
)
},
() => {
@@ -147,7 +163,7 @@ export function adoptQuietSearchFieldsForItem(args: {
args.item.id,
family === 'assignees' ? { assignees: serverList } : { reviewRequests: serverList },
args.item.repoId,
- { sourceContext: args.sourceContext }
+ { sourceContext: args.sourceContext, ownerRepo }
)
},
() => {
diff --git a/src/renderer/src/components/use-task-page-github-detail.ts b/src/renderer/src/components/use-task-page-github-detail.ts
index b2c61f64bde..655accb61d7 100644
--- a/src/renderer/src/components/use-task-page-github-detail.ts
+++ b/src/renderer/src/components/use-task-page-github-detail.ts
@@ -49,7 +49,8 @@ export function useTaskPageGitHubDetail(model: TaskPageGitHubListStateModel) {
const dialogWorkItemKey = githubTaskDrawerWorkItem
? {
id: githubTaskDrawerWorkItem.id,
- repoId: githubTaskDrawerWorkItem.repoId
+ repoId: githubTaskDrawerWorkItem.repoId,
+ url: githubTaskDrawerWorkItem.url
}
: null
const appliedWorkItemsCacheQuery = useMemo(
@@ -67,7 +68,7 @@ export function useTaskPageGitHubDetail(model: TaskPageGitHubListStateModel) {
)
)
- // Why: derive the dialog item from the cache for optimistic patches, falling back to the click-time snapshot for new stubs; key by repoId so same-number issues across repos resolve to the clicked row.
+ // Keep cache patches in the clicked conversation when origin and upstream share an issue number.
const cachedDialogWorkItem = useAppStore((s) =>
findTaskPageDialogWorkItem(s.workItemsCache, dialogWorkItemKey)
)
diff --git a/src/renderer/src/hooks/useGitHubRepoMetadata.ts b/src/renderer/src/hooks/useGitHubRepoMetadata.ts
new file mode 100644
index 00000000000..8cdb20376bc
--- /dev/null
+++ b/src/renderer/src/hooks/useGitHubRepoMetadata.ts
@@ -0,0 +1,95 @@
+import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
+import type {
+ GitHubAssignableUser,
+ GitHubOwnerRepo
+} from '../../../shared/github/pull-request-types'
+import { githubRepoIdentityKey } from '../../../shared/github/repository-identity-key'
+import { createMetadataRequestStore } from './metadata-request-cache'
+import { useMetadataListRequest, type MetadataListState } from './useMetadataListRequest'
+
+type GitHubMetadataOptions = {
+ runtimeEnvironmentId?: string | null
+ activeRuntimeEnvironmentId?: string | null
+ ownerRepo?: GitHubOwnerRepo | null
+}
+
+const ghLabelStore = createMetadataRequestStore()
+const ghAssigneeStore = createMetadataRequestStore()
+
+export function useRepoLabels(
+ repoPath: string | null,
+ repoId?: string | null,
+ options?: GitHubMetadataOptions
+): MetadataListState {
+ const runtimeEnvironmentId =
+ options?.runtimeEnvironmentId?.trim() || options?.activeRuntimeEnvironmentId?.trim() || null
+ const repoSelector = repoId ?? repoPath ?? ''
+ const ownerRepo = runtimeEnvironmentId ? null : options?.ownerRepo
+ const repositoryKey = ownerRepo
+ ? `${repoSelector}::${githubRepoIdentityKey(ownerRepo)}`
+ : repoSelector
+ const cacheKey =
+ repoPath || repoId
+ ? runtimeEnvironmentId
+ ? `runtime:${runtimeEnvironmentId}:${repoSelector}`
+ : repositoryKey
+ : null
+
+ return useMetadataListRequest({
+ cacheKey,
+ store: ghLabelStore,
+ errorFallback: 'Failed to load labels',
+ load: () =>
+ runtimeEnvironmentId
+ ? callRuntimeRpc(
+ { kind: 'environment', environmentId: runtimeEnvironmentId },
+ 'github.listLabels',
+ { repo: repoSelector },
+ { timeoutMs: 15_000 }
+ )
+ : window.api.gh.listLabels({
+ repoPath: repoPath ?? '',
+ repoId: repoId ?? undefined,
+ ...(ownerRepo ? { ownerRepo } : {})
+ })
+ })
+}
+
+export function useRepoAssignees(
+ repoPath: string | null,
+ repoId?: string | null,
+ options?: GitHubMetadataOptions
+): MetadataListState {
+ const runtimeEnvironmentId =
+ options?.runtimeEnvironmentId?.trim() || options?.activeRuntimeEnvironmentId?.trim() || null
+ const repoSelector = repoId ?? repoPath ?? ''
+ const ownerRepo = runtimeEnvironmentId ? null : options?.ownerRepo
+ const repositoryKey = ownerRepo
+ ? `${repoSelector}::${githubRepoIdentityKey(ownerRepo)}`
+ : repoSelector
+ const cacheKey =
+ repoPath || repoId
+ ? runtimeEnvironmentId
+ ? `runtime:${runtimeEnvironmentId}:${repoSelector}`
+ : repositoryKey
+ : null
+
+ return useMetadataListRequest({
+ cacheKey,
+ store: ghAssigneeStore,
+ errorFallback: 'Failed to load assignees',
+ load: () =>
+ runtimeEnvironmentId
+ ? callRuntimeRpc(
+ { kind: 'environment', environmentId: runtimeEnvironmentId },
+ 'github.listAssignableUsers',
+ { repo: repoSelector },
+ { timeoutMs: 15_000 }
+ )
+ : window.api.gh.listAssignableUsers({
+ repoPath: repoPath ?? '',
+ repoId: repoId ?? undefined,
+ ...(ownerRepo ? { ownerRepo } : {})
+ })
+ })
+}
diff --git a/src/renderer/src/hooks/useIssueMetadata.test.tsx b/src/renderer/src/hooks/useIssueMetadata.test.tsx
index bff14b1c091..f586e03fada 100644
--- a/src/renderer/src/hooks/useIssueMetadata.test.tsx
+++ b/src/renderer/src/hooks/useIssueMetadata.test.tsx
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
clearLinearMetadataCache,
useRepoLabels,
+ useRepoAssignees,
useTeamStates,
useTeamsStates
} from './useIssueMetadata'
@@ -17,7 +18,7 @@ const linearMocks = vi.hoisted(() => ({
}))
const runtimeMocks = vi.hoisted(() => ({ callRuntimeRpc: vi.fn() }))
-const githubMocks = vi.hoisted(() => ({ listLabels: vi.fn() }))
+const githubMocks = vi.hoisted(() => ({ listLabels: vi.fn(), listAssignableUsers: vi.fn() }))
vi.mock('@/runtime/runtime-linear-project-client', () => ({
linearTeamStates: linearMocks.linearTeamStates,
@@ -38,7 +39,12 @@ const roots: Root[] = []
function installWindowApi(): void {
Object.defineProperty(window, 'api', {
configurable: true,
- value: { gh: { listLabels: githubMocks.listLabels } }
+ value: {
+ gh: {
+ listLabels: githubMocks.listLabels,
+ listAssignableUsers: githubMocks.listAssignableUsers
+ }
+ }
})
}
@@ -67,6 +73,7 @@ describe('useIssueMetadata hooks', () => {
linearMocks.linearTeamMembers.mockReset()
runtimeMocks.callRuntimeRpc.mockReset()
githubMocks.listLabels.mockReset()
+ githubMocks.listAssignableUsers.mockReset()
installWindowApi()
})
@@ -97,6 +104,70 @@ describe('useIssueMetadata hooks', () => {
expect(runtimeMocks.callRuntimeRpc).not.toHaveBeenCalled()
})
+ it('keeps concurrent fork and upstream label lists separate when the fork reply is late', async () => {
+ const fork = { owner: 'fork', repo: 'widgets', host: 'github.com' }
+ const upstream = { owner: 'upstream', repo: 'widgets', host: 'github.com' }
+ let finishFork: (labels: string[]) => void = () => {}
+ const forkReply = new Promise((resolve) => {
+ finishFork = resolve
+ })
+ githubMocks.listLabels.mockImplementation(({ ownerRepo }) =>
+ ownerRepo.owner === 'fork' ? forkReply : Promise.resolve(['upstream-label'])
+ )
+ let forkLabels: string[] = []
+ let upstreamLabels: string[] = []
+ function Probe(): null {
+ forkLabels = useRepoLabels(null, 'concurrent-folder', { ownerRepo: fork }).data
+ upstreamLabels = useRepoLabels(null, 'concurrent-folder', { ownerRepo: upstream }).data
+ return null
+ }
+ renderProbe()
+ await flushEffects()
+ expect(forkLabels).toEqual([])
+ expect(upstreamLabels).toEqual(['upstream-label'])
+ await act(async () => {
+ finishFork(['fork-label'])
+ })
+ await flushEffects()
+ expect(forkLabels).toEqual(['fork-label'])
+ expect(upstreamLabels).toEqual(['upstream-label'])
+ expect(githubMocks.listLabels).toHaveBeenCalledTimes(2)
+ })
+
+ it('ignores a late fork assignee reply after the opened issue switches to upstream', async () => {
+ const fork = { owner: 'fork', repo: 'widgets', host: 'github.com' }
+ const upstream = { owner: 'upstream', repo: 'widgets', host: 'github.com' }
+ let finishFork: (users: { login: string; name: null; avatarUrl: string }[]) => void = () => {}
+ const forkReply = new Promise<{ login: string; name: null; avatarUrl: string }[]>((resolve) => {
+ finishFork = resolve
+ })
+ githubMocks.listAssignableUsers.mockImplementation(({ ownerRepo }) =>
+ ownerRepo.owner === 'fork'
+ ? forkReply
+ : Promise.resolve([{ login: 'upstream-user', name: null, avatarUrl: '' }])
+ )
+ let logins: string[] = []
+ function Probe({ ownerRepo }: { ownerRepo: typeof fork }): null {
+ logins = useRepoAssignees(null, 'switch-folder', { ownerRepo }).data.map((user) => user.login)
+ return null
+ }
+ renderProbe()
+ await flushEffects()
+ const root = roots.at(-1)
+ if (!root) {
+ throw new Error('Expected the rendered probe')
+ }
+ act(() => root.render())
+ await flushEffects()
+ expect(logins).toEqual(['upstream-user'])
+ await act(async () => {
+ finishFork([{ login: 'fork-user', name: null, avatarUrl: '' }])
+ })
+ await flushEffects()
+ expect(logins).toEqual(['upstream-user'])
+ expect(githubMocks.listAssignableUsers).toHaveBeenCalledTimes(2)
+ })
+
it('prefers an explicit remote environment and repo id', async () => {
let labels: string[] = []
runtimeMocks.callRuntimeRpc.mockResolvedValue(['remote'])
diff --git a/src/renderer/src/hooks/useIssueMetadata.ts b/src/renderer/src/hooks/useIssueMetadata.ts
index a7e55b80700..2ce65c3055f 100644
--- a/src/renderer/src/hooks/useIssueMetadata.ts
+++ b/src/renderer/src/hooks/useIssueMetadata.ts
@@ -1,12 +1,11 @@
import { useEffect, useMemo, useRef, useState } from 'react'
-import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
+import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import {
linearTeamLabels,
linearTeamMembers,
linearTeamStates
} from '@/runtime/runtime-linear-project-client'
import type { RuntimeLinearSettings } from '@/runtime/runtime-linear-client'
-import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types'
import type {
LinearLabel,
LinearMember,
@@ -23,79 +22,7 @@ import {
} from './metadata-request-cache'
import { useMetadataListRequest, type MetadataListState } from './useMetadataListRequest'
-type GitHubMetadataOptions = {
- runtimeEnvironmentId?: string | null
- activeRuntimeEnvironmentId?: string | null
-}
-
-const ghLabelStore = createMetadataRequestStore()
-const ghAssigneeStore = createMetadataRequestStore()
-
-export function useRepoLabels(
- repoPath: string | null,
- repoId?: string | null,
- options?: GitHubMetadataOptions
-): MetadataListState {
- const runtimeEnvironmentId =
- options?.runtimeEnvironmentId?.trim() || options?.activeRuntimeEnvironmentId?.trim() || null
- const repoSelector = repoId ?? repoPath ?? ''
- const cacheKey =
- repoPath || repoId
- ? runtimeEnvironmentId
- ? `runtime:${runtimeEnvironmentId}:${repoSelector}`
- : repoSelector
- : null
-
- return useMetadataListRequest({
- cacheKey,
- store: ghLabelStore,
- errorFallback: 'Failed to load labels',
- load: () =>
- runtimeEnvironmentId
- ? callRuntimeRpc(
- { kind: 'environment', environmentId: runtimeEnvironmentId },
- 'github.listLabels',
- { repo: repoSelector },
- { timeoutMs: 15_000 }
- )
- : window.api.gh
- .listLabels({ repoPath: repoPath ?? '', repoId: repoId ?? undefined })
- .then((labels) => labels as string[])
- })
-}
-
-export function useRepoAssignees(
- repoPath: string | null,
- repoId?: string | null,
- options?: GitHubMetadataOptions
-): MetadataListState {
- const runtimeEnvironmentId =
- options?.runtimeEnvironmentId?.trim() || options?.activeRuntimeEnvironmentId?.trim() || null
- const repoSelector = repoId ?? repoPath ?? ''
- const cacheKey =
- repoPath || repoId
- ? runtimeEnvironmentId
- ? `runtime:${runtimeEnvironmentId}:${repoSelector}`
- : repoSelector
- : null
-
- return useMetadataListRequest({
- cacheKey,
- store: ghAssigneeStore,
- errorFallback: 'Failed to load assignees',
- load: () =>
- runtimeEnvironmentId
- ? callRuntimeRpc(
- { kind: 'environment', environmentId: runtimeEnvironmentId },
- 'github.listAssignableUsers',
- { repo: repoSelector },
- { timeoutMs: 15_000 }
- )
- : window.api.gh
- .listAssignableUsers({ repoPath: repoPath ?? '', repoId: repoId ?? undefined })
- .then((users) => users as GitHubAssignableUser[])
- })
-}
+export { useRepoLabels, useRepoAssignees } from './useGitHubRepoMetadata'
const linearStateStore = createMetadataRequestStore()
const linearLabelStore = createMetadataRequestStore()
diff --git a/src/renderer/src/lib/github-work-item-source-lookup.test.ts b/src/renderer/src/lib/github-work-item-source-lookup.test.ts
index 336583b1d6d..432401ce3e5 100644
--- a/src/renderer/src/lib/github-work-item-source-lookup.test.ts
+++ b/src/renderer/src/lib/github-work-item-source-lookup.test.ts
@@ -41,7 +41,8 @@ describe('GitHub source lookup routing', () => {
repoId: 'renderer-repo',
sourceContext: runtimeSourceContext,
number: 42,
- type: 'issue'
+ type: 'issue',
+ ownerRepo: { owner: 'fork-owner', repo: 'widgets', host: 'github.com' }
})
).resolves.toBeNull()
@@ -100,4 +101,22 @@ describe('GitHub source lookup routing', () => {
})
expect(callRuntimeRpc).not.toHaveBeenCalled()
})
+
+ it('sends a local issue row repository to the repo-scoped details route', async () => {
+ const ownerRepo = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' }
+ vi.mocked(window.api.gh.workItemDetails).mockResolvedValue(null)
+
+ await lookupGitHubWorkItemDetailsForSource({
+ repoPath: '/home/fixture/widgets',
+ repoId: 'local-repo',
+ number: 12,
+ type: 'issue',
+ ownerRepo
+ })
+
+ expect(window.api.gh.workItemDetails).toHaveBeenCalledWith(
+ expect.objectContaining({ repoPath: '/home/fixture/widgets', ownerRepo })
+ )
+ expect(callRuntimeRpc).not.toHaveBeenCalled()
+ })
})
diff --git a/src/renderer/src/lib/github-work-item-source-lookup.ts b/src/renderer/src/lib/github-work-item-source-lookup.ts
index 5a49e15ba83..da9e3775767 100644
--- a/src/renderer/src/lib/github-work-item-source-lookup.ts
+++ b/src/renderer/src/lib/github-work-item-source-lookup.ts
@@ -1,5 +1,6 @@
import type { GitHubWorkItem, GitHubWorkItemDetails } from '../../../shared/github/work-item-types'
import type { TaskSourceContext } from '../../../shared/task-source-context'
+import type { GitHubOwnerRepo } from '../../../shared/github/pull-request-types'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import {
getGitHubRuntimeRepoId,
@@ -28,6 +29,7 @@ type GitHubWorkItemDetailsLookupArgs = {
sourceContext?: TaskSourceContext | null
number: number
type: 'issue' | 'pr'
+ ownerRepo?: GitHubOwnerRepo | null
}
function runtimeRepoId(args: Pick): string {
@@ -112,6 +114,7 @@ export function lookupGitHubWorkItemDetailsForSource(
repoId: args.repoId,
sourceContext,
number: args.number,
- type: args.type
+ type: args.type,
+ ...(args.ownerRepo ? { ownerRepo: args.ownerRepo } : {})
})
}
diff --git a/src/renderer/src/store/github/cache-model.ts b/src/renderer/src/store/github/cache-model.ts
index 27db1bf8167..d1546ff4768 100644
--- a/src/renderer/src/store/github/cache-model.ts
+++ b/src/renderer/src/store/github/cache-model.ts
@@ -61,4 +61,5 @@ export type ProjectRowContentPatch = {
export type GitHubPatchWorkItemOptions = {
sourceContext?: TaskSourceContext | null
+ ownerRepo?: GitHubOwnerRepo | null
}
diff --git a/src/renderer/src/store/github/work-item-mutation-actions.ts b/src/renderer/src/store/github/work-item-mutation-actions.ts
index 97a6d3b66f3..fde46aef606 100644
--- a/src/renderer/src/store/github/work-item-mutation-actions.ts
+++ b/src/renderer/src/store/github/work-item-mutation-actions.ts
@@ -3,6 +3,8 @@ import type { AppState } from '../types'
import type { GitHubSlice } from './slice-types'
import { toast } from 'sonner'
import type { GitHubWorkItem } from '../../../../shared/github/work-item-types'
+import { parseGitHubIssueOrPRLink } from '../../../../shared/github/links'
+import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key'
import { getTaskSourceCacheScope } from '../../../../shared/task-source-context'
import { translate } from '@/i18n/i18n'
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
@@ -24,6 +26,7 @@ export const createWorkItemMutationActions = (
options?.sourceContext?.provider === 'github'
? getTaskSourceCacheScope(options.sourceContext)
: null
+ const repositoryKey = options?.ownerRepo ? githubRepoIdentityKey(options.ownerRepo) : null
for (const key of Object.keys(nextCache)) {
// Why: don't patch another host/account's visually identical issue/PR cache entry.
if (sourceScope && key !== sourceScope && !key.startsWith(`${sourceScope}::`)) {
@@ -34,9 +37,18 @@ export const createWorkItemMutationActions = (
continue
}
// Why: issue/PR ids are only unique within a repo; cross-repo views can share `pr:42`.
- const idx = entry.data.findIndex(
- (item) => item.id === itemId && (!repoId || item.repoId === repoId)
- )
+ const idx = entry.data.findIndex((item) => {
+ if (item.id !== itemId || (repoId && item.repoId !== repoId)) {
+ return false
+ }
+ if (!repositoryKey) {
+ return true
+ }
+ const itemRepository = parseGitHubIssueOrPRLink(item.url)?.slug
+ return (
+ itemRepository !== undefined && githubRepoIdentityKey(itemRepository) === repositoryKey
+ )
+ })
if (idx === -1) {
continue
}
diff --git a/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts b/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts
index 77f997083aa..6fe2a9e068a 100644
--- a/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts
+++ b/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts
@@ -111,6 +111,43 @@ describe('createGitHubSlice.patchWorkItem', () => {
})
expect(secondPatched).toBe(secondItem)
})
+
+ it('scopes a canonical repository patch within its existing host and account scope', () => {
+ const store = createTestStore()
+ const source = githubSourceContext('local', 'repo-1')
+ const otherAccount = { ...source, projectHostSetupId: 'another-account-setup' }
+ const canonical: GitHubWorkItem = {
+ id: 'issue:42',
+ repoId: 'repo-1',
+ type: 'issue',
+ number: 42,
+ title: 'Fork issue',
+ state: 'open',
+ labels: [],
+ updatedAt: '',
+ author: null,
+ url: 'https://github.com/fork/widgets/issues/42'
+ }
+ const upstream = { ...canonical, url: 'https://github.com/upstream/widgets/issues/42' }
+ const enterprise = { ...canonical, url: 'https://ghe.example:8443/fork/widgets/issues/42' }
+ const sourceKey = workItemsCacheKey('repo-1', 20, '', getTaskSourceCacheScope(source))
+ const otherKey = workItemsCacheKey('repo-1', 20, '', getTaskSourceCacheScope(otherAccount))
+ store.setState({
+ workItemsCache: {
+ [sourceKey]: { data: [upstream, enterprise, canonical], fetchedAt: 1 },
+ [otherKey]: { data: [canonical], fetchedAt: 1 }
+ }
+ })
+ store.getState().patchWorkItem(canonical.id, { labels: ['bug'] }, canonical.repoId, {
+ sourceContext: source,
+ ownerRepo: { owner: 'FORK', repo: 'Widgets', host: ' GitHub.com ' }
+ })
+ const rows = store.getState().workItemsCache[sourceKey]?.data
+ expect(rows?.[0]).toBe(upstream)
+ expect(rows?.[1]).toBe(enterprise)
+ expect(rows?.[2].labels).toEqual(['bug'])
+ expect(store.getState().workItemsCache[otherKey]?.data?.[0]).toBe(canonical)
+ })
})
describe('createGitHubSlice.fetchWorkItems cache identity', () => {
From 94c446afb3e32f074c94bf717280a655272e2afc Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sat, 3 Oct 2026 23:19:26 -0700
Subject: [PATCH 19/23] fix(native-chat): a rejected /compact stays in the
chat, said once
A /compact the host recorded and then rejected was hidden, so on several paths
(blocked after the reply stopped waiting, rejected on restart, or seen from
another window or the phone) it vanished with nothing saying it failed. It now
stays as a not-sent row for every viewer, like any message the host recorded.
Its line says only "Your message was not sent." when a loaded host row
already says why: its turn's result row (found by the command's id) or the
failed start's row. The sender's command reply no longer repeats it when the
loaded journal shows the host recorded the command under the operation id,
and the text stays the row's rather than coming back to the composer.
The desktop pane's delivery-notice wiring moves into its own hook.
---
.../mobile-native-chat-unsent-notices.test.ts | 36 +++++
.../mobile-native-chat-unsent-notices.ts | 9 +-
...mobile-structured-composer-command.test.ts | 26 ++++
.../mobile-structured-composer-command.ts | 17 ++-
...use-mobile-structured-send-with-outcome.ts | 6 +
.../structured-agent-session-command-turn.ts | 3 +-
.../NativeChatStructuredSession.tsx | 52 ++-----
...ructured-agent-session-delivery-notices.ts | 16 +-
...d-agent-session-message-projection.test.ts | 137 ++++++++++++------
...ructured-conversation-command-send.test.ts | 33 ++++-
.../structured-conversation-command-send.ts | 7 +
...tured-agent-session-command-result-rows.ts | 18 +++
...ent-session-command-start-failure.test.tsx | 31 +++-
...ructured-agent-session-delivery-notices.ts | 53 +++++++
.../use-structured-agent-session-mutate.ts | 8 +-
.../use-structured-agent-session.ts | 6 +
.../structured-agent-session-command-entry.ts | 25 ++++
...ctured-agent-session-message-projection.ts | 47 ++++--
...-agent-session-recorded-rejection-words.ts | 15 +-
19 files changed, 420 insertions(+), 125 deletions(-)
create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-command-result-rows.ts
create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts
diff --git a/mobile/src/session/mobile-native-chat-unsent-notices.test.ts b/mobile/src/session/mobile-native-chat-unsent-notices.test.ts
index 5f4b503e06d..485159a856b 100644
--- a/mobile/src/session/mobile-native-chat-unsent-notices.test.ts
+++ b/mobile/src/session/mobile-native-chat-unsent-notices.test.ts
@@ -9,6 +9,7 @@ import type {
AgentJournalRenderItem,
AgentJournalSubmission
} from '../../../src/shared/agent-session-journal-types'
+import { structuredAgentSessionCommandResultRowIdentity } from '../../../src/shared/structured-agent-session-command-entry'
import { structuredAgentSessionStartFailureRowIdentity } from '../../../src/shared/structured-agent-session-start-failure-row-key'
import { mobileNativeChatUnsentNotices } from './mobile-native-chat-unsent-notices'
@@ -62,6 +63,41 @@ describe('the line under a message the host recorded and did not deliver', () =>
)
})
+ // Its turn's result row says how a refused command ended; a blocked one has only its own line.
+ it("says only that a command was not sent when its result row says why, else the command's words", () => {
+ const providerRejected: AgentSessionFailureFact = {
+ kind: 'providerRejected',
+ detail: { text: 'busy', audience: 'person' }
+ }
+ const refused = rejected({ clientMessageId: 'compact-1', rejection: providerRejected })
+ const resultRow: AgentJournalRenderItem = {
+ itemId: agentJournalItemKey(structuredAgentSessionCommandResultRowIdentity('compact-1')),
+ revision: 1,
+ sequence: 2,
+ observedAt: 2,
+ body: {
+ kind: 'status',
+ tone: 'error',
+ ...agentSessionFailureWords(
+ { kind: 'compactionFailed', detail: { text: 'busy', audience: 'person' } },
+ { agentName: 'Codex', surface: 'row' }
+ )
+ }
+ }
+ const blocked = rejected({
+ clientMessageId: 'compact-2',
+ rejection: { kind: 'commandRefused' }
+ })
+ const notices = mobileNativeChatUnsentNotices(
+ { items: [resultRow], submissions: [refused, blocked] },
+ 'codex'
+ )
+ expect(notices.get(agentJournalSubmissionKey('compact-1'))).toBe('Your message was not sent.')
+ expect(notices.get(agentJournalSubmissionKey('compact-2'))).toBe(
+ "This command didn't run. Try it again."
+ )
+ })
+
it('has nothing to say when no send was rejected', () => {
expect(
mobileNativeChatUnsentNotices(
diff --git a/mobile/src/session/mobile-native-chat-unsent-notices.ts b/mobile/src/session/mobile-native-chat-unsent-notices.ts
index d04cc894012..b8f76a876a4 100644
--- a/mobile/src/session/mobile-native-chat-unsent-notices.ts
+++ b/mobile/src/session/mobile-native-chat-unsent-notices.ts
@@ -3,6 +3,7 @@
import { formatAgentTypeLabel } from '../../../src/shared/agent-type-label'
import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key'
+import { structuredAgentSessionCommandResultRows } from '../../../src/shared/structured-agent-session-command-entry'
import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice'
import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership'
import {
@@ -22,6 +23,7 @@ export function mobileNativeChatUnsentNotices(
return NO_NOTICES
}
const startFailures = structuredAgentSessionStartFailureFacts(journal.items)
+ const commandResults = structuredAgentSessionCommandResultRows(journal.items)
const context = {
retryControl: false,
...(agent ? { agentName: formatAgentTypeLabel(agent) } : {})
@@ -30,7 +32,12 @@ export function mobileNativeChatUnsentNotices(
rejected.map((submission) => [
agentJournalSubmissionKey(submission.clientMessageId),
agentSessionWriteNoticeEnglish(
- structuredAgentSessionRecordedRejectionParts(submission, context, startFailures)
+ structuredAgentSessionRecordedRejectionParts(
+ submission,
+ context,
+ startFailures,
+ commandResults
+ )
)
])
)
diff --git a/mobile/src/session/mobile-structured-composer-command.test.ts b/mobile/src/session/mobile-structured-composer-command.test.ts
index 8a54140a7dc..f60bab8486c 100644
--- a/mobile/src/session/mobile-structured-composer-command.test.ts
+++ b/mobile/src/session/mobile-structured-composer-command.test.ts
@@ -33,6 +33,7 @@ function setup() {
conversationCommands: ['clear', 'compact']
},
canRun: () => true,
+ recorded: () => false,
onError: vi.fn(),
timeoutMs: 15000
}
@@ -119,6 +120,31 @@ describe('mobile structured conversation commands', () => {
expect(fields.command).toBe('clear')
expect(asyncStorage.setItem).not.toHaveBeenCalled()
})
+ // Its row in the chat says it was not sent and why, for every viewer, so no banner repeats it.
+ it('banners a refused /compact only when the journal does not show the host recorded it', async () => {
+ const refused = {
+ ok: true,
+ result: {
+ ok: true,
+ value: { command: 'compact', state: 'completed', error: "This command didn't run." }
+ }
+ }
+ const { input, sendRequest } = setup()
+ sendRequest.mockResolvedValue(refused)
+ const sentId = () => {
+ const envelope = requestFields(sendRequest.mock.calls.at(-1)).envelope
+ return typeof envelope === 'object' && envelope !== null && 'clientOperationId' in envelope
+ ? envelope.clientOperationId
+ : undefined
+ }
+ input.recorded = (clientMessageId) => clientMessageId === sentId()
+ expect(await dispatchMobileStructuredCommand(input)).toBe('accepted')
+ expect(input.onError).not.toHaveBeenCalled()
+
+ input.recorded = () => false
+ expect(await dispatchMobileStructuredCommand(input)).toBe('rejected')
+ expect(input.onError).toHaveBeenCalledWith("This command didn't run.")
+ })
it('keeps ordinary messages on the existing send path', async () => {
const { input, sendRequest } = setup()
expect(await dispatchMobileStructuredCommand({ ...input, text: 'hello' })).toBeNull()
diff --git a/mobile/src/session/mobile-structured-composer-command.ts b/mobile/src/session/mobile-structured-composer-command.ts
index ebe30fe8803..83ccd8fe263 100644
--- a/mobile/src/session/mobile-structured-composer-command.ts
+++ b/mobile/src/session/mobile-structured-composer-command.ts
@@ -7,6 +7,7 @@ import {
import type { RpcClient } from '../transport/rpc-client'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import { requestStructuredAgentSessionMutation } from './mobile-structured-agent-session-rpc'
+import { structuredSessionOperationId } from './structured-session-operation-id'
export async function dispatchMobileStructuredCommand(input: {
text: string
@@ -17,6 +18,8 @@ export async function dispatchMobileStructuredCommand(input: {
pending: { current: boolean }
controller: StructuredAgentSessionComposerOptions
canRun: () => boolean
+ /** Whether the loaded journal shows the message the host recorded under this id. */
+ recorded: (clientMessageId: string) => boolean
onError: (message: string) => void
timeoutMs: number
}): Promise {
@@ -41,6 +44,7 @@ export async function dispatchMobileStructuredCommand(input: {
}
}
input.pending.current = true
+ const clientOperationId = structuredSessionOperationId()
try {
const result =
await requestStructuredAgentSessionMutation({
@@ -50,6 +54,7 @@ export async function dispatchMobileStructuredCommand(input: {
method: 'agentSession.conversationCommand',
fingerprintMethod: 'agentSession.conversationCommand',
fields: { command },
+ clientOperationId,
timeoutMs: Math.max(input.timeoutMs, 195_000)
})
if (
@@ -62,9 +67,15 @@ export async function dispatchMobileStructuredCommand(input: {
error: 'Conversation operation was not confirmed.'
}
}
- return result.status === 'accepted'
- ? { accepted: !result.value.error, error: result.value.error ?? null }
- : { accepted: false, error: result.message }
+ if (result.status !== 'accepted') {
+ return { accepted: false, error: result.message }
+ }
+ // The host answered for a command it recorded: its row in the chat says how it went, so
+ // no banner repeats it and the text stays the row's, not the composer's.
+ if (result.value.state === 'completed' && input.recorded(clientOperationId)) {
+ return { accepted: true, error: null }
+ }
+ return { accepted: !result.value.error, error: result.value.error ?? null }
} finally {
input.pending.current = false
}
diff --git a/mobile/src/session/use-mobile-structured-send-with-outcome.ts b/mobile/src/session/use-mobile-structured-send-with-outcome.ts
index 44c94f777a2..1ea04fb8c7f 100644
--- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts
+++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts
@@ -10,6 +10,7 @@ import {
type StructuredAgentSessionAttachment
} from '../../../src/shared/structured-agent-session-outbox'
import type { StructuredAgentSessionComposerOptions } from '../../../src/shared/structured-agent-session-composer'
+import { structuredAgentSessionJournalShowsSubmission } from '../../../src/shared/structured-agent-session-message-projection'
import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer'
import type { RpcClient } from '../transport/rpc-client'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
@@ -98,6 +99,11 @@ export function useMobileStructuredSendWithOutcome(args: {
!stateRef.current.items.some(
(item) => pendingStructuredApproval(item) || pendingStructuredQuestion(item)
),
+ recorded: (clientMessageId) =>
+ structuredAgentSessionJournalShowsSubmission(
+ stateRef.current.submissions,
+ clientMessageId
+ ),
onError: onSendError,
timeoutMs
})
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts
index 9eb5cb02ea4..ec8cde5aec0 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts
@@ -31,6 +31,7 @@ import type { AgentSessionConversationCommand } from '../../../shared/agent-sess
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
import { agentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals'
+import { structuredAgentSessionCommandResultRowIdentity } from '../../../shared/structured-agent-session-command-entry'
import {
agentJournalTurnBody,
readAgentJournalTurn
@@ -102,7 +103,7 @@ export function structuredAgentSessionCommandTurn(clientMessageId: string): {
identity,
itemId: agentJournalItemKey(identity),
turnId: `compact:${clientMessageId}`,
- resultIdentity: { provider: 'orca', clientMessageId: `command-result:${clientMessageId}` }
+ resultIdentity: structuredAgentSessionCommandResultRowIdentity(clientMessageId)
}
}
diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
index 48e24f4d464..7ab28caab1e 100644
--- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useMemo, useRef, useState } from 'react'
import { agentSessionPromptQuestions } from '../../../../shared/agent-session-question-answer'
import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer'
import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection'
@@ -28,11 +28,7 @@ import { useAppStore } from '../../store'
import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label'
import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner'
import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice'
-import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts'
-import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices'
-import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
-
-const NO_SUBMISSIONS: readonly AgentJournalSubmission[] = []
+import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent-session-delivery-notices'
export function NativeChatStructuredSession(
props: Omit
@@ -113,42 +109,16 @@ export function NativeChatStructuredSession(
}),
[controller, props.agent, props.sessionId]
)
- // Read at click time, so the notices stay put while the outbox's Retry is rebuilt each render.
- const retryRef = useRef(controller.retry)
- useEffect(() => {
- retryRef.current = controller.retry
- })
- const retryDelivery = useCallback((clientMessageId: string) => {
- retryRef.current(clientMessageId)
- }, [])
const agentLabel = structuredAgentLabel(props.agent === 'codex' ? 'codex' : 'claude')
- // Only a row shown as not sent reads the journal's rows, so a new batch of them re-renders no row
- // else. Read from the transcript, not this window's outbox: the host's record alone shows one.
- const hasRejected = controller.messages.some((message) => message.unsent === true)
- const rejectionRows = hasRejected ? controller.submissions : NO_SUBMISSIONS
- const startFailures = useStructuredAgentSessionStartFailureFacts(
- controller.journalItems,
- hasRejected
- )
- const deliveryNotices = useMemo(
- () =>
- structuredAgentSessionDeliveryNotices(
- controller.outbox,
- agentLabel,
- retryDelivery,
- rejectionRows,
- startFailures,
- controller.failedHere
- ),
- [
- controller.outbox,
- agentLabel,
- retryDelivery,
- rejectionRows,
- startFailures,
- controller.failedHere
- ]
- )
+ const deliveryNotices = useStructuredAgentSessionDeliveryNotices({
+ messages: controller.messages,
+ journalItems: controller.journalItems,
+ submissions: controller.submissions,
+ outbox: controller.outbox,
+ failedHere: controller.failedHere,
+ retry: controller.retry,
+ agentLabel
+ })
const viewState = selectNativeChatViewState(session, { readRetries: true })
const readFailure =
controller.status === 'error'
diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts
index 51a391b584b..acaf5227c99 100644
--- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts
+++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts
@@ -8,10 +8,11 @@
// queue moves.
//
// A message the host recorded and then rejected is worded from the journal's own fact, found by id;
-// the message keeps only a smaller copy, read when its submission is not loaded. A rejection that
-// is a failed start's, the fact its loaded row states, says only that it was not sent: the row
-// already says why. One no outbox entry here carries (another client's send, or one whose entry is
-// gone) is worded from the journal alone, with no Retry: this client holds nothing to send.
+// the message keeps only a smaller copy, read when its submission is not loaded. A rejection a
+// loaded host row already states (a failed start's, or a command's result row) says only that it
+// was not sent: the row already says why. One no outbox entry here carries (another client's
+// send, or one whose entry is gone) is worded from the journal alone, with no Retry: this client
+// holds nothing to send.
import {
readWholeAgentSessionFailureFact,
@@ -106,7 +107,9 @@ export function structuredAgentSessionDeliveryNotices(
/** What the loaded start-failure rows state, from `structuredAgentSessionStartFailureFacts`. */
startFailures: readonly AgentSessionFailureFact[],
/** Ids whose send failed or was refused while this chat was open: only they word their cause. */
- failedHere: ReadonlySet
+ failedHere: ReadonlySet,
+ /** Commands whose loaded result row says how they ended, from `structuredAgentSessionCommandResultRows`. */
+ commandResults?: ReadonlySet
): ReadonlyMap {
const admission = admitStructuredAgentSessionOutboxEntry(outbox)
const held = admission.state === 'blocked' ? admission.entry.clientMessageId : null
@@ -148,7 +151,8 @@ export function structuredAgentSessionDeliveryNotices(
structuredAgentSessionRecordedRejectionParts(
submission,
{ agentName, retryControl: false },
- startFailures
+ startFailures,
+ commandResults
)
)
})
diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts
index 27a0d68584c..de97a002234 100644
--- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts
+++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts
@@ -10,6 +10,13 @@ import {
agentJournalSubmissionKey
} from '../../../../shared/agent-session-journal-item-key'
import { agentJournalTurnBody } from '../../../../shared/agent-session-turn-record'
+import {
+ structuredAgentSessionCommandResultRowIdentity,
+ structuredAgentSessionCommandResultRows
+} from '../../../../shared/structured-agent-session-command-entry'
+import { structuredAgentSessionStartFailureFacts } from '../../../../shared/structured-agent-session-recorded-rejection-words'
+import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key'
+import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices'
import { projectStructuredAgentSessionMessages as projectForPhone } from '../../../../shared/structured-agent-session-message-projection'
import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection'
@@ -122,31 +129,60 @@ describe('structured agent session message projection', () => {
])
})
- // The command's reply (blocked) or its turn's result row (refused) already says it failed.
+ // A rejected command is the host's like any send: its row stays, and its line says why only
+ // where no loaded host row already does. The sender's reply stays quiet (command-send tests).
const busy = { text: 'thread busy', audience: 'person' as const }
+ const startFailed: AgentSessionFailureFact = { kind: 'providerStartFailed' }
it.each<{
name: string
rejection: AgentSessionFailureFact
- resultRow?: AgentSessionFailureFact
+ row?: 'result' | 'start'
+ line: string
}>([
- { name: 'blocked at handover', rejection: { kind: 'commandRefused' } },
+ {
+ name: 'blocked at handover',
+ rejection: { kind: 'commandRefused' },
+ line: "This command didn't run. Try it again."
+ },
{
name: 'refused by the provider',
rejection: { kind: 'providerRejected', detail: busy },
- resultRow: { kind: 'compactionFailed', detail: busy }
+ row: 'result',
+ line: 'Your message was not sent.'
+ },
+ {
+ name: 'rejected by a failed start',
+ rejection: startFailed,
+ row: 'start',
+ line: 'Your message was not sent.'
}
- ])(
- 'says a /compact $name failed only where the command reports it',
- ({ rejection, resultRow }) => {
- const opensTurn = resultRow !== undefined
- const compact = {
- ...submission(0),
- dispatchState: 'rejected' as const,
- providerItemId: null,
- rejection
+ ])('keeps a /compact $name, said once', ({ rejection, row, line }) => {
+ const compact = {
+ ...submission(0),
+ dispatchState: 'rejected' as const,
+ providerItemId: null,
+ rejection
+ }
+ const userItemId = agentJournalSubmissionKey(compact.clientMessageId)
+ const turnItemId = agentJournalItemKey({ provider: 'orca', clientMessageId: 'command-turn:c' })
+ const hostRow = (
+ itemId: string,
+ fact: AgentSessionFailureFact,
+ turnScope?: AgentJournalRenderItem['turnScope']
+ ): AgentJournalRenderItem => ({
+ itemId,
+ revision: 1,
+ sequence: 2,
+ observedAt: 2,
+ ...(turnScope ? { turnScope } : {}),
+ body: {
+ kind: 'status',
+ tone: 'error',
+ ...agentSessionFailureWords(fact, { agentName: 'Codex', surface: 'row' })
}
- const userItemId = agentJournalSubmissionKey(compact.clientMessageId)
- const commandItem: AgentJournalRenderItem = {
+ })
+ const items: AgentJournalRenderItem[] = [
+ {
itemId: userItemId,
revision: 1,
sequence: 0,
@@ -157,16 +193,8 @@ describe('structured agent session message projection', () => {
blocks: [{ type: 'text', text: '/compact' }],
command: { name: 'compact' }
}
- }
- const turnItemId = agentJournalItemKey({
- provider: 'orca',
- clientMessageId: 'command-turn:c'
- })
- const resultId = agentJournalItemKey({
- provider: 'orca',
- clientMessageId: 'command-result:c'
- })
- const turnRows: AgentJournalRenderItem[] = resultRow
+ },
+ ...(row === 'result'
? [
{
itemId: turnItemId,
@@ -183,31 +211,44 @@ describe('structured agent session message projection', () => {
completedAt: 2
})
},
- {
- itemId: resultId,
- revision: 1,
- sequence: 2,
- observedAt: 2,
- turnScope: { kind: 'turn', turnItemId },
- body: {
- kind: 'status',
- tone: 'error',
- ...agentSessionFailureWords(resultRow, { agentName: 'Codex', surface: 'row' })
- }
- }
+ hostRow(
+ agentJournalItemKey(
+ structuredAgentSessionCommandResultRowIdentity(compact.clientMessageId)
+ ),
+ { kind: 'compactionFailed', detail: busy },
+ { kind: 'turn', turnItemId }
+ )
]
- : []
- const items = [commandItem, ...turnRows]
- for (const messages of [
- projectStructuredAgentSessionMessages(items, [], [compact]),
- projectForPhone(items, [], [compact])
- ]) {
- expect(messages.find((message) => message.id === userItemId)).toBeUndefined()
- expect(messages.some((message) => message.unsent === true)).toBe(false)
- expect(messages.some((message) => message.id === resultId)).toBe(opensTurn)
- }
+ : []),
+ ...(row === 'start'
+ ? [
+ hostRow(
+ agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('g')),
+ startFailed
+ )
+ ]
+ : [])
+ ]
+ for (const messages of [
+ projectStructuredAgentSessionMessages(items, [], [compact]),
+ projectForPhone(items, [], [compact])
+ ]) {
+ expect(messages.filter((message) => message.unsent === true).map((m) => m.id)).toEqual([
+ userItemId
+ ])
+ expect(messages).toHaveLength(row ? 2 : 1)
}
- )
+ const notices = structuredAgentSessionDeliveryNotices(
+ [],
+ 'Codex',
+ () => {},
+ [compact],
+ structuredAgentSessionStartFailureFacts(items),
+ new Set(),
+ structuredAgentSessionCommandResultRows(items)
+ )
+ expect(notices.get(userItemId)?.text).toBe(line)
+ })
// The host sends a queued draft under a fresh id per hand-off; its card keeps the text meanwhile.
it('leaves a rejected queued-draft hand-off to its card: one bubble once a later hand-off lands', () => {
diff --git a/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts b/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts
index 498e9d4fc01..b448c3c1396 100644
--- a/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts
+++ b/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts
@@ -53,7 +53,8 @@ function hostResult(
async function sent(
result: AgentSessionConversationCommandResult,
- provider: 'claude' | 'codex' = 'claude'
+ provider: 'claude' | 'codex' = 'claude',
+ recordedIds: readonly string[] = []
) {
return sendStructuredConversationCommand({
command: result.command,
@@ -61,7 +62,8 @@ async function sent(
pending: { current: false },
blocked: false,
startFailures: () => [],
- send: async () => ({ kind: 'done', value: result })
+ recorded: (clientMessageId) => recordedIds.includes(clientMessageId),
+ send: async () => ({ kind: 'done', value: result, operationId: 'op-command' })
})
}
@@ -98,6 +100,30 @@ describe('the line under the composer after a conversation command failed', () =
)
})
+ // Its row in the chat says it was not sent and why, for every viewer, so the reply stays quiet.
+ it('says nothing for a command the host recorded, and leaves its text to its row', async () => {
+ for (const result of [
+ hostResult('compact', { kind: 'commandRefused' }),
+ hostResult('compact', {
+ kind: 'providerRejected',
+ detail: { text: 'busy', audience: 'person' }
+ }),
+ hostResult('compact', START_FAILED)
+ ]) {
+ expect(await sent(result, 'codex', ['op-command'])).toEqual({ accepted: true, error: null })
+ }
+ // Not answered yet: nothing on its row says how it went.
+ const notStarted: AgentSessionConversationCommandResult = {
+ command: 'compact',
+ state: 'unknown',
+ error: 'The command has not started yet.'
+ }
+ expect(await sent(notStarted, 'codex', ['op-command'])).toEqual({
+ accepted: false,
+ error: 'The command has not started yet.'
+ })
+ })
+
it("says it in the reader's language", async () => {
await i18n.changeLanguage('fr')
expect((await sent(hostResult('clear', START_FAILED))).error).toBe(
@@ -156,7 +182,8 @@ describe('the line under the composer after a conversation command failed', () =
pending: { current: false },
blocked: false,
startFailures: () => [START_FAILED],
- send: async () => ({ kind: 'done', value: result })
+ recorded: () => false,
+ send: async () => ({ kind: 'done', value: result, operationId: 'op-command' })
})
).toEqual({ accepted: false, error })
})
diff --git a/src/renderer/src/components/native-chat/structured-conversation-command-send.ts b/src/renderer/src/components/native-chat/structured-conversation-command-send.ts
index 666715d265b..adf08feb2af 100644
--- a/src/renderer/src/components/native-chat/structured-conversation-command-send.ts
+++ b/src/renderer/src/components/native-chat/structured-conversation-command-send.ts
@@ -21,6 +21,8 @@ export async function sendStructuredConversationCommand(input: {
blocked: boolean
/** What the chat's loaded start-failure rows state, read when the reply lands. */
startFailures: () => readonly AgentSessionFailureFact[]
+ /** Whether the loaded journal shows the message the host recorded under this id. */
+ recorded: (clientMessageId: string) => boolean
send: (
command: AgentSessionConversationCommand
) => Promise>
@@ -45,6 +47,11 @@ export async function sendStructuredConversationCommand(input: {
return { accepted: false, error: null }
}
const { value } = outcome
+ // The host answered for a command it recorded: its row in the chat says how it went, so the
+ // reply says nothing more, and the text is the row's, not the composer's.
+ if (value.state === 'completed' && input.recorded(outcome.operationId)) {
+ return { accepted: true, error: null }
+ }
// The chat's own start failed and its loaded row already says why, as for a message that start
// rejected. A /clear's failed start is its new chat's, whose row this pane never shows, and a
// command this build doesn't know may be either, so its host's words are shown.
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-command-result-rows.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-command-result-rows.ts
new file mode 100644
index 00000000000..a526c2c227a
--- /dev/null
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-command-result-rows.ts
@@ -0,0 +1,18 @@
+import { useMemo } from 'react'
+import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
+import { structuredAgentSessionCommandResultRows } from '../../../../shared/structured-agent-session-command-entry'
+
+const NO_ROWS: ReadonlySet = new Set()
+
+/** The commands whose result row is loaded, read only while `enabled`. Held while unchanged, so a
+ * streaming turn does not rebuild every row's delivery notice. */
+export function useStructuredAgentSessionCommandResultRows(
+ items: readonly AgentJournalRenderItem[],
+ enabled: boolean
+): ReadonlySet {
+ const key = useMemo(
+ () => (enabled ? [...structuredAgentSessionCommandResultRows(items)].sort().join('\0') : ''),
+ [enabled, items]
+ )
+ return useMemo(() => (key === '' ? NO_ROWS : new Set(key.split('\0'))), [key])
+}
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx
index 698d83e6106..14225571465 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx
@@ -5,7 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure'
import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words'
import { agentJournalItemKey } from '../../../../shared/agent-session-journal-item-key'
-import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
+import type {
+ AgentJournalRenderItem,
+ AgentJournalSubmission
+} from '../../../../shared/agent-session-journal-types'
import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key'
const mocks = vi.hoisted(() => ({
@@ -15,6 +18,7 @@ const mocks = vi.hoisted(() => ({
}))
let fence = 3
let items: AgentJournalRenderItem[] = []
+let submissions: AgentJournalSubmission[] = []
vi.mock('sonner', () => ({ toast: { error: mocks.toastError, message: vi.fn() } }))
@@ -28,7 +32,7 @@ vi.mock('./use-structured-agent-session-read', () => ({
state: {
fence,
items,
- submissions: [],
+ submissions,
status: 'ready',
error: null,
hasOlder: false,
@@ -150,6 +154,7 @@ beforeEach(() => {
vi.clearAllMocks()
fence = 3
items = []
+ submissions = []
})
afterEach(async () => {
@@ -204,6 +209,26 @@ describe('a conversation command whose reply lands after the fence moved', () =>
).toEqual({ accepted: false, error: null })
})
+ // The host recorded the /compact under the operation id, so its row in the chat says it.
+ it('says nothing for a /compact the host recorded, and clears its text: its row holds it', async () => {
+ submissions = [
+ {
+ clientMessageId: 'operation-1',
+ fence: 3,
+ payloadFingerprint: 'fingerprint',
+ dispatchState: 'rejected',
+ providerItemId: null,
+ reason: 'busy',
+ rejection: { kind: 'commandRefused' },
+ submittedAt: 1,
+ resolvedAt: 1
+ }
+ ]
+ expect(
+ await commandAcrossFenceMove('compact', commandReply('compact', { kind: 'commandRefused' }))
+ ).toEqual({ accepted: true, error: null })
+ })
+
it("says why a /compact's start failed while its row is not loaded", async () => {
expect(
await commandAcrossFenceMove('compact', commandReply('compact', RESTART_FAILED))
@@ -284,7 +309,7 @@ describe('a conversation command whose reply lands after the fence moved', () =>
await Promise.all([older, newer])
})
expect(await older).toEqual({ kind: 'dropped' })
- expect(await newer).toEqual({ kind: 'done', value: commandReply('clear').value })
+ expect(await newer).toMatchObject({ kind: 'done', value: commandReply('clear').value })
})
it('drops a reply once the pane has moved to another chat or closed', async () => {
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts
new file mode 100644
index 00000000000..3b943dfc921
--- /dev/null
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts
@@ -0,0 +1,53 @@
+import { useCallback, useEffect, useMemo, useRef } from 'react'
+import type {
+ AgentJournalRenderItem,
+ AgentJournalSubmission
+} from '../../../../shared/agent-session-journal-types'
+import type { NativeChatMessage } from '../../../../shared/native-chat-types'
+import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
+import type { NativeChatDeliveryNotice } from './NativeChatMessageRow'
+import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices'
+import { useStructuredAgentSessionCommandResultRows } from './use-structured-agent-session-command-result-rows'
+import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts'
+
+const NO_SUBMISSIONS: readonly AgentJournalSubmission[] = []
+
+/** The structured chat's delivery notices, keyed by the message id each row renders under. */
+export function useStructuredAgentSessionDeliveryNotices(input: {
+ messages: readonly NativeChatMessage[]
+ journalItems: readonly AgentJournalRenderItem[]
+ submissions: readonly AgentJournalSubmission[]
+ outbox: readonly StructuredAgentSessionOutboxEntry[]
+ failedHere: ReadonlySet
+ retry: (clientMessageId: string) => void
+ agentLabel: string
+}): ReadonlyMap {
+ const { outbox, failedHere, agentLabel } = input
+ // Read at click time, so the notices stay put while the outbox's Retry is rebuilt each render.
+ const retryRef = useRef(input.retry)
+ useEffect(() => {
+ retryRef.current = input.retry
+ })
+ const retry = useCallback((clientMessageId: string) => {
+ retryRef.current(clientMessageId)
+ }, [])
+ // Only a row shown as not sent reads the journal's rows, so a new batch of them re-renders no row
+ // else. Read from the transcript, not this window's outbox: the host's record alone shows one.
+ const hasNotSent = input.messages.some((message) => message.unsent === true)
+ const rejectionRows = hasNotSent ? input.submissions : NO_SUBMISSIONS
+ const startFailures = useStructuredAgentSessionStartFailureFacts(input.journalItems, hasNotSent)
+ const commandResults = useStructuredAgentSessionCommandResultRows(input.journalItems, hasNotSent)
+ return useMemo(
+ () =>
+ structuredAgentSessionDeliveryNotices(
+ outbox,
+ agentLabel,
+ retry,
+ rejectionRows,
+ startFailures,
+ failedHere,
+ commandResults
+ ),
+ [outbox, agentLabel, retry, rejectionRows, startFailures, failedHere, commandResults]
+ )
+}
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts
index 1f5116718a5..9aaf350f6ce 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts
@@ -27,7 +27,8 @@ import { structuredSessionOperationId } from './use-structured-agent-session-out
import { agentSessionWriteFailureText } from './agent-session-write-notice-text'
export type StructuredAgentSessionWriteOutcome =
- | { kind: 'done'; value: T }
+ /** `operationId`: the id the host recorded this write under. */
+ | { kind: 'done'; value: T; operationId: string }
/** Refused or failed, with what to tell the person. */
| { kind: 'not-done'; notice: string }
/** Settled for an owner or session this pane no longer shows; there is nothing to say. */
@@ -105,12 +106,13 @@ export function useStructuredAgentSessionMutate(args: {
}
return enabledRef.current && (stateRef.current.fence === targetFence || waitedOn)
}
+ const clientOperationId = structuredSessionOperationId()
let result: AgentSessionMutationResult
try {
result = await callStructuredAgentSession>(target, method, {
envelope: {
sessionId,
- clientOperationId: structuredSessionOperationId(),
+ clientOperationId,
expectedRuntimeFence: targetFence,
payloadFingerprint: structuredAgentSessionPayloadFingerprint({
method: fingerprintMethod,
@@ -148,7 +150,7 @@ export function useStructuredAgentSessionMutate(args: {
if (!settlesHere()) {
return { kind: 'dropped' }
}
- return { kind: 'done', value: result.value }
+ return { kind: 'done', value: result.value, operationId: clientOperationId }
},
[enabled, sessionId, stateRef, target]
)
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts
index 0f10c1ded2b..57a28bafed1 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts
@@ -37,6 +37,7 @@ import { useStructuredAgentSessionRailOutline } from './use-structured-agent-ses
import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages'
import { outboxOutsideQueuedCards } from './structured-agent-session-queued-cards'
import { structuredAgentSessionStartFailureFacts } from '../../../../shared/structured-agent-session-recorded-rejection-words'
+import { structuredAgentSessionJournalShowsSubmission } from '../../../../shared/structured-agent-session-message-projection'
import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership'
export type { StructuredPromptItem } from './structured-agent-session-message-projection'
@@ -191,6 +192,11 @@ export function useStructuredAgentSession(args: {
outbox.length
),
startFailures: () => structuredAgentSessionStartFailureFacts(stateRef.current.items),
+ recorded: (clientMessageId) =>
+ structuredAgentSessionJournalShowsSubmission(
+ stateRef.current.submissions,
+ clientMessageId
+ ),
send: (command) =>
write(
'agentSession.conversationCommand',
diff --git a/src/shared/structured-agent-session-command-entry.ts b/src/shared/structured-agent-session-command-entry.ts
index fd76b45d43a..0da091c388b 100644
--- a/src/shared/structured-agent-session-command-entry.ts
+++ b/src/shared/structured-agent-session-command-entry.ts
@@ -2,13 +2,17 @@
// request of the user's to the agent: the sidebar's prompt, preview and verdict, the completion
// feed and restart resume all read past them to the last real request.
+import { parseAgentJournalItemKey } from './agent-session-journal-item-key'
import type {
AgentJournalItemBody,
+ AgentJournalItemIdentity,
AgentJournalRenderItem,
AgentJournalTurnLifecycle
} from './agent-session-journal-types'
import { readAgentJournalTurn } from './agent-session-turn-record'
+const COMMAND_RESULT_ROW = 'command-result:'
+
export function isStructuredAgentSessionCommandEntry(
body: AgentJournalItemBody | null | undefined
): boolean {
@@ -52,3 +56,24 @@ export function isStructuredAgentSessionCommandRow(
(scope?.kind === 'turn' && commandTurnItemIds.has(scope.turnItemId))
)
}
+
+/** The one row a command's turn reports how it ended on, keyed by the command's message id. */
+export function structuredAgentSessionCommandResultRowIdentity(
+ clientMessageId: string
+): Extract {
+ return { provider: 'orca', clientMessageId: `${COMMAND_RESULT_ROW}${clientMessageId}` }
+}
+
+/** The message ids of the commands whose result row is in `items`. */
+export function structuredAgentSessionCommandResultRows(
+ items: readonly AgentJournalRenderItem[]
+): Set {
+ const ids = new Set()
+ for (const item of items) {
+ const identity = parseAgentJournalItemKey(item.itemId)
+ if (identity?.provider === 'orca' && identity.clientMessageId.startsWith(COMMAND_RESULT_ROW)) {
+ ids.add(identity.clientMessageId.slice(COMMAND_RESULT_ROW.length))
+ }
+ }
+ return ids
+}
diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts
index 7de8a923efd..8757341d8e2 100644
--- a/src/shared/structured-agent-session-message-projection.ts
+++ b/src/shared/structured-agent-session-message-projection.ts
@@ -8,9 +8,39 @@ import type { StructuredAgentSessionOutboxEntry } from './structured-agent-sessi
import { structuredAgentSessionEntryHeldForRetry } from './structured-agent-session-outbox-admission'
import { reconcileStructuredAgentSessionOutboxWithQueue } from './structured-agent-session-draft-hand-off'
import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection'
-import { isStructuredAgentSessionCommandEntry } from './structured-agent-session-command-entry'
import { projectStructuredItemsToNativeChat } from './structured-agent-session-projection'
+/**
+ * Whether the host's record of a send keeps it in the conversation, for every viewer. A send the
+ * host recorded and then did not deliver stays, shown as not sent: dropping the sender's outbox
+ * entry can never make it vanish. It leaves only where something else owns it: the user withdrew
+ * it with Stop, or its queued draft's card keeps the text.
+ */
+export function structuredAgentSessionRecordStaysInChat(
+ submission: Pick<
+ AgentJournalSubmission,
+ 'dispatchState' | 'queuedMessageId' | 'reason' | 'rejection'
+ >
+): boolean {
+ return (
+ submission.dispatchState !== 'rejected' ||
+ (!dispatchWasWithdrawn(submission) && submission.queuedMessageId === undefined)
+ )
+}
+
+/** Whether the loaded journal draws the send recorded under `clientMessageId` in the chat, where
+ * its row, not a reply, says how it went. */
+export function structuredAgentSessionJournalShowsSubmission(
+ submissions: readonly AgentJournalSubmission[],
+ clientMessageId: string
+): boolean {
+ return submissions.some(
+ (submission) =>
+ submission.clientMessageId === clientMessageId &&
+ structuredAgentSessionRecordStaysInChat(submission)
+ )
+}
+
export function projectStructuredAgentSessionMessages(
items: readonly AgentJournalRenderItem[],
outbox: readonly StructuredAgentSessionOutboxEntry[],
@@ -18,10 +48,6 @@ export function projectStructuredAgentSessionMessages(
projectItems = projectStructuredItemsToNativeChat
): NativeChatMessage[] {
const optimistic = reconcileStructuredAgentSessionOutboxWithQueue(outbox, submissions)
- // A send the host recorded and then did not deliver stays in the conversation from the host's
- // record, shown as not sent, for every viewer: dropping the sender's outbox entry can never make
- // it vanish. It leaves only where something else owns it: the user withdrew it with Stop, its
- // queued draft's card keeps the text, or it is a command whose reply or result row says it failed.
const notSent = new Set()
const ownedElsewhere = new Set()
for (const submission of submissions) {
@@ -29,19 +55,16 @@ export function projectStructuredAgentSessionMessages(
continue
}
const key = agentJournalSubmissionKey(submission.clientMessageId)
- if (dispatchWasWithdrawn(submission) || submission.queuedMessageId !== undefined) {
- ownedElsewhere.add(key)
- } else {
+ if (structuredAgentSessionRecordStaysInChat(submission)) {
notSent.add(key)
+ } else {
+ ownedElsewhere.add(key)
}
}
const visibleItems: AgentJournalRenderItem[] = []
const refused = new Map()
for (const item of items) {
- if (
- ownedElsewhere.has(item.itemId) ||
- (notSent.has(item.itemId) && isStructuredAgentSessionCommandEntry(item.body))
- ) {
+ if (ownedElsewhere.has(item.itemId)) {
refused.set(item.itemId, item)
} else {
visibleItems.push(item)
diff --git a/src/shared/structured-agent-session-recorded-rejection-words.ts b/src/shared/structured-agent-session-recorded-rejection-words.ts
index dd3dfe724b6..e57928cf480 100644
--- a/src/shared/structured-agent-session-recorded-rejection-words.ts
+++ b/src/shared/structured-agent-session-recorded-rejection-words.ts
@@ -1,6 +1,6 @@
// What a send the host recorded and then did not deliver says on its own row, on every client.
-// A rejection that is a failed start's, the fact its loaded row states, says only that it was not
-// sent: the row already says why.
+// A rejection a loaded host row already states says only that it was not sent: a failed start's,
+// the fact its row states, or a command's, whose turn's result row says how it ended.
import {
readAgentSessionFailureFact,
@@ -61,13 +61,20 @@ export function agentSessionFailureStatedByStartRow(
)
}
+const NO_COMMAND_RESULTS: ReadonlySet = new Set()
+
/** The words for a recorded rejection that no outbox entry on this client carries. */
export function structuredAgentSessionRecordedRejectionParts(
submission: AgentJournalSubmission,
context: AgentSessionFailureWordsContext,
- startFailures: readonly AgentSessionFailureFact[]
+ startFailures: readonly AgentSessionFailureFact[],
+ /** Commands whose result row is loaded, from `structuredAgentSessionCommandResultRows`. */
+ commandResults: ReadonlySet = NO_COMMAND_RESULTS
): AgentSessionWriteNoticePart[] {
- if (agentSessionFailureStatedByStartRow(submission.rejection, startFailures)) {
+ if (
+ commandResults.has(submission.clientMessageId) ||
+ agentSessionFailureStatedByStartRow(submission.rejection, startFailures)
+ ) {
return agentSessionWriteNotDoneParts('send')
}
return structuredAgentSessionAttemptFailureParts(
From 7ebec5b869c966b0b2403fec7c5b3ef0d4a66e07 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sun, 4 Oct 2026 00:00:20 -0700
Subject: [PATCH 20/23] perf(native-chat): read only status rows when finding
command result rows
The scan parsed every journal key on each update while a row showed as not
sent, about 3-17 ms per call at 3000 items. Every result row is a status
row, so the scan now skips the rest before parsing (about 0.02-0.04 ms).
---
src/shared/structured-agent-session-command-entry.ts | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/src/shared/structured-agent-session-command-entry.ts b/src/shared/structured-agent-session-command-entry.ts
index 0da091c388b..d0eb38b4ba0 100644
--- a/src/shared/structured-agent-session-command-entry.ts
+++ b/src/shared/structured-agent-session-command-entry.ts
@@ -70,6 +70,10 @@ export function structuredAgentSessionCommandResultRows(
): Set {
const ids = new Set()
for (const item of items) {
+ // Every result row is a status row; parsing only those keeps a long chat's scan cheap.
+ if (item.body.kind !== 'status') {
+ continue
+ }
const identity = parseAgentJournalItemKey(item.itemId)
if (identity?.provider === 'orca' && identity.clientMessageId.startsWith(COMMAND_RESULT_ROW)) {
ids.add(identity.clientMessageId.slice(COMMAND_RESULT_ROW.length))
From f6784ad53bb0c84051eca43a3c7b28ab0e9f58df Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sun, 4 Oct 2026 00:00:21 -0700
Subject: [PATCH 21/23] fix(mobile): resend the same text as a new message when
its kept id was rejected
After a lost answer, the phone keeps that message's id for its text. If the
host since recorded and rejected it, sending the same text again replayed the
id, which answered with the same rejection: no banner, no bubble, and an empty
composer, so the press did nothing. Such a replay now goes out under a fresh
id, as a withdrawn one already does.
"Sent, but this phone couldn't update its record" is no longer said for a
resend the host recorded and rejected; its row says it was not sent.
---
...ructured-agent-session-send-bypass.test.ts | 56 ++++++++++++++++++-
.../mobile-structured-agent-session-send.ts | 54 ++++++++++++------
2 files changed, 90 insertions(+), 20 deletions(-)
diff --git a/mobile/src/session/mobile-structured-agent-session-send-bypass.test.ts b/mobile/src/session/mobile-structured-agent-session-send-bypass.test.ts
index c9da99a3cba..8631e61809d 100644
--- a/mobile/src/session/mobile-structured-agent-session-send-bypass.test.ts
+++ b/mobile/src/session/mobile-structured-agent-session-send-bypass.test.ts
@@ -31,8 +31,33 @@ function queuedAnswer(clientMessageId: string, state: 'waiting' | 'withdrawn'):
})
}
-/** Each `agentSession.send` answered in turn: a lost answer, or a queued draft in that state. */
-function hostAnswering(answers: readonly ('lost' | 'waiting' | 'withdrawn')[]) {
+function submissionAnswer(clientMessageId: string, dispatchState: 'accepted' | 'rejected') {
+ return ok({
+ ok: true,
+ replayed: false,
+ fence: 3,
+ cursor: { epoch: 'epoch-1', sequence: 1 },
+ value: {
+ clientMessageId,
+ submission: {
+ clientMessageId,
+ fence: 3,
+ payloadFingerprint: 'fingerprint',
+ dispatchState,
+ providerItemId: null,
+ reason: dispatchState === 'rejected' ? 'provider_write_failed: broken pipe' : null,
+ submittedAt: 1,
+ resolvedAt: 1
+ }
+ }
+ })
+}
+
+/** Each `agentSession.send` answered in turn: a lost answer, a queued draft in that state, or a
+ * recorded submission in that state. */
+function hostAnswering(
+ answers: readonly ('lost' | 'waiting' | 'withdrawn' | 'accepted' | 'rejected')[]
+) {
const ids: string[] = []
const deliveries: unknown[] = []
const sendRequest = vi.fn(async (_method, params) => {
@@ -44,7 +69,9 @@ function hostAnswering(answers: readonly ('lost' | 'waiting' | 'withdrawn')[]) {
if (answer === 'lost' || answer === undefined) {
throw markRpcDeliveryUnknown(new Error('Connection closed'))
}
- return queuedAnswer(id, answer)
+ return answer === 'accepted' || answer === 'rejected'
+ ? submissionAnswer(id, answer)
+ : queuedAnswer(id, answer)
})
const client: RpcClient = {
sendRequest,
@@ -154,4 +181,27 @@ describe('a resend past a saved record storage would not clear', () => {
"Sent, but this phone couldn't update its record of sent messages."
)
})
+
+ // Its row says it was not sent, so replaying the kept id would answer that again and do nothing.
+ it('sends the same text as a new message when its kept id answers as recorded and rejected', async () => {
+ const { client, ids } = hostAnswering(['lost', 'rejected', 'accepted'])
+ const onError = vi.fn()
+ expect(await sendAgain(client, onError, false)).toBe('unknown')
+ expect(await sendAgain(client, onError, false)).toBe('accepted')
+ expect(ids).toHaveLength(3)
+ expect(ids[1]).toBe(ids[0])
+ expect(ids[2]).not.toBe(ids[0])
+ expect(onError).not.toHaveBeenCalled()
+ })
+
+ it('never says a resend went out when the host recorded and rejected it', async () => {
+ const { client, ids } = hostAnswering(['lost', 'withdrawn', 'rejected'])
+ const onError = vi.fn()
+ expect(await sendAgain(client, onError)).toBe('unknown')
+ asyncStorage.setItem.mockRejectedValue(new Error('disk full'))
+ asyncStorage.removeItem.mockRejectedValue(new Error('disk full'))
+ expect(await sendAgain(client, onError)).toBe('queued')
+ expect(ids).toHaveLength(3)
+ expect(onError).not.toHaveBeenCalled()
+ })
})
diff --git a/mobile/src/session/mobile-structured-agent-session-send.ts b/mobile/src/session/mobile-structured-agent-session-send.ts
index ce7f60a8b92..8ad04187d63 100644
--- a/mobile/src/session/mobile-structured-agent-session-send.ts
+++ b/mobile/src/session/mobile-structured-agent-session-send.ts
@@ -11,10 +11,12 @@ import type { RpcClient } from '../transport/rpc-client'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import {
requestStructuredAgentSessionMutation,
- timeoutForDeadline
+ timeoutForDeadline,
+ type StructuredAgentSessionMutationCallResult
} from './mobile-structured-agent-session-rpc'
import { structuredSessionOperationId } from './structured-session-operation-id'
import { mobileStructuredSendDelivery } from './mobile-structured-send-delivery'
+import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection'
import {
bypassedMobileStructuredSendOperationId,
clearMobileStructuredSendOperation,
@@ -36,13 +38,13 @@ export async function sendMobileStructuredAgentSessionMessage(input: {
delivery?: 'queue-if-active'
deadline?: number
onError: (message: string) => void
- /** Internal: the one fresh-id resend after a withdrawn replay. */
- resendingAfterWithdrawal?: true
- /** Internal: that resend when the withdrawn id's record could not be cleared. It bypasses the
+ /** Internal: the one fresh-id resend after a replay that settled without delivering. */
+ resendingAfterSettledReplay?: true
+ /** Internal: that resend when the settled id's record could not be cleared. It bypasses the
* record, so failing storage never blocks the send; its id is kept in memory for this app run,
* so a retry after a lost answer replays it rather than sending again. */
bypassRetainedRecord?: true
- /** Internal: on that resend, the key the withdrawn record matched. The remembered id is keyed
+ /** Internal: on that resend, the key the settled record matched. The remembered id is keyed
* by it, so a capability change since the lost answer never mints another id. */
resendOperationKey?: string
}): Promise {
@@ -165,22 +167,22 @@ export async function sendMobileStructuredAgentSessionMessage(input: {
result.status === 'accepted' &&
'queued' in result.value &&
result.value.queued?.state === 'withdrawn'
- if (withdrawnReplay && operation.retained && !input.resendingAfterWithdrawal) {
- // The retained id's draft was withdrawn, so it never reached the agent:
- // this identical message is a new one, not a replay to swallow. A record
- // storage would not clear is bookkeeping: it is reported, never allowed to
- // block the send.
- const resent = await sendMobileStructuredAgentSessionMessage({
+ if (
+ operation.retained &&
+ !input.resendingAfterSettledReplay &&
+ // A recorded rejection's row already says it was not sent, so a replay would do nothing.
+ (withdrawnReplay || recordedRejection(result))
+ ) {
+ // The retained id's message never reached the agent (its draft was withdrawn, or the host
+ // recorded and rejected it): this identical message is a new one, not a replay to swallow. A
+ // record storage would not clear is bookkeeping: it is reported, never allowed to block the
+ // send.
+ return sendMobileStructuredAgentSessionMessage({
...input,
- resendingAfterWithdrawal: true,
+ resendingAfterSettledReplay: true,
resendOperationKey: operationKey,
...(released ? {} : { bypassRetainedRecord: true as const })
})
- // Only when the resend is known to have gone out; an unconfirmed one may not have.
- if (!released && (resent === 'accepted' || resent === 'queued')) {
- input.onError("Sent, but this phone couldn't update its record of sent messages.")
- }
- return resent
}
if (withdrawnReplay) {
// Not resent: no card and no bubble holds the text, so it goes back to the
@@ -191,6 +193,13 @@ export async function sendMobileStructuredAgentSessionMessage(input: {
if (outcome.error !== null) {
input.onError(outcome.error)
}
+ // Only once the resend is known to have gone out: an unconfirmed one may not have, and one the
+ // host recorded and rejected shows as not sent.
+ const wentOut =
+ outcome.outcome === 'accepted' || (outcome.outcome === 'queued' && !recordedRejection(result))
+ if (input.bypassRetainedRecord && wentOut) {
+ input.onError("Sent, but this phone couldn't update its record of sent messages.")
+ }
return outcome.outcome
}
@@ -240,3 +249,14 @@ async function adoptBypassedOperation(input: {
return bypassOperation(input.operationKey, input.payloadFingerprint, input.attachmentPaths)
}
}
+
+/** An answer that the host recorded the message and then rejected it, not by a Stop. */
+function recordedRejection(
+ result: StructuredAgentSessionMutationCallResult
+): boolean {
+ const submission =
+ result.status === 'accepted' && 'submission' in result.value
+ ? result.value.submission
+ : undefined
+ return submission?.dispatchState === 'rejected' && !dispatchWasWithdrawn(submission)
+}
From b1b78b4d20b3664b1f3948a923664a2e7ba517f4 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sun, 4 Oct 2026 07:06:01 +0000
Subject: [PATCH 22/23] Update README downloads badge
---
docs/assets/readme-downloads.svg | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg
index fdf7ecb03a0..3f3cf232ad1 100644
--- a/docs/assets/readme-downloads.svg
+++ b/docs/assets/readme-downloads.svg
@@ -1,5 +1,5 @@
-