From e7f15367a845aebd1c6b235a1cd5a0f331e1f610 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:51:32 -0700 Subject: [PATCH 001/497] fix(agent-send-target): acknowledge delivery even when picker closes bef (#17835) Delivery callbacks and telemetry belong to the completed send operation, not to the picker instance that launched it. Remove early returns that skipped delivery acknowledgment and success toast when the popover was already closed. --- .../store/slices/ui-agent-send-target.test.ts | 39 +++++++++++++++++-- src/renderer/src/store/slices/ui.ts | 11 ++---- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/store/slices/ui-agent-send-target.test.ts b/src/renderer/src/store/slices/ui-agent-send-target.test.ts index 956ac31abd4..07c0f69959f 100644 --- a/src/renderer/src/store/slices/ui-agent-send-target.test.ts +++ b/src/renderer/src/store/slices/ui-agent-send-target.test.ts @@ -342,6 +342,37 @@ describe('createUISlice agent send target mode', () => { expect(store.getState().agentSendPopoverTargetMode).toBeNull() }) + it('acknowledges delivery when the picker closes before the send completes', async () => { + const store = createUIStore() + const onPromptDelivered = vi.fn() + const write = deferred<{ status: 'sent' }>() + seedAgentSendState(store) + mocks.sendNotesToActiveAgentSession.mockReturnValue(write.promise) + store.getState().openAgentSendPopoverTargetMode({ + id: 'send-1', + worktreeId, + source: 'diff-notes', + prompt: 'Review this', + label: 'All unsent notes', + launchSource: 'notes_send', + onPromptDelivered + }) + + const send = store.getState().sendPromptToSidebarAgentTarget(readyPaneKey) + store.getState().closeAgentSendPopoverTargetMode('send-1') + write.resolve({ status: 'sent' }) + + await expect(send).resolves.toBe(true) + expect(onPromptDelivered).toHaveBeenCalledTimes(1) + expect(mocks.track).toHaveBeenCalledWith('agent_prompt_sent', { + agent_kind: 'codex', + launch_source: 'notes_send', + request_kind: 'followup' + }) + expect(mocks.toastSuccess).toHaveBeenCalledWith('Sent to Codex') + expect(store.getState().agentSendPopoverTargetMode).toBeNull() + }) + it('does not let an older send close a reopened popover with the same id', async () => { const store = createUIStore() const onPromptDelivered = vi.fn() @@ -371,7 +402,7 @@ describe('createUISlice agent send target mode', () => { const reopenedMode = store.getState().agentSendPopoverTargetMode write.resolve({ status: 'sent' }) - await expect(send).resolves.toBe(false) + await expect(send).resolves.toBe(true) expect(store.getState().agentSendPopoverTargetMode).toBe(reopenedMode) expect(store.getState().agentSendPopoverTargetMode).toMatchObject({ @@ -379,9 +410,9 @@ describe('createUISlice agent send target mode', () => { prompt: 'Review this again', status: 'open' }) - expect(onPromptDelivered).not.toHaveBeenCalled() - expect(mocks.track).not.toHaveBeenCalled() - expect(mocks.toastSuccess).not.toHaveBeenCalled() + expect(onPromptDelivered).toHaveBeenCalledTimes(1) + expect(mocks.track).toHaveBeenCalledTimes(1) + expect(mocks.toastSuccess).toHaveBeenCalledWith('Sent to Codex') expect(mocks.toastError).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index c2a13d41b93..8db85a78e25 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -1193,10 +1193,6 @@ export const createUISlice: StateCreator = (set, get) return current?.id === mode.id && current.instanceId === mode.instanceId } - if (!stillCurrent()) { - return false - } - if (result.status !== 'sent') { const message = activeAgentNotesSendFailureMessage(result.status, { explicitTarget: true }) set((s) => @@ -1225,11 +1221,10 @@ export const createUISlice: StateCreator = (set, get) return false } - const [{ toast }, { track }] = await Promise.all([import('sonner'), import('@/lib/telemetry')]) - if (!stillCurrent()) { - return false - } + // Delivery ack, telemetry, and toast belong to the completed send, not to the + // picker that launched it; only the close below is scoped to this instance. mode.onPromptDelivered?.() + const [{ toast }, { track }] = await Promise.all([import('sonner'), import('@/lib/telemetry')]) track('agent_prompt_sent', { agent_kind: agentKindForAgentType(target.entry.agentType), launch_source: mode.launchSource, From 0b2912b5072fcb9c4ce58f463b8a138ea5e05f35 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:05:00 -0700 Subject: [PATCH 002/497] fix(mobile-native-chat): retire an image echo glued with the send beside it (#17783) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile-native-chat): retire an image echo glued with the send beside it A message sent with images could render two or three times over, with the copy carrying the photos sorting below the reply that answered it — and it never cleared. A send issued while the agent is mid-turn is glued onto the agent's input line with any send adjacent to it, so the pair lands as one transcript row whose text is the concatenation. Every retirement path then declined the pair: - The image matcher wanted the whole row to equal the echo's text, so a glued row never bound. That also stranded the local preview: the phone's photo never reached the authoritative row. - The exact-count path skips image echoes by design. - The glue path excluded image echoes too, which made one a *barrier* — splitting the run so the text-only send beside it was left alone, and a lone match is rejected as an ordinary landing. So neither echo could ever retire, and the unmatched image echo fell through to the trailing bucket, which is what put it below the reply. Match a glued row in the image matcher, and let an image echo take part in the glue pass once its preview has been rebound. It stays a barrier while unbound, so the existing guarantee is kept: an image echo never retires before its local preview reaches the transcript row, or the photo would disappear. * fix(mobile-chat): require image provenance for glued prefix matches --------- Co-authored-by: Merge Sim --- ...mobile-native-chat-draft-reconcile.test.ts | 39 +++++++++++++++++++ .../mobile-native-chat-draft-reconcile.ts | 27 ++++++++++++- ...ile-native-chat-pending-retirement.test.ts | 32 +++++++++++++++ .../mobile-native-chat-pending-retirement.ts | 19 +++++++-- 4 files changed, 113 insertions(+), 4 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts index b7453e7a67d..4447e3511ee 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts @@ -47,6 +47,45 @@ describe('mobile native chat image preview reconciliation', () => { ]) }) + it('binds an image echo to the row it was glued into with a following send', () => { + // Regression: a send issued while the agent was mid-turn glues onto the input line + // with the send beside it, so the landed row's text is the concatenation. Demanding + // the whole row equal this echo left it unbound — the phone-local photo never + // reached the authoritative row and the echo could never be retired. + const messages = [ + userText('source', '[Image: source: /tmp/a.png]'), + userText('prompt', 'look at this[Image #1] is it still working?') + ] + const preview = { ...pending('pending', ['file:///a.jpg']), text: 'look at this' } + + expect(findLandedImagePreviewEchoes(messages, [preview])).toEqual([ + { pendingId: 'pending', messageId: 'prompt', images: ['file:///a.jpg'] } + ]) + }) + + it('does not bind an image echo to a row that merely shares a word', () => { + const messages = [ + userText('source', '[Image: source: /tmp/a.png]'), + userText('prompt', 'totally different[Image #1]') + ] + const preview = { ...pending('pending', ['file:///a.jpg']), text: 'look at this' } + + expect(findLandedImagePreviewEchoes(messages, [preview])).toEqual([]) + }) + + it('does not bind a glued image echo to an ordinary row with the same prefix', () => { + const messages = [ + userText('ordinary', 'look at this later'), + userText('source', '[Image: source: /tmp/a.png]'), + userText('prompt', 'look at this[Image #1] is it still working?') + ] + const preview = { ...pending('pending', ['file:///a.jpg']), text: 'look at this' } + + expect(findLandedImagePreviewEchoes(messages, [preview])).toEqual([ + { pendingId: 'pending', messageId: 'prompt', images: ['file:///a.jpg'] } + ]) + }) + it('reconciles a middle-marker echo without changing its rendered whitespace', () => { const messages = [ userText('source', '[Image: source: /tmp/a.png]'), diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index a68fe5bcf32..6c2c8c0286d 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -153,6 +153,20 @@ export function findLandedImagePreviewEchoes( ): LandedImagePreviewEcho[] { const normalized = normalizeImageTranscriptMessages(messages) const messageIndexById = new Map(normalized.map((message, index) => [message.id, index])) + // Keep provenance from the raw transcript: normalization removes image markers, + // so a plain text row must not become a candidate merely because it shares a + // caption prefix with a glued image send. + const imageMessageIds = new Set( + messages + .filter( + (message) => + message.role === 'user' && + (isImageSourceUserTurn(message) || + hasImagePromptMarker(message) || + message.blocks.some(isImageRefBlock)) + ) + .map((message) => message.id) + ) const claimedMessageIds = new Set() const landed: LandedImagePreviewEcho[] = [] @@ -166,7 +180,18 @@ export function findLandedImagePreviewEchoes( return false } if (targetText) { - return normalizedUserText(message) === targetText + const text = normalizedUserText(message) + if (text === null) { + return false + } + // Why not equality alone: a send is glued onto the agent's input line with any + // send adjacent to it, so an image send that shares a turn with a following + // text-only send lands in a row whose text is the concatenation. Requiring the + // whole row to equal this echo left it unmatched, and since both other + // retirement paths skip image echoes, nothing could ever retire it. + return ( + text === targetText || (imageMessageIds.has(message.id) && text.startsWith(targetText)) + ) } const imageCount = message.blocks.filter(isImageRefBlock).length return message.blocks.length === 0 || imageCount >= entry.images!.length diff --git a/mobile/src/session/mobile-native-chat-pending-retirement.test.ts b/mobile/src/session/mobile-native-chat-pending-retirement.test.ts index 1dedec13d44..00231c7f114 100644 --- a/mobile/src/session/mobile-native-chat-pending-retirement.test.ts +++ b/mobile/src/session/mobile-native-chat-pending-retirement.test.ts @@ -232,6 +232,38 @@ describe('selectGluedPendingIds', () => { ).toEqual(['p1', 'p2']) }) + it('retires an image send glued with the text send beside it once its preview rebinds', () => { + // Regression: one message typed, images attached, then a second send glued onto the + // same input line. The image echo could not match the glued row (its matcher wanted + // the whole row to equal the echo), and because an image echo was excluded from the + // glue pass it also acted as a barrier — stranding the text send in a run of one, + // which `matchGluedRun` rejects. Neither echo could ever retire, so the message + // rendered twice over, with the image copy sorting below the reply it preceded. + const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two', 5000)] + const pending = [ + { ...pendingSend('p1', 'one', 'm1'), images: ['file:///a.png'] }, + pendingSend('p2', 'two', 'm1') + ] + const rebound: ReadonlySet = new Set(['p1']) + expect([...selectGluedPendingIds(messages, pending, new Set(), rebound)].sort()).toEqual([ + 'p1', + 'p2' + ]) + expect( + retireLandedMobileNativeChatPending(messages, pending, rebound).map((item) => item.id) + ).toEqual([]) + }) + + it('still strands nothing when the glued row belongs to an older baseline', () => { + // The per-send boundary still applies to a rebound image echo. + const messages = [userTurn('m1', 'one two', 1000), assistantTurn('m2', 'ready', 5000)] + const pending = [ + { ...pendingSend('p1', 'one', 'm2'), images: ['file:///a.png'] }, + pendingSend('p2', 'two', 'm2') + ] + expect([...selectGluedPendingIds(messages, pending, new Set(), new Set(['p1']))]).toEqual([]) + }) + it('does nothing for a single pending send', () => { const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two', 5000)] expect(retiredIds(messages, [pendingSend('p1', 'one', 'm1')])).toEqual([]) diff --git a/mobile/src/session/mobile-native-chat-pending-retirement.ts b/mobile/src/session/mobile-native-chat-pending-retirement.ts index ffdde70ddd4..7f720302ecc 100644 --- a/mobile/src/session/mobile-native-chat-pending-retirement.ts +++ b/mobile/src/session/mobile-native-chat-pending-retirement.ts @@ -23,7 +23,10 @@ type GlueSegment = { text: string; tail: number } | null export function selectGluedPendingIds( messages: readonly NativeChatMessage[], pending: readonly MobileNativeChatPendingMessage[], - excludedPendingIds: ReadonlySet = NO_PENDING_IDS + excludedPendingIds: ReadonlySet = NO_PENDING_IDS, + /** Image echoes whose local preview has been rebound onto its transcript row. + * Until that happens an image echo must stay put, so it is not a glue segment. */ + reboundImagePendingIds: ReadonlySet = NO_PENDING_IDS ): ReadonlySet { const retired = new Set() if (pending.length < 2) { @@ -44,9 +47,14 @@ export function selectGluedPendingIds( item.baselineTailMessageId === null ? -1 : (messageIndexById.get(item.baselineTailMessageId) ?? null) + // An image send glues onto the input line like any other, so once its preview is + // rebound its text is a real segment of the resulting row. While it is still + // unbound it stays a barrier: retiring it early would drop the phone-local photo, + // which the transcript's host path cannot render. + const unboundImage = Boolean(item.images?.length) && !reboundImagePendingIds.has(item.id) return excludedPendingIds.has(item.id) || !item.baselineResolved || - item.images?.length || + unboundImage || text === '' || tail === null ? null @@ -146,6 +154,10 @@ export function retireLandedMobileNativeChatPending( } } const landedPendingIds = new Set() + // Why a separate set: a barrier preserves adjacency after a landing consumed a whole + // row. An image landing can share its row with the send glued after it, so treating it + // as a barrier would strand that send in a run of one and keep its echo forever. + const exactLandedIds = new Set() for (const item of current) { if (landedImagePendingIds.has(item.id)) { landedPendingIds.add(item.id) @@ -164,9 +176,10 @@ export function retireLandedMobileNativeChatPending( : (landedCounts.get(normalizeReconcileText(item.text)) ?? 0) >= item.expectedOccurrence if (landed) { landedPendingIds.add(item.id) + exactLandedIds.add(item.id) } } - const glued = selectGluedPendingIds(messages, current, landedPendingIds) + const glued = selectGluedPendingIds(messages, current, exactLandedIds, landedImagePendingIds) return landedPendingIds.size === 0 && glued.size === 0 ? current : current.filter((item) => !landedPendingIds.has(item.id) && !glued.has(item.id)) From 286c21005b0be89f204fb75034980803525dc1ce Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:26:45 -0700 Subject: [PATCH 003/497] refactor(renderer): split oversized UI surfaces (cherry picked from commit da89be434509f8ba5c4156c4a8193ef0bcdbc70e) --- src/renderer/src/app-startup-routing.test.ts | 10 +- src/renderer/src/components/TaskPage.tsx | 3179 +------------- .../src/components/TaskPageContent.tsx | 75 + src/renderer/src/components/TaskPageFrame.tsx | 19 + .../TaskPageGitHubAssigneesCell.tsx | 295 ++ .../src/components/TaskPageGitHubAvatars.tsx | 54 + .../components/TaskPageGitHubChecksCell.tsx | 86 + .../TaskPageGitHubDuplicatePicker.tsx | 118 + .../src/components/TaskPageGitHubFilters.tsx | 256 ++ .../components/TaskPageGitHubIssueDialog.tsx | 241 ++ .../TaskPageGitHubIssueSelectors.tsx | 207 + .../src/components/TaskPageGitHubList.tsx | 320 ++ .../components/TaskPageGitHubMergeCell.tsx | 266 ++ .../components/TaskPageGitHubModeControls.tsx | 150 + .../components/TaskPageGitHubReviewCell.tsx | 345 ++ .../TaskPageGitHubReviewerPicker.tsx | 222 + .../src/components/TaskPageGitHubRows.tsx | 388 ++ .../components/TaskPageGitHubStatusCell.tsx | 396 ++ .../src/components/TaskPageGitLabDialog.tsx | 29 + .../src/components/TaskPageGitLabFilters.tsx | 167 + .../src/components/TaskPageGitLabItemList.tsx | 148 + .../src/components/TaskPageGitLabTodoList.tsx | 104 + .../components/TaskPageJiraConnectDialog.tsx | 10 + .../src/components/TaskPageJiraContent.tsx | 158 + .../src/components/TaskPageJiraFilters.tsx | 183 + .../components/TaskPageJiraIssueDialog.tsx | 380 ++ .../TaskPageLinearConnectDialog.tsx | 23 + .../src/components/TaskPageLinearContent.tsx | 79 + .../TaskPageLinearCustomViewList.tsx | 56 + .../TaskPageLinearCustomViewProjects.tsx | 102 + .../src/components/TaskPageLinearFilters.tsx | 316 ++ .../TaskPageLinearIssueAttributes.tsx | 372 ++ .../components/TaskPageLinearIssueBoard.tsx | 185 + .../TaskPageLinearIssueBoardColumns.tsx | 227 + .../components/TaskPageLinearIssueDialog.tsx | 171 + .../components/TaskPageLinearIssueList.tsx | 133 + .../components/TaskPageLinearIssueRows.tsx | 9 + .../components/TaskPageLinearIssueTable.tsx | 280 ++ .../components/TaskPageLinearIssueToolbar.tsx | 197 + .../TaskPageLinearProjectDialog.tsx | 159 + .../TaskPageLinearProjectFields.tsx | 375 ++ .../components/TaskPageLinearProjectList.tsx | 63 + .../TaskPageLinearProjectOverview.tsx | 73 + .../src/components/TaskPageListChrome.tsx | 37 + .../src/components/TaskPagePaginationBar.tsx | 87 + .../components/TaskPageProviderFilters.tsx | 23 + .../src/components/TaskPageSourceBar.tsx | 231 + .../src/components/TaskPageSurface.tsx | 34 + src/renderer/src/components/Terminal.tsx | 2968 +------------ .../components/TerminalLegacyBrowserPanes.tsx | 59 + .../TerminalLegacyEditorSurface.tsx | 28 + .../TerminalLegacyTerminalPanes.tsx | 112 + .../TerminalLegacyWorkspaceSurface.tsx | 21 + .../TerminalSplitWorkspaceSurfaces.tsx | 69 + .../src/components/TerminalSurface.tsx | 28 + .../src/components/TerminalTitlebarTabs.tsx | 105 + .../components/TerminalWorkspaceDialogs.tsx | 117 + .../TerminalWorktreeSplitSurface.tsx | 96 + .../src/components/WorktreeJumpPalette.tsx | 3856 +---------------- .../automations/AutomationsListPanel.tsx | 4 + .../automations/AutomationsPage.tsx | 2834 +----------- .../automations/AutomationsPageSurface.tsx | 381 ++ .../automations/automation-hermes-save.ts | 89 + .../automation-management-actions.ts | 102 + .../automation-orca-save-operations.ts | 264 ++ .../automations/automation-orca-save.ts | 288 ++ .../automations/automation-run-actions.ts | 122 + ...automation-run-completion-evidence.test.ts | 88 + .../automation-run-completion-evidence.ts | 38 + .../automation-run-workspace-action.ts | 71 + .../automations/automation-save-action.ts | 118 + .../automations/automation-save-context.ts | 17 + .../automations-page-action-context.ts | 21 + .../use-automation-draft-effects.ts | 179 + .../use-automation-editor-actions.ts | 263 ++ .../use-automation-run-page-state.ts | 236 + .../use-automation-source-availability.ts | 13 + .../use-automations-page-controller.ts | 109 + .../use-automations-page-destination-form.ts | 237 + .../use-automations-page-destination-state.ts | 214 + .../use-automations-page-escape.ts | 54 + .../use-automations-page-list-state.ts | 161 + .../use-automations-page-local-state.ts | 229 + ...use-automations-page-presentation-state.ts | 121 + .../use-automations-page-refresh.ts | 166 + .../use-automations-page-setup-state.ts | 199 + .../use-automations-page-store-state.ts | 98 + .../use-external-automation-actions.ts | 155 + .../palette-activation-focus-routing.test.ts | 16 +- .../src/components/editor/MarkdownPreview.tsx | 2181 +--------- .../MarkdownPreviewAnnotationComposer.tsx | 144 + .../components/editor/MarkdownPreviewBody.tsx | 90 + .../editor/MarkdownPreviewReviewToolbar.tsx | 80 + .../editor/MarkdownPreviewSearchBar.tsx | 100 + .../editor/MarkdownPreviewSurface.tsx | 83 + .../markdown-preview-anchor-navigation.ts | 16 + .../editor/markdown-preview-block-model.ts | 51 + .../editor/markdown-preview-editor-reveal.ts | 42 + .../editor/markdown-preview-link-actions.ts | 263 ++ .../editor/markdown-preview-source-routing.ts | 175 + .../markdown-preview-system-link-action.ts | 93 + .../editor/markdown-preview-types.ts | 44 + ...-markdown-preview-annotation-renderers.tsx | 255 ++ .../use-markdown-preview-components.tsx | 300 ++ .../editor/use-markdown-preview-foundation.ts | 151 + .../use-markdown-preview-review-actions.ts | 206 + .../use-markdown-preview-scroll-viewport.ts | 73 + .../use-markdown-preview-source-foundation.ts | 205 + .../editor/use-markdown-preview-viewport.ts | 321 ++ ...ture-interaction-writer-boundaries.test.ts | 49 +- .../FloatingTerminalEmptyState.tsx | 144 + .../FloatingTerminalOrchestrationCard.tsx | 55 + .../FloatingTerminalPanel.tsx | 2266 +--------- .../FloatingTerminalPanelSurface.tsx | 349 ++ .../FloatingTerminalSaveDialog.tsx | 92 + .../floating-terminal-focus-reporting.ts | 36 + .../floating-terminal-panel-drag-actions.ts | 77 + .../floating-terminal-panel-drag-target.ts | 6 + .../floating-terminal-panel-initial-bounds.ts | 54 + .../floating-terminal-panel-types.ts | 28 + .../use-floating-terminal-close-actions.ts | 224 + .../use-floating-terminal-create-actions.ts | 152 + ...se-floating-terminal-editor-close-queue.ts | 121 + .../use-floating-terminal-focus-lifecycle.ts | 108 + ...ting-terminal-global-shortcut-listeners.ts | 150 + .../use-floating-terminal-guest-bridge.ts | 49 + ...floating-terminal-initial-focus-effects.ts | 35 + ...oating-terminal-orchestration-dismissal.ts | 18 + ...ating-terminal-orchestration-visibility.ts | 58 + .../use-floating-terminal-panel-controller.ts | 100 + ...e-floating-terminal-panel-focus-reclaim.ts | 116 + .../use-floating-terminal-panel-geometry.ts | 130 + .../use-floating-terminal-panel-items.ts | 225 + ...use-floating-terminal-panel-local-state.ts | 107 + .../use-floating-terminal-panel-maximize.ts | 116 + .../use-floating-terminal-panel-shortcuts.ts | 247 ++ ...use-floating-terminal-panel-store-state.ts | 64 + .../use-floating-terminal-shortcut-details.ts | 17 + ...rkspaceNameField-source-boundaries.test.ts | 119 +- .../new-workspace/SmartWorkspaceNameField.tsx | 2532 +---------- .../smart-workspace-cross-repo-dialog.tsx | 92 + .../smart-workspace-github-direct-link.ts | 111 + .../smart-workspace-name-field-copy.ts | 119 + .../smart-workspace-name-field-model.ts | 105 + .../smart-workspace-name-field-surface.tsx | 228 + .../smart-workspace-name-input-surface.tsx | 307 ++ .../smart-workspace-provider-availability.ts | 17 + .../smart-workspace-repo-slug.ts | 61 + ...smart-workspace-source-results-surface.tsx | 156 + .../smart-workspace-source-row-content.tsx | 168 + .../use-smart-workspace-field-availability.ts | 255 ++ ...se-smart-workspace-field-focus-controls.ts | 94 + .../use-smart-workspace-github-search.ts | 240 + .../use-smart-workspace-gitlab-search.ts | 173 + .../use-smart-workspace-name-field-actions.ts | 235 + ...e-smart-workspace-name-field-controller.ts | 151 + ...e-smart-workspace-name-field-foundation.ts | 201 + ...smart-workspace-name-field-presentation.ts | 301 ++ .../use-smart-workspace-name-field-state.ts | 99 + .../use-smart-workspace-secondary-searches.ts | 259 ++ .../refactor-hook-contract.test-support.ts | 163 + .../status-bar/ClaudeSwitcherMenu.tsx | 323 ++ .../status-bar/CodexSwitcherMenu.tsx | 298 ++ .../status-bar/InlineProviderUsage.tsx | 140 + .../status-bar/ProviderDetailsMenu.tsx | 150 + .../status-bar/ResourceUsageAppSection.tsx | 98 + .../status-bar/ResourceUsageKillDialog.tsx | 98 + .../status-bar/ResourceUsageMetrics.tsx | 130 + .../ResourceUsagePopoverContent.tsx | 378 ++ .../status-bar/ResourceUsageTree.tsx | 127 + .../status-bar/ResourceUsageTrigger.tsx | 95 + .../status-bar/ResourceUsageWorktreeRow.tsx | 260 ++ .../src/components/status-bar/StatusBar.tsx | 2615 +---------- .../status-bar/StatusBarAccountControls.tsx | 116 + .../status-bar/StatusBarProviderSegment.tsx | 253 ++ .../status-bar/StatusBarSurface.tsx | 300 ++ .../status-bar/StatusBarVisibilityMenu.tsx | 169 + .../status-bar/WorkspaceSpaceManagerPanel.tsx | 2319 +--------- .../status-bar/codex-switcher-projection.ts | 32 + .../status-bar/resource-usage-sort.ts | 47 + .../status-bar/status-bar-claude-accounts.ts | 184 + .../status-bar/status-bar-codex-accounts.ts | 186 + .../status-bar-container-observer.ts | 12 + .../status-bar/status-bar-runtime-targets.ts | 99 + .../use-codex-switcher-controller.ts | 325 ++ .../status-bar/use-resource-usage-actions.ts | 2 + .../use-resource-usage-foundation.ts | 213 + .../use-resource-usage-projection.ts | 150 + .../status-bar/use-status-bar-controller.ts | 273 ++ ...use-workspace-space-decision-projection.ts | 133 + .../use-workspace-space-git-refresh-action.ts | 109 + .../use-workspace-space-manager-bindings.ts | 122 + .../use-workspace-space-manager-panel.ts | 239 + .../use-workspace-space-manager-projection.ts | 215 + .../workspace-space-breakdown-list.tsx | 127 + .../workspace-space-decision-details.ts | 204 + .../workspace-space-decision-hover-card.tsx | 265 ++ .../workspace-space-manager-overview.tsx | 180 + ...pace-space-manager-source-boundary.test.ts | 79 + .../workspace-space-manager-state-types.ts | 15 + .../workspace-space-manager-table.tsx | 173 + .../workspace-space-manager-toolbar.tsx | 188 + .../workspace-space-manager-view.tsx | 20 + .../status-bar/workspace-space-metrics.tsx | 67 + .../workspace-space-presentation.test.ts | 138 +- .../workspace-space-selection-controls.tsx | 67 + .../workspace-space-state-resolution.ts | 37 + .../workspace-space-status-badge.tsx | 126 + .../status-bar/workspace-space-treemap.tsx | 187 + .../workspace-space-worktree-row.tsx | 197 + .../components/task-page-draft-storage.tsx | 56 + .../task-page-github-issue-creation.test.ts | 14 +- .../task-page-github-landing-refresh-run.tsx | 398 ++ .../task-page-github-quiet-refresh-run.tsx | 314 ++ .../task-page-github-review-model.tsx | 78 + .../task-page-github-reviewer-actions.ts | 254 ++ .../task-page-linear-issue-model.tsx | 267 ++ .../task-page-linear-jira-list-model.tsx | 194 + .../components/task-page-source-context.tsx | 176 + .../task-page-source-family.test-support.ts | 65 + .../task-page-source-switch-boundary.test.ts | 80 +- .../task-page-task-creation-drafts.test.ts | 22 +- ...k-page-workspace-composer-boundary.test.ts | 35 +- .../components/terminal-agent-tab-shortcut.ts | 42 + .../components/terminal-cold-activation.ts | 172 + .../components/terminal-pane/TerminalPane.tsx | 3507 +-------------- .../TerminalPaneNativeChatPortal.tsx | 91 + .../TerminalPaneRuntimePortals.tsx | 197 + .../terminal-pane/TerminalPaneSurface.tsx | 309 ++ .../TerminalQuickCommandEditorDialog.tsx | 32 + ...nal-ios-hangul-preedit-coexistence.test.ts | 2 +- .../terminal-pane-hook-order-parity.test.ts | 108 + .../terminal-pane-layout-restore.ts | 84 + .../terminal-pane-lifecycle-close.ts | 103 + .../terminal-pane-lifecycle-primitives.ts | 283 ++ .../terminal-pane-lifecycle-types.ts | 132 + ...erminal-pane-listener-order-parity.test.ts | 94 + .../terminal-pane-manager-options.ts | 176 + .../terminal-pane-mount-bootstrap.ts | 47 + .../terminal-pane-mount-cleanup.ts | 139 + .../terminal-pane-mount-context.ts | 57 + .../terminal-pane-mount-events.ts | 118 + .../terminal-pane-mount-preparation.ts | 294 ++ .../terminal-pane-pane-closed.ts | 169 + .../terminal-pane-pane-created.ts | 226 + .../terminal-pane/terminal-pane-pane-input.ts | 205 + .../terminal-pane/terminal-pane-pane-links.ts | 206 + .../terminal-pane-paste-execution.ts | 150 + .../terminal-pane-paste-listeners.ts | 248 ++ .../terminal-pane/terminal-pane-pty-deps.ts | 112 + .../terminal-pane/terminal-pane-types.ts | 18 + .../use-terminal-pane-chat-state.ts | 268 ++ .../use-terminal-pane-close-actions.ts | 226 + .../use-terminal-pane-context-actions.ts | 245 ++ .../use-terminal-pane-controller.ts | 41 + .../use-terminal-pane-foundation.ts | 231 + .../use-terminal-pane-global-listeners.ts | 247 ++ .../use-terminal-pane-layout-bindings.ts | 173 + .../use-terminal-pane-layout-persistence.ts | 226 + .../use-terminal-pane-lifecycle-refs.ts | 36 + .../use-terminal-pane-lifecycle-stage.ts | 147 + .../use-terminal-pane-lifecycle.ts | 2494 +---------- .../use-terminal-pane-mobile-actions.ts | 254 ++ .../use-terminal-pane-mount-lifecycle.ts | 131 + .../use-terminal-pane-paste-listeners.ts | 40 + .../use-terminal-pane-process-exit-actions.ts | 254 ++ .../use-terminal-pane-projection.ts | 202 + .../use-terminal-pane-reconciliation.ts | 154 + .../use-terminal-pane-startup-actions.ts | 233 + .../use-terminal-pane-store-bindings.ts | 85 + .../use-terminal-pane-title-effects.ts | 255 ++ .../use-terminal-pane-title-state.ts | 155 + .../terminal-parking-pass-candidates.ts | 152 + .../components/terminal-workspace-keydown.ts | 268 ++ .../components/terminal-workspace-model.ts | 106 + .../terminal/initial-terminal-wiring.test.ts | 13 +- .../use-task-page-composer-actions.ts | 264 ++ .../use-task-page-detail-routing.ts | 241 ++ ...e-task-page-github-cache-reconciliation.ts | 160 + .../components/use-task-page-github-detail.ts | 292 ++ .../use-task-page-github-issue-creation.ts | 196 + .../use-task-page-github-issue-draft.ts | 169 + .../use-task-page-github-landing-refresh.ts | 35 + .../use-task-page-github-list-projection.ts | 201 + .../use-task-page-github-list-state.ts | 290 ++ .../use-task-page-github-mutation-state.ts | 171 + ...e-task-page-github-quiet-refresh-effect.ts | 17 + .../use-task-page-github-quiet-refresh.ts | 42 + .../use-task-page-github-search-pagination.ts | 223 + .../use-task-page-gitlab-loading.ts | 194 + .../use-task-page-global-effects.ts | 124 + .../use-task-page-jira-creation-metadata.ts | 132 + .../use-task-page-jira-creation-projects.ts | 179 + .../use-task-page-jira-creation-state.ts | 182 + .../use-task-page-jira-issue-creation.ts | 135 + .../use-task-page-jira-list-effects.ts | 169 + .../use-task-page-jira-list-projection.ts | 70 + .../use-task-page-jira-list-state.ts | 141 + .../components/use-task-page-linear-board.ts | 258 ++ ...use-task-page-linear-collection-effects.ts | 204 + .../use-task-page-linear-context-actions.ts | 245 ++ .../use-task-page-linear-context-state.ts | 129 + .../use-task-page-linear-creation-state.ts | 305 ++ ...se-task-page-linear-custom-view-effects.ts | 210 + .../use-task-page-linear-filter-selection.ts | 158 + .../use-task-page-linear-in-orca-effects.ts | 143 + .../use-task-page-linear-issue-creation.ts | 162 + .../use-task-page-linear-list-effects.ts | 293 ++ .../use-task-page-linear-list-presentation.ts | 100 + .../use-task-page-linear-list-projection.ts | 248 ++ .../use-task-page-linear-list-selection.ts | 239 + .../use-task-page-linear-project-creation.ts | 151 + .../use-task-page-linear-view-state.ts | 251 ++ .../use-task-page-provider-metadata.ts | 127 + .../use-task-page-provider-state.ts | 174 + .../use-task-page-repo-selection.ts | 266 ++ .../use-task-page-resume-restoration.ts | 239 + .../components/use-task-page-runtime-hosts.ts | 197 + .../use-task-page-search-actions.ts | 257 ++ .../use-task-page-source-availability.ts | 175 + .../use-task-page-source-summary.ts | 189 + .../use-task-page-store-bindings.ts | 155 + .../use-task-page-workspace-actions.ts | 131 + .../use-terminal-activation-actions.ts | 67 + .../use-terminal-browser-retention.ts | 106 + .../use-terminal-bulk-close-actions.ts | 152 + .../components/use-terminal-close-actions.ts | 120 + .../src/components/use-terminal-controller.ts | 40 + .../components/use-terminal-create-actions.ts | 215 + ...se-terminal-editor-close-dialog-actions.ts | 153 + .../use-terminal-editor-close-foundation.ts | 89 + .../use-terminal-editor-close-queue.ts | 137 + .../use-terminal-keyboard-shortcuts.ts | 54 + .../use-terminal-parking-foundation.ts | 171 + .../components/use-terminal-parking-pass.ts | 174 + .../use-terminal-watcher-effects.ts | 200 + .../use-terminal-window-lifecycle.ts | 117 + .../use-terminal-workspace-foundation.ts | 94 + .../use-terminal-workspace-projection.ts | 110 + .../use-terminal-workspace-store-bindings.ts | 118 + .../use-worktree-jump-palette-controller.ts | 127 + ...use-worktree-jump-palette-create-action.ts | 225 + .../use-worktree-jump-palette-filter.ts | 126 + .../use-worktree-jump-palette-list-entries.ts | 230 + .../use-worktree-jump-palette-local-state.ts | 65 + .../use-worktree-jump-palette-open-tabs.ts | 275 ++ ...e-worktree-jump-palette-project-targets.ts | 134 + ...use-worktree-jump-palette-quick-actions.ts | 148 + .../use-worktree-jump-palette-recent-tabs.ts | 200 + .../use-worktree-jump-palette-sections.ts | 134 + ...worktree-jump-palette-selection-actions.ts | 265 ++ ...rktree-jump-palette-selection-lifecycle.ts | 237 + .../use-worktree-jump-palette-store-state.ts | 170 + .../use-worktree-jump-palette-worktrees.ts | 226 + .../inactive-workspace-estimate.ts | 38 + .../workspace-surface-projection.test.ts | 53 +- ...ee-jump-palette-browser-simulator-rows.tsx | 177 + .../worktree-jump-palette-empty-state.ts | 71 + .../worktree-jump-palette-entry.tsx | 78 + .../components/worktree-jump-palette-model.ts | 108 + .../worktree-jump-palette-primitives.tsx | 173 + ...ktree-jump-palette-project-action-rows.tsx | 114 + .../worktree-jump-palette-recent-inclusion.ts | 49 + ...ktree-jump-palette-sleeping-filter.test.ts | 16 +- ...mp-palette-source-context-boundary.test.ts | 20 +- ...rktree-jump-palette-source.test-support.ts | 6 + .../worktree-jump-palette-surface.tsx | 166 + ...orktree-jump-palette-workspace-tab-row.tsx | 103 + .../worktree-jump-palette-worktree-row.tsx | 135 + src/renderer/src/lib/cmd-j-match-relevance.ts | 96 + 370 files changed, 53838 insertions(+), 30703 deletions(-) create mode 100644 src/renderer/src/components/TaskPageContent.tsx create mode 100644 src/renderer/src/components/TaskPageFrame.tsx create mode 100644 src/renderer/src/components/TaskPageGitHubAssigneesCell.tsx create mode 100644 src/renderer/src/components/TaskPageGitHubAvatars.tsx create mode 100644 src/renderer/src/components/TaskPageGitHubChecksCell.tsx create mode 100644 src/renderer/src/components/TaskPageGitHubDuplicatePicker.tsx create mode 100644 src/renderer/src/components/TaskPageGitHubFilters.tsx create mode 100644 src/renderer/src/components/TaskPageGitHubIssueDialog.tsx create mode 100644 src/renderer/src/components/TaskPageGitHubIssueSelectors.tsx create mode 100644 src/renderer/src/components/TaskPageGitHubList.tsx create mode 100644 src/renderer/src/components/TaskPageGitHubMergeCell.tsx create mode 100644 src/renderer/src/components/TaskPageGitHubModeControls.tsx create mode 100644 src/renderer/src/components/TaskPageGitHubReviewCell.tsx create mode 100644 src/renderer/src/components/TaskPageGitHubReviewerPicker.tsx create mode 100644 src/renderer/src/components/TaskPageGitHubRows.tsx create mode 100644 src/renderer/src/components/TaskPageGitHubStatusCell.tsx create mode 100644 src/renderer/src/components/TaskPageGitLabDialog.tsx create mode 100644 src/renderer/src/components/TaskPageGitLabFilters.tsx create mode 100644 src/renderer/src/components/TaskPageGitLabItemList.tsx create mode 100644 src/renderer/src/components/TaskPageGitLabTodoList.tsx create mode 100644 src/renderer/src/components/TaskPageJiraConnectDialog.tsx create mode 100644 src/renderer/src/components/TaskPageJiraContent.tsx create mode 100644 src/renderer/src/components/TaskPageJiraFilters.tsx create mode 100644 src/renderer/src/components/TaskPageJiraIssueDialog.tsx create mode 100644 src/renderer/src/components/TaskPageLinearConnectDialog.tsx create mode 100644 src/renderer/src/components/TaskPageLinearContent.tsx create mode 100644 src/renderer/src/components/TaskPageLinearCustomViewList.tsx create mode 100644 src/renderer/src/components/TaskPageLinearCustomViewProjects.tsx create mode 100644 src/renderer/src/components/TaskPageLinearFilters.tsx create mode 100644 src/renderer/src/components/TaskPageLinearIssueAttributes.tsx create mode 100644 src/renderer/src/components/TaskPageLinearIssueBoard.tsx create mode 100644 src/renderer/src/components/TaskPageLinearIssueBoardColumns.tsx create mode 100644 src/renderer/src/components/TaskPageLinearIssueDialog.tsx create mode 100644 src/renderer/src/components/TaskPageLinearIssueList.tsx create mode 100644 src/renderer/src/components/TaskPageLinearIssueRows.tsx create mode 100644 src/renderer/src/components/TaskPageLinearIssueTable.tsx create mode 100644 src/renderer/src/components/TaskPageLinearIssueToolbar.tsx create mode 100644 src/renderer/src/components/TaskPageLinearProjectDialog.tsx create mode 100644 src/renderer/src/components/TaskPageLinearProjectFields.tsx create mode 100644 src/renderer/src/components/TaskPageLinearProjectList.tsx create mode 100644 src/renderer/src/components/TaskPageLinearProjectOverview.tsx create mode 100644 src/renderer/src/components/TaskPageListChrome.tsx create mode 100644 src/renderer/src/components/TaskPagePaginationBar.tsx create mode 100644 src/renderer/src/components/TaskPageProviderFilters.tsx create mode 100644 src/renderer/src/components/TaskPageSourceBar.tsx create mode 100644 src/renderer/src/components/TaskPageSurface.tsx create mode 100644 src/renderer/src/components/TerminalLegacyBrowserPanes.tsx create mode 100644 src/renderer/src/components/TerminalLegacyEditorSurface.tsx create mode 100644 src/renderer/src/components/TerminalLegacyTerminalPanes.tsx create mode 100644 src/renderer/src/components/TerminalLegacyWorkspaceSurface.tsx create mode 100644 src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx create mode 100644 src/renderer/src/components/TerminalSurface.tsx create mode 100644 src/renderer/src/components/TerminalTitlebarTabs.tsx create mode 100644 src/renderer/src/components/TerminalWorkspaceDialogs.tsx create mode 100644 src/renderer/src/components/TerminalWorktreeSplitSurface.tsx create mode 100644 src/renderer/src/components/automations/AutomationsPageSurface.tsx create mode 100644 src/renderer/src/components/automations/automation-hermes-save.ts create mode 100644 src/renderer/src/components/automations/automation-management-actions.ts create mode 100644 src/renderer/src/components/automations/automation-orca-save-operations.ts create mode 100644 src/renderer/src/components/automations/automation-orca-save.ts create mode 100644 src/renderer/src/components/automations/automation-run-actions.ts create mode 100644 src/renderer/src/components/automations/automation-run-completion-evidence.test.ts create mode 100644 src/renderer/src/components/automations/automation-run-completion-evidence.ts create mode 100644 src/renderer/src/components/automations/automation-run-workspace-action.ts create mode 100644 src/renderer/src/components/automations/automation-save-action.ts create mode 100644 src/renderer/src/components/automations/automation-save-context.ts create mode 100644 src/renderer/src/components/automations/automations-page-action-context.ts create mode 100644 src/renderer/src/components/automations/use-automation-draft-effects.ts create mode 100644 src/renderer/src/components/automations/use-automation-editor-actions.ts create mode 100644 src/renderer/src/components/automations/use-automation-run-page-state.ts create mode 100644 src/renderer/src/components/automations/use-automation-source-availability.ts create mode 100644 src/renderer/src/components/automations/use-automations-page-controller.ts create mode 100644 src/renderer/src/components/automations/use-automations-page-destination-form.ts create mode 100644 src/renderer/src/components/automations/use-automations-page-destination-state.ts create mode 100644 src/renderer/src/components/automations/use-automations-page-escape.ts create mode 100644 src/renderer/src/components/automations/use-automations-page-list-state.ts create mode 100644 src/renderer/src/components/automations/use-automations-page-local-state.ts create mode 100644 src/renderer/src/components/automations/use-automations-page-presentation-state.ts create mode 100644 src/renderer/src/components/automations/use-automations-page-refresh.ts create mode 100644 src/renderer/src/components/automations/use-automations-page-setup-state.ts create mode 100644 src/renderer/src/components/automations/use-automations-page-store-state.ts create mode 100644 src/renderer/src/components/automations/use-external-automation-actions.ts create mode 100644 src/renderer/src/components/editor/MarkdownPreviewAnnotationComposer.tsx create mode 100644 src/renderer/src/components/editor/MarkdownPreviewBody.tsx create mode 100644 src/renderer/src/components/editor/MarkdownPreviewReviewToolbar.tsx create mode 100644 src/renderer/src/components/editor/MarkdownPreviewSearchBar.tsx create mode 100644 src/renderer/src/components/editor/MarkdownPreviewSurface.tsx create mode 100644 src/renderer/src/components/editor/markdown-preview-anchor-navigation.ts create mode 100644 src/renderer/src/components/editor/markdown-preview-block-model.ts create mode 100644 src/renderer/src/components/editor/markdown-preview-editor-reveal.ts create mode 100644 src/renderer/src/components/editor/markdown-preview-link-actions.ts create mode 100644 src/renderer/src/components/editor/markdown-preview-source-routing.ts create mode 100644 src/renderer/src/components/editor/markdown-preview-system-link-action.ts create mode 100644 src/renderer/src/components/editor/markdown-preview-types.ts create mode 100644 src/renderer/src/components/editor/use-markdown-preview-annotation-renderers.tsx create mode 100644 src/renderer/src/components/editor/use-markdown-preview-components.tsx create mode 100644 src/renderer/src/components/editor/use-markdown-preview-foundation.ts create mode 100644 src/renderer/src/components/editor/use-markdown-preview-review-actions.ts create mode 100644 src/renderer/src/components/editor/use-markdown-preview-scroll-viewport.ts create mode 100644 src/renderer/src/components/editor/use-markdown-preview-source-foundation.ts create mode 100644 src/renderer/src/components/editor/use-markdown-preview-viewport.ts create mode 100644 src/renderer/src/components/floating-terminal/FloatingTerminalEmptyState.tsx create mode 100644 src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationCard.tsx create mode 100644 src/renderer/src/components/floating-terminal/FloatingTerminalPanelSurface.tsx create mode 100644 src/renderer/src/components/floating-terminal/FloatingTerminalSaveDialog.tsx create mode 100644 src/renderer/src/components/floating-terminal/floating-terminal-focus-reporting.ts create mode 100644 src/renderer/src/components/floating-terminal/floating-terminal-panel-drag-actions.ts create mode 100644 src/renderer/src/components/floating-terminal/floating-terminal-panel-drag-target.ts create mode 100644 src/renderer/src/components/floating-terminal/floating-terminal-panel-initial-bounds.ts create mode 100644 src/renderer/src/components/floating-terminal/floating-terminal-panel-types.ts create mode 100644 src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.ts create mode 100644 src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts create mode 100644 src/renderer/src/components/floating-terminal/use-floating-terminal-editor-close-queue.ts create mode 100644 src/renderer/src/components/floating-terminal/use-floating-terminal-focus-lifecycle.ts create mode 100644 src/renderer/src/components/floating-terminal/use-floating-terminal-global-shortcut-listeners.ts create mode 100644 src/renderer/src/components/floating-terminal/use-floating-terminal-guest-bridge.ts create mode 100644 src/renderer/src/components/floating-terminal/use-floating-terminal-initial-focus-effects.ts create mode 100644 src/renderer/src/components/floating-terminal/use-floating-terminal-orchestration-dismissal.ts create mode 100644 src/renderer/src/components/floating-terminal/use-floating-terminal-orchestration-visibility.ts create mode 100644 src/renderer/src/components/floating-terminal/use-floating-terminal-panel-controller.ts create mode 100644 src/renderer/src/components/floating-terminal/use-floating-terminal-panel-focus-reclaim.ts create mode 100644 src/renderer/src/components/floating-terminal/use-floating-terminal-panel-geometry.ts create mode 100644 src/renderer/src/components/floating-terminal/use-floating-terminal-panel-items.ts create mode 100644 src/renderer/src/components/floating-terminal/use-floating-terminal-panel-local-state.ts create mode 100644 src/renderer/src/components/floating-terminal/use-floating-terminal-panel-maximize.ts create mode 100644 src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts create mode 100644 src/renderer/src/components/floating-terminal/use-floating-terminal-panel-store-state.ts create mode 100644 src/renderer/src/components/floating-terminal/use-floating-terminal-shortcut-details.ts create mode 100644 src/renderer/src/components/new-workspace/smart-workspace-cross-repo-dialog.tsx create mode 100644 src/renderer/src/components/new-workspace/smart-workspace-github-direct-link.ts create mode 100644 src/renderer/src/components/new-workspace/smart-workspace-name-field-copy.ts create mode 100644 src/renderer/src/components/new-workspace/smart-workspace-name-field-model.ts create mode 100644 src/renderer/src/components/new-workspace/smart-workspace-name-field-surface.tsx create mode 100644 src/renderer/src/components/new-workspace/smart-workspace-name-input-surface.tsx create mode 100644 src/renderer/src/components/new-workspace/smart-workspace-provider-availability.ts create mode 100644 src/renderer/src/components/new-workspace/smart-workspace-repo-slug.ts create mode 100644 src/renderer/src/components/new-workspace/smart-workspace-source-results-surface.tsx create mode 100644 src/renderer/src/components/new-workspace/smart-workspace-source-row-content.tsx create mode 100644 src/renderer/src/components/new-workspace/use-smart-workspace-field-availability.ts create mode 100644 src/renderer/src/components/new-workspace/use-smart-workspace-field-focus-controls.ts create mode 100644 src/renderer/src/components/new-workspace/use-smart-workspace-github-search.ts create mode 100644 src/renderer/src/components/new-workspace/use-smart-workspace-gitlab-search.ts create mode 100644 src/renderer/src/components/new-workspace/use-smart-workspace-name-field-actions.ts create mode 100644 src/renderer/src/components/new-workspace/use-smart-workspace-name-field-controller.ts create mode 100644 src/renderer/src/components/new-workspace/use-smart-workspace-name-field-foundation.ts create mode 100644 src/renderer/src/components/new-workspace/use-smart-workspace-name-field-presentation.ts create mode 100644 src/renderer/src/components/new-workspace/use-smart-workspace-name-field-state.ts create mode 100644 src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts create mode 100644 src/renderer/src/components/refactor-hook-contract.test-support.ts create mode 100644 src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx create mode 100644 src/renderer/src/components/status-bar/CodexSwitcherMenu.tsx create mode 100644 src/renderer/src/components/status-bar/InlineProviderUsage.tsx create mode 100644 src/renderer/src/components/status-bar/ProviderDetailsMenu.tsx create mode 100644 src/renderer/src/components/status-bar/ResourceUsageAppSection.tsx create mode 100644 src/renderer/src/components/status-bar/ResourceUsageKillDialog.tsx create mode 100644 src/renderer/src/components/status-bar/ResourceUsageMetrics.tsx create mode 100644 src/renderer/src/components/status-bar/ResourceUsagePopoverContent.tsx create mode 100644 src/renderer/src/components/status-bar/ResourceUsageTree.tsx create mode 100644 src/renderer/src/components/status-bar/ResourceUsageTrigger.tsx create mode 100644 src/renderer/src/components/status-bar/ResourceUsageWorktreeRow.tsx create mode 100644 src/renderer/src/components/status-bar/StatusBarAccountControls.tsx create mode 100644 src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx create mode 100644 src/renderer/src/components/status-bar/StatusBarSurface.tsx create mode 100644 src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx create mode 100644 src/renderer/src/components/status-bar/codex-switcher-projection.ts create mode 100644 src/renderer/src/components/status-bar/resource-usage-sort.ts create mode 100644 src/renderer/src/components/status-bar/status-bar-claude-accounts.ts create mode 100644 src/renderer/src/components/status-bar/status-bar-codex-accounts.ts create mode 100644 src/renderer/src/components/status-bar/status-bar-container-observer.ts create mode 100644 src/renderer/src/components/status-bar/status-bar-runtime-targets.ts create mode 100644 src/renderer/src/components/status-bar/use-codex-switcher-controller.ts create mode 100644 src/renderer/src/components/status-bar/use-resource-usage-foundation.ts create mode 100644 src/renderer/src/components/status-bar/use-resource-usage-projection.ts create mode 100644 src/renderer/src/components/status-bar/use-status-bar-controller.ts create mode 100644 src/renderer/src/components/status-bar/use-workspace-space-decision-projection.ts create mode 100644 src/renderer/src/components/status-bar/use-workspace-space-git-refresh-action.ts create mode 100644 src/renderer/src/components/status-bar/use-workspace-space-manager-bindings.ts create mode 100644 src/renderer/src/components/status-bar/use-workspace-space-manager-panel.ts create mode 100644 src/renderer/src/components/status-bar/use-workspace-space-manager-projection.ts create mode 100644 src/renderer/src/components/status-bar/workspace-space-breakdown-list.tsx create mode 100644 src/renderer/src/components/status-bar/workspace-space-decision-details.ts create mode 100644 src/renderer/src/components/status-bar/workspace-space-decision-hover-card.tsx create mode 100644 src/renderer/src/components/status-bar/workspace-space-manager-overview.tsx create mode 100644 src/renderer/src/components/status-bar/workspace-space-manager-source-boundary.test.ts create mode 100644 src/renderer/src/components/status-bar/workspace-space-manager-state-types.ts create mode 100644 src/renderer/src/components/status-bar/workspace-space-manager-table.tsx create mode 100644 src/renderer/src/components/status-bar/workspace-space-manager-toolbar.tsx create mode 100644 src/renderer/src/components/status-bar/workspace-space-manager-view.tsx create mode 100644 src/renderer/src/components/status-bar/workspace-space-metrics.tsx create mode 100644 src/renderer/src/components/status-bar/workspace-space-selection-controls.tsx create mode 100644 src/renderer/src/components/status-bar/workspace-space-state-resolution.ts create mode 100644 src/renderer/src/components/status-bar/workspace-space-status-badge.tsx create mode 100644 src/renderer/src/components/status-bar/workspace-space-treemap.tsx create mode 100644 src/renderer/src/components/status-bar/workspace-space-worktree-row.tsx create mode 100644 src/renderer/src/components/task-page-draft-storage.tsx create mode 100644 src/renderer/src/components/task-page-github-landing-refresh-run.tsx create mode 100644 src/renderer/src/components/task-page-github-quiet-refresh-run.tsx create mode 100644 src/renderer/src/components/task-page-github-review-model.tsx create mode 100644 src/renderer/src/components/task-page-github-reviewer-actions.ts create mode 100644 src/renderer/src/components/task-page-linear-issue-model.tsx create mode 100644 src/renderer/src/components/task-page-linear-jira-list-model.tsx create mode 100644 src/renderer/src/components/task-page-source-context.tsx create mode 100644 src/renderer/src/components/task-page-source-family.test-support.ts create mode 100644 src/renderer/src/components/terminal-agent-tab-shortcut.ts create mode 100644 src/renderer/src/components/terminal-cold-activation.ts create mode 100644 src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx create mode 100644 src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx create mode 100644 src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx create mode 100644 src/renderer/src/components/terminal-pane/TerminalQuickCommandEditorDialog.tsx create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-layout-restore.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-close.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-primitives.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-types.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-listener-order-parity.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-manager-options.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-mount-bootstrap.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-mount-cleanup.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-mount-context.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-mount-events.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-pane-closed.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-pane-input.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-pane-links.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-paste-execution.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-paste-listeners.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-pty-deps.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-types.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-pane-context-actions.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-pane-controller.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-pane-foundation.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-pane-global-listeners.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-pane-layout-bindings.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle-refs.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle-stage.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-pane-mobile-actions.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-pane-mount-lifecycle.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-pane-paste-listeners.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-pane-process-exit-actions.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-pane-reconciliation.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-pane-startup-actions.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-pane-store-bindings.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-pane-title-effects.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-pane-title-state.ts create mode 100644 src/renderer/src/components/terminal-parking-pass-candidates.ts create mode 100644 src/renderer/src/components/terminal-workspace-keydown.ts create mode 100644 src/renderer/src/components/terminal-workspace-model.ts create mode 100644 src/renderer/src/components/use-task-page-composer-actions.ts create mode 100644 src/renderer/src/components/use-task-page-detail-routing.ts create mode 100644 src/renderer/src/components/use-task-page-github-cache-reconciliation.ts create mode 100644 src/renderer/src/components/use-task-page-github-detail.ts create mode 100644 src/renderer/src/components/use-task-page-github-issue-creation.ts create mode 100644 src/renderer/src/components/use-task-page-github-issue-draft.ts create mode 100644 src/renderer/src/components/use-task-page-github-landing-refresh.ts create mode 100644 src/renderer/src/components/use-task-page-github-list-projection.ts create mode 100644 src/renderer/src/components/use-task-page-github-list-state.ts create mode 100644 src/renderer/src/components/use-task-page-github-mutation-state.ts create mode 100644 src/renderer/src/components/use-task-page-github-quiet-refresh-effect.ts create mode 100644 src/renderer/src/components/use-task-page-github-quiet-refresh.ts create mode 100644 src/renderer/src/components/use-task-page-github-search-pagination.ts create mode 100644 src/renderer/src/components/use-task-page-gitlab-loading.ts create mode 100644 src/renderer/src/components/use-task-page-global-effects.ts create mode 100644 src/renderer/src/components/use-task-page-jira-creation-metadata.ts create mode 100644 src/renderer/src/components/use-task-page-jira-creation-projects.ts create mode 100644 src/renderer/src/components/use-task-page-jira-creation-state.ts create mode 100644 src/renderer/src/components/use-task-page-jira-issue-creation.ts create mode 100644 src/renderer/src/components/use-task-page-jira-list-effects.ts create mode 100644 src/renderer/src/components/use-task-page-jira-list-projection.ts create mode 100644 src/renderer/src/components/use-task-page-jira-list-state.ts create mode 100644 src/renderer/src/components/use-task-page-linear-board.ts create mode 100644 src/renderer/src/components/use-task-page-linear-collection-effects.ts create mode 100644 src/renderer/src/components/use-task-page-linear-context-actions.ts create mode 100644 src/renderer/src/components/use-task-page-linear-context-state.ts create mode 100644 src/renderer/src/components/use-task-page-linear-creation-state.ts create mode 100644 src/renderer/src/components/use-task-page-linear-custom-view-effects.ts create mode 100644 src/renderer/src/components/use-task-page-linear-filter-selection.ts create mode 100644 src/renderer/src/components/use-task-page-linear-in-orca-effects.ts create mode 100644 src/renderer/src/components/use-task-page-linear-issue-creation.ts create mode 100644 src/renderer/src/components/use-task-page-linear-list-effects.ts create mode 100644 src/renderer/src/components/use-task-page-linear-list-presentation.ts create mode 100644 src/renderer/src/components/use-task-page-linear-list-projection.ts create mode 100644 src/renderer/src/components/use-task-page-linear-list-selection.ts create mode 100644 src/renderer/src/components/use-task-page-linear-project-creation.ts create mode 100644 src/renderer/src/components/use-task-page-linear-view-state.ts create mode 100644 src/renderer/src/components/use-task-page-provider-metadata.ts create mode 100644 src/renderer/src/components/use-task-page-provider-state.ts create mode 100644 src/renderer/src/components/use-task-page-repo-selection.ts create mode 100644 src/renderer/src/components/use-task-page-resume-restoration.ts create mode 100644 src/renderer/src/components/use-task-page-runtime-hosts.ts create mode 100644 src/renderer/src/components/use-task-page-search-actions.ts create mode 100644 src/renderer/src/components/use-task-page-source-availability.ts create mode 100644 src/renderer/src/components/use-task-page-source-summary.ts create mode 100644 src/renderer/src/components/use-task-page-store-bindings.ts create mode 100644 src/renderer/src/components/use-task-page-workspace-actions.ts create mode 100644 src/renderer/src/components/use-terminal-activation-actions.ts create mode 100644 src/renderer/src/components/use-terminal-browser-retention.ts create mode 100644 src/renderer/src/components/use-terminal-bulk-close-actions.ts create mode 100644 src/renderer/src/components/use-terminal-close-actions.ts create mode 100644 src/renderer/src/components/use-terminal-controller.ts create mode 100644 src/renderer/src/components/use-terminal-create-actions.ts create mode 100644 src/renderer/src/components/use-terminal-editor-close-dialog-actions.ts create mode 100644 src/renderer/src/components/use-terminal-editor-close-foundation.ts create mode 100644 src/renderer/src/components/use-terminal-editor-close-queue.ts create mode 100644 src/renderer/src/components/use-terminal-keyboard-shortcuts.ts create mode 100644 src/renderer/src/components/use-terminal-parking-foundation.ts create mode 100644 src/renderer/src/components/use-terminal-parking-pass.ts create mode 100644 src/renderer/src/components/use-terminal-watcher-effects.ts create mode 100644 src/renderer/src/components/use-terminal-window-lifecycle.ts create mode 100644 src/renderer/src/components/use-terminal-workspace-foundation.ts create mode 100644 src/renderer/src/components/use-terminal-workspace-projection.ts create mode 100644 src/renderer/src/components/use-terminal-workspace-store-bindings.ts create mode 100644 src/renderer/src/components/use-worktree-jump-palette-controller.ts create mode 100644 src/renderer/src/components/use-worktree-jump-palette-create-action.ts create mode 100644 src/renderer/src/components/use-worktree-jump-palette-filter.ts create mode 100644 src/renderer/src/components/use-worktree-jump-palette-list-entries.ts create mode 100644 src/renderer/src/components/use-worktree-jump-palette-local-state.ts create mode 100644 src/renderer/src/components/use-worktree-jump-palette-open-tabs.ts create mode 100644 src/renderer/src/components/use-worktree-jump-palette-project-targets.ts create mode 100644 src/renderer/src/components/use-worktree-jump-palette-quick-actions.ts create mode 100644 src/renderer/src/components/use-worktree-jump-palette-recent-tabs.ts create mode 100644 src/renderer/src/components/use-worktree-jump-palette-sections.ts create mode 100644 src/renderer/src/components/use-worktree-jump-palette-selection-actions.ts create mode 100644 src/renderer/src/components/use-worktree-jump-palette-selection-lifecycle.ts create mode 100644 src/renderer/src/components/use-worktree-jump-palette-store-state.ts create mode 100644 src/renderer/src/components/use-worktree-jump-palette-worktrees.ts create mode 100644 src/renderer/src/components/workspace-cleanup/inactive-workspace-estimate.ts create mode 100644 src/renderer/src/components/worktree-jump-palette-browser-simulator-rows.tsx create mode 100644 src/renderer/src/components/worktree-jump-palette-empty-state.ts create mode 100644 src/renderer/src/components/worktree-jump-palette-entry.tsx create mode 100644 src/renderer/src/components/worktree-jump-palette-model.ts create mode 100644 src/renderer/src/components/worktree-jump-palette-primitives.tsx create mode 100644 src/renderer/src/components/worktree-jump-palette-project-action-rows.tsx create mode 100644 src/renderer/src/components/worktree-jump-palette-recent-inclusion.ts create mode 100644 src/renderer/src/components/worktree-jump-palette-source.test-support.ts create mode 100644 src/renderer/src/components/worktree-jump-palette-surface.tsx create mode 100644 src/renderer/src/components/worktree-jump-palette-workspace-tab-row.tsx create mode 100644 src/renderer/src/components/worktree-jump-palette-worktree-row.tsx create mode 100644 src/renderer/src/lib/cmd-j-match-relevance.ts diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index a64fcc3cbff..24a9cc75120 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -21,7 +21,7 @@ const PERSISTED_UI_WRITER_PATH = 'src/renderer/src/app-shell/use-persisted-ui-wr describe('renderer startup runtime routing', () => { it('routes packaged terminal restore through the daemon adoption gate', () => { const source = readFileSync( - join(process.cwd(), 'src/renderer/src/components/Terminal.tsx'), + join(process.cwd(), 'src/renderer/src/components/use-terminal-watcher-effects.ts'), 'utf8' ) const gateStart = source.indexOf('const startupActivationGateWorktreeIdsRef') @@ -226,7 +226,9 @@ describe('renderer startup runtime routing', () => { }) it('keeps the persisted Automations view from starting its own bootstrap worktree scan', () => { - const source = readSource('src/renderer/src/components/automations/AutomationsPage.tsx') + const source = readSource( + 'src/renderer/src/components/automations/use-automations-page-refresh.ts' + ) const fullRefreshStart = source.indexOf('const mountedBeforeStartupWorktreeRefreshRef') const fullRefreshEffect = source.slice( fullRefreshStart, @@ -348,7 +350,7 @@ describe('renderer startup runtime routing', () => { 'utf8' ) const terminalSource = readFileSync( - join(process.cwd(), 'src/renderer/src/components/Terminal.tsx'), + join(process.cwd(), 'src/renderer/src/components/use-terminal-watcher-effects.ts'), 'utf8' ) const hydrateIndex = appSource.indexOf("timeRendererStartupSyncStep('hydrate-session-stores'") @@ -468,7 +470,7 @@ describe('renderer startup runtime routing', () => { }) it('does not eagerly import optional status-bar segments on startup', () => { - const source = readSource('src/renderer/src/components/status-bar/StatusBar.tsx') + const source = readSource('src/renderer/src/components/status-bar/StatusBarSurface.tsx') expect(source).toContain("import('./ResourceUsageStatusSegment').then") expect(source).toContain("import('./PortsStatusSegment').then") diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 6138948e88d..fcd84c20856 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -1,3101 +1,84 @@ -/* eslint-disable max-lines -- Why: repo selector, task-source controls, and task list stay co-located so their wiring reads in one place. */ -import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { useShallow } from 'zustand/react/shallow' -import { toast } from 'sonner' - -import { useAppStore } from '@/store' - -import { parseTaskQuery, stripRepoQualifiers, withQualifier } from '../../../shared/task-query' -import type { PRFilterChange } from '@/components/github/PRFilterDropdowns' - -import { - overlayPendingOnTaskPagePages, - patchTaskPageGitHubWorkItemPages -} from '@/components/task-page-github-work-item-mutations' - -import type { ItemDialogTab } from '@/components/GitHubItemDialog' -import { getTaskPresetQuery } from '../../../shared/task-preset-query' - -import { - buildTaskPageRepoSourceState, - findTaskPageDialogWorkItem, - reconcileTaskPagePagesWithWorkItemsCache, - selectTaskPageUnresolvedSourceRepos, - selectTaskPageWorkItemsCacheEntries, - type TaskPageRepoSourceState -} from '@/components/task-page-cache-selectors' -import { shouldHideTaskPageListChrome } from '@/components/task-page-list-chrome-visibility' -import { taskPageGitHubResumeCache } from '@/components/task-page-github-resume-cache' -import { - filterLinearIssuesForInOrcaWorkspace, - readLinkedLinearIssuesWithLimit -} from '@/components/task-page-linear-in-orca-issues' -import { serializeLinearIssueViewResumeState } from '../../../shared/linear/issue-view-resume-state' -import { saveLinearIssueView } from './linear-issue-view-storage' - -import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' -import type { GitHubWorkItem } from '../../../shared/github/work-item-types' -import type { GitLabWorkItem } from '../../../shared/gitlab-types' - -import type { LinearIssue } from '../../../shared/linear/issue-types' -import type { - LinearCustomViewSummary, - LinearProjectSummary -} from '../../../shared/linear/project-types' -import type { LinearCollectionResult } from '../../../shared/linear/workspace-types' -import type { TaskViewPresetId } from '../../../shared/ui-chrome-types' -import { clampLinearIssueListLimit } from '../../../shared/linear/issue-read-limits' -import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard' -import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' - -import { translate } from '@/i18n/i18n' -import type { GitHubTaskKind } from '@/components/task-page-localized-options' -import { useGitHubTaskSearchCommit } from '@/components/use-github-task-search-commit' -import { - getDefaultPresetForGitHubTaskKind, - getGitHubTaskKind, - scopeGitHubTaskSearch -} from '@/components/task-page-github-task-kind' - -import { - LINEAR_ITEM_LIMIT, - TASK_SEARCH_DEBOUNCE_MS -} from '@/components/task-page/task-page-list-limits' - -import { - getTaskPageRepoCacheInput, - getTaskPageRepoSourceContext -} from '@/components/task-page/source/repo-source-context' - -import { - LINEAR_CUSTOM_VIEW_MODELS, - mergeLinearCollectionResults -} from '@/components/task-page/linear/linear-issue-grouping' - -import type { TaskPageSourceToolbarProps } from '@/components/task-page/chrome/task-page-source-toolbar' -import type { TaskPageGithubModeBarProps } from '@/components/task-page/chrome/task-page-github-mode-bar' -import type { TaskPageGithubItemFiltersProps } from '@/components/task-page/chrome/task-page-github-item-filters' -import type { TaskPageLinearFiltersProps } from '@/components/task-page/chrome/task-page-linear-filters' -import type { TaskPageJiraFiltersProps } from '@/components/task-page/chrome/task-page-jira-filters' -import type { TaskPageGitlabFiltersProps } from '@/components/task-page/chrome/task-page-gitlab-filters' -import type { GithubDetailHostProps } from '@/components/task-page/github/github-detail-host' -import type { GithubWorkItemTableProps } from '@/components/task-page/github/github-work-item-table' -import { startGitHubListScrollRestore } from '@/components/task-page/github/github-list-scroll-restore' -import type { GitlabWorkItemListProps } from '@/components/task-page/gitlab/gitlab-work-item-list' -import type { JiraIssueListHostProps } from '@/components/task-page/jira/jira-issue-list-host' -import type { NewGithubIssueDialogProps } from '@/components/task-page/dialogs/new-github-issue-dialog' -import type { NewLinearProjectDialogProps } from '@/components/task-page/dialogs/new-linear-project-dialog' -import type { NewLinearIssueDialogProps } from '@/components/task-page/dialogs/new-linear-issue-dialog' -import type { NewJiraIssueDialogProps } from '@/components/task-page/dialogs/new-jira-issue-dialog' -import type { TaskPageConnectDialogsProps } from '@/components/task-page/dialogs/task-page-connect-dialogs' -import type { LinearViewsHostProps } from '@/components/task-page/linear/linear-views-host' -import { useTaskPageStoreBindings } from '@/components/task-page/hooks/use-task-page-store-bindings' -import { useTaskPageRepoSelection } from '@/components/task-page/source/use-task-page-repo-selection' -import { useTaskPageAccountScopes } from '@/components/task-page/hooks/use-task-page-account-scopes' -import { useTaskPageVisibleSources } from '@/components/task-page/hooks/use-task-page-visible-sources' -import { useTaskPageTaskSourceState } from '@/components/task-page/hooks/use-task-page-task-source-state' -import { useTaskPageRuntimePreflight } from '@/components/task-page/hooks/use-task-page-runtime-preflight' -import { useTaskPageSourceAvailability } from '@/components/task-page/hooks/use-task-page-source-availability' -import { useTaskPageSourceNotices } from '@/components/task-page/hooks/use-task-page-source-notices' -import { useTaskPageSourceSync } from '@/components/task-page/hooks/use-task-page-source-sync' -import { useTaskPageGitLabListState } from '@/components/task-page/hooks/use-task-page-gitlab-list-state' -import { useTaskPageGitHubListState } from '@/components/task-page/hooks/use-task-page-github-list-state' -import { useTaskPageLinearListState } from '@/components/task-page/hooks/use-task-page-linear-list-state' -import { useTaskPageJiraListState } from '@/components/task-page/hooks/use-task-page-jira-list-state' -import { useTaskPageGitLabFetch } from '@/components/task-page/hooks/use-task-page-gitlab-fetch' -import { useTaskPageGitHubNewIssueState } from '@/components/task-page/hooks/use-task-page-github-new-issue-state' -import { useTaskPageSessionResume } from '@/components/task-page/hooks/use-task-page-session-resume' -import { useTaskPageSelectedIssueState } from '@/components/task-page/hooks/use-task-page-selected-issue-state' -import { useTaskPageLinearTeams } from '@/components/task-page/hooks/use-task-page-linear-teams' -import { useTaskPageJiraProjects } from '@/components/task-page/hooks/use-task-page-jira-projects' -import { useTaskPageLinearActiveCollection } from '@/components/task-page/hooks/use-task-page-linear-active-collection' -import { useTaskPageLinearBoard } from '@/components/task-page/hooks/use-task-page-linear-board' -import { useTaskPageLinearCreateDialogs } from '@/components/task-page/hooks/use-task-page-linear-create-dialogs' -import { useTaskPageGitHubMutationSession } from '@/components/task-page/hooks/use-task-page-github-mutation-session' -import { useTaskPageCreateGithubSubmit } from '@/components/task-page/hooks/use-task-page-create-github-submit' -import { useTaskPageLinearFetch } from '@/components/task-page/hooks/use-task-page-linear-fetch' -import { useTaskPageLinearIssueWindow } from '@/components/task-page/hooks/use-task-page-linear-issue-window' -import { useTaskPageLinearActions } from '@/components/task-page/hooks/use-task-page-linear-actions' -import { useTaskPageJiraFetch } from '@/components/task-page/hooks/use-task-page-jira-fetch' -import { useTaskPageJiraActions } from '@/components/task-page/hooks/use-task-page-jira-actions' -import { useTaskPageGitHubFilteredItems } from '@/components/task-page/hooks/use-task-page-github-filtered-items' -import { useTaskPageGitHubPageLoader } from '@/components/task-page/hooks/use-task-page-github-page-loader' -import { useTaskPageCreateLinearSubmits } from '@/components/task-page/hooks/use-task-page-create-linear-submits' -import { useTaskPageCreateJiraSubmit } from '@/components/task-page/hooks/use-task-page-create-jira-submit' -import { useTaskPageJiraDisplayedIssues } from '@/components/task-page/hooks/use-task-page-jira-displayed-issues' -import { useTaskPageJiraCreateDialog } from '@/components/task-page/hooks/use-task-page-jira-create-dialog' -import { useTaskPageCreateDialogResets } from '@/components/task-page/hooks/use-task-page-create-dialog-resets' -import { useTaskPageUseItemActions } from '@/components/task-page/hooks/use-task-page-use-item-actions' -import { TaskPageLayout } from '@/components/task-page/task-page-layout' -import { useTaskPageGitHubFetch } from '@/components/task-page/hooks/use-task-page-github-fetch' -import { useTaskPageGitHubQuietRevalidate } from '@/components/task-page/hooks/use-task-page-github-quiet-revalidate' +import React from 'react' +import { useTaskPageStoreBindings } from './use-task-page-store-bindings' +import { useTaskPageRepoSelection } from './use-task-page-repo-selection' +import { useTaskPageRuntimeHosts } from './use-task-page-runtime-hosts' +import { useTaskPageSourceAvailability } from './use-task-page-source-availability' +import { useTaskPageProviderState } from './use-task-page-provider-state' +import { useTaskPageGitHubListState } from './use-task-page-github-list-state' +import { useTaskPageGitHubDetail } from './use-task-page-github-detail' +import { useTaskPageGitHubCacheReconciliation } from './use-task-page-github-cache-reconciliation' +import { useTaskPageGitHubIssueDraft } from './use-task-page-github-issue-draft' +import { useTaskPageDetailRouting } from './use-task-page-detail-routing' +import { useTaskPageLinearViewState } from './use-task-page-linear-view-state' +import { useTaskPageJiraListState } from './use-task-page-jira-list-state' +import { useTaskPageResumeRestoration } from './use-task-page-resume-restoration' +import { useTaskPageProviderMetadata } from './use-task-page-provider-metadata' +import { useTaskPageGitLabLoading } from './use-task-page-gitlab-loading' +import { useTaskPageLinearListSelection } from './use-task-page-linear-list-selection' +import { useTaskPageLinearListProjection } from './use-task-page-linear-list-projection' +import { useTaskPageLinearBoard } from './use-task-page-linear-board' +import { useTaskPageJiraListProjection } from './use-task-page-jira-list-projection' +import { useTaskPageLinearCreationState } from './use-task-page-linear-creation-state' +import { useTaskPageGitHubMutationState } from './use-task-page-github-mutation-state' +import { useTaskPageJiraCreationState } from './use-task-page-jira-creation-state' +import { useTaskPageJiraCreationMetadata } from './use-task-page-jira-creation-metadata' +import { useTaskPageGitHubListProjection } from './use-task-page-github-list-projection' +import { useTaskPageGitHubSearchPagination } from './use-task-page-github-search-pagination' +import { useTaskPageGitHubLandingRefresh } from './use-task-page-github-landing-refresh' +import { useTaskPageGitHubQuietRefresh } from './use-task-page-github-quiet-refresh' +import { useTaskPageSearchActions } from './use-task-page-search-actions' +import { useTaskPageWorkspaceActions } from './use-task-page-workspace-actions' +import { useTaskPageGitHubIssueCreation } from './use-task-page-github-issue-creation' +import { useTaskPageLinearProjectCreation } from './use-task-page-linear-project-creation' +import { useTaskPageLinearIssueCreation } from './use-task-page-linear-issue-creation' +import { useTaskPageJiraIssueCreation } from './use-task-page-jira-issue-creation' +import { useTaskPageGlobalEffects } from './use-task-page-global-effects' +import { useTaskPageLinearListEffects } from './use-task-page-linear-list-effects' +import { useTaskPageLinearInOrcaEffects } from './use-task-page-linear-in-orca-effects' +import { useTaskPageLinearCollectionEffects } from './use-task-page-linear-collection-effects' +import { useTaskPageJiraListEffects } from './use-task-page-jira-list-effects' +import { useTaskPageComposerActions } from './use-task-page-composer-actions' +import { TaskPageSurface } from './TaskPageSurface' export default function TaskPage(): React.JSX.Element { - const { - settings, - persistedUIReady, - taskResumeState, - setTaskResumeState, - pageData, - openTaskPage, - closeTaskPage, - activeModal, - repos, - sshConnectionStates, - sshTargetLabels, - runtimeEnvironments, - runtimeStatusByEnvironmentId, - repoMap, - allWorktrees, - openModal, - updateSettings, - fetchWorkItemsAcrossRepos, - fetchPRChecks, - getCachedWorkItems, - setIssueSourcePreference, - workItemsInvalidationNonce, - linearStatus, - linearStatusContextKey, - preflightStatus, - preflightStatusChecked, - preflightStatusContextKey, - selectLinearWorkspace, - searchLinearIssues, - listLinearIssues, - linearListInvalidationToken, - folderWorkspaces, - invalidateLinearIssueLists, - getCachedLinearIssues, - fetchLinearIssue, - refreshLinearIssue, - getCachedLinearTeams, - listLinearTeams, - getCachedLinearProjects, - listLinearProjectsFromStore, - fetchLinearProject, - listLinearProjectIssues, - getCachedLinearCustomViews, - listLinearCustomViews, - fetchLinearCustomView, - listLinearCustomViewIssues, - listLinearCustomViewProjects, - patchLinearIssue, - checkLinearConnection, - refreshPreflightStatus, - expectedPreflightContextKey, - jiraStatus, - jiraStatusContextKey, - selectJiraSite, - searchJiraIssues, - listJiraIssues, - checkJiraConnection, - providerRuntimeContextKey, - providerRuntimeContextKeyRef, - preflightStatusCurrent, - linearStatusReady, - jiraStatusReady, - linearConnected, - jiraConnected, - submitShortcutLabel, - eligibleRepos - } = useTaskPageStoreBindings() - - const { - resolvedInitialSelection, - repoSelection, - setRepoSelection, - taskPickerGroups, - taskPickerRepos, - selectedRepos, - selectedReposKey, - primaryRepo - } = useTaskPageRepoSelection({ - eligibleRepos, - pageDataPreselectedRepoId: pageData.preselectedRepoId, - defaultRepoSelection: settings?.defaultRepoSelection - }) - const { - linearWorkspaces, - selectedLinearWorkspaceId, - selectedLinearWorkspace, - jiraSites, - selectedJiraSiteId, - selectedJiraSite - } = useTaskPageAccountScopes({ linearStatus, jiraStatus }) - const { - visibleTaskProviders, - sourceOptions, - githubModeButtons, - linearModeOptions, - jiraPresets, - gitLabIssueFilters, - gitLabMRFilters, - linearViewOptions, - linearGroupOptions, - linearOrderOptions, - linearDisplayPropertyOptions, - visibleSourceOptions, - hideTaskSource, - defaultTaskViewPreset, - initialTaskQuery, - preferredTaskSource - } = useTaskPageVisibleSources({ - settings, - preflightStatusCurrent, - preflightStatus, - linearConnected, - updateSettings, - pageData - }) - const { taskSource, setTaskSource } = useTaskPageTaskSourceState({ - preferredTaskSource, - visibleTaskProviders - }) - const { - runtimePreflightStatusByHostId, - taskSourceRepoContexts, - hostRegistryById, - hostLabelById, - getTaskPickerRepoHostLabel - } = useTaskPageRuntimePreflight({ - taskSource, - selectedRepos, - repos, - settings, - sshTargetLabels, - sshConnectionStates, - runtimeEnvironments, - runtimeStatusByEnvironmentId - }) - const { - taskSourceHostAvailability, - accountBackedTaskSourceHostId, - linearTaskSourceContext, - linearListInvalidationVersionForSource, - jiraTaskSourceContext, - jiraTaskSourceScopeKey, - accountBackedTaskSourceHostAvailability - } = useTaskPageSourceAvailability({ - taskSource, - selectedRepos, - hostRegistryById, - taskSourceRepoContexts, - runtimePreflightStatusByHostId, - preflightStatus, - preflightStatusChecked, - preflightStatusCurrent, - settings, - linearListInvalidationToken, - providerRuntimeContextKey, - selectedLinearWorkspace, - selectedLinearWorkspaceId, - selectedJiraSite, - selectedJiraSiteId - }) - const { - taskSourceAvailabilityNoticeByProvider, - taskSourceContextSummary, - taskSourceAvailabilityNotice, - githubEmptyState - } = useTaskPageSourceNotices({ - taskSource, - selectedRepos, - hostRegistryById, - hostLabelById, - preflightStatus, - preflightStatusCurrent, - preflightStatusChecked, - runtimePreflightStatusByHostId, - taskSourceRepoContexts, - accountBackedTaskSourceHostId, - accountBackedTaskSourceHostAvailability, - taskSourceHostAvailability, - selectedLinearWorkspace, - selectedJiraSite, - sourceOptions - }) - const { - taskSourceManuallyChangedRef, - taskResumeAppliedRef, - githubSearchPersistReadyRef, - linearSearchPersistReadyRef, - linearViewPersistReadyRef, - jiraSearchPersistReadyRef, - taskResumeApplied, - setTaskResumeApplied - } = useTaskPageSourceSync({ - pageData, - preferredTaskSource, - taskSource, - setTaskSource, - visibleTaskProviders, - settings - }) - - // Why: Project mode is a GitHub sub-tab — visible on the GitHub source, but actual entry is gated on a non-null activeProject. - const projectModeVisible = taskSource === 'github' - const [githubMode, setGithubMode] = useState<'items' | 'project'>('items') - - const { - setGitlabFilter, - gitlabItems, - setGitlabItems, - gitlabLoading, - setGitlabLoading, - gitlabError, - setGitlabError, - gitlabRefreshNonce, - setGitlabRefreshNonce, - gitlabDialogItem, - setGitlabDialogItem, - gitlabView, - setGitlabView, - gitlabIssuePage, - setGitlabIssuePage, - gitlabIssueTotalPages, - setGitlabIssueTotalPages, - gitlabIssueLoadingTargetPage, - setGitlabIssueLoadingTargetPage, - gitlabTodos, - setGitlabTodos, - gitlabTodosLoading, - setGitlabTodosLoading, - gitlabEmptyState, - activeGitlabFilter, - displayedGitLabItems - } = useTaskPageGitLabListState({ taskSource, selectedRepos, selectedReposKey }) - const { - taskSearchInput, - setTaskSearchInput, - appliedTaskSearch, - setAppliedTaskSearch, - taskSearchInputRef, - activeTaskPreset, - setActiveTaskPreset, - tasksLoading, - setTasksLoading, - tasksRefreshing, - setTasksRefreshing, - tasksFiltering, - setTasksFiltering, - tasksError, - setTasksError, - failedCount, - setFailedCount, - githubUnavailable, - setGithubUnavailable, - taskRefreshNonce, - setTaskRefreshNonce, - quietRefreshNonce, - setQuietRefreshNonce, - githubViewerLogin, - setGitHubViewerLogin, - lastFetchedNonceRef, - lastFetchedInvalidationNonceRef, - paginationGenerationRef, - landingGitHubRefreshKeysRef, - githubPerRepoPageLimit, - githubPageSize, - githubResumeContextKey, - pages, - setPages, - currentPage, - setCurrentPage, - pagesRef, - currentPageRef, - githubResumeConsumedRef, - githubResumeContextRef, - githubListScrollRef, - githubListScrollTopRef, - pendingGithubScrollRestoreRef, - githubRestoreScrollWriteRef, - paginationLoading, - setPaginationLoading, - loadingTargetPage, - setLoadingTargetPage, - countedTotalPages, - setCountedTotalPages, - provenPageLimit, - setProvenPageLimit, - countedTotalPagesRef, - hardRefreshEpochRef, - fetchWorkItemsNextPage, - countWorkItemsAcrossRepos - } = useTaskPageGitHubListState({ - initialTaskQuery, - defaultTaskViewPreset, - selectedRepos, - selectedReposKey, - getCachedWorkItems - }) - - useEffect(() => { - const page = pages[currentPage] - if (!taskResumeApplied || taskSource !== 'github' || githubMode !== 'items' || !page) { - return - } - taskPageGitHubResumeCache.write(githubResumeContextKey, currentPage, page) - }, [currentPage, githubMode, githubResumeContextKey, pages, taskResumeApplied, taskSource]) - - const taskListPositionRef = useRef<{ - contextKey: string - page: number - scrollTop: number - } | null>(null) - useLayoutEffect(() => { - if ( - taskSource !== 'github' || - githubMode !== 'items' || - pageData.openGitHubWorkItem || - pendingGithubScrollRestoreRef.current !== null - ) { - return - } - taskListPositionRef.current = { - contextKey: githubResumeContextKey, - page: currentPage, - scrollTop: githubListScrollTopRef.current - } - }, [ - currentPage, - githubMode, - githubResumeContextKey, - pageData.openGitHubWorkItem, - taskSource, - githubListScrollTopRef, - pendingGithubScrollRestoreRef - ]) - - useEffect( - () => () => { - const position = taskListPositionRef.current - const state = useAppStore.getState() - if (position && !state.taskPageData.openGitHubWorkItem) { - state.setTaskListPosition({ - contextKey: position.contextKey, - page: position.page, - scrollTop: position.scrollTop - }) - } - }, - [] - ) - - // Why: keyed on selectedReposKey, not the selectedRepos array — a background - // repos:changed refresh mid-flight would otherwise bump the generation and - // silently discard the user's page navigation (#11485). Mirrors every dep of - // the fetch effect that resets page state, so a reset always invalidates - // in-flight page requests. - useEffect(() => { - paginationGenerationRef.current += 1 - setPaginationLoading(false) - setLoadingTargetPage(null) - }, [ - selectedReposKey, - appliedTaskSearch, - workItemsInvalidationNonce, - taskRefreshNonce, - taskSource, - githubMode, - taskResumeApplied, - - setPaginationLoading, - paginationGenerationRef, - setLoadingTargetPage - ]) - - // Why: the dialog's "Use" button routes through the same direct-launch flow as the row-level "Use" CTA so behavior is consistent regardless of entry point. - const githubTaskDrawerWorkItem = useAppStore((s) => s.githubTaskDrawerWorkItem) - const setGithubTaskDrawerWorkItem = useAppStore((s) => s.setGithubTaskDrawerWorkItem) - const [dialogInitialTab, setDialogInitialTab] = useState('conversation') - const dialogWorkItemKey = githubTaskDrawerWorkItem - ? { id: githubTaskDrawerWorkItem.id, repoId: githubTaskDrawerWorkItem.repoId } - : null - - const appliedWorkItemsCacheQuery = useMemo( - () => stripRepoQualifiers(appliedTaskSearch.trim()), - [appliedTaskSearch] - ) - const selectedWorkItemsCacheEntries = useAppStore( - useShallow((s) => - selectTaskPageWorkItemsCacheEntries( - s.workItemsCache, - selectedRepos.map(getTaskPageRepoCacheInput), - githubPerRepoPageLimit, - appliedWorkItemsCacheQuery - ) - ) - ) - - // 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. - const cachedDialogWorkItem = useAppStore((s) => - findTaskPageDialogWorkItem(s.workItemsCache, dialogWorkItemKey) - ) - const dialogWorkItem = dialogWorkItemKey - ? (cachedDialogWorkItem ?? githubTaskDrawerWorkItem) - : null - - useLayoutEffect(() => { - const target = pendingGithubScrollRestoreRef.current - // Start observing as soon as the list mounts; its page rows may be committed - // by a later render, and the restore helper will retry when they appear. - // Keep the target armed while the detail route is transitioning and the list is still mounted. - if (target === null || pageData.openGitHubWorkItem) { - return - } - return startGitHubListScrollRestore({ - target, - scrollElementRef: githubListScrollRef, - pendingRestoreRef: pendingGithubScrollRestoreRef, - restoreWriteRef: githubRestoreScrollWriteRef, - onScrollTopApplied: (scrollTop) => { - githubListScrollTopRef.current = scrollTop - taskListPositionRef.current = { - contextKey: githubResumeContextKey, - page: currentPage, - scrollTop - } - } - }) - }, [ - currentPage, - dialogWorkItem, - githubResumeContextKey, - pageData.openGitHubWorkItem, - pages, - githubListScrollTopRef, - pendingGithubScrollRestoreRef, - githubRestoreScrollWriteRef, - githubListScrollRef.current?.scrollTop, - githubListScrollRef - ]) - - const dialogRepoPath = dialogWorkItem ? (repoMap.get(dialogWorkItem.repoId)?.path ?? null) : null - const dialogSourceContext = useMemo(() => { - if (!dialogWorkItem) { - return null - } - if ( - pageData.openGitHubSourceContext?.provider === 'github' && - pageData.openGitHubWorkItem?.id === dialogWorkItem.id && - pageData.openGitHubWorkItem.repoId === dialogWorkItem.repoId - ) { - return pageData.openGitHubSourceContext - } - return getTaskPageRepoSourceContext(repoMap.get(dialogWorkItem.repoId), 'github') - }, [dialogWorkItem, pageData.openGitHubSourceContext, pageData.openGitHubWorkItem, repoMap]) - const gitlabDialogRepo = useMemo( - () => - gitlabDialogItem - ? (selectedRepos.find((r) => r.id === gitlabDialogItem.repoId) ?? primaryRepo) - : null, - [gitlabDialogItem, primaryRepo, selectedRepos] - ) - const gitlabDialogSourceContext = useMemo(() => { - if (!gitlabDialogItem) { - return null - } - if ( - pageData.openGitLabSourceContext?.provider === 'gitlab' && - pageData.openGitLabWorkItem?.id === gitlabDialogItem.id && - pageData.openGitLabWorkItem?.repoId === gitlabDialogItem.repoId - ) { - return pageData.openGitLabSourceContext - } - return getTaskPageRepoSourceContext(gitlabDialogRepo, 'gitlab', gitlabDialogItem.projectRef) - }, [ - gitlabDialogItem, - gitlabDialogRepo, - pageData.openGitLabSourceContext, - pageData.openGitLabWorkItem - ]) - - const setDialogWorkItem = useCallback( - (item: GitHubWorkItem | null, initialTab: ItemDialogTab = 'conversation') => { - setDialogInitialTab(item ? initialTab : 'conversation') - setGithubTaskDrawerWorkItem(item) - }, - [setGithubTaskDrawerWorkItem] - ) - - useEffect(() => { - if (!pageData.openGitHubWorkItem) { - setDialogWorkItem(null) - return - } - setGithubMode('items') - setDialogWorkItem(pageData.openGitHubWorkItem, pageData.openGitHubInitialTab) - }, [pageData.openGitHubInitialTab, pageData.openGitHubWorkItem, setDialogWorkItem]) - - useEffect(() => { - setGitlabDialogItem(pageData.openGitLabWorkItem ?? null) - }, [pageData.openGitLabWorkItem, setGitlabDialogItem]) - - const openGitHubDetailPage = useCallback( - (item: GitHubWorkItem, initialTab: ItemDialogTab = 'conversation') => { - const scrollTop = githubListScrollRef.current?.scrollTop ?? githubListScrollTopRef.current - githubListScrollTopRef.current = scrollTop - pendingGithubScrollRestoreRef.current = scrollTop - taskListPositionRef.current = { - contextKey: githubResumeContextKey, - page: currentPageRef.current, - scrollTop - } - useAppStore.getState().setTaskListPosition(taskListPositionRef.current) - openTaskPage( - { - taskSource: 'github', - preselectedRepoId: item.repoId, - openGitHubWorkItem: item, - openGitHubSourceContext: getTaskPageRepoSourceContext(repoMap.get(item.repoId), 'github'), - openGitHubInitialTab: initialTab - }, - { recordTasksInteraction: false } - ) - }, - [ - githubResumeContextKey, - openTaskPage, - repoMap, - githubListScrollTopRef, - pendingGithubScrollRestoreRef, - currentPageRef, - githubListScrollRef.current?.scrollTop - ] - ) - - const openGitLabDetailPage = useCallback( - (item: GitLabWorkItem) => { - openTaskPage( - { - taskSource: 'gitlab', - preselectedRepoId: item.repoId, - openGitLabWorkItem: item, - openGitLabSourceContext: getTaskPageRepoSourceContext( - repoMap.get(item.repoId), - 'gitlab', - item.projectRef - ) - }, - { recordTasksInteraction: false } - ) - }, - [openTaskPage, repoMap] - ) - - const patchTaskPageWorkItemRows = useCallback( - ( - itemKey: { id: string; repoId: string }, - patch: Partial, - shouldPatch?: (item: GitHubWorkItem) => boolean - ): void => { - setPages((current) => { - return patchTaskPageGitHubWorkItemPages(current, itemKey, patch, shouldPatch) - }) - }, - [setPages] - ) - const handleDialogReviewRequestsChange = useCallback( - (itemKey: { id: string; repoId: string }, reviewRequests: GitHubAssignableUser[]): void => { - patchTaskPageWorkItemRows(itemKey, { reviewRequests }) - }, - [patchTaskPageWorkItemRows] - ) - - // Why: the per-repo issue-source indicator and retry banner both derive from the same workItemsCache entry, so no extra IPC. - // Why: subscribe only to entries this page renders; the selector returns entry refs so shallow equality filters unrelated cache writes. - const perRepoSourceState = useMemo( - () => buildTaskPageRepoSourceState(selectedRepos, selectedWorkItemsCacheEntries), - [selectedRepos, selectedWorkItemsCacheEntries] - ) - - // Why: repos that fetched but resolved no GitHub source (#9660) show empty like a genuine zero-result; surface them explicitly with Retry. - const unresolvedSourceRepos = useMemo( - () => selectTaskPageUnresolvedSourceRepos(selectedRepos, perRepoSourceState), - [selectedRepos, perRepoSourceState] - ) - - useEffect(() => { - if (taskSource !== 'github' || githubMode !== 'items') { - return - } - // Why: inline/dialog edits patch `workItemsCache`; the paged table renders - // from a local snapshot so it needs the patched row objects copied across. - // Hard guarantee (K4): always overlay pending after reconcile so list - // fetch clobbers never paint unprotected coordinator fields. - setPages((current) => - reconcileTaskPagePagesWithWorkItemsCache(current, selectedWorkItemsCacheEntries).map( - (page) => (page ? (overlayPendingOnTaskPagePages([page])[0] ?? []) : null) - ) - ) - }, [githubMode, selectedWorkItemsCacheEntries, taskSource, setPages]) - - // Why: one-time toast per repo when the 'upstream' preference fell back to origin (ref-gated); deliberately don't auto-reset the preference so re-adding upstream later still applies. - const fellBackToastedRef = useRef>(new Set()) - useEffect(() => { - if (taskSource !== 'github') { - return - } - for (const [index, r] of selectedRepos.entries()) { - const entry = selectedWorkItemsCacheEntries[index] - if (!entry?.issueSourceFellBack) { - continue - } - if (fellBackToastedRef.current.has(r.id)) { - continue - } - const prSlug = entry.sources?.prs - ? `${entry.sources.prs.owner}/${entry.sources.prs.repo}` - : r.displayName - toast.message( - translate( - 'auto.components.TaskPage.f4374519ae', - 'Your preferred issue source (upstream) is no longer configured for {{value0}}. Using origin.', - { value0: prSlug } - ) - ) - fellBackToastedRef.current.add(r.id) - } - }, [selectedRepos, selectedWorkItemsCacheEntries, taskSource]) - - // Why: partial-failure retry leaves the cache populated so tasksLoading never flips, giving no feedback; track retry-in-flight per source so only the clicked banner shows "Retrying…". - const [retryingSourceKeys, setRetryingSourceKeys] = useState>(() => new Set()) - - const handleRetryIssuesFetch = useCallback( - (sourceKey: string) => { - const source = perRepoSourceState.find((s) => s.sourceKey === sourceKey) - if (!source) { - return - } - // Why: nonce bump reuses the fetch path as force=true so retry doesn't dedupe onto a still-failing in-flight request (refreshes all repos; Retrying… stays scoped to the clicked source). - setRetryingSourceKeys((prev) => { - const next = new Set(prev) - next.add(source.sourceKey) - return next - }) - setTaskRefreshNonce((n) => n + 1) - }, - [perRepoSourceState, setTaskRefreshNonce] - ) - const handleRefreshGithubTasks = useCallback((): void => { - setTasksRefreshing(true) - setTaskRefreshNonce((current) => current + 1) - }, [setTasksRefreshing, setTaskRefreshNonce]) - const { - newIssueOpen, - setNewIssueOpen, - newIssueTitle, - setNewIssueTitle, - newIssueBody, - setNewIssueBody, - newIssueLabels, - setNewIssueLabels, - newIssueAssignees, - setNewIssueAssignees, - newIssueSubmitting, - setNewIssueSubmitting, - newIssueRepoId, - setNewIssueRepoId, - setNewIssueDraft, - clearNewIssueDraft, - newIssueTargetRepo, - newIssueSourceContext, - newIssueRuntimeTarget, - newIssueRepoLabels, - newIssueRepoAssignees - } = useTaskPageGitHubNewIssueState({ - selectedRepos, - settings, - repos - }) - - const { - selectedLinearIssueId, - setSelectedLinearIssueFallback, - selectedLinearIssueCanFloat, - linearCacheSnapshot, - jiraCacheSnapshot, - selectedLinearIssue, - linearDetailSourceContext, - setSelectedLinearIssue, - clearSelectedLinearIssue, - openLinearDetailPage, - openRelatedLinearIssue, - closeTaskDetailPage, - selectedJiraIssueKey, - setSelectedJiraIssueKey, - selectedJiraIssueFallback, - setSelectedJiraIssueFallback, - selectedJiraIssue, - jiraDetailSourceContext, - setSelectedJiraIssue, - openJiraDetailPage - } = useTaskPageSelectedIssueState({ - pageData, - linearTaskSourceContext, - jiraTaskSourceContext, - openTaskPage, - setDialogWorkItem - }) - - const { - linearMode, - setLinearMode, - linearIssues, - setLinearIssues, - linearIssueLimit, - setLinearIssueLimit, - linearIssuePage, - setLinearIssuePage, - linearIssueLoadingTargetPage, - setLinearIssueLoadingTargetPage, - linearIssuesHasMore, - setLinearIssuesHasMore, - linearLoading, - setLinearLoading, - linearError, - setLinearError, - linearSearchInput, - setLinearSearchInput, - appliedLinearSearch, - setAppliedLinearSearch, - linearIssueFiltersByWorkspaceId, - setLinearIssueFiltersByWorkspaceId, - linearAttributeFilterWorkspaceId, - linearAttributeFilter, - linearAttributeFilterReadRef, - linearPrimaryTeamRef, - linearViewMode, - setLinearViewMode, - linearGroupBy, - setLinearGroupBy, - linearOrderBy, - setLinearOrderBy, - linearDisplayProperties, - setLinearDisplayProperties, - linearTeamPropertyTouched, - setLinearTeamPropertyTouched, - linearRefreshNonce, - setLinearRefreshNonce, - linearProjectSearchInput, - setLinearProjectSearchInput, - appliedLinearProjectSearch, - setAppliedLinearProjectSearch, - linearProjectsResult, - setLinearProjectsResult, - linearProjectsLoading, - setLinearProjectsLoading, - linearProjectsError, - setLinearProjectsError, - selectedLinearProject, - setSelectedLinearProject, - selectedLinearProjectDetail, - setSelectedLinearProjectDetail, - linearProjectDetailLoading, - setLinearProjectDetailLoading, - linearProjectDetailError, - setLinearProjectDetailError, - linearProjectTab, - setLinearProjectTab, - linearProjectIssuesResult, - setLinearProjectIssuesResult, - linearProjectIssueLimit, - setLinearProjectIssueLimit, - linearProjectIssuePage, - setLinearProjectIssuePage, - linearProjectIssueLoadingTargetPage, - setLinearProjectIssueLoadingTargetPage, - linearProjectIssuesLoading, - setLinearProjectIssuesLoading, - linearProjectIssuesError, - setLinearProjectIssuesError, - linearCustomViewsResult, - setLinearCustomViewsResult, - linearCustomViewsLoading, - setLinearCustomViewsLoading, - linearCustomViewsError, - setLinearCustomViewsError, - selectedLinearCustomView, - setSelectedLinearCustomView, - linearProjectParentView, - setLinearProjectParentView, - linearCustomViewIssuesResult, - setLinearCustomViewIssuesResult, - linearCustomViewIssueLimit, - setLinearCustomViewIssueLimit, - linearCustomViewIssuePage, - setLinearCustomViewIssuePage, - linearCustomViewIssueLoadingTargetPage, - setLinearCustomViewIssueLoadingTargetPage, - linearCustomViewProjectsResult, - setLinearCustomViewProjectsResult, - linearCustomViewContentsLoading, - setLinearCustomViewContentsLoading, - linearCustomViewContentsError, - setLinearCustomViewContentsError, - openLinearProjectContext, - openLinearCustomViewContext, - linearBoardDraggingIssueId, - setLinearBoardDraggingIssueId, - linearBoardDragOverKey, - setLinearBoardDragOverKey, - linearBoardUpdatingIssueIds, - setLinearBoardUpdatingIssueIds, - lastLinearRequestRef, - landingLinearRefreshKeysRef, - linearContextResumeAttemptedRef, - patchScopedLinearIssue, - selectLinearMode - } = useTaskPageLinearListState({ - selectedLinearWorkspaceId, - clearSelectedLinearIssue, - setTaskResumeState - }) - - const { - jiraIssues, - setJiraIssues, - jiraLoading, - setJiraLoading, - jiraError, - setJiraError, - jiraErrorDetailsOpen, - setJiraErrorDetailsOpen, - jiraSearchInput, - setJiraSearchInput, - appliedJiraSearch, - setAppliedJiraSearch, - activeJiraPreset, - setActiveJiraPreset, - jiraRefreshNonce, - setJiraRefreshNonce, - jiraProjectStatusOrder, - setJiraProjectStatusOrder, - jiraOrderBy, - jiraOrderDirection, - jiraPrioritiesBySite, - handleJiraSort - } = useTaskPageJiraListState({ - selectedJiraSiteId, - taskSource, - jiraConnected, - jiraTaskSourceContext, - settings - }) - - useTaskPageSessionResume({ - persistedUIReady, - settings, - pageData, - resolvedInitialSelection, - taskResumeState, - visibleTaskProviders, - setTaskSource, - setRepoSelection, - setGithubMode, - setTaskSearchInput, - setAppliedTaskSearch, - setActiveTaskPreset, - setLinearMode, - setLinearSearchInput, - setAppliedLinearSearch, - setLinearViewMode, - setLinearGroupBy, - setLinearOrderBy, - setLinearDisplayProperties, - setLinearTeamPropertyTouched, - setLinearIssueFiltersByWorkspaceId, - setActiveJiraPreset, - setJiraSearchInput, - setAppliedJiraSearch, - taskResumeAppliedRef, - setTaskResumeApplied, - linearContextResumeAttemptedRef, - taskResumeApplied, - taskSource, - linearConnected, - fetchLinearProject, - fetchLinearCustomView, - listLinearCustomViews, - linearTaskSourceContext, - setTaskResumeState, - setSelectedLinearProject, - setSelectedLinearProjectDetail, - setLinearProjectParentView, - setLinearProjectsError, - setLinearCustomViewsLoading, - setLinearCustomViewsError, - setSelectedLinearCustomView - }) - - const { availableTeams, setAvailableTeams, setLinearTeamRefreshNonce } = useTaskPageLinearTeams({ - taskResumeApplied, - taskSource, - linearConnected, - selectedLinearWorkspaceId, - getCachedLinearTeams, - linearTaskSourceContext, - listLinearTeams - }) - - const { availableJiraProjects, jiraProjectsLoading } = useTaskPageJiraProjects({ - taskResumeApplied, - taskSource, - jiraConnected, - jiraTaskSourceContext, - settings, - selectedJiraSiteId - }) - - useTaskPageGitLabFetch({ - taskSource, - gitlabView, - activeGitlabFilter, - gitlabRefreshNonce, - selectedRepos, - selectedReposKey, - primaryRepo, - gitlabIssuePage, - setGitlabItems, - setGitlabLoading, - setGitlabError, - setGitlabIssuePage, - setGitlabIssueTotalPages, - setGitlabIssueLoadingTargetPage, - setGitlabTodos, - setGitlabTodosLoading - }) - - const { - defaultLinearTeamSelection, - linearTeamSelection, - setLinearTeamSelection, - activeLinearIssues, - activeLinearIssueLoading, - activeLinearIssueError, - activeLinearIssueHasCollectionError, - activeLinearIssueContextLabel, - activeLinearIssuePage, - activeLinearIssueLoadingTargetPage, - activeLinearIssueCanRequestMore, - activeLinearIssueLimit - } = useTaskPageLinearActiveCollection({ - settings, - selectedLinearProject, - linearProjectTab, - linearProjectIssuesResult, - selectedLinearCustomView, - linearCustomViewIssuesResult, - linearIssues, - linearProjectIssuesLoading, - linearCustomViewContentsLoading, - linearLoading, - linearStatus, - linearProjectIssuesError, - linearCustomViewContentsError, - linearError, - appliedLinearSearch, - linearIssuesHasMore, - linearIssueLimit, - linearProjectIssueLimit, - linearCustomViewIssueLimit, - linearProjectIssuePage, - linearCustomViewIssuePage, - linearIssuePage, - linearProjectIssueLoadingTargetPage, - linearCustomViewIssueLoadingTargetPage, - linearIssueLoadingTargetPage - }) - - const { - linearTeamOptions, - linearAttributePrimaryTeam, - applyLinearAttributeFilter, - linearSearchActive, - showLinearAttributeFilters, - linearIssueAttachmentIndex, - inOrcaLinkedLinearRefsSignature, - inOrcaLinkedLinearRefsRef, - filteredLinearIssues, - linearIssueTotalPages, - visibleLinearIssuePage, - pagedLinearIssues, - showLinearIssuePagination, - handleLinearIssuePageChange, - showLinearEmptyFilteredLoadMore, - handleLinearEmptyFilteredLoadMore - } = useTaskPageLinearIssueWindow({ - activeLinearIssues, - linearCacheSnapshot, - availableTeams, - defaultLinearTeamSelection, - linearTeamSelection, - setLinearTeamSelection, - linearAttributeFilterWorkspaceId, - setLinearIssueFiltersByWorkspaceId, - setLinearIssueLimit, - setLinearIssuePage, - setLinearIssueLoadingTargetPage, - linearPrimaryTeamRef, - linearAttributeFilter, - linearSearchInput, - appliedLinearSearch, - linearMode, - activeLinearIssueContextLabel, - allWorktrees, - folderWorkspaces, - selectedLinearWorkspaceId, - linearStatus, - linearOrderBy, - activeLinearIssueCanRequestMore, - activeLinearIssuePage, - activeLinearIssueError, - activeLinearIssueLoading, - selectedLinearProject, - linearProjectTab, - selectedLinearCustomView, - setLinearProjectIssuePage, - setLinearCustomViewIssuePage, - setLinearProjectIssueLoadingTargetPage, - setLinearCustomViewIssueLoadingTargetPage, - setLinearProjectIssueLimit, - setLinearCustomViewIssueLimit, - activeLinearIssueLimit, - activeLinearIssueLoadingTargetPage, - activeLinearIssueHasCollectionError - }) - - const { - selectedLinearTeamForExternalLink, - effectiveLinearDisplayProperties, - linearIssueGridStyle, - linearIssueListRows, - linearBoardSections, - linearStatusBoardEnabled, - handleLinearBoardCardDragStart, - handleLinearBoardDragOver, - handleLinearBoardDrop, - toggleLinearDisplayProperty - } = useTaskPageLinearBoard({ - linearTeamSelection, - linearTeamOptions, - linearDisplayProperties, - linearGroupBy, - linearTeamPropertyTouched, - pagedLinearIssues, - linearOrderBy, - linearBoardUpdatingIssueIds, - linearBoardDraggingIssueId, - filteredLinearIssues, - linearTaskSourceContext, - settings, - patchLinearIssue, - patchScopedLinearIssue, - invalidateLinearIssueLists, - setLinearBoardDraggingIssueId, - setLinearBoardDragOverKey, - setLinearBoardUpdatingIssueIds, - setSelectedLinearIssueFallback, - setLinearDisplayProperties, - setLinearTeamPropertyTouched - }) - - const { displayedJiraIssues, displayedJiraStatusOrder, sortedJiraIssues } = - useTaskPageJiraDisplayedIssues({ - jiraIssues, - jiraIssueCache: jiraCacheSnapshot.issueCache, - jiraSearchCache: jiraCacheSnapshot.searchCache, - jiraTaskSourceContext, - jiraTaskSourceScopeKey, - jiraProjectStatusOrder, - jiraOrderBy, - jiraOrderDirection, - jiraPrioritiesBySite - }) - - const { - newLinearProjectOpen, - setNewLinearProjectOpen, - newLinearProjectName, - setNewLinearProjectName, - newLinearProjectDescription, - setNewLinearProjectDescription, - newLinearProjectContent, - setNewLinearProjectContent, - setNewLinearProjectTeamId, - newLinearProjectLeadId, - setNewLinearProjectLeadId, - newLinearProjectMemberIds, - setNewLinearProjectMemberIds, - newLinearProjectLabelIds, - setNewLinearProjectLabelIds, - newLinearProjectPriority, - setNewLinearProjectPriority, - newLinearProjectStartDate, - setNewLinearProjectStartDate, - newLinearProjectTargetDate, - setNewLinearProjectTargetDate, - newLinearProjectSubmitting, - setNewLinearProjectSubmitting, - newLinearProjectTargetTeam, - newLinearProjectMembers, - newLinearProjectLabels, - discardNewLinearProjectDraft, - newLinearIssueOpen, - setNewLinearIssueOpen, - newLinearIssueTitle, - setNewLinearIssueTitle, - newLinearIssueBody, - setNewLinearIssueBody, - newLinearIssueTeamId, - setNewLinearIssueTeamId, - newLinearIssueSubmitting, - setNewLinearIssueSubmitting, - newLinearIssueStateId, - setNewLinearIssueStateId, - newLinearIssueAssigneeId, - setNewLinearIssueAssigneeId, - newLinearIssuePriority, - setNewLinearIssuePriority, - newLinearIssueProjectId, - setNewLinearIssueProjectId, - newLinearIssueLabelIds, - setNewLinearIssueLabelIds, - discardNewLinearIssueDraft, - newLinearIssueTargetTeam, - newLinearIssueProjects, - setNewLinearIssueProjects, - newLinearIssueProjectsLoading, - setNewLinearIssueProjectsLoading, - newLinearStates, - newLinearMembers, - newLinearLabels, - linearConnectOpen, - setLinearConnectOpen - } = useTaskPageLinearCreateDialogs({ - availableTeams, - settings, - linearConnected, - selectedLinearWorkspaceId, - linearTaskSourceContext, - selectedLinearProject - }) - - const [jiraConnectOpen, setJiraConnectOpen] = useState(false) - useContextualTour( - 'tasks', - !dialogWorkItem && - !gitlabDialogItem && - !selectedLinearIssue && - !newIssueOpen && - !newLinearProjectOpen && - !newLinearIssueOpen && - !linearConnectOpen && - !jiraConnectOpen && - activeModal === 'none', - 'tasks_open' - ) - - const activeGithubTaskKind = getGitHubTaskKind(activeTaskPreset, appliedTaskSearch) - const appliedTaskQuery = useMemo(() => parseTaskQuery(appliedTaskSearch), [appliedTaskSearch]) - - const { githubWorkItemMutationQueryKey, githubWorkItemMutation, selectedGitHubRepoExternalLink } = - useTaskPageGitHubMutationSession({ - selectedRepos, - githubMode, - appliedTaskSearch, - appliedTaskQuery, - githubViewerLogin, - setGitHubViewerLogin, - patchTaskPageWorkItemRows, - pages, - taskSource, - perRepoSourceState, - activeGithubTaskKind, - setQuietRefreshNonce - }) - - const { - newJiraIssueOpen, - setNewJiraIssueOpen, - newJiraIssueTitle, - setNewJiraIssueTitle, - newJiraIssueBody, - setNewJiraIssueBody, - setNewJiraIssueProjectId, - newJiraIssueProjectComboboxOpen, - setNewJiraIssueProjectComboboxOpen, - newJiraIssueProjectQuery, - setNewJiraIssueProjectQuery, - newJiraIssueProjectCommandValue, - setNewJiraIssueProjectCommandValue, - newJiraIssueTypeId, - setNewJiraIssueTypeId, - newJiraIssueSubmitting, - setNewJiraIssueSubmitting, - newJiraIssueProjectSearchInputRef, - availableJiraIssueTypes, - setAvailableJiraIssueTypes, - jiraIssueTypesLoading, - setJiraIssueTypesLoading, - setJiraCreateFields, - jiraCreateFieldsLoading, - setJiraCreateFieldsLoading, - jiraCreateFieldsError, - setJiraCreateFieldsError, - newJiraIssueCustomFieldValues, - setNewJiraIssueCustomFieldValues, - jiraUserFieldSelections, - setJiraUserFieldSelections, - discardNewJiraIssueDraft, - includeJiraSiteNameInProjectLabel, - sortedAvailableJiraProjects, - filteredNewJiraIssueProjects, - newJiraIssueTargetProject, - newJiraIssueTargetProjectSelectionKey, - newJiraIssueTargetType, - visibleJiraCreateFields, - hasMissingJiraCreateField, - handleNewJiraIssueProjectComboboxOpenChange, - handleNewJiraIssueProjectSelect, - handleNewJiraIssueProjectTriggerKeyDown - } = useTaskPageJiraCreateDialog({ - selectedJiraSiteId, - availableJiraProjects, - jiraConnected, - jiraViewer: jiraConnected ? jiraStatus.viewer : null, - jiraViewerSiteId: jiraConnected ? (jiraStatus.activeSiteId ?? null) : null, - settings, - jiraTaskSourceContext - }) - useTaskPageCreateDialogResets({ - providerRuntimeContextKey, - setNewLinearIssueOpen, - setNewLinearIssueTitle, - setNewLinearIssueBody, - setNewLinearIssueTeamId, - setNewLinearIssueStateId, - setNewLinearIssueAssigneeId, - setNewLinearIssuePriority, - setNewLinearIssueProjectId, - setNewLinearIssueLabelIds, - setNewLinearIssueProjects, - setNewLinearIssueProjectsLoading, - setNewLinearIssueSubmitting, - setNewJiraIssueOpen, - setNewJiraIssueTitle, - setNewJiraIssueBody, - setNewJiraIssueProjectId, - setNewJiraIssueProjectComboboxOpen, - setNewJiraIssueProjectQuery, - setNewJiraIssueProjectCommandValue, - setNewJiraIssueTypeId, - setAvailableJiraIssueTypes, - setJiraIssueTypesLoading, - setJiraCreateFields, - setJiraCreateFieldsLoading, - setJiraCreateFieldsError, - setNewJiraIssueCustomFieldValues, - setNewJiraIssueSubmitting - }) - - const { - filteredWorkItems, - softHiddenVisibleCount, - showGitHubTaskSkeletons, - loadedGitHubAuthorLogins, - primaryGithubFilterSlug, - showPRManagementColumns, - githubTaskGridClass, - ensurePRChecksLoaded, - totalPages - } = useTaskPageGitHubFilteredItems({ - activeGithubTaskKind, - pages, - currentPage, - githubWorkItemMutation, - tasksFiltering, - tasksLoading, - perRepoSourceState, - repoMap, - fetchPRChecks, - patchTaskPageWorkItemRows, - taskSource, - githubMode, - githubPageSize, - countedTotalPages, - provenPageLimit - }) - - const { handleLoadNextPage } = useTaskPageGitHubPageLoader({ - paginationLoading, - selectedRepos, - appliedTaskSearch, - paginationGenerationRef, - currentPage, - setPaginationLoading, - setLoadingTargetPage, - fetchWorkItemsNextPage, - githubPerRepoPageLimit, - githubPageSize, - countedTotalPagesRef, - setProvenPageLimit, - setCountedTotalPages, - pagesRef, - currentPageRef, - setPages, - setCurrentPage - }) - - const commitTaskSearch = useCallback( - (value: string): void => { - const scoped = scopeGitHubTaskSearch(value, activeGithubTaskKind) - if (scoped !== appliedTaskSearch) { - setTasksFiltering(true) - } - setAppliedTaskSearch(scoped) - }, - [activeGithubTaskKind, appliedTaskSearch, setAppliedTaskSearch, setTasksFiltering] - ) - useGitHubTaskSearchCommit({ - enabled: taskResumeApplied, - onCommit: commitTaskSearch, - value: taskSearchInput - }) - - useEffect(() => { - if (!taskResumeApplied) { - return - } - if (!githubSearchPersistReadyRef.current) { - githubSearchPersistReadyRef.current = true - return - } - // Why: persist the applied query unconditionally to cover paths that change appliedTaskSearch outside the preset handler. - setTaskResumeState({ - githubItemsPreset: activeTaskPreset, - githubItemsQuery: appliedTaskSearch.trim() - }) - }, [ - activeTaskPreset, - appliedTaskSearch, - setTaskResumeState, - taskResumeApplied, - githubSearchPersistReadyRef - ]) - - useTaskPageGitHubFetch({ - taskResumeApplied, - taskSource, - githubMode, - selectedRepos, - setRetryingSourceKeys, - setTasksRefreshing, - setTasksFiltering, - appliedTaskSearch, - githubResumeContextRef, - githubResumeContextKey, - githubResumeConsumedRef, - currentPageRef, - pagesRef, - pendingGithubScrollRestoreRef, - getCachedWorkItems, - githubPerRepoPageLimit, - githubPageSize, - githubWorkItemMutationQueryKey, - setPages, - setCurrentPage, - setCountedTotalPages, - countedTotalPagesRef, - setProvenPageLimit, - setTasksError, - setFailedCount, - setGithubUnavailable, - setTasksLoading, - taskRefreshNonce, - lastFetchedNonceRef, - workItemsInvalidationNonce, - lastFetchedInvalidationNonceRef, - hardRefreshEpochRef, - landingGitHubRefreshKeysRef, - paginationGenerationRef, - setPaginationLoading, - setLoadingTargetPage, - fetchWorkItemsNextPage, - fetchWorkItemsAcrossRepos, - countWorkItemsAcrossRepos, - retryingSourceKeys, - selectedReposKey - }) - - useTaskPageGitHubQuietRevalidate({ - githubWorkItemMutationQueryKey, - quietRefreshNonce, - taskSource, - githubMode, - selectedRepos, - pagesRef, - pages, - currentPage, - currentPageRef, - appliedTaskSearch, - hardRefreshEpochRef, - fetchWorkItemsAcrossRepos, - fetchWorkItemsNextPage, - githubPerRepoPageLimit, - githubPageSize, - setPages, - setQuietRefreshNonce - }) - - const applyPRFilterChange = useCallback( - (change: PRFilterChange): void => { - let next = scopeGitHubTaskSearch(taskSearchInput, activeGithubTaskKind) - // Why: withQualifier round-trips through parseTaskQuery so each dropdown's patch preserves prior filters and free-text. - if ('author' in change) { - next = withQualifier(next, 'author', change.author ?? null) - } - if ('assignee' in change) { - next = withQualifier(next, 'assignee', change.assignee ?? null) - } - if ('labels' in change) { - next = withQualifier(next, 'labels', change.labels ?? []) - } - if ('state' in change && change.state) { - next = withQualifier(next, 'state', change.state) - if (change.state !== 'open') { - next = withQualifier(next, 'draft', null) - } - } - if ('draft' in change) { - next = withQualifier(next, 'draft', change.draft ? 'true' : 'false') - } - if ('reviewer' in change) { - // Why: the two reviewer qualifiers are mutually exclusive — clear the other whenever one is set so the chip matches the query. - const reviewer = change.reviewer ?? null - if (reviewer === null) { - next = withQualifier(next, 'reviewRequested', null) - next = withQualifier(next, 'reviewedBy', null) - } else if (reviewer.kind === 'requested') { - next = withQualifier(next, 'reviewedBy', null) - next = withQualifier(next, 'reviewRequested', reviewer.login) - } else { - next = withQualifier(next, 'reviewRequested', null) - next = withQualifier(next, 'reviewedBy', reviewer.login) - } - } - setTaskSearchInput(next) - setAppliedTaskSearch(next) - setActiveTaskPreset(null) - setTaskResumeState({ githubItemsPreset: null, githubItemsQuery: next }) - // Why: a filter change replaces every row's meaning; show the load skeleton so stale rows don't read as if the filter did nothing. - setTasksFiltering(true) - setTaskRefreshNonce((current) => current + 1) - }, - [ - activeGithubTaskKind, - setTaskResumeState, - taskSearchInput, - setTaskSearchInput, - setActiveTaskPreset, - setTasksFiltering, - setAppliedTaskSearch, - setTaskRefreshNonce - ] - ) - - const handleApplyTaskSearch = useCallback((): void => { - const scoped = scopeGitHubTaskSearch(taskSearchInput, activeGithubTaskKind) - setTaskSearchInput(scoped) - setAppliedTaskSearch(scoped) - setActiveTaskPreset(null) - setTaskResumeState({ githubItemsPreset: null, githubItemsQuery: scoped }) - setTasksFiltering(true) - setTaskRefreshNonce((current) => current + 1) - }, [ - activeGithubTaskKind, - setTaskResumeState, - taskSearchInput, - setAppliedTaskSearch, - setTaskRefreshNonce, - setTaskSearchInput, - setActiveTaskPreset, - setTasksFiltering - ]) - - const handleTaskSearchChange = useCallback( - (event: React.ChangeEvent): void => { - const next = event.target.value - setTaskSearchInput(next) - setActiveTaskPreset(null) - }, - [setTaskSearchInput, setActiveTaskPreset] - ) - - const handleSetDefaultTaskPreset = useCallback( - (presetId: TaskViewPresetId): void => { - // Why: the default task view is a durable preference, so persist it instead of only changing page state. - void updateSettings({ defaultTaskViewPreset: presetId }).catch(() => { - toast.error( - translate('auto.components.TaskPage.fe380f306c', 'Failed to save default task view.') - ) - }) - }, - [updateSettings] - ) - - const handleSelectGithubTaskKind = useCallback( - (kind: GitHubTaskKind): void => { - const preset = getDefaultPresetForGitHubTaskKind(kind) - const query = getTaskPresetQuery(preset) - setTaskSearchInput(query) - setAppliedTaskSearch(query) - setActiveTaskPreset(preset) - setTaskResumeState({ - githubItemsPreset: preset, - githubItemsQuery: query - }) - setTasksFiltering(true) - setTaskRefreshNonce((current) => current + 1) - }, - [ - setTaskResumeState, - setAppliedTaskSearch, - setTaskRefreshNonce, - setTaskSearchInput, - setActiveTaskPreset, - setTasksFiltering - ] - ) - - const handleResetGithubTaskSearch = useCallback((): void => { - handleSelectGithubTaskKind(activeGithubTaskKind) - }, [activeGithubTaskKind, handleSelectGithubTaskKind]) - - const handleTaskSearchKeyDown = useCallback( - (event: React.KeyboardEvent): void => { - if (event.key === 'Enter') { - // React SyntheticEvent does not expose isComposing; use nativeEvent. - if ( - shouldSuppressEnterSubmit( - { isComposing: event.nativeEvent.isComposing, shiftKey: event.shiftKey }, - false - ) - ) { - return - } - event.preventDefault() - handleApplyTaskSearch() - } - }, - [handleApplyTaskSearch] - ) - - useEffect(() => { - if ( - taskSource !== 'github' || - githubMode !== 'items' || - dialogWorkItem || - newIssueOpen || - newLinearProjectOpen || - newLinearIssueOpen || - newJiraIssueOpen || - activeModal !== 'none' - ) { - return - } - - const onKeyDown = (event: KeyboardEvent): void => { - const isMac = navigator.userAgent.includes('Mac') - const modifierPressed = isMac ? event.metaKey : event.ctrlKey - if (!modifierPressed || event.altKey || event.shiftKey || event.key.toLowerCase() !== 'f') { - return - } - - const input = taskSearchInputRef.current - if (!input) { - return - } - const target = event.target - if ( - target instanceof HTMLElement && - target !== input && - (target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement || - target.isContentEditable) - ) { - return - } - - event.preventDefault() - event.stopPropagation() - input.focus() - input.select() - } - - window.addEventListener('keydown', onKeyDown, { capture: true }) - return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) - }, [ - activeModal, - dialogWorkItem, - githubMode, - newIssueOpen, - newLinearProjectOpen, - newLinearIssueOpen, - newJiraIssueOpen, - taskSource, - - taskSearchInputRef - ]) - - const { handleUseWorkItem, handleOpenOrUseGitHubWorkItem, handleUseGitLabItem } = - useTaskPageUseItemActions({ - openModal, - repoMap - }) - - const { handleCreateNewIssue } = useTaskPageCreateGithubSubmit({ - newIssueTargetRepo, - newIssueTitle, - newIssueSubmitting, - newIssueRuntimeTarget, - newIssueSourceContext, - newIssueBody, - newIssueLabels, - newIssueAssignees, - setNewIssueSubmitting, - setNewIssueOpen, - setNewIssueTitle, - setNewIssueDraft, - setNewIssueBody, - setNewIssueLabels, - setNewIssueAssignees, - clearNewIssueDraft, - setTaskRefreshNonce, - openGitHubDetailPage, - setDialogWorkItem - }) - - const { handleCreateNewLinearProject, handleCreateNewLinearIssue } = - useTaskPageCreateLinearSubmits({ - newLinearProjectTargetTeam, - newLinearProjectName, - newLinearProjectSubmitting, - linearTaskSourceContext, - settings, - newLinearProjectDescription, - newLinearProjectContent, - newLinearProjectLeadId, - newLinearProjectMemberIds, - newLinearProjectLabelIds, - newLinearProjectPriority, - newLinearProjectStartDate, - newLinearProjectTargetDate, - discardNewLinearProjectDraft, - setNewLinearProjectSubmitting, - setNewLinearProjectOpen, - setNewLinearProjectName, - setNewLinearProjectDescription, - setNewLinearProjectContent, - setNewLinearProjectLeadId, - setNewLinearProjectMemberIds, - setNewLinearProjectLabelIds, - setNewLinearProjectPriority, - setNewLinearProjectStartDate, - setNewLinearProjectTargetDate, - setAppliedLinearProjectSearch, - setLinearProjectSearchInput, - setLinearProjectsResult, - setSelectedLinearProjectDetail, - openLinearProjectContext, - setLinearRefreshNonce, - newLinearIssueTargetTeam, - newLinearIssueTitle, - newLinearIssueSubmitting, - selectedLinearProject, - newLinearIssueProjectId, - providerRuntimeContextKey, - providerRuntimeContextKeyRef, - newLinearIssueBody, - newLinearIssueStateId, - newLinearIssuePriority, - newLinearIssueAssigneeId, - newLinearIssueLabelIds, - discardNewLinearIssueDraft, - setNewLinearIssueSubmitting, - setNewLinearIssueOpen, - setNewLinearIssueTitle, - setNewLinearIssueBody, - setNewLinearIssueStateId, - setNewLinearIssueAssigneeId, - setNewLinearIssuePriority, - setNewLinearIssueProjectId, - setNewLinearIssueLabelIds, - setSelectedLinearIssue - }) - - const { handleCreateNewJiraIssue } = useTaskPageCreateJiraSubmit({ - newJiraIssueTargetProject, - newJiraIssueTargetType, - newJiraIssueTitle, - newJiraIssueSubmitting, - hasMissingJiraCreateField, - jiraCreateFieldsLoading, - visibleJiraCreateFields, - newJiraIssueCustomFieldValues, - providerRuntimeContextKey, - providerRuntimeContextKeyRef, - jiraTaskSourceContext, - settings, - newJiraIssueBody, - discardNewJiraIssueDraft, - setNewJiraIssueSubmitting, - setNewJiraIssueOpen, - setNewJiraIssueTitle, - setNewJiraIssueBody, - setNewJiraIssueCustomFieldValues, - setJiraRefreshNonce, - setJiraIssues, - setSelectedJiraIssue - }) - - const githubTasksBusy = tasksLoading || tasksRefreshing || tasksFiltering - - useEffect(() => { - // Why: when a modal is open, let it own Esc dismissal. - if ( - dialogWorkItem || - selectedJiraIssue || - selectedLinearIssue || - newIssueOpen || - newLinearIssueOpen || - newJiraIssueOpen || - activeModal !== 'none' - ) { - return - } - - const onKeyDown = (event: KeyboardEvent): void => { - if (event.key !== 'Escape') { - return - } - - const target = event.target - if (!(target instanceof HTMLElement)) { - return - } - - // Why: open menus/popovers/selects own Esc; capture-phase leave would steal it from Radix. - if ( - document.querySelector( - '[data-slot="dropdown-menu-content"], [data-slot="popover-content"], [data-slot="select-content"], [role="menu"]' - ) - ) { - return - } - - // Why: Esc first blurs a focused input so it doesn't accidentally close the whole page; only closes once focus is outside an input. - if ( - target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement || - target instanceof HTMLSelectElement || - target.isContentEditable - ) { - event.preventDefault() - target.blur() - return - } - - event.preventDefault() - closeTaskPage() - } - - window.addEventListener('keydown', onKeyDown, { capture: true }) - return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) - }, [ - activeModal, - closeTaskPage, - dialogWorkItem, - newIssueOpen, - newLinearIssueOpen, - newJiraIssueOpen, - selectedLinearIssue, - selectedJiraIssue - ]) - - useEffect(() => { - if (!preflightStatusCurrent || !preflightStatusChecked) { - void refreshPreflightStatus() - } - if (!linearStatusReady) { - void checkLinearConnection() - } - if (!jiraStatusReady) { - void checkJiraConnection() - } - }, [ - checkJiraConnection, - checkLinearConnection, - expectedPreflightContextKey, - jiraStatusContextKey, - jiraStatusReady, - linearStatusContextKey, - linearStatusReady, - providerRuntimeContextKey, - preflightStatusContextKey, - preflightStatusChecked, - preflightStatusCurrent, - refreshPreflightStatus - ]) - - // Why: debounce the Linear search input so we don't fire a request per keystroke (300ms, matching GitHub search). - useEffect(() => { - if (!taskResumeApplied) { - return - } - const timeout = window.setTimeout(() => { - setAppliedLinearSearch(linearSearchInput) - }, TASK_SEARCH_DEBOUNCE_MS) - return () => window.clearTimeout(timeout) - }, [linearSearchInput, taskResumeApplied, setAppliedLinearSearch]) - - useEffect(() => { - if (!taskResumeApplied) { - return - } - if (!linearSearchPersistReadyRef.current) { - linearSearchPersistReadyRef.current = true - return - } - setTaskResumeState({ linearQuery: appliedLinearSearch.trim() }) - }, [appliedLinearSearch, setTaskResumeState, taskResumeApplied, linearSearchPersistReadyRef]) - - useEffect(() => { - if (!taskResumeApplied) { - return - } - if (!linearViewPersistReadyRef.current) { - linearViewPersistReadyRef.current = true - return - } - saveLinearIssueView( - serializeLinearIssueViewResumeState({ - viewMode: linearViewMode, - groupBy: linearGroupBy, - orderBy: linearOrderBy, - displayProperties: linearDisplayProperties, - teamPropertyTouched: linearTeamPropertyTouched, - filtersByWorkspaceId: linearIssueFiltersByWorkspaceId - }) - ) - }, [ - linearDisplayProperties, - linearGroupBy, - linearIssueFiltersByWorkspaceId, - linearOrderBy, - linearTeamPropertyTouched, - linearViewMode, - taskResumeApplied, - - linearViewPersistReadyRef - ]) - - useEffect(() => { - setLinearIssueLimit(LINEAR_ITEM_LIMIT) - setLinearIssuePage(0) - setLinearIssueLoadingTargetPage(null) - }, [ - appliedLinearSearch, - linearMode, - selectedLinearCustomView?.id, - selectedLinearProject?.id, - selectedLinearWorkspaceId, - taskSource, - setLinearIssueLoadingTargetPage, - setLinearIssueLimit, - setLinearIssuePage - ]) - - useTaskPageLinearFetch({ - taskResumeApplied, - taskSource, - linearMode, - linearConnected, - appliedLinearSearch, - linearIssueLimit, - linearAttributeFilter, - selectedLinearWorkspaceId, - getCachedLinearIssues, - linearTaskSourceContext, - linearAttributeFilterWorkspaceId, - linearAttributeFilterReadRef, - lastLinearRequestRef, - landingLinearRefreshKeysRef, - linearRefreshNonce, - searchLinearIssues, - listLinearIssues, - setLinearError, - setLinearIssuesHasMore, - setLinearIssues, - setLinearLoading, - linearListInvalidationVersionForSource - }) - - // Why: Has Worktree loads Linear tickets linked on local worktrees, not a Linear list/search query. - useEffect(() => { - if (!taskResumeApplied) { - return - } - if (taskSource !== 'linear' || linearMode !== 'in-orca' || !linearConnected) { - return - } - - let cancelled = false - const linkedRefs = inOrcaLinkedLinearRefsRef.current - const requestSignature = `in-orca::${selectedLinearWorkspaceId ?? 'default'}::${inOrcaLinkedLinearRefsSignature}` - const previousRequest = lastLinearRequestRef.current - const isNewSignature = previousRequest?.signature !== requestSignature - const forceRefresh = linearRefreshNonce > 0 && previousRequest?.nonce !== linearRefreshNonce - lastLinearRequestRef.current = { nonce: linearRefreshNonce, signature: requestSignature } - setLinearIssuesHasMore(false) - setLinearError(null) - - if (linkedRefs.length === 0) { - setLinearIssues([]) - setLinearLoading(false) - return () => { - cancelled = true - } - } - - if (isNewSignature) { - setLinearIssues([]) - } - setLinearLoading(true) - // Why: fetchLinearIssue serves anything under the 60s TTL and ignores `force`, so an - // explicit refresh has to go through refreshLinearIssue or the button does nothing. - void readLinkedLinearIssuesWithLimit(linkedRefs, (ref) => { - const read = forceRefresh ? refreshLinearIssue : fetchLinearIssue - return read(ref.identifier, ref.workspaceId ?? selectedLinearWorkspaceId, { - sourceContext: ref.sourceContext ?? linearTaskSourceContext - }) - }) - .then((results) => { - if ( - cancelled || - lastLinearRequestRef.current?.signature !== requestSignature || - lastLinearRequestRef.current?.nonce !== linearRefreshNonce - ) { - return - } - const loaded = results.filter((issue): issue is LinearIssue => issue != null) - // Why: reads resolve to null instead of throwing, so an all-null result with links - // present is a load failure — not the "nothing linked yet" empty state. - if (loaded.length === 0) { - setLinearError( - translate( - 'auto.components.TaskPage.linearHasWorktreeLoadFailed', - 'Unable to load Linear issues linked to an Orca workspace.' - ) - ) - setLinearIssues([]) - setLinearLoading(false) - return - } - if (loaded.length !== results.length) { - setLinearError( - translate( - 'auto.components.TaskPage.linearHasWorktreePartialLoadFailed', - 'Some Linear issues linked to an Orca workspace could not be loaded. Refresh to try again.' - ) - ) - } - setLinearIssues(filterLinearIssuesForInOrcaWorkspace(loaded, selectedLinearWorkspaceId)) - setLinearLoading(false) - }) - .catch((err) => { - if ( - cancelled || - lastLinearRequestRef.current?.signature !== requestSignature || - lastLinearRequestRef.current?.nonce !== linearRefreshNonce - ) { - return - } - setLinearError(err instanceof Error ? err.message : 'Failed to load Linear issues.') - setLinearLoading(false) - }) - - return () => { - cancelled = true - } - // Why: linkedRefs are read from a ref keyed by their signature, so unrelated worktree - // churn (activity stamps, unread flags) can't re-issue one read per linked ticket. - }, [ - fetchLinearIssue, - inOrcaLinkedLinearRefsSignature, - linearConnected, - linearMode, - linearRefreshNonce, - linearTaskSourceContext, - inOrcaLinkedLinearRefsRef, - lastLinearRequestRef, - lastLinearRequestRef.current?.nonce, - lastLinearRequestRef.current?.signature, - refreshLinearIssue, - setLinearError, - setLinearIssues, - setLinearIssuesHasMore, - setLinearLoading, - selectedLinearWorkspaceId, - taskResumeApplied, - taskSource - ]) - - useEffect(() => { - if (!taskResumeApplied) { - return - } - const timeout = window.setTimeout(() => { - setAppliedLinearProjectSearch(linearProjectSearchInput) - }, TASK_SEARCH_DEBOUNCE_MS) - return () => window.clearTimeout(timeout) - }, [linearProjectSearchInput, setAppliedLinearProjectSearch, taskResumeApplied]) - - useEffect(() => { - if (!taskResumeApplied || taskSource !== 'linear' || linearMode !== 'projects') { - return - } - if (!linearConnected || selectedLinearProject) { - return - } - let cancelled = false - const query = appliedLinearProjectSearch.trim() - const cached = getCachedLinearProjects(query || undefined, LINEAR_ITEM_LIMIT, undefined, { - sourceContext: linearTaskSourceContext - }) - if (cached) { - setLinearProjectsResult(cached) - } - const force = linearRefreshNonce > 0 - setLinearProjectsLoading(force || cached === null) - setLinearProjectsError(null) - void listLinearProjectsFromStore(query || undefined, LINEAR_ITEM_LIMIT, undefined, { - force, - sourceContext: linearTaskSourceContext - }) - .then((result) => { - if (!cancelled) { - setLinearProjectsResult(result) - setLinearProjectsLoading(false) - } - }) - .catch((error) => { - if (!cancelled) { - setLinearProjectsError( - error instanceof Error ? error.message : 'Failed to load projects.' - ) - setLinearProjectsLoading(false) - } - }) - return () => { - cancelled = true - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - taskResumeApplied, - taskSource, - linearMode, - linearConnected, - selectedLinearWorkspaceId, - selectedLinearProject, - appliedLinearProjectSearch, - linearRefreshNonce, - getCachedLinearProjects, - linearTaskSourceContext - ]) - - useEffect(() => { - if (!selectedLinearProject?.workspaceId) { - setSelectedLinearProjectDetail(null) - return - } - let cancelled = false - setLinearProjectDetailLoading(true) - setLinearProjectDetailError(null) - void fetchLinearProject(selectedLinearProject.id, selectedLinearProject.workspaceId, { - force: linearRefreshNonce > 0, - sourceContext: linearTaskSourceContext - }) - .then((project) => { - if (!cancelled) { - setSelectedLinearProjectDetail(project) - setLinearProjectDetailLoading(false) - if (!project) { - setSelectedLinearProject(null) - setLinearProjectParentView(null) - setLinearProjectDetailError(null) - setLinearProjectsError('Project was not found.') - setTaskResumeState({ linearContext: undefined }) - } - } - }) - .catch((error) => { - if (!cancelled) { - setLinearProjectDetailError( - error instanceof Error ? error.message : 'Failed to load project.' - ) - setLinearProjectDetailLoading(false) - } - }) - return () => { - cancelled = true - } - }, [ - fetchLinearProject, - linearRefreshNonce, - selectedLinearProject, - setLinearProjectDetailError, - setLinearProjectDetailLoading, - setLinearProjectParentView, - setLinearProjectsError, - setSelectedLinearProject, - setSelectedLinearProjectDetail, - setTaskResumeState, - linearTaskSourceContext - ]) - - useEffect(() => { - if (!selectedLinearProject?.workspaceId || linearProjectTab !== 'issues') { - return - } - let cancelled = false - setLinearProjectIssuesLoading(true) - setLinearProjectIssuesError(null) - const effectiveLimit = clampLinearIssueListLimit(linearProjectIssueLimit) - void listLinearProjectIssues( - selectedLinearProject.id, - selectedLinearProject.workspaceId, - effectiveLimit, - { force: linearRefreshNonce > 0, sourceContext: linearTaskSourceContext } - ) - .then((result) => { - if (!cancelled) { - setLinearProjectIssuesResult(result) - setLinearProjectIssuesLoading(false) - } - }) - .catch((error) => { - if (!cancelled) { - setLinearProjectIssuesError( - error instanceof Error ? error.message : 'Failed to load project issues.' - ) - setLinearProjectIssuesLoading(false) - } - }) - return () => { - cancelled = true - } - }, [ - linearProjectIssueLimit, - linearProjectTab, - linearRefreshNonce, - listLinearProjectIssues, - linearTaskSourceContext, - selectedLinearProject, - setLinearProjectIssuesError, - setLinearProjectIssuesLoading, - setLinearProjectIssuesResult - ]) - - useEffect(() => { - if (!taskResumeApplied || taskSource !== 'linear' || linearMode !== 'views') { - return - } - if (!linearConnected || selectedLinearCustomView) { - return - } - let cancelled = false - const cachedResults = LINEAR_CUSTOM_VIEW_MODELS.map((model) => - getCachedLinearCustomViews(model, LINEAR_ITEM_LIMIT, undefined, { - sourceContext: linearTaskSourceContext - }) - ) - const allCached = cachedResults.every( - (result): result is LinearCollectionResult => result !== null - ) - if (allCached) { - setLinearCustomViewsResult(mergeLinearCollectionResults(cachedResults)) - } - const force = linearRefreshNonce > 0 - setLinearCustomViewsLoading(force || !allCached) - setLinearCustomViewsError(null) - // Why: the Views tab already has a Model column, so list both models rather than add a redundant Issues/Projects switch. - void Promise.all( - LINEAR_CUSTOM_VIEW_MODELS.map((model) => - listLinearCustomViews(model, LINEAR_ITEM_LIMIT, undefined, { - force, - sourceContext: linearTaskSourceContext - }) - ) - ) - .then((result) => { - if (!cancelled) { - setLinearCustomViewsResult(mergeLinearCollectionResults(result)) - setLinearCustomViewsLoading(false) - } - }) - .catch((error) => { - if (!cancelled) { - setLinearCustomViewsError( - error instanceof Error ? error.message : 'Failed to load views.' - ) - setLinearCustomViewsLoading(false) - } - }) - return () => { - cancelled = true - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - taskResumeApplied, - taskSource, - linearMode, - linearConnected, - selectedLinearWorkspaceId, - selectedLinearCustomView, - linearRefreshNonce, - getCachedLinearCustomViews, - listLinearCustomViews, - linearTaskSourceContext - ]) - - useEffect(() => { - if (!selectedLinearCustomView?.workspaceId) { - setLinearCustomViewIssuesResult({ items: [] }) - setLinearCustomViewProjectsResult({ items: [] }) - return - } - let cancelled = false - setLinearCustomViewContentsLoading(true) - setLinearCustomViewContentsError(null) - const issueLimit = clampLinearIssueListLimit(linearCustomViewIssueLimit) - const request = - selectedLinearCustomView.model === 'issue' - ? listLinearCustomViewIssues( - selectedLinearCustomView.id, - selectedLinearCustomView.workspaceId, - issueLimit, - { force: linearRefreshNonce > 0, sourceContext: linearTaskSourceContext } - ) - : listLinearCustomViewProjects( - selectedLinearCustomView.id, - selectedLinearCustomView.workspaceId, - LINEAR_ITEM_LIMIT, - { force: linearRefreshNonce > 0, sourceContext: linearTaskSourceContext } - ) - void request - .then((result) => { - if (cancelled) { - return - } - if (selectedLinearCustomView.model === 'issue') { - setLinearCustomViewIssuesResult(result as LinearCollectionResult) - } else { - setLinearCustomViewProjectsResult(result as LinearCollectionResult) - } - setLinearCustomViewContentsLoading(false) - }) - .catch((error) => { - if (!cancelled) { - setLinearCustomViewContentsError( - error instanceof Error ? error.message : 'Failed to load view contents.' - ) - setLinearCustomViewContentsLoading(false) - } - }) - return () => { - cancelled = true - } - }, [ - linearRefreshNonce, - linearCustomViewIssueLimit, - listLinearCustomViewIssues, - listLinearCustomViewProjects, - linearTaskSourceContext, - selectedLinearCustomView, - setLinearCustomViewContentsError, - setLinearCustomViewContentsLoading, - setLinearCustomViewIssuesResult, - setLinearCustomViewProjectsResult - ]) - - useEffect(() => { - if (!taskResumeApplied || taskSource !== 'linear') { - return - } - - if (!linearConnected) { - clearSelectedLinearIssue() - return - } - - if (filteredLinearIssues.length === 0) { - if (!selectedLinearIssueCanFloat) { - clearSelectedLinearIssue() - } - return - } - - // Why: list-first — keep an open inspector only while its issue stays in the filter, not auto-open row 1; user-directed sub-issue nav stays. - if ( - selectedLinearIssueId && - !selectedLinearIssueCanFloat && - !filteredLinearIssues.some((issue) => issue.id === selectedLinearIssueId) - ) { - clearSelectedLinearIssue() - } - }, [ - clearSelectedLinearIssue, - filteredLinearIssues, - linearConnected, - selectedLinearIssueCanFloat, - selectedLinearIssueId, - taskResumeApplied, - taskSource - ]) - - useTaskPageJiraFetch({ - taskResumeApplied, - jiraSearchInput, - setAppliedJiraSearch, - jiraSearchPersistReadyRef, - appliedJiraSearch, - setTaskResumeState, - taskSource, - jiraConnected, - setJiraLoading, - setJiraError, - setJiraErrorDetailsOpen, - searchJiraIssues, - listJiraIssues, - activeJiraPreset, - jiraTaskSourceContext, - selectedJiraSiteId, - jiraRefreshNonce, - jiraTaskSourceScopeKey, - settings, - setJiraIssues, - setJiraProjectStatusOrder, - displayedJiraIssues, - selectedJiraIssueKey, - setSelectedJiraIssueKey, - selectedJiraIssueFallback, - setSelectedJiraIssueFallback - }) - - const { - handleUseLinearItem, - handleOpenOrUseLinearItem, - handleLinearWorkspaceChange, - handleLinearTeamSelectionChange, - handleLinearScopeOpen, - handleLinearAccessConnected - } = useTaskPageLinearActions({ - linearTaskSourceContext, - openModal, - clearSelectedLinearIssue, - setSelectedLinearProject, - setSelectedLinearProjectDetail, - setSelectedLinearCustomView, - setLinearProjectParentView, - setLinearProjectTab, - setLinearProjectsResult, - setLinearCustomViewsResult, - setLinearProjectIssuesResult, - setLinearCustomViewIssuesResult, - setLinearCustomViewProjectsResult, - setLinearProjectDetailError, - setLinearProjectsError, - setLinearCustomViewsError, - setLinearCustomViewContentsError, - setTaskResumeState, - linearMode, - linearContextResumeAttemptedRef, - setLinearIssues, - setLinearError, - setLinearLoading, - selectLinearWorkspace, - setLinearTeamRefreshNonce, - setLinearTeamSelection, - updateSettings, - checkLinearConnection, - listLinearTeams, - selectedLinearWorkspaceId, - setAvailableTeams, - setLinearRefreshNonce - }) - - const { handleUseJiraItem } = useTaskPageJiraActions({ - jiraSites, - jiraTaskSourceContext, - openModal - }) - - const taskPageListChromeHidden = shouldHideTaskPageListChrome({ - taskSource, - hasGitHubDetail: Boolean(dialogWorkItem), - hasGitLabDetail: Boolean(gitlabDialogItem), - hasJiraDetail: Boolean(selectedJiraIssue), - hasLinearIssueDetail: Boolean(selectedLinearIssue), - hasLinearProjectContext: Boolean(selectedLinearProject), - hasLinearViewContext: Boolean(selectedLinearCustomView) - }) - - const sourceToolbar: TaskPageSourceToolbarProps = { - closeTaskPage, - visibleSourceOptions, - taskSource, - taskSourceAvailabilityNoticeByProvider, - taskSourceManuallyChangedRef, - openTaskPage, - updateSettings, - taskSourceContextSummary, - linearConnected, - linearWorkspaces, - selectedLinearWorkspaceId, - linearTeamOptions, - linearTeamSelection, - defaultLinearTeamSelection, - onLinearWorkspaceChange: handleLinearWorkspaceChange, - onLinearTeamSelectionChange: handleLinearTeamSelectionChange, - onOpenLinearConnect: () => setLinearConnectOpen(true), - onLinearScopeOpen: handleLinearScopeOpen, - selectedLinearTeamForExternalLink, - jiraConnected, - jiraSites, - selectedJiraSiteId, - selectJiraSite, - setSelectedJiraIssueKey, - setSelectedJiraIssueFallback, - setJiraIssues, - setJiraError, - setJiraLoading, - taskSourceAvailabilityNotice - } - const githubModeBar: TaskPageGithubModeBarProps = { - projectModeVisible, - githubModeButtons, - githubMode, - activeGithubTaskKind, - setGithubMode, - setTaskResumeState, - handleSelectGithubTaskKind, - taskPickerGroups, - repoSelection, - getTaskPickerRepoHostLabel, - eligibleRepos, - setRepoSelection, - updateSettings, - taskPickerRepos, - selectedGitHubRepoExternalLink - } - const githubItemFilters: TaskPageGithubItemFiltersProps = { - activeGithubTaskKind, - activeTaskPreset, - setTaskSearchInput, - setAppliedTaskSearch, - setActiveTaskPreset, - setTaskResumeState, - setTaskRefreshNonce, - handleSetDefaultTaskPreset, - appliedTaskQuery, - loadedGitHubAuthorLogins, - primaryGithubFilterSlug, - settings, - applyPRFilterChange, - taskSearchInputRef, - taskSearchInput, - handleTaskSearchChange, - handleTaskSearchKeyDown, - appliedTaskSearch, - handleResetGithubTaskSearch, - selectedRepos, - setNewIssueTitle, - setNewIssueBody, - setNewIssueLabels, - setNewIssueAssignees, - setNewIssueRepoId, - setNewIssueOpen, - newIssueTargetRepo, - handleRefreshGithubTasks, - githubTasksBusy, - perRepoSourceState, - setIssueSourcePreference - } - const linearFilters: TaskPageLinearFiltersProps = { - linearModeOptions, - linearMode, - selectLinearMode, - selectedLinearProject, - availableTeams, - setNewLinearProjectName, - setNewLinearProjectDescription, - setNewLinearProjectContent, - setNewLinearProjectTeamId, - setNewLinearProjectLeadId, - setNewLinearProjectMemberIds, - setNewLinearProjectLabelIds, - setNewLinearProjectPriority, - setNewLinearProjectStartDate, - setNewLinearProjectTargetDate, - setNewLinearProjectOpen, - setNewLinearIssueTitle, - setNewLinearIssueBody, - setNewLinearIssueTeamId, - setNewLinearIssueProjectId, - setNewLinearIssueOpen, - setLinearRefreshNonce, - linearLoading, - linearProjectsLoading, - linearProjectDetailLoading, - linearCustomViewsLoading, - linearCustomViewContentsLoading, - showLinearAttributeFilters, - linearAttributeFilter, - applyLinearAttributeFilter, - linearAttributeFilterWorkspaceId, - linearAttributePrimaryTeam, - linearTeamSelection, - linearTeamOptions, - linearTaskSourceContext, - settings, - linearSearchInput, - setLinearSearchInput, - setAppliedLinearSearch, - setTaskResumeState, - linearProjectSearchInput, - setLinearProjectSearchInput, - setAppliedLinearProjectSearch - } - const jiraFilters: TaskPageJiraFiltersProps = { - jiraPresets, - jiraSearchInput, - activeJiraPreset, - setJiraSearchInput, - setAppliedJiraSearch, - setActiveJiraPreset, - setTaskResumeState, - setJiraRefreshNonce, - sortedAvailableJiraProjects, - setNewJiraIssueTitle, - setNewJiraIssueBody, - setNewJiraIssueProjectId, - setNewJiraIssueProjectQuery, - setNewJiraIssueProjectCommandValue, - setNewJiraIssueTypeId, - setNewJiraIssueOpen, - jiraProjectsLoading, - jiraLoading - } - const gitlabFilters: TaskPageGitlabFiltersProps = { - gitlabView, - setGitlabView, - taskPickerGroups, - repoSelection, - getTaskPickerRepoHostLabel, - eligibleRepos, - setRepoSelection, - updateSettings, - taskPickerRepos, - gitLabIssueFilters, - gitLabMRFilters, - activeGitlabFilter, - setGitlabFilter, - setGitlabRefreshNonce, - gitlabLoading, - gitlabTodosLoading - } - const githubDetail: GithubDetailHostProps | null = dialogWorkItem - ? { - dialogWorkItem, - dialogInitialTab, - dialogRepoPath, - dialogSourceContext, - setDialogWorkItem, - handleUseWorkItem, - handleDialogReviewRequestsChange, - closeTaskDetailPage - } - : null - const githubTable: GithubWorkItemTableProps = { - githubListScrollRef, - githubResumeContextKey, - currentPageRef, - pendingGithubScrollRestoreRef, - githubRestoreScrollWriteRef, - githubListScrollTopRef, - taskListPositionRef, - githubTaskGridClass, - activeGithubTaskKind, - showPRManagementColumns, - tasksError, - githubUnavailable, - failedCount, - selectedRepos, - perRepoSourceState, - handleRetryIssuesFetch, - tasksLoading, - retryingSourceKeys, - unresolvedSourceRepos, - showGitHubTaskSkeletons, - filteredWorkItems, - softHiddenVisibleCount, - totalPages, - githubEmptyState, - repoMap, - allWorktrees, - openGitHubDetailPage, - githubWorkItemMutation, - ensurePRChecksLoaded, - handleOpenOrUseGitHubWorkItem, - handleUseWorkItem, - currentPage, - loadingTargetPage, - pages, - handleLoadNextPage, - setCurrentPage - } - const gitlabList: GitlabWorkItemListProps = { - gitlabError, - gitlabLoading, - gitlabItems, - displayedGitLabItems, - gitlabEmptyState, - openGitLabDetailPage, - handleUseGitLabItem, - showGitlabIssuePagination: gitlabView === 'issues' && gitlabIssueTotalPages > 1, - gitlabIssuePage, - gitlabIssueTotalPages, - gitlabIssueLoadingTargetPage, - onGitlabIssuePageChange: (page: number) => { - if (page === gitlabIssuePage) { - return - } - setGitlabIssueLoadingTargetPage(page) - setGitlabIssuePage(page) - } - } - const jiraList: JiraIssueListHostProps = { - jiraStatusReady, - jiraConnected, - setJiraConnectOpen, - hideTaskSource, - displayedJiraIssues, - jiraOrderDirection, - handleJiraSort, - jiraOrderBy, - jiraStatusCredentialError: jiraStatus.credentialError, - jiraError, - jiraErrorDetailsOpen, - setJiraErrorDetailsOpen, - jiraLoading, - jiraIssues, - jiraSearchInput, - sortedJiraIssues, - openJiraDetailPage, - handleUseJiraItem, - selectedJiraIssue, - selectedJiraSiteId, - displayedJiraStatusOrder, - closeTaskDetailPage, - jiraDetailSourceContext - } - const linearViews: LinearViewsHostProps = { - selectedLinearIssue, - activeLinearIssueContextLabel, - handleUseLinearItem, - openRelatedLinearIssue, - closeTaskDetailPage, - linearDetailSourceContext, - linearStatusReady, - linearConnected, - setLinearConnectOpen, - selectedLinearProject, - linearProjectTab, - selectedLinearProjectDetail, - linearProjectDetailLoading, - linearProjectDetailError, - linearProjectParentView, - setSelectedLinearProject, - setSelectedLinearProjectDetail, - setLinearProjectTab, - setLinearMode, - setSelectedLinearCustomView, - setTaskResumeState, - setLinearProjectParentView, - setLinearRefreshNonce, - linearMode, - linearProjectsError, - linearProjectsResult, - linearProjectsLoading, - selectedLinearWorkspaceId, - openLinearProjectContext, - selectedLinearCustomView, - linearCustomViewsError, - linearCustomViewsResult, - linearCustomViewsLoading, - openLinearCustomViewContext, - linearCustomViewContentsError, - linearCustomViewProjectsResult, - linearCustomViewContentsLoading, - issueList: { - toolbar: { - activeLinearIssueContextLabel, - selectedLinearProject, - setLinearProjectTab, - setSelectedLinearCustomView, - setLinearProjectParentView, - setTaskResumeState, - linearMode, - linearViewOptions, - linearViewMode, - setLinearViewMode, - linearGroupBy, - setLinearGroupBy, - linearGroupOptions, - linearOrderBy, - setLinearOrderBy, - linearOrderOptions, - linearDisplayPropertyOptions, - effectiveLinearDisplayProperties, - toggleLinearDisplayProperty, - linearIssueGridStyle - }, - empty: { - activeLinearIssueError, - activeLinearIssueLoading, - activeLinearIssues, - activeLinearIssueHasCollectionError, - linearMode, - linearSearchActive, - activeLinearIssueContextLabel, - linearAttributeFilter, - filteredLinearIssues, - linearIssuesHasMore, - setLinearIssueLimit - }, - board: { - linearBoardSections, - handleLinearBoardDragOver, - handleLinearBoardDrop, - linearBoardDragOverKey, - selectedLinearIssueId, - linearBoardDraggingIssueId, - linearBoardUpdatingIssueIds, - selectedLinearWorkspaceId, - linearIssueAttachmentIndex, - linearStatusBoardEnabled, - handleLinearBoardCardDragStart, - setLinearBoardDraggingIssueId, - setLinearBoardDragOverKey, - openLinearDetailPage, - effectiveLinearDisplayProperties, - handleOpenOrUseLinearItem, - linearTaskSourceContext - }, - list: { - linearIssueListRows, - selectedLinearIssueId, - selectedLinearWorkspaceId, - linearIssueAttachmentIndex, - openLinearDetailPage, - linearIssueGridStyle, - effectiveLinearDisplayProperties, - linearTaskSourceContext, - handleOpenOrUseLinearItem - }, - linearViewMode, - selectedLinearProject, - linearProjectTab, - selectedLinearCustomView, - linearProjectIssuesResult, - linearCustomViewIssuesResult, - linearIssues, - showLinearEmptyFilteredLoadMore, - handleLinearEmptyFilteredLoadMore, - activeLinearIssueLoading, - showLinearIssuePagination, - visibleLinearIssuePage, - linearIssueTotalPages, - activeLinearIssueLoadingTargetPage, - handleLinearIssuePageChange, - pagedLinearIssuesCount: pagedLinearIssues.length - } - } - const newGithubIssue: NewGithubIssueDialogProps = { - newIssueOpen, - newIssueSubmitting, - setNewIssueOpen, - handleCreateNewIssue, - newIssueTargetRepo, - perRepoSourceState, - setIssueSourcePreference, - selectedRepos, - newIssueRepoId, - setNewIssueRepoId, - setNewIssueLabels, - setNewIssueAssignees, - newIssueTitle, - setNewIssueTitle, - newIssueBody, - setNewIssueBody, - newIssueRepoLabels, - newIssueLabels, - newIssueRepoAssignees, - newIssueAssignees, - submitShortcutLabel - } - const newLinearProject: NewLinearProjectDialogProps = { - newLinearProjectOpen, - newLinearProjectSubmitting, - setNewLinearProjectOpen, - handleCreateNewLinearProject, - availableTeams, - newLinearProjectTargetTeam, - setNewLinearProjectTeamId, - newLinearProjectName, - setNewLinearProjectName, - newLinearProjectDescription, - setNewLinearProjectDescription, - newLinearProjectContent, - setNewLinearProjectContent, - submitShortcutLabel, - newLinearProjectPriority, - setNewLinearProjectPriority, - newLinearProjectMembers, - newLinearProjectLeadId, - setNewLinearProjectLeadId, - newLinearProjectMemberIds, - setNewLinearProjectMemberIds, - newLinearProjectLabelIds, - setNewLinearProjectLabelIds, - newLinearProjectLabels, - newLinearProjectStartDate, - setNewLinearProjectStartDate, - newLinearProjectTargetDate, - setNewLinearProjectTargetDate - } - const newLinearIssue: NewLinearIssueDialogProps = { - newLinearIssueOpen, - newLinearIssueSubmitting, - setNewLinearIssueOpen, - handleCreateNewLinearIssue, - availableTeams, - newLinearIssueTargetTeam, - newLinearIssueTeamId, - setNewLinearIssueTeamId, - newLinearIssueTitle, - setNewLinearIssueTitle, - newLinearIssueBody, - setNewLinearIssueBody, - submitShortcutLabel, - newLinearStates, - newLinearIssueStateId, - setNewLinearIssueStateId, - newLinearMembers, - newLinearIssueAssigneeId, - setNewLinearIssueAssigneeId, - newLinearIssuePriority, - setNewLinearIssuePriority, - newLinearIssueProjects, - newLinearIssueProjectId, - setNewLinearIssueProjectId, - newLinearIssueProjectsLoading, - newLinearIssueLabelIds, - setNewLinearIssueLabelIds, - newLinearLabels - } - const newJiraIssue: NewJiraIssueDialogProps = { - newJiraIssueOpen, - newJiraIssueSubmitting, - setNewJiraIssueOpen, - handleCreateNewJiraIssue, - newJiraIssueTargetProject, - newJiraIssueProjectComboboxOpen, - handleNewJiraIssueProjectComboboxOpenChange, - handleNewJiraIssueProjectTriggerKeyDown, - sortedAvailableJiraProjects, - includeJiraSiteNameInProjectLabel, - newJiraIssueProjectCommandValue, - setNewJiraIssueProjectCommandValue, - newJiraIssueProjectSearchInputRef, - newJiraIssueProjectQuery, - setNewJiraIssueProjectQuery, - filteredNewJiraIssueProjects, - newJiraIssueTargetProjectSelectionKey, - handleNewJiraIssueProjectSelect, - newJiraIssueTypeId, - newJiraIssueTargetType, - setNewJiraIssueTypeId, - jiraIssueTypesLoading, - availableJiraIssueTypes, - newJiraIssueTitle, - setNewJiraIssueTitle, - newJiraIssueBody, - setNewJiraIssueBody, - jiraCreateFieldsLoading, - jiraCreateFieldsError, - visibleJiraCreateFields, - newJiraIssueCustomFieldValues, - setNewJiraIssueCustomFieldValues, - jiraUserFieldSelections, - setJiraUserFieldSelections, - jiraProviderSettings: jiraTaskSourceContext ?? settings, - submitShortcutLabel, - hasMissingJiraCreateField - } - const connectDialogs: TaskPageConnectDialogsProps = { - gitlabDialogItem, - gitlabDialogRepo, - gitlabDialogSourceContext, - setGitlabDialogItem, - handleUseGitLabItem, - linearConnectOpen, - setLinearConnectOpen, - selectedLinearWorkspace, - handleLinearAccessConnected, - jiraConnectOpen, - setJiraConnectOpen - } - - return ( - - ) + const stage1 = useTaskPageStoreBindings() + const stage2 = useTaskPageRepoSelection(stage1) + const stage3 = useTaskPageRuntimeHosts(stage2) + const stage4 = useTaskPageSourceAvailability(stage3) + const stage5 = useTaskPageProviderState(stage4) + const stage6 = useTaskPageGitHubListState(stage5) + const stage7 = useTaskPageGitHubDetail(stage6) + const stage8 = useTaskPageGitHubCacheReconciliation(stage7) + const stage9 = useTaskPageGitHubIssueDraft(stage8) + const stage10 = useTaskPageDetailRouting(stage9) + const stage11 = useTaskPageLinearViewState(stage10) + const stage12 = useTaskPageJiraListState(stage11) + const stage13 = useTaskPageResumeRestoration(stage12) + const stage14 = useTaskPageProviderMetadata(stage13) + const stage15 = useTaskPageGitLabLoading(stage14) + const stage16 = useTaskPageLinearListSelection(stage15) + const stage17 = useTaskPageLinearListProjection(stage16) + const stage18 = useTaskPageLinearBoard(stage17) + const stage19 = useTaskPageJiraListProjection(stage18) + const stage20 = useTaskPageLinearCreationState(stage19) + const stage21 = useTaskPageGitHubMutationState(stage20) + const stage22 = useTaskPageJiraCreationState(stage21) + const stage23 = useTaskPageJiraCreationMetadata(stage22) + const stage24 = useTaskPageGitHubListProjection(stage23) + const stage25 = useTaskPageGitHubSearchPagination(stage24) + const stage26 = useTaskPageGitHubLandingRefresh(stage25) + const stage27 = useTaskPageGitHubQuietRefresh(stage26) + const stage28 = useTaskPageSearchActions(stage27) + const stage29 = useTaskPageWorkspaceActions(stage28) + const stage30 = useTaskPageGitHubIssueCreation(stage29) + const stage31 = useTaskPageLinearProjectCreation(stage30) + const stage32 = useTaskPageLinearIssueCreation(stage31) + const stage33 = useTaskPageJiraIssueCreation(stage32) + const stage34 = useTaskPageGlobalEffects(stage33) + const stage35 = useTaskPageLinearListEffects(stage34) + const stage36 = useTaskPageLinearInOrcaEffects(stage35) + const stage37 = useTaskPageLinearCollectionEffects(stage36) + const stage38 = useTaskPageJiraListEffects(stage37) + const stage39 = useTaskPageComposerActions(stage38) + return } diff --git a/src/renderer/src/components/TaskPageContent.tsx b/src/renderer/src/components/TaskPageContent.tsx new file mode 100644 index 00000000000..7c6419c6742 --- /dev/null +++ b/src/renderer/src/components/TaskPageContent.tsx @@ -0,0 +1,75 @@ +import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' +import PullRequestPage from '@/components/PullRequestPage' +import GitHubItemDialog from '@/components/GitHubItemDialog' +import ProjectViewWrapper from '@/components/github-project/ProjectViewWrapper' +import { TaskPageGitHubList } from './TaskPageGitHubList' +import { TaskPageGitLabTodoList } from './TaskPageGitLabTodoList' +import { TaskPageGitLabItemList } from './TaskPageGitLabItemList' +import { TaskPageJiraContent } from './TaskPageJiraContent' +export function TaskPageContent({ + model +}: { + model: TaskPageComposerActionsModel +}): React.JSX.Element | null { + const { + repoSelection, + taskSource, + githubMode, + gitlabView, + dialogInitialTab, + dialogWorkItem, + dialogRepoPath, + dialogSourceContext, + setDialogWorkItem, + handleDialogReviewRequestsChange, + closeTaskDetailPage, + handleUseWorkItem + } = model + return taskSource === 'github' && dialogWorkItem ? ( + dialogWorkItem.type === 'pr' ? ( + { + setDialogWorkItem(null) + handleUseWorkItem(item) + }} + onReviewRequestsChange={handleDialogReviewRequestsChange} + onClose={closeTaskDetailPage} + /> + ) : ( + { + setDialogWorkItem(null) + handleUseWorkItem(item) + }} + onReviewRequestsChange={handleDialogReviewRequestsChange} + onClose={closeTaskDetailPage} + /> + ) + ) : taskSource === 'github' && githubMode === 'project' ? ( +
+ +
+ ) : taskSource === 'github' ? ( + // Why: bottom of the joined GitHub list card — flush under the filter + // chrome (no gap, no top border/radius) so toolbar + table read as one. + + ) : taskSource === 'gitlab' && gitlabView === 'todos' ? ( + + ) : taskSource === 'gitlab' ? ( + + ) : ( + + ) +} diff --git a/src/renderer/src/components/TaskPageFrame.tsx b/src/renderer/src/components/TaskPageFrame.tsx new file mode 100644 index 00000000000..07d86bc07e2 --- /dev/null +++ b/src/renderer/src/components/TaskPageFrame.tsx @@ -0,0 +1,19 @@ +import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' +import { TaskPageListChrome } from './TaskPageListChrome' +import { TaskPageContent } from './TaskPageContent' +export function TaskPageFrame({ + model +}: { + model: TaskPageComposerActionsModel +}): React.JSX.Element | null { + return ( +
+ {/* Why: pt-1.5 (6px) aligns this 32px icon cluster's center with the sidebar Tasks row, 22px below the titlebar. */} +
+ + + +
+
+ ) +} diff --git a/src/renderer/src/components/TaskPageGitHubAssigneesCell.tsx b/src/renderer/src/components/TaskPageGitHubAssigneesCell.tsx new file mode 100644 index 00000000000..3d8bad1ed6d --- /dev/null +++ b/src/renderer/src/components/TaskPageGitHubAssigneesCell.tsx @@ -0,0 +1,295 @@ +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' +import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' +import type { Repo } from '../../../shared/repo-types' +import { + type TaskSourceContext, + getTaskSourceRuntimeSettings +} from '../../../shared/task-source-context' +import React, { useMemo, useState, useCallback } from 'react' +import { useAppStore } from '@/store' +import { useShallow } from 'zustand/react/shallow' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { parseGitHubIssueOrPRLink } from '@/lib/github-links' +import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' +import { translate } from '@/i18n/i18n' +import { getActiveRuntimeTarget, callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { githubProjectHost } from '../../../shared/github/project-identity' +import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import { LoaderCircle, ChevronDown, Check } from 'lucide-react' +import type { TaskPageGitHubWorkItemMutationRunner } from './task-page-linear-jira-list-model' +import { GitHubAssigneeAvatar } from './TaskPageGitHubAvatars' +export function GHAssigneesCell({ + item, + repo, + sourceContext, + workItemMutation +}: { + item: GitHubWorkItem + repo: Repo | null + sourceContext?: TaskSourceContext | null + workItemMutation: TaskPageGitHubWorkItemMutationRunner +}): React.JSX.Element { + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null)) + ) + const sourceSettings = useMemo( + () => + sourceContext?.provider === 'github' + ? ({ + ...repoOwnerSettings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as typeof repoOwnerSettings) + : repoOwnerSettings, + [repoOwnerSettings, sourceContext] + ) + const [open, setOpen] = useState(false) + const [pendingLogin, setPendingLogin] = useState(null) + const assignees = useMemo(() => item.assignees ?? [], [item.assignees]) + const parsed = useMemo(() => parseGitHubIssueOrPRLink(item.url), [item.url]) + const owner = parsed?.slug.owner ?? null + const repoName = parsed?.slug.repo ?? null + const seedLogins = useMemo( + () => + assignees + .map((a) => a.login) + .sort() + .filter(Boolean), + [assignees] + ) + const metadata = useRepoAssigneesBySlug( + open ? owner : null, + open ? repoName : null, + seedLogins, + sourceSettings, + parsed?.slug.host + ) + const toggleAssignee = useCallback( + async (user: GitHubAssignableUser): Promise => { + if (item.type !== 'issue') { + return + } + const userLoginKey = user.login.toLowerCase() + const isOn = assignees.some((a) => a.login.toLowerCase() === userLoginKey) + if ( + workItemMutation.isIntentPending({ + item, + intent: { + type: 'toggleAssignee', + user + }, + sourceContext + }) + ) { + return + } + setPendingLogin(user.login) + try { + await workItemMutation.run({ + item, + intent: { + type: 'toggleAssignee', + user + }, + sourceContext, + errorToast: translate( + 'auto.components.TaskPage.ca63694b4c', + 'Failed to update assignees.' + ), + mutate: async () => { + const updates = isOn + ? { + removeAssignees: [user.login] + } + : { + addAssignees: [user.login] + } + const target = getActiveRuntimeTarget(sourceSettings) + if (owner && repoName) { + const args = { + owner, + repo: repoName, + host: githubProjectHost(parsed?.slug.host), + number: item.number, + updates + } + const res = + target.kind === 'environment' + ? await callRuntimeRpc< + Awaited> + >(target, 'github.project.updateIssueBySlug', args, { + timeoutMs: 30_000 + }) + : await window.api.gh.updateIssueBySlug(args) + if (!res.ok) { + throw new Error(res.error.message) + } + return res + } + if (repo) { + const runtimeRepoId = + sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id + const res = + target.kind === 'environment' + ? await callRuntimeRpc<{ + ok?: boolean + error?: string + }>( + target, + 'github.updateIssue', + { + repo: runtimeRepoId, + number: item.number, + updates + }, + { + timeoutMs: 30_000 + } + ) + : await window.api.gh.updateIssue({ + repoPath: repo.path, + repoId: repo.id, + sourceContext, + number: item.number, + updates + }) + if (res && res.ok === false) { + throw new Error(res.error) + } + return res + } + throw new Error('No GitHub repository context available for this issue.') + } + }) + } finally { + setPendingLogin(null) + } + }, + [ + assignees, + item, + owner, + parsed?.slug.host, + repo, + repoName, + sourceContext, + sourceSettings, + workItemMutation + ] + ) + const triggerContent = + assignees.length > 0 ? ( + <> +
+ {assignees.slice(0, 3).map((assignee) => ( + + ))} +
+ {assignees.length > 3 ? ( + + +{assignees.length - 3} + + ) : null} + + ) : ( + - + ) + return ( + + + + + event.stopPropagation()} + > + {!owner || !repoName ? ( +
+ {translate('auto.components.TaskPage.53e002d895', 'Issue has no repo slug.')} +
+ ) : metadata.loading ? ( +
+ {translate('auto.components.TaskPage.0eacf48491', 'Loading…')} +
+ ) : metadata.error ? ( +
{metadata.error}
+ ) : metadata.data.length === 0 ? ( +
+ {translate('auto.components.TaskPage.edf4bc4135', 'No assignable users.')} +
+ ) : ( + metadata.data.map((user) => { + const isOn = assignees.some((a) => a.login.toLowerCase() === user.login.toLowerCase()) + const pending = pendingLogin === user.login + return ( + + ) + }) + )} +
+
+ ) +} diff --git a/src/renderer/src/components/TaskPageGitHubAvatars.tsx b/src/renderer/src/components/TaskPageGitHubAvatars.tsx new file mode 100644 index 00000000000..14b8bacaee8 --- /dev/null +++ b/src/renderer/src/components/TaskPageGitHubAvatars.tsx @@ -0,0 +1,54 @@ +import type { GitHubPRPrimaryReviewer } from '@/components/github-pr-reviewer-display' +import React from 'react' +import { GitHubUserAvatar } from '@/components/github/github-user-avatar' +import { Users } from 'lucide-react' +import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' +export function ReviewChipAvatar({ + reviewer, + avatarHost +}: { + reviewer: GitHubPRPrimaryReviewer | null + avatarHost?: string +}): React.JSX.Element { + if (reviewer?.login) { + // Why: review requests may contain only logins; use the PR host before falling back to initials. + const avatarUrl = + reviewer.avatarUrl || `https://${avatarHost ?? 'github.com'}/${reviewer.login}.png?size=40` + return ( + + ) + } + return +} +export function GitHubAssigneeAvatar({ + assignee +}: { + assignee: GitHubAssignableUser +}): React.JSX.Element { + if (assignee.avatarUrl) { + return ( + {assignee.login} + ) + } + return ( + + {assignee.login.slice(0, 1).toUpperCase()} + + ) +} diff --git a/src/renderer/src/components/TaskPageGitHubChecksCell.tsx b/src/renderer/src/components/TaskPageGitHubChecksCell.tsx new file mode 100644 index 00000000000..7f782a21371 --- /dev/null +++ b/src/renderer/src/components/TaskPageGitHubChecksCell.tsx @@ -0,0 +1,86 @@ +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' +import React, { useRef, useEffect } from 'react' +import { translate } from '@/i18n/i18n' +import { CheckCircle2, AlertCircle, Clock3, Minus } from 'lucide-react' +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { getChecksPillTone, getChecksLabel } from '@/components/task-page-checks-pill' +export function PRChecksCell({ + item, + onOpen, + onLoadChecks +}: { + item: GitHubWorkItem + onOpen: () => void + onLoadChecks: () => void +}): React.JSX.Element { + const triggerRef = useRef(null) + useEffect(() => { + if (item.type !== 'pr' || item.checksSummary) { + return + } + const node = triggerRef.current + if (!node || typeof IntersectionObserver === 'undefined') { + return + } + let requested = false + const observer = new IntersectionObserver( + (entries) => { + if (requested || !entries.some((entry) => entry.isIntersecting)) { + return + } + requested = true + onLoadChecks() + observer.disconnect() + }, + { + rootMargin: '160px 0px' + } + ) + observer.observe(node) + return () => observer.disconnect() + }, [item.checksSummary, item.type, onLoadChecks]) + if (item.type !== 'pr') { + return ( + + {translate('auto.components.TaskPage.b1eaa18ace', 'Issue')} + + ) + } + const summary = item.checksSummary + const Icon = + summary?.state === 'success' + ? CheckCircle2 + : summary?.state === 'failure' + ? AlertCircle + : summary?.state === 'pending' + ? Clock3 + : Minus + return ( + + + + + + {translate('auto.components.TaskPage.995dd6af9b', 'Open PR checks')} + + + ) +} diff --git a/src/renderer/src/components/TaskPageGitHubDuplicatePicker.tsx b/src/renderer/src/components/TaskPageGitHubDuplicatePicker.tsx new file mode 100644 index 00000000000..31ba2a4d75a --- /dev/null +++ b/src/renderer/src/components/TaskPageGitHubDuplicatePicker.tsx @@ -0,0 +1,118 @@ +import type React from 'react' +import { CheckCircle2, ChevronLeft, CircleDot, Copy, Search } from 'lucide-react' + +import { translate } from '@/i18n/i18n' +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' + +export function TaskPageGitHubDuplicatePicker({ + closeAsDuplicate, + directDuplicateTarget, + duplicateError, + duplicatePickerTitle, + duplicateSearch, + filteredDuplicateCandidates, + handleDuplicateSearchSubmit, + setDuplicateError, + setDuplicatePickerOpen, + setDuplicateSearch +}: { + closeAsDuplicate: (targetIssueNumber: number | string) => void + directDuplicateTarget: number | null + duplicateError: string | null + duplicatePickerTitle: string + duplicateSearch: string + filteredDuplicateCandidates: GitHubWorkItem[] + handleDuplicateSearchSubmit: () => void + setDuplicateError: React.Dispatch> + setDuplicatePickerOpen: React.Dispatch> + setDuplicateSearch: React.Dispatch> +}): React.JSX.Element { + return ( +
+
+ + {duplicatePickerTitle} +
+
+ + { + setDuplicateSearch(event.target.value) + setDuplicateError(null) + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + handleDuplicateSearchSubmit() + } + }} + placeholder={translate('auto.components.TaskPage.searchIssues', 'Search issues')} + className="h-9 pl-8 text-[12px]" + aria-invalid={duplicateError ? true : undefined} + /> +
+ {duplicateError ? ( +

{duplicateError}

+ ) : null} +
+ {directDuplicateTarget ? ( + + ) : null} + {filteredDuplicateCandidates.map((candidate) => ( + + ))} + {!directDuplicateTarget && filteredDuplicateCandidates.length === 0 ? ( +

+ {translate( + 'auto.components.TaskPage.noMatchingIssuesLoaded', + 'No matching issues loaded.' + )} +

+ ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/TaskPageGitHubFilters.tsx b/src/renderer/src/components/TaskPageGitHubFilters.tsx new file mode 100644 index 00000000000..918808b1763 --- /dev/null +++ b/src/renderer/src/components/TaskPageGitHubFilters.tsx @@ -0,0 +1,256 @@ +import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' +import { getGitHubTaskKindPresets } from '@/components/task-page-localized-options' +import { cn } from '@/lib/utils' +import PRFilterDropdowns from '@/components/github/PRFilterDropdowns' +import { Search, X, Plus, LoaderCircle, RefreshCw } from 'lucide-react' +import { Input } from '@/components/ui/input' +import { translate } from '@/i18n/i18n' +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' +import { Button } from '@/components/ui/button' +import { resolveNewIssueOpenSeed } from '@/components/task-page-new-issue-draft' +import { useAppStore } from '@/store' +import { hasUpstreamCandidateDivergence, hasDivergentSources } from './task-page-draft-storage' +import IssueSourceIndicator from '@/components/github/IssueSourceIndicator' +import IssueSourceSelector, { issueSourceChipClass } from '@/components/github/IssueSourceSelector' +import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +export function TaskPageGitHubFilters({ + model +}: { + model: TaskPageComposerActionsModel +}): React.JSX.Element | null { + const { + settings, + setTaskResumeState, + setIssueSourcePreference, + selectedRepos, + taskSearchInput, + setTaskSearchInput, + appliedTaskSearch, + setAppliedTaskSearch, + taskSearchInputRef, + activeTaskPreset, + setActiveTaskPreset, + setTaskRefreshNonce, + perRepoSourceState, + handleRefreshGithubTasks, + setNewIssueOpen, + setNewIssueTitle, + setNewIssueBody, + setNewIssueLabels, + setNewIssueAssignees, + setNewIssueRepoId, + newIssueTargetRepo, + activeGithubTaskKind, + appliedTaskQuery, + loadedGitHubAuthorLogins, + primaryGithubFilterSlug, + applyPRFilterChange, + handleTaskSearchChange, + handleSetDefaultTaskPreset, + handleResetGithubTaskSearch, + handleTaskSearchKeyDown, + githubTasksBusy + } = model + return ( + // Why: top of the joined GitHub list card — pairs with the + // table shell below (rounded-t-none border-t-0) as one surface. +
+
+ {getGitHubTaskKindPresets(activeGithubTaskKind).map((option) => { + const active = activeTaskPreset === option.id + return ( + + ) + })} +
+
+ applyPRFilterChange(change)} + /> +
+ + + {taskSearchInput || appliedTaskSearch ? ( + + ) : null} +
+
+ + + + + + {translate('auto.components.TaskPage.d3d0998b7d', 'New GitHub issue')} + + + + + + + + {githubTasksBusy + ? translate('auto.components.TaskPage.31f81cc334', 'Refreshing GitHub work…') + : translate('auto.components.TaskPage.ff53631e6f', 'Refresh GitHub work')} + + +
+
+ + {(() => { + // Why: show the source-slug chip only when the selector can't render (no upstream to toggle); otherwise it duplicates the selector. + const rows = perRepoSourceState.filter( + (s) => hasUpstreamCandidateDivergence(s) || hasDivergentSources(s) + ) + if (rows.length === 0) { + return null + } + return ( +
+ {rows.map((s) => { + const repo = selectedRepos.find((r) => r.id === s.repoId) + const showRepoBadgeLabel = selectedRepos.length > 1 && repo + const selectorRenderable = hasUpstreamCandidateDivergence(s) + // Why: render the indicator standalone — it has its own chip styles, so nesting it in our chip would double-border it. + if (!selectorRenderable && hasDivergentSources(s)) { + return ( + + ) + } + if (!selectorRenderable || !repo) { + return null + } + // Why:
not — the child selector renders a block
(div-in-span is invalid HTML); inline-flex class looks identical. + return ( +
+ {showRepoBadgeLabel ? ( + + ) : null} + { + void setIssueSourcePreference(repo.id, repo.path, next) + }} + /> +
+ ) + })} +
+ ) + })()} +
+ ) +} diff --git a/src/renderer/src/components/TaskPageGitHubIssueDialog.tsx b/src/renderer/src/components/TaskPageGitHubIssueDialog.tsx new file mode 100644 index 00000000000..441f07e9b5e --- /dev/null +++ b/src/renderer/src/components/TaskPageGitHubIssueDialog.tsx @@ -0,0 +1,241 @@ +import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter +} from '@/components/ui/dialog' +import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' +import { translate } from '@/i18n/i18n' +import { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator' +import IssueSourceSelector from '@/components/github/IssueSourceSelector' +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem +} from '@/components/ui/select' +import { resolveUserRepoSwitchReset } from '@/components/task-page-new-issue-draft' +import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import { Input } from '@/components/ui/input' +import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer' +import { + GitHubIssueLabelSelector, + GitHubIssueAssigneeSelector +} from './TaskPageGitHubIssueSelectors' +import { Button } from '@/components/ui/button' +import { LoaderCircle } from 'lucide-react' +export function TaskPageGitHubIssueDialog({ + model +}: { + model: TaskPageComposerActionsModel +}): React.JSX.Element | null { + const { + setIssueSourcePreference, + submitShortcutLabel, + selectedRepos, + perRepoSourceState, + newIssueOpen, + setNewIssueOpen, + newIssueTitle, + setNewIssueTitle, + newIssueBody, + setNewIssueBody, + newIssueLabels, + setNewIssueLabels, + newIssueAssignees, + setNewIssueAssignees, + newIssueSubmitting, + newIssueRepoId, + setNewIssueRepoId, + newIssueTargetRepo, + newIssueRepoLabels, + newIssueRepoAssignees, + handleCreateNewIssue + } = model + return ( + { + if (!newIssueSubmitting) { + setNewIssueOpen(open) + } + }} + > + { + if (isScreenSubmitShortcut(event)) { + event.preventDefault() + void handleCreateNewIssue() + } + }} + > + + + {translate('auto.components.TaskPage.d3d0998b7d', 'New GitHub issue')} + + {(() => { + // Why: inline the resolved {owner}/{repo} slug as the source indicator; fall back to displayName when unresolved. + const entry = newIssueTargetRepo + ? perRepoSourceState.find((s) => s.repoId === newIssueTargetRepo.id) + : undefined + const issuesSlug = entry?.sources?.issues + ? `${entry.sources.issues.owner}/${entry.sources.issues.repo}` + : null + const fallback = newIssueTargetRepo?.displayName ?? 'this repository' + return ( + + {translate('auto.components.TaskPage.9f2b4c03a6', 'Filing in')} + {issuesSlug ?? fallback} + + ) + })()} + {(() => { + // Why: mirror the Tasks-view target selector so a fork contributor can flip target at filing time (fork-routing regression #1076). + // Sibling (not nested) because DialogDescription renders a

and the selector a

— nesting is invalid HTML. + if (!newIssueTargetRepo) { + return null + } + const entry = perRepoSourceState.find((s) => s.repoId === newIssueTargetRepo.id) + if (!entry || !entry.sources?.upstreamCandidate || !entry.sources?.originCandidate) { + return null + } + if ( + sameGitHubOwnerRepo(entry.sources.originCandidate, entry.sources.upstreamCandidate) + ) { + return null + } + return ( +
+ { + void setIssueSourcePreference( + newIssueTargetRepo.id, + newIssueTargetRepo.path, + next + ) + }} + /> +
+ ) + })()} + +
+ {selectedRepos.length > 1 ? ( +
+ + +
+ ) : null} +
+ + setNewIssueTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.nativeEvent.isComposing) { + e.preventDefault() + void handleCreateNewIssue() + } + }} + placeholder={translate('auto.components.TaskPage.578f730c16', 'Short summary')} + disabled={newIssueSubmitting} + /> +
+
+ + void handleCreateNewIssue()} + /> +
+
+ + +
+

+ {submitShortcutLabel} {translate('auto.components.TaskPage.fc0d8a1fa4', 'to submit.')} +

+
+ + + + + +
+ ) +} diff --git a/src/renderer/src/components/TaskPageGitHubIssueSelectors.tsx b/src/renderer/src/components/TaskPageGitHubIssueSelectors.tsx new file mode 100644 index 00000000000..5c39c9c7b98 --- /dev/null +++ b/src/renderer/src/components/TaskPageGitHubIssueSelectors.tsx @@ -0,0 +1,207 @@ +import React, { useMemo, useCallback } from 'react' +import { translate } from '@/i18n/i18n' +import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' +import { Button } from '@/components/ui/button' +import { LoaderCircle, Check } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' +import { GitHubAssigneeAvatar } from './TaskPageGitHubAvatars' +export function GitHubIssueLabelSelector({ + labels, + selectedLabels, + loading, + error, + disabled, + onChange +}: { + labels: string[] + selectedLabels: string[] + loading: boolean + error: string | null + disabled: boolean + onChange: (labels: string[]) => void +}): React.JSX.Element { + const selectedSet = useMemo(() => new Set(selectedLabels), [selectedLabels]) + const toggleLabel = useCallback( + (label: string) => { + onChange( + selectedSet.has(label) + ? selectedLabels.filter((name) => name !== label) + : [...selectedLabels, label] + ) + }, + [onChange, selectedLabels, selectedSet] + ) + return ( +
+ + + + + + + {error ? ( +
{error}
+ ) : labels.length === 0 ? ( +
+ {translate('auto.components.TaskPage.b36f4bf9de', 'No labels.')} +
+ ) : ( + labels.map((label) => ( + + )) + )} +
+
+
+ ) +} +export function GitHubIssueAssigneeSelector({ + assignees, + selectedAssignees, + loading, + error, + disabled, + onChange +}: { + assignees: GitHubAssignableUser[] + selectedAssignees: GitHubAssignableUser[] + loading: boolean + error: string | null + disabled: boolean + onChange: (assignees: GitHubAssignableUser[]) => void +}): React.JSX.Element { + const selectedLogins = useMemo( + () => new Set(selectedAssignees.map((assignee) => assignee.login.toLowerCase())), + [selectedAssignees] + ) + const toggleAssignee = useCallback( + (assignee: GitHubAssignableUser) => { + const key = assignee.login.toLowerCase() + onChange( + selectedLogins.has(key) + ? selectedAssignees.filter((current) => current.login.toLowerCase() !== key) + : [...selectedAssignees, assignee] + ) + }, + [onChange, selectedAssignees, selectedLogins] + ) + return ( +
+ + + + + + + {error ? ( +
{error}
+ ) : assignees.length === 0 ? ( +
+ {translate('auto.components.TaskPage.edf4bc4135', 'No assignable users.')} +
+ ) : ( + assignees.map((assignee) => { + const selected = selectedLogins.has(assignee.login.toLowerCase()) + return ( + + ) + }) + )} +
+
+
+ ) +} diff --git a/src/renderer/src/components/TaskPageGitHubList.tsx b/src/renderer/src/components/TaskPageGitHubList.tsx new file mode 100644 index 00000000000..aa53374ffa7 --- /dev/null +++ b/src/renderer/src/components/TaskPageGitHubList.tsx @@ -0,0 +1,320 @@ +import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' +import { useAppStore } from '@/store' +import { cn } from '@/lib/utils' +import { + GITHUB_TASK_HEADER_SURFACE_CLASS, + GITHUB_TASK_STICKY_ID_HEADER_CLASS, + GITHUB_TASK_STICKY_TITLE_HEADER_CLASS, + GITHUB_TASK_STICKY_ID_CELL_CLASS, + GITHUB_TASK_STICKY_TITLE_CELL_CLASS +} from './task-page-source-context' +import { translate } from '@/i18n/i18n' +import { Button } from '@/components/ui/button' +import { LoaderCircle } from 'lucide-react' +import { TaskPageGitHubRows } from './TaskPageGitHubRows' +import { PaginationBar } from './TaskPagePaginationBar' +export function TaskPageGitHubList({ + model +}: { + model: TaskPageComposerActionsModel +}): React.JSX.Element | null { + const { + selectedRepos, + githubEmptyState, + tasksLoading, + tasksError, + failedCount, + githubUnavailable, + githubResumeContextKey, + pages, + currentPage, + setCurrentPage, + currentPageRef, + githubListScrollRef, + githubListScrollTopRef, + pendingGithubScrollRestoreRef, + loadingTargetPage, + taskListPositionRef, + perRepoSourceState, + unresolvedSourceRepos, + retryingSourceKeys, + handleRetryIssuesFetch, + activeGithubTaskKind, + filteredWorkItems, + softHiddenVisibleCount, + showGitHubTaskSkeletons, + showPRManagementColumns, + githubTaskGridClass, + totalPages, + handleLoadNextPage + } = model + return ( + // Why: bottom of the joined GitHub list card — flush under the filter + // chrome (no gap, no top border/radius) so toolbar + table read as one. +
+
{ + const state = useAppStore.getState() + if ( + state.activeView !== 'tasks' || + state.taskPageData.openGitHubWorkItem || + pendingGithubScrollRestoreRef.current !== null + ) { + return + } + const scrollTop = event.currentTarget.scrollTop + githubListScrollTopRef.current = scrollTop + taskListPositionRef.current = { + contextKey: githubResumeContextKey, + page: currentPageRef.current, + scrollTop + } + }} + > +
span]:flex [&>span]:items-center', + GITHUB_TASK_HEADER_SURFACE_CLASS, + githubTaskGridClass + )} + > + + {translate('auto.components.TaskPage.eb10c32872', 'ID')} + + + {translate('auto.components.TaskPage.5eccb3c841', 'Title / Context')} + + {activeGithubTaskKind === 'issues' ? ( + {translate('auto.components.TaskPage.8aba10579d', 'Assignees')} + ) : null} + {showPRManagementColumns ? ( + <> + {translate('auto.components.TaskPage.f6fa3c97d0', 'Reviewers')} + {translate('auto.components.TaskPage.a7396b05c6', 'Checks')} + {translate('auto.components.TaskPage.443f7dd928', 'Merge')} + + ) : ( + {translate('auto.components.TaskPage.154b0fa623', 'Status')} + )} + {translate('auto.components.TaskPage.f362667d55', 'Updated')} + +
+ + {tasksError ? ( +
+ {tasksError} +
+ ) : null} + + {!tasksError && githubUnavailable ? ( + // Why: name the GitHub outage explicitly so an empty list isn't misread as an Orca bug; takes priority over the count banner. +
+ {translate( + 'auto.components.TaskPage.75a38d7df8', + 'GitHub data is temporarily unavailable. Its API may be down, rate-limited, or unreachable. Please try again shortly.' + )} +
+ ) : null} + + {!tasksError && !githubUnavailable && failedCount > 0 ? ( + // Why: per-repo partial-failure signal, distinct from a hard IPC reject (tasksError); the two are mutually exclusive. +
+ {failedCount} {translate('auto.components.TaskPage.7762f4b03a', 'of')}{' '} + {selectedRepos.length}{' '} + {translate('auto.components.TaskPage.d1766fd62d', 'projects failed to load')} +
+ ) : null} + + {perRepoSourceState + .filter((s) => s.error) + .map((s) => { + const err = s.error! + // Why: Retry re-fetches force=true via the shared refresh nonce, invalidating any still-failing in-flight request first. + return ( +
+ + {translate('auto.components.TaskPage.0c0de0fc0e', "Couldn't load issues from")}{' '} + + {err.source.owner}/{err.source.repo} + {' '} + — {err.message} + + +
+ ) + })} + + {unresolvedSourceRepos.map((r) => ( + // Why: null-source repos (#9660) render empty like genuine zero — name the repo and offer Retry so a transient resolve blip is recoverable. +
+ + {translate( + 'auto.components.TaskPage.noGithubSourceDetected', + 'No GitHub source detected for' + )}{' '} + {r.label} —{' '} + {translate( + 'auto.components.TaskPage.noGithubSourceDetectedHint', + 'it may have no GitHub remote, or the source could not be resolved.' + )} + + +
+ ))} + + {showGitHubTaskSkeletons ? ( + // Why: render enough shimmer rows to fill a typical viewport + // so the table doesn't visibly grow when results land. A + // 3-row stub jumps to ~30 real rows; matching the steady- + // state height keeps layout stable across the load. +
+ {Array.from({ + length: 12 + }).map((_, i) => ( +
+
+
+
+
+
+
+
+ {!showPRManagementColumns ? ( +
+
+
+ ) : null} + {showPRManagementColumns ? ( + <> +
+
+
+
+
+
+
+
+
+ + ) : ( +
+
+
+ )} +
+
+
+
+
+
+
+ ))} +
+ ) : null} + + {/* Why: suppress the generic empty state when any error banner is + visible (IPC reject via tasksError, cross-repo partial failure + via failedCount, or per-repo issue-side error). Showing + "No matching GitHub work" next to "Couldn't load issues from X/Y" + is contradictory and misleads the user into thinking they + typed the wrong query. */} + {/* Why: soft-hidden membership exits must not look like an empty + query when pager/count still implies items remain — but on a + single page (totalPages <= 1) there's nothing else to show, so + surface the empty state instead of a blank panel. */} + {!showGitHubTaskSkeletons && + filteredWorkItems.length === 0 && + (softHiddenVisibleCount === 0 || totalPages <= 1) && + !tasksError && + !githubUnavailable && + failedCount === 0 && + unresolvedSourceRepos.length === 0 && + perRepoSourceState.every((s) => !s.error) ? ( +
+

{githubEmptyState.title}

+

{githubEmptyState.description}

+
+ ) : null} + + +
+ + {/* Why: pagination sits outside the scroll container so it + remains pinned at the bottom of the panel rather than + hiding below the last row inside the scrolling region. */} + {(filteredWorkItems.length > 0 || softHiddenVisibleCount > 0) && + !showGitHubTaskSkeletons && + totalPages > 1 ? ( +
+ { + pendingGithubScrollRestoreRef.current = null + githubListScrollTopRef.current = 0 + if (githubListScrollRef.current) { + githubListScrollRef.current.scrollTop = 0 + } + if (pages[page] !== null && pages[page] !== undefined) { + currentPageRef.current = page + setCurrentPage(page) + } else { + void handleLoadNextPage(page) + } + }} + /> +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/TaskPageGitHubMergeCell.tsx b/src/renderer/src/components/TaskPageGitHubMergeCell.tsx new file mode 100644 index 00000000000..cca2d2b2a96 --- /dev/null +++ b/src/renderer/src/components/TaskPageGitHubMergeCell.tsx @@ -0,0 +1,266 @@ +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' +import type { GitHubPRMergeMethod } from '../../../shared/github/pull-request-types' +import type { Repo } from '../../../shared/repo-types' +import { + type TaskSourceContext, + getTaskSourceRuntimeSettings +} from '../../../shared/task-source-context' +import React, { useState, useMemo } from 'react' +import { useConfirmationDialog } from '@/components/confirmation-dialog-context' +import { useAppStore } from '@/store' +import { useShallow } from 'zustand/react/shallow' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { translate } from '@/i18n/i18n' +import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' +import { + resolveGitHubPRMergeMethods, + GITHUB_PR_MERGE_METHOD_LABELS +} from '../../../shared/github/pull-request-merge-methods' +import { getActiveRuntimeTarget, callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { LoaderCircle, GitMerge, ChevronDown, ExternalLink } from 'lucide-react' +import type { TaskPageGitHubWorkItemMutationRunner } from './task-page-linear-jira-list-model' +import { resolveTaskPullRequestRepo } from './task-page-github-review-model' +export function PRMergeCell({ + item, + repo, + sourceContext, + workItemMutation +}: { + item: GitHubWorkItem + repo: Repo | null + sourceContext?: TaskSourceContext | null + workItemMutation: TaskPageGitHubWorkItemMutationRunner +}): React.JSX.Element { + const [merging, setMerging] = useState(false) + const confirm = useConfirmationDialog() + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null)) + ) + const sourceSettings = useMemo( + () => + sourceContext?.provider === 'github' + ? ({ + ...repoOwnerSettings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as typeof repoOwnerSettings) + : repoOwnerSettings, + [repoOwnerSettings, sourceContext] + ) + if (item.type !== 'pr') { + return ( + + {translate('auto.components.TaskPage.b1eaa18ace', 'Issue')} + + ) + } + const mergePresentation = presentGitHubPRMergeState(item) + const mergeMethods = resolveGitHubPRMergeMethods(item.mergeMethodSettings) + const prRepo = resolveTaskPullRequestRepo(item) + const mergeMutationPending = workItemMutation.isIntentPending({ + item, + intent: { + type: 'merge' + }, + sourceContext + }) + const autoMergeMutationPending = mergePresentation.autoMergeAction + ? workItemMutation.isIntentPending({ + item, + intent: { + type: 'setAutoMerge', + enabled: mergePresentation.autoMergeAction.kind === 'enable' + }, + sourceContext + }) + : false + const mergeDisabled = + !repo || merging || mergeMutationPending || !mergePresentation.directMergeAvailable + const handleMerge = async (method: GitHubPRMergeMethod): Promise => { + if (!repo || mergeDisabled) { + return + } + const label = GITHUB_PR_MERGE_METHOD_LABELS[method] + const confirmed = await confirm({ + title: translate('auto.components.TaskPage.844dc193c7', '{{value0}} PR #{{value1}}?', { + value0: label, + value1: item.number + }), + description: translate( + 'auto.components.TaskPage.0506a78337', + 'This will update the pull request on GitHub.' + ), + confirmLabel: label + }) + if (!confirmed) { + return + } + setMerging(true) + try { + await workItemMutation.run({ + item, + intent: { + type: 'merge' + }, + sourceContext, + successToast: translate('auto.components.TaskPage.a161925adc', 'Pull request merged'), + errorToast: translate( + 'auto.components.TaskPage.88f478cdef', + 'Failed to merge pull request' + ), + mutate: async () => { + const target = getActiveRuntimeTarget(sourceSettings) + const runtimeRepoId = + sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id + return target.kind === 'environment' + ? callRuntimeRpc<{ + ok: boolean + error?: string + }>( + target, + 'github.mergePR', + { + repo: runtimeRepoId, + prNumber: item.number, + method, + prRepo + }, + { + timeoutMs: 30_000 + } + ) + : window.api.gh.mergePR({ + repoPath: repo.path, + repoId: repo.id, + sourceContext, + prNumber: item.number, + method, + prRepo + }) + } + }) + } finally { + setMerging(false) + } + } + const handleAutoMerge = async (): Promise => { + if (!repo || autoMergeMutationPending || !mergePresentation.autoMergeAction) { + return + } + const enabled = mergePresentation.autoMergeAction.kind === 'enable' + setMerging(true) + try { + await workItemMutation.run({ + item, + intent: { + type: 'setAutoMerge', + enabled + }, + sourceContext, + successToast: enabled + ? translate('auto.components.TaskPage.fed317634c', 'Auto-merge enabled') + : translate('auto.components.TaskPage.a5bf86defe', 'Auto-merge disabled'), + errorToast: enabled + ? translate('auto.components.TaskPage.a3318684bc', 'Failed to enable auto-merge') + : translate('auto.components.TaskPage.1a9ea003dc', 'Failed to disable auto-merge'), + mutate: async () => { + const target = getActiveRuntimeTarget(sourceSettings) + const runtimeRepoId = + sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id + return target.kind === 'environment' + ? callRuntimeRpc<{ + ok: boolean + error?: string + }>( + target, + 'github.setPRAutoMerge', + { + repo: runtimeRepoId, + prNumber: item.number, + enabled, + method: enabled ? mergeMethods.defaultMethod : undefined, + prRepo + }, + { + timeoutMs: 30_000 + } + ) + : window.api.gh.setPRAutoMerge({ + repoPath: repo.path, + repoId: repo.id, + sourceContext, + prNumber: item.number, + enabled, + method: enabled ? mergeMethods.defaultMethod : undefined, + prRepo + }) + } + }) + } finally { + setMerging(false) + } + } + return ( + + + + + + + + + {mergePresentation.tooltip} + + + event.stopPropagation()}> + {mergePresentation.autoMergeAction && ( + void handleAutoMerge()} + > + + {mergePresentation.autoMergeAction.label} + + )} + {mergePresentation.autoMergeAction && } + {mergeMethods.methods.map(({ method, label }) => ( + void handleMerge(method)} + > + + {label} + + ))} + window.api.shell.openUrl(item.url)}> + + {translate('auto.components.TaskPage.37d60046e3', 'Open GitHub merge box')} + + + + ) +} diff --git a/src/renderer/src/components/TaskPageGitHubModeControls.tsx b/src/renderer/src/components/TaskPageGitHubModeControls.tsx new file mode 100644 index 00000000000..d1252bf4bfc --- /dev/null +++ b/src/renderer/src/components/TaskPageGitHubModeControls.tsx @@ -0,0 +1,150 @@ +import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' +import { cn } from '@/lib/utils' +import TaskProjectSourceCombobox from '@/components/task-project-source-combobox' +import { normalizeTaskRepoSelection } from '@/components/task-page-default-repo-selection' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' +import { Button } from '@/components/ui/button' +import { ExternalLink } from 'lucide-react' +export function TaskPageGitHubModeControls({ + model +}: { + model: TaskPageComposerActionsModel +}): React.JSX.Element | null { + const { + setTaskResumeState, + updateSettings, + eligibleRepos, + repoSelection, + setRepoSelection, + taskPickerGroups, + taskPickerRepos, + githubModeButtons, + taskSource, + getTaskPickerRepoHostLabel, + projectModeVisible, + githubMode, + setGithubMode, + activeGithubTaskKind, + selectedGitHubRepoExternalLink, + handleSelectGithubTaskKind + } = model + return taskSource === 'github' ? ( +
+ {projectModeVisible ? ( +
+ {githubModeButtons.map((mode) => { + const active = + mode.id === 'project' + ? githubMode === 'project' + : githubMode === 'items' && activeGithubTaskKind === mode.id + return ( + + ) + })} +
+ ) : null} + {/* Why: Project rows are repo-scoped, so the selection must stay visible in both GitHub modes. */} +
+ { + const normalized = normalizeTaskRepoSelection(eligibleRepos, next) + setRepoSelection(normalized) + void updateSettings({ + defaultRepoSelection: [...normalized] + }).catch(() => { + toast.error( + translate( + 'auto.components.TaskPage.dfd72673e7', + 'Failed to save project selection.' + ) + ) + }) + }} + onSelectAll={() => { + const allIds = new Set(taskPickerRepos.map((r) => r.id)) + setRepoSelection(allIds) + void updateSettings({ + defaultRepoSelection: null + }).catch(() => { + toast.error( + translate( + 'auto.components.TaskPage.dfd72673e7', + 'Failed to save project selection.' + ) + ) + }) + }} + triggerClassName="h-8 w-auto max-w-[220px] rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none" + /> +
+ + + + + + {selectedGitHubRepoExternalLink + ? translate('auto.components.TaskPage.8d1e17a3ef', 'Open {{value0}} in GitHub', { + value0: selectedGitHubRepoExternalLink.label + }) + : translate( + 'auto.components.TaskPage.bc46d8204e', + 'Select one project to open in GitHub' + )} + + +
+ ) : null +} diff --git a/src/renderer/src/components/TaskPageGitHubReviewCell.tsx b/src/renderer/src/components/TaskPageGitHubReviewCell.tsx new file mode 100644 index 00000000000..288db753496 --- /dev/null +++ b/src/renderer/src/components/TaskPageGitHubReviewCell.tsx @@ -0,0 +1,345 @@ +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' +import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' +import type { Repo } from '../../../shared/repo-types' +import { + type TaskSourceContext, + getTaskSourceRuntimeSettings +} from '../../../shared/task-source-context' +import React, { useState, useMemo, useRef, useCallback } from 'react' +import { useAppStore } from '@/store' +import { useShallow } from 'zustand/react/shallow' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' +import { + getGitHubPRReviewerQueryState, + filterGitHubPRReviewerCandidates +} from '@/components/github/github-pr-reviewer-candidate-filter' +import { translate } from '@/i18n/i18n' +import { + getGitHubPRPrimaryReviewer, + getGitHubPRReviewerRows, + getGitHubPRReviewLabel +} from '@/components/github-pr-reviewer-display' +import { cn } from '@/lib/utils' +import { ChevronDown } from 'lucide-react' +import { Popover, PopoverTrigger } from '@/components/ui/popover' +import type { TaskPageGitHubWorkItemMutationRunner } from './task-page-linear-jira-list-model' +import { + mergeReviewerSuggestions, + resolveTaskPullRequestRepo +} from './task-page-github-review-model' +import { ReviewChipAvatar } from './TaskPageGitHubAvatars' +import { createTaskPageGitHubReviewerActions } from './task-page-github-reviewer-actions' +import { TaskPageGitHubReviewerPicker } from './TaskPageGitHubReviewerPicker' +export function PRReviewCell({ + item, + repo, + sourceContext, + workItemMutation +}: { + item: GitHubWorkItem + repo: Repo | null + sourceContext?: TaskSourceContext | null + workItemMutation: TaskPageGitHubWorkItemMutationRunner +}): React.JSX.Element { + const [open, setOpen] = useState(false) + const [reviewerInput, setReviewerInput] = useState('') + const [localReviewRequests, setLocalReviewRequests] = useState( + () => item.reviewRequests ?? [] + ) + const [reviewerPickerSide, setReviewerPickerSide] = useState<'top' | 'bottom'>('bottom') + const [reviewerPickerMaxHeight, setReviewerPickerMaxHeight] = useState(null) + const [reviewRequestsSource, setReviewRequestsSource] = useState(() => ({ + itemId: item.id, + repoId: item.repoId, + reviewRequests: item.reviewRequests + })) + const [activeReviewerCursor, setActiveReviewerCursor] = useState({ + resetKey: '', + index: 0 + }) + const [submitting, setSubmitting] = useState(false) + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null)) + ) + const sourceSettings = useMemo( + () => + sourceContext?.provider === 'github' + ? ({ + ...repoOwnerSettings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as typeof repoOwnerSettings) + : repoOwnerSettings, + [repoOwnerSettings, sourceContext] + ) + const reviewerInputRef = useRef(null) + const reviewerTriggerRef = useRef(null) + const reviewerInputFocusFrameRef = useRef(null) + const cancelReviewerInputFocusFrame = useCallback((): void => { + if (reviewerInputFocusFrameRef.current === null) { + return + } + cancelAnimationFrame(reviewerInputFocusFrameRef.current) + reviewerInputFocusFrameRef.current = null + }, []) + const setReviewerInputNode = useCallback( + (node: HTMLInputElement | null): void => { + // Why: the queued picker focus is only valid while this input is mounted. + if (!node) { + cancelReviewerInputFocusFrame() + } + reviewerInputRef.current = node + }, + [cancelReviewerInputFocusFrame] + ) + + // Why: reviewer edits are optimistic, but item switches/refetches must clear stale local requests before paint (a passive Effect leaves one stale frame). + if ( + reviewRequestsSource.itemId !== item.id || + reviewRequestsSource.repoId !== item.repoId || + reviewRequestsSource.reviewRequests !== item.reviewRequests + ) { + setReviewRequestsSource({ + itemId: item.id, + repoId: item.repoId, + reviewRequests: item.reviewRequests + }) + setLocalReviewRequests(item.reviewRequests ?? []) + } + const reviewerSeedUsers = useMemo(() => { + const byLogin = new Map() + const add = (user: GitHubAssignableUser): void => { + if (!user.login) { + return + } + byLogin.set(user.login.toLowerCase(), user) + } + for (const user of localReviewRequests) { + add(user) + } + for (const review of item.latestReviews ?? []) { + add({ + login: review.login, + name: null, + avatarUrl: review.avatarUrl ?? '' + }) + } + if (item.author) { + add({ + login: item.author, + name: null, + avatarUrl: '' + }) + } + return Array.from(byLogin.values()) + }, [item.author, item.latestReviews, localReviewRequests]) + const reviewRepo = useMemo(() => resolveTaskPullRequestRepo(item), [item]) + const reviewerMetadata = useRepoAssigneesBySlug( + open && reviewRepo ? reviewRepo.owner : null, + open && reviewRepo ? reviewRepo.repo : null, + reviewerSeedUsers.map((user) => user.login), + sourceSettings, + reviewRepo?.host + ) + const authorLogin = item.author?.toLowerCase() ?? null + const reviewerCandidates = useMemo( + () => + mergeReviewerSuggestions(reviewerMetadata.data, reviewerSeedUsers).filter( + (user) => user.login.toLowerCase() !== authorLogin + ), + [authorLogin, reviewerMetadata.data, reviewerSeedUsers] + ) + const reviewerCandidatesByLogin = useMemo( + () => new Map(reviewerCandidates.map((user) => [user.login.toLowerCase(), user])), + [reviewerCandidates] + ) + const selectedReviewerLogins = useMemo( + () => + new Set( + localReviewRequests.map((reviewer) => reviewer.login.trim().toLowerCase()).filter(Boolean) + ), + [localReviewRequests] + ) + const reviewerQueryState = useMemo( + () => getGitHubPRReviewerQueryState(reviewerInput), + [reviewerInput] + ) + const reviewerQuery = reviewerQueryState.query + const filteredReviewerCandidates = useMemo( + () => + filterGitHubPRReviewerCandidates({ + candidates: reviewerCandidates, + queryState: reviewerQueryState + }), + [reviewerCandidates, reviewerQueryState] + ) + const suggestedReviewerRows = useMemo( + () => + reviewerQuery.length === 0 && !reviewerQueryState.isTooLarge + ? reviewerSeedUsers + .filter((user) => !selectedReviewerLogins.has(user.login.toLowerCase())) + .filter((user) => user.login.toLowerCase() !== authorLogin) + .map((user) => reviewerCandidatesByLogin.get(user.login.toLowerCase()) ?? user) + .slice(0, 1) + : [], + [ + authorLogin, + reviewerCandidatesByLogin, + reviewerQuery.length, + reviewerQueryState.isTooLarge, + reviewerSeedUsers, + selectedReviewerLogins + ] + ) + const everyoneElseReviewerRows = useMemo(() => { + const suggestedLogins = new Set(suggestedReviewerRows.map((user) => user.login.toLowerCase())) + return filteredReviewerCandidates.filter( + (user) => !suggestedLogins.has(user.login.toLowerCase()) + ) + }, [filteredReviewerCandidates, suggestedReviewerRows]) + const actionableReviewerRows = useMemo( + () => [...suggestedReviewerRows, ...everyoneElseReviewerRows], + [everyoneElseReviewerRows, suggestedReviewerRows] + ) + const reviewerCursorResetKey = `${reviewerQuery}\u0000${actionableReviewerRows.length}` + if (activeReviewerCursor.resetKey !== reviewerCursorResetKey) { + setActiveReviewerCursor({ + resetKey: reviewerCursorResetKey, + index: 0 + }) + } + const activeReviewerIndex = + activeReviewerCursor.resetKey === reviewerCursorResetKey ? activeReviewerCursor.index : 0 + const setActiveReviewerIndex = useCallback( + (nextIndex: number | ((current: number) => number)): void => { + setActiveReviewerCursor((current) => { + const currentIndex = current.resetKey === reviewerCursorResetKey ? current.index : 0 + return { + resetKey: reviewerCursorResetKey, + index: typeof nextIndex === 'function' ? nextIndex(currentIndex) : nextIndex + } + }) + }, + [reviewerCursorResetKey] + ) + if (item.type !== 'pr') { + return ( + + {translate('auto.components.TaskPage.b1eaa18ace', 'Issue')} + + ) + } + const itemWithLocalReviewRequests = { + ...item, + reviewRequests: localReviewRequests + } + const primaryReviewer = getGitHubPRPrimaryReviewer(itemWithLocalReviewRequests) + const reviewerRows = getGitHubPRReviewerRows(itemWithLocalReviewRequests) + const extraReviewerCount = Math.max(0, reviewerRows.length - 1) + const hasReviewerMetadata = + item.reviewDecision !== undefined || + localReviewRequests.length > 0 || + item.reviewRequests !== undefined || + item.latestReviews !== undefined + const { handleRequestReview, requestReviewer } = createTaskPageGitHubReviewerActions({ + item, + localReviewRequests, + repo, + reviewRepo, + reviewerCandidates, + reviewerInput, + selectedReviewerLogins, + setLocalReviewRequests, + setOpen, + setReviewerInput, + setSubmitting, + sourceContext, + sourceSettings, + submitting, + workItemMutation + }) + const handleReviewerPickerOpenChange = (nextOpen: boolean): void => { + if (nextOpen) { + const rect = reviewerTriggerRef.current?.getBoundingClientRect() + const gap = 8 + const availableBelow = rect ? window.innerHeight - rect.bottom - gap : 0 + const availableAbove = rect ? rect.top - gap : 0 + const nextSide = availableBelow < 240 && availableAbove > availableBelow ? 'top' : 'bottom' + const available = nextSide === 'top' ? availableAbove : availableBelow + setReviewerPickerSide(nextSide) + setReviewerPickerMaxHeight(Math.max(180, Math.min(360, available || 360))) + } + setOpen(nextOpen) + if (nextOpen) { + cancelReviewerInputFocusFrame() + reviewerInputFocusFrameRef.current = requestAnimationFrame(() => { + reviewerInputFocusFrameRef.current = null + reviewerInputRef.current?.focus() + }) + return + } + cancelReviewerInputFocusFrame() + setReviewerInput('') + } + return ( + + + + + + + ) +} diff --git a/src/renderer/src/components/TaskPageGitHubReviewerPicker.tsx b/src/renderer/src/components/TaskPageGitHubReviewerPicker.tsx new file mode 100644 index 00000000000..2a18fcf15ad --- /dev/null +++ b/src/renderer/src/components/TaskPageGitHubReviewerPicker.tsx @@ -0,0 +1,222 @@ +import type { Dispatch, RefCallback, SetStateAction } from 'react' +import { Check } from 'lucide-react' + +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { Input } from '@/components/ui/input' +import { PopoverContent } from '@/components/ui/popover' + +import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' +import type { Repo } from '../../../shared/repo-types' + +type ReviewerPickerProps = { + actionableReviewerRows: GitHubAssignableUser[] + activeReviewerIndex: number + everyoneElseReviewerRows: GitHubAssignableUser[] + filteredReviewerCandidates: GitHubAssignableUser[] + handleRequestReview: (requestedLogins?: string[]) => Promise + handleReviewerPickerOpenChange: (nextOpen: boolean) => void + hasReviewerMetadata: boolean + repo: Repo | null + requestReviewer: (reviewer: GitHubAssignableUser) => Promise + reviewerInput: string + reviewerMetadataError: string | null + reviewerMetadataLoading: boolean + reviewerPickerMaxHeight: number | null + reviewerPickerSide: 'top' | 'bottom' + selectedReviewerLogins: ReadonlySet + setActiveReviewerIndex: (nextIndex: number | ((current: number) => number)) => void + setReviewerInput: Dispatch> + setReviewerInputNode: RefCallback + submitting: boolean + suggestedReviewerRows: GitHubAssignableUser[] +} + +export function TaskPageGitHubReviewerPicker({ + actionableReviewerRows, + activeReviewerIndex, + everyoneElseReviewerRows, + filteredReviewerCandidates, + handleRequestReview, + handleReviewerPickerOpenChange, + hasReviewerMetadata, + repo, + requestReviewer, + reviewerInput, + reviewerMetadataError, + reviewerMetadataLoading, + reviewerPickerMaxHeight, + reviewerPickerSide, + selectedReviewerLogins, + setActiveReviewerIndex, + setReviewerInput, + setReviewerInputNode, + submitting, + suggestedReviewerRows +}: ReviewerPickerProps): React.JSX.Element { + const renderReviewerPickerRow = ( + reviewer: GitHubAssignableUser, + options: { + suggested: boolean + activeIndex: number + } + ): React.JSX.Element => { + const selected = selectedReviewerLogins.has(reviewer.login.toLowerCase()) + const active = actionableReviewerRows[activeReviewerIndex]?.login === reviewer.login + return ( + + ) + } + + return ( + event.stopPropagation()} + onOpenAutoFocus={(event) => { + event.preventDefault() + }} + > +
+
+ {translate('auto.components.TaskPage.62c7bd789f', 'Request up to 15 reviewers')} +
+
+
+ setReviewerInput(event.target.value)} + placeholder={translate('auto.components.TaskPage.0b9b04f4b5', 'Type or choose a user')} + disabled={!repo || submitting} + className="h-8 rounded-md bg-background px-2 text-[13px]" + aria-label={translate('auto.components.TaskPage.0b9b04f4b5', 'Type or choose a user')} + aria-autocomplete="list" + onKeyDown={(event) => { + if (event.key === 'ArrowDown' && actionableReviewerRows.length > 0) { + event.preventDefault() + setActiveReviewerIndex((current) => (current + 1) % actionableReviewerRows.length) + return + } + if (event.key === 'ArrowUp' && actionableReviewerRows.length > 0) { + event.preventDefault() + setActiveReviewerIndex( + (current) => + (current - 1 + actionableReviewerRows.length) % actionableReviewerRows.length + ) + return + } + if (event.key === 'Enter') { + event.preventDefault() + const activeReviewer = actionableReviewerRows[activeReviewerIndex] + if (activeReviewer) { + void requestReviewer(activeReviewer) + return + } + void handleRequestReview() + return + } + if (event.key === 'Escape') { + event.preventDefault() + handleReviewerPickerOpenChange(false) + } + }} + /> +
+
+ {reviewerMetadataLoading ? ( +
+ {translate('auto.components.TaskPage.0eacf48491', 'Loading…')} +
+ ) : filteredReviewerCandidates.length > 0 ? ( + <> + {suggestedReviewerRows.length > 0 ? ( + <> +
+ {translate('auto.components.TaskPage.3ace2e6bcf', 'Suggestions')} +
+ {suggestedReviewerRows.map((reviewer, index) => + renderReviewerPickerRow(reviewer, { + suggested: true, + activeIndex: index + }) + )} + + ) : null} +
+ {translate('auto.components.TaskPage.67755a83a1', 'Everyone else')} +
+ {everyoneElseReviewerRows.length > 0 ? ( + everyoneElseReviewerRows.map((reviewer, index) => + renderReviewerPickerRow(reviewer, { + suggested: false, + activeIndex: suggestedReviewerRows.length + index + }) + ) + ) : ( +
+ {translate('auto.components.TaskPage.8a22eb3f7b', 'No matching reviewers.')} +
+ )} + + ) : ( +
+ {reviewerMetadataError ?? + (hasReviewerMetadata + ? translate('auto.components.TaskPage.8a22eb3f7b', 'No matching reviewers.') + : translate( + 'auto.components.TaskPage.9e03c17847', + 'Open the PR details to view current reviewers.' + ))} +
+ )} +
+
+ ) +} diff --git a/src/renderer/src/components/TaskPageGitHubRows.tsx b/src/renderer/src/components/TaskPageGitHubRows.tsx new file mode 100644 index 00000000000..39d7da64803 --- /dev/null +++ b/src/renderer/src/components/TaskPageGitHubRows.tsx @@ -0,0 +1,388 @@ +import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' +import { findGithubWorkItemWorkspaceAttachment } from '@/lib/github-work-item-workspace-attachment' +import { getWorktreeAttachmentLabel } from '@/lib/worktree-attachment-label' +import { formatPRDelta } from '@/components/task-page-pr-delta-summary' +import { + isTaskPageGitHubDraftPR, + getTaskPageGitHubPRIconTone +} from '@/components/task-page-github-work-item-status' +import { + GitPullRequestDraft, + GitPullRequest, + CircleDot, + Files, + FolderKanban, + ArrowRight, + ChevronDown, + Plus, + ExternalLink, + EllipsisVertical +} from 'lucide-react' +import { cn } from '@/lib/utils' +import { + GITHUB_TASK_STICKY_ID_CELL_CLASS, + GITHUB_TASK_STICKY_TITLE_CELL_CLASS, + getTaskPageRepoSourceContext, + formatRelativeTime +} from './task-page-source-context' +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import { TaskPageGitHubWorkItemStateBadge } from '@/components/task-page-github-work-item-status-badge' +import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import { GHAssigneesCell } from './TaskPageGitHubAssigneesCell' +import { PRReviewCell } from './TaskPageGitHubReviewCell' +import { PRChecksCell } from './TaskPageGitHubChecksCell' +import { PRMergeCell } from './TaskPageGitHubMergeCell' +import { GHStatusCell } from './TaskPageGitHubStatusCell' +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem +} from '@/components/ui/dropdown-menu' +import { ButtonGroup } from '@/components/ui/button-group' +import { Button } from '@/components/ui/button' +export function TaskPageGitHubRows({ + model +}: { + model: TaskPageComposerActionsModel +}): React.JSX.Element | null { + const { + repoMap, + allWorktrees, + selectedRepos, + openGitHubDetailPage, + githubWorkItemMutation, + filteredWorkItems, + showGitHubTaskSkeletons, + showPRManagementColumns, + githubTaskGridClass, + ensurePRChecksLoaded, + handleUseWorkItem, + handleOpenOrUseGitHubWorkItem + } = model + return ( +
+ {!showGitHubTaskSkeletons && + filteredWorkItems.map((item) => { + const itemRepo = repoMap.get(item.repoId) ?? null + const attachedWorkspace = findGithubWorkItemWorkspaceAttachment( + allWorktrees, + item.repoId, + item.type, + item.number + ) + const attachedWorkspaceLabel = attachedWorkspace + ? getWorktreeAttachmentLabel(attachedWorkspace) + : null + const prDelta = item.type === 'pr' ? formatPRDelta(item) : null + const githubTaskIdPill = ( + + {item.type === 'pr' ? ( + isTaskPageGitHubDraftPR(item) ? ( + + ) + return ( + // Why: clickable div not a + + + + + event.stopPropagation()}> + {attachedWorkspace ? ( + handleUseWorkItem(item)}> + + {translate('auto.components.TaskPage.b6329379ca', 'Start new workspace')} + + ) : null} + window.api.shell.openUrl(item.url)}> + + {translate('auto.components.TaskPage.c1d1600362', 'Open in browser')} + + + + ) : ( + + )} + {item.type !== 'pr' ? ( + + + + + e.stopPropagation()}> + {attachedWorkspace ? ( + handleUseWorkItem(item)}> + + {translate('auto.components.TaskPage.b6329379ca', 'Start new workspace')} + + ) : null} + window.api.shell.openUrl(item.url)}> + + {translate('auto.components.TaskPage.c1d1600362', 'Open in browser')} + + + + ) : null} +
+
+ ) + })} +
+ ) +} diff --git a/src/renderer/src/components/TaskPageGitHubStatusCell.tsx b/src/renderer/src/components/TaskPageGitHubStatusCell.tsx new file mode 100644 index 00000000000..70fe2df2b00 --- /dev/null +++ b/src/renderer/src/components/TaskPageGitHubStatusCell.tsx @@ -0,0 +1,396 @@ +import type { GitHubWorkItem } from '../../../shared/github/work-item-types' +import type { GitHubIssueUpdate } from '../../../shared/issue-mutation-types' +import type { Repo } from '../../../shared/repo-types' +import { + type TaskSourceContext, + getTaskSourceRuntimeSettings +} from '../../../shared/task-source-context' +import React, { useState, useMemo, useCallback } from 'react' +import { + createTaskPageGitHubStatusStateDraft, + resolveTaskPageGitHubStatusStateDraft, + updateTaskPageGitHubStatusLocalState +} from '@/components/task-page-github-status-state' +import { useAppStore } from '@/store' +import { useShallow } from 'zustand/react/shallow' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { parseGitHubIssueOrPRLink } from '@/lib/github-links' +import { + getTaskPageGitHubDuplicateCandidates, + validateTaskPageGitHubDuplicateTarget, + type TaskPageGitHubCloseAction, + buildTaskPageGitHubCloseUpdate, + getTaskPageGitHubDuplicateTargetErrorMessage +} from '@/components/task-page-github-status-actions' +import { translate } from '@/i18n/i18n' +import { getActiveRuntimeTarget, callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { githubProjectHost } from '../../../shared/github/project-identity' +import { TaskPageGitHubWorkItemStateBadge } from '@/components/task-page-github-work-item-status-badge' +import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import { CircleDot, ChevronDown, Copy, CheckCircle2, Ban, ChevronRight } from 'lucide-react' +import type { TaskPageGitHubWorkItemMutationRunner } from './task-page-linear-jira-list-model' +import { TaskPageGitHubDuplicatePicker } from './TaskPageGitHubDuplicatePicker' +export function GHStatusCell({ + item, + repo, + sourceContext, + workItemMutation +}: { + item: GitHubWorkItem + repo: Repo | null + sourceContext?: TaskSourceContext | null + workItemMutation: TaskPageGitHubWorkItemMutationRunner +}): React.JSX.Element { + const [statusStateDraft, setStatusStateDraft] = useState(() => + createTaskPageGitHubStatusStateDraft(item) + ) + const [open, setOpen] = useState(false) + const [statusUpdating, setStatusUpdating] = useState(false) + const [duplicatePickerOpen, setDuplicatePickerOpen] = useState(false) + const [duplicateSearch, setDuplicateSearch] = useState('') + const [duplicateError, setDuplicateError] = useState(null) + const duplicateIssueCandidates = useAppStore( + useShallow((s) => { + if (!duplicatePickerOpen) { + return [] + } + const deduped = new Map() + for (const entry of Object.values(s.workItemsCache)) { + for (const candidate of entry.data ?? []) { + if ( + candidate.type === 'issue' && + candidate.repoId === item.repoId && + candidate.number !== item.number && + !deduped.has(candidate.number) + ) { + deduped.set(candidate.number, candidate) + } + } + } + return Array.from(deduped.values()).sort((a, b) => b.number - a.number) + }) + ) + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null)) + ) + const sourceSettings = useMemo( + () => + sourceContext?.provider === 'github' + ? ({ + ...repoOwnerSettings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as typeof repoOwnerSettings) + : repoOwnerSettings, + [repoOwnerSettings, sourceContext] + ) + const parsedIssueLink = useMemo(() => parseGitHubIssueOrPRLink(item.url), [item.url]) + const filteredDuplicateCandidates = useMemo( + () => + getTaskPageGitHubDuplicateCandidates(duplicateIssueCandidates, item.number, duplicateSearch), + [duplicateIssueCandidates, duplicateSearch, item.number] + ) + const directDuplicateTarget = useMemo(() => { + const trimmed = duplicateSearch.trim() + const validation = validateTaskPageGitHubDuplicateTarget(trimmed, item.number) + if (!trimmed || !validation.ok) { + return null + } + if ( + filteredDuplicateCandidates.some((candidate) => candidate.number === validation.duplicateOf) + ) { + return null + } + return validation.duplicateOf + }, [duplicateSearch, filteredDuplicateCandidates, item.number]) + const duplicatePickerTitle = parsedIssueLink?.slug + ? `${parsedIssueLink.slug.owner}/${parsedIssueLink.slug.repo}` + : (repo?.displayName ?? translate('auto.components.TaskPage.repository', 'Repository')) + const resolvedStatusStateDraft = resolveTaskPageGitHubStatusStateDraft(statusStateDraft, item) + if (resolvedStatusStateDraft !== statusStateDraft) { + // Why: item rows can refresh from the cache while this cell is mounted; reconcile before paint to avoid one stale status frame. + setStatusStateDraft(resolvedStatusStateDraft) + } + const localState = resolvedStatusStateDraft.localState + const stateMutationPending = workItemMutation.isIntentPending({ + item, + intent: { + type: 'setState', + state: localState === 'open' ? 'closed' : 'open' + }, + sourceContext + }) + const updateLocalState = useCallback( + (nextState: GitHubWorkItem['state']) => { + setStatusStateDraft((current) => + updateTaskPageGitHubStatusLocalState(current, item, nextState) + ) + }, + [item] + ) + const handleStateChange = useCallback( + async (newState: 'open' | 'closed', closeAction?: TaskPageGitHubCloseAction) => { + if ( + statusUpdating || + stateMutationPending || + newState === localState || + item.type !== 'issue' + ) { + return + } + const parsedOwnerRepo = parsedIssueLink?.slug + if (!repo && !parsedOwnerRepo) { + return + } + const updates: GitHubIssueUpdate = + newState === 'closed' && closeAction + ? buildTaskPageGitHubCloseUpdate(closeAction) + : { + state: newState + } + updateLocalState(newState) + // Why: coordinator owns durable patch + soft-hide + quiet revalidate; keep + // the status draft so one-frame flash is still covered until proven safe. + setStatusUpdating(true) + try { + await workItemMutation.run({ + item, + intent: { + type: 'setState', + state: newState, + closeAction + }, + sourceContext, + errorToast: translate('auto.components.TaskPage.1c893195ac', 'Failed to update state'), + mutate: async () => { + const target = getActiveRuntimeTarget(sourceSettings) + // Why: issue rows can be sourced by owner/repo URL instead of the local + // repo context; slug-aware writes preserve close reasons and duplicates. + if (parsedOwnerRepo) { + return target.kind === 'environment' + ? callRuntimeRpc<{ + ok?: boolean + error?: + | { + message?: string + } + | string + }>( + target, + 'github.project.updateIssueBySlug', + { + owner: parsedOwnerRepo.owner, + repo: parsedOwnerRepo.repo, + host: githubProjectHost(parsedOwnerRepo.host), + number: item.number, + updates + }, + { + timeoutMs: 30_000 + } + ) + : window.api.gh.updateIssueBySlug({ + owner: parsedOwnerRepo.owner, + repo: parsedOwnerRepo.repo, + host: githubProjectHost(parsedOwnerRepo.host), + number: item.number, + updates + }) + } + if (!repo) { + throw new Error('No GitHub repository context available for this issue.') + } + const runtimeRepoId = + sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id + return target.kind === 'environment' + ? callRuntimeRpc<{ + ok?: boolean + error?: string + }>( + target, + 'github.updateIssue', + { + repo: runtimeRepoId, + number: item.number, + updates + }, + { + timeoutMs: 30_000 + } + ) + : window.api.gh.updateIssue({ + repoPath: repo.path, + repoId: repo.id, + sourceContext, + number: item.number, + updates + }) + } + }) + } finally { + setStatusUpdating(false) + } + // Why: draft realigns from item.state via resolveTaskPageGitHubStatusStateDraft + // when patchWorkItem (begin/rollback) updates the cache-backed row. + }, + [ + item, + localState, + parsedIssueLink, + repo, + sourceContext, + sourceSettings, + stateMutationPending, + statusUpdating, + updateLocalState, + workItemMutation + ] + ) + const closeAsDuplicate = useCallback( + (targetIssueNumber: number | string) => { + const validation = validateTaskPageGitHubDuplicateTarget( + String(targetIssueNumber), + item.number + ) + if (!validation.ok) { + setDuplicateError(getTaskPageGitHubDuplicateTargetErrorMessage(validation, translate)) + return + } + setDuplicateError(null) + handleStateChange('closed', { + stateReason: 'duplicate', + duplicateOf: validation.duplicateOf + }) + setOpen(false) + setDuplicatePickerOpen(false) + }, + [handleStateChange, item.number] + ) + const handleDuplicateSearchSubmit = useCallback(() => { + const validation = validateTaskPageGitHubDuplicateTarget(duplicateSearch, item.number) + if (!validation.ok) { + setDuplicateError(getTaskPageGitHubDuplicateTargetErrorMessage(validation, translate)) + return + } + closeAsDuplicate(validation.duplicateOf) + }, [closeAsDuplicate, duplicateSearch, item.number]) + const handlePopoverOpenChange = useCallback((nextOpen: boolean) => { + setOpen(nextOpen) + if (!nextOpen) { + setDuplicatePickerOpen(false) + setDuplicateSearch('') + setDuplicateError(null) + } + }, []) + if (item.type !== 'issue' || (!repo && !parsedIssueLink?.slug)) { + return + } + return ( + + + + + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + {duplicatePickerOpen ? ( + + ) : ( + <> + + + + + + )} + + + ) +} diff --git a/src/renderer/src/components/TaskPageGitLabDialog.tsx b/src/renderer/src/components/TaskPageGitLabDialog.tsx new file mode 100644 index 00000000000..c8b9a588e70 --- /dev/null +++ b/src/renderer/src/components/TaskPageGitLabDialog.tsx @@ -0,0 +1,29 @@ +import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' +import GitLabItemDialog from '@/components/GitLabItemDialog' +export function TaskPageGitLabDialog({ + model +}: { + model: TaskPageComposerActionsModel +}): React.JSX.Element | null { + const { + gitlabDialogItem, + setGitlabDialogItem, + gitlabDialogRepo, + gitlabDialogSourceContext, + handleUseGitLabItem + } = model + return ( + { + setGitlabDialogItem(null) + handleUseGitLabItem(item) + }} + onClose={() => setGitlabDialogItem(null)} + /> + ) +} diff --git a/src/renderer/src/components/TaskPageGitLabFilters.tsx b/src/renderer/src/components/TaskPageGitLabFilters.tsx new file mode 100644 index 00000000000..330ea8cf00c --- /dev/null +++ b/src/renderer/src/components/TaskPageGitLabFilters.tsx @@ -0,0 +1,167 @@ +import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' +import { cn } from '@/lib/utils' +import TaskProjectSourceCombobox from '@/components/task-project-source-combobox' +import { normalizeTaskRepoSelection } from '@/components/task-page-default-repo-selection' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' +import { Button } from '@/components/ui/button' +import { LoaderCircle, RefreshCw } from 'lucide-react' +export function TaskPageGitLabFilters({ + model +}: { + model: TaskPageComposerActionsModel +}): React.JSX.Element | null { + const { + updateSettings, + eligibleRepos, + repoSelection, + setRepoSelection, + taskPickerGroups, + taskPickerRepos, + gitLabIssueFilters, + gitLabMRFilters, + getTaskPickerRepoHostLabel, + setGitlabFilter, + gitlabLoading, + setGitlabRefreshNonce, + gitlabView, + setGitlabView, + gitlabTodosLoading, + activeGitlabFilter + } = model + return ( + <> +
+
+ {(['issues', 'mrs', 'todos'] as const).map((view) => { + const active = gitlabView === view + const label = view === 'issues' ? 'Issues' : view === 'mrs' ? 'MRs' : 'My Todos' + return ( + + ) + })} +
+
+ { + const normalized = normalizeTaskRepoSelection(eligibleRepos, next) + setRepoSelection(normalized) + void updateSettings({ + defaultRepoSelection: [...normalized] + }).catch(() => { + toast.error( + translate( + 'auto.components.TaskPage.dfd72673e7', + 'Failed to save project selection.' + ) + ) + }) + }} + onSelectAll={() => { + const allIds = new Set(taskPickerRepos.map((r) => r.id)) + setRepoSelection(allIds) + void updateSettings({ + defaultRepoSelection: null + }).catch(() => { + toast.error( + translate( + 'auto.components.TaskPage.dfd72673e7', + 'Failed to save project selection.' + ) + ) + }) + }} + triggerClassName="h-8 w-full rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none" + /> +
+
+
+
+
+
+ {gitlabView === 'issues' || gitlabView === 'mrs' + ? (gitlabView === 'issues' ? gitLabIssueFilters : gitLabMRFilters).map( + ({ id, label }) => { + const active = activeGitlabFilter === id + return ( + + ) + } + ) + : null} +
+
+
+ + + + + + {gitlabView === 'todos' + ? translate('auto.components.TaskPage.c679af7ad9', 'Refresh My Todos') + : translate('auto.components.TaskPage.d4c2830063', 'Refresh GitLab work items')} + + +
+
+
+ + ) +} diff --git a/src/renderer/src/components/TaskPageGitLabItemList.tsx b/src/renderer/src/components/TaskPageGitLabItemList.tsx new file mode 100644 index 00000000000..5799c131e8a --- /dev/null +++ b/src/renderer/src/components/TaskPageGitLabItemList.tsx @@ -0,0 +1,148 @@ +import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' +import { Button } from '@/components/ui/button' +import { ArrowRight, ExternalLink } from 'lucide-react' +export function TaskPageGitLabItemList({ + model +}: { + model: TaskPageComposerActionsModel +}): React.JSX.Element | null { + const { + gitlabItems, + gitlabLoading, + gitlabError, + gitlabEmptyState, + displayedGitLabItems, + openGitLabDetailPage, + handleUseGitLabItem + } = model + return ( +
+
+ {translate('auto.components.TaskPage.eb10c32872', 'ID')} + {translate('auto.components.TaskPage.16cba35bee', 'Title')} + {translate('auto.components.TaskPage.00b7ffb952', 'Type / State')} + {translate('auto.components.TaskPage.f362667d55', 'Updated')} + +
+
+ {gitlabError ? ( +
+ {gitlabError} +
+ ) : null} + {gitlabLoading && gitlabItems.length === 0 ? ( + // Why: shimmer rows fill the viewport so the card never flashes empty and the table doesn't jump when real rows land. +
+ {Array.from({ + length: 12 + }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ ) : null} + {!gitlabLoading && displayedGitLabItems.length === 0 && !gitlabError ? ( +
+

{gitlabEmptyState.title}

+

{gitlabEmptyState.description}

+
+ ) : null} +
+ {displayedGitLabItems.map((item) => ( + // Why: div role="button" not a + + + {translate('auto.components.TaskPage.9497f2787c', 'Start workspace')} + + + +
+
+ ))} +
+
+
+ ) +} diff --git a/src/renderer/src/components/TaskPageGitLabTodoList.tsx b/src/renderer/src/components/TaskPageGitLabTodoList.tsx new file mode 100644 index 00000000000..99b68710c92 --- /dev/null +++ b/src/renderer/src/components/TaskPageGitLabTodoList.tsx @@ -0,0 +1,104 @@ +import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' +import { translate } from '@/i18n/i18n' +import { ExternalLink } from 'lucide-react' +export function TaskPageGitLabTodoList({ + model +}: { + model: TaskPageComposerActionsModel +}): React.JSX.Element | null { + const { primaryRepo, gitlabTodos, gitlabTodosLoading } = model + return ( +
+
+ {translate('auto.components.TaskPage.8396825a14', 'Action')} + {translate('auto.components.TaskPage.16cba35bee', 'Title')} + {translate('auto.components.TaskPage.00022ec0ba', 'Project')} + {translate('auto.components.TaskPage.f362667d55', 'Updated')} + +
+
+ {gitlabTodosLoading && gitlabTodos.length === 0 ? ( +
+ {Array.from({ + length: 12 + }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ ) : null} + {!gitlabTodosLoading && gitlabTodos.length === 0 ? ( +
+ {primaryRepo + ? translate( + 'auto.components.TaskPage.d591aac6ae', + 'No pending todos. You’re all caught up!' + ) + : translate( + 'auto.components.TaskPage.03da966159', + 'Select a project so we can authenticate to GitLab.' + )} +
+ ) : null} +
+ {gitlabTodos.map((todo) => ( +
void window.api.shell.openUrl(todo.targetUrl)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + void window.api.shell.openUrl(todo.targetUrl) + } + }} + className="grid w-full cursor-pointer gap-3 px-3 py-2 text-left grid-cols-[110px_minmax(0,3fr)_minmax(120px,1.2fr)_110px_50px] hover:bg-muted/50" + title={ + todo.targetType === 'MergeRequest' + ? translate('auto.components.TaskPage.a0544fb653', 'MR !{{value0}}', { + value0: todo.targetIid ?? '' + }) + : todo.targetType === 'Issue' + ? translate('auto.components.TaskPage.e9b6955dcd', 'Issue #{{value0}}', { + value0: todo.targetIid ?? '' + }) + : todo.targetType + } + > + + {/* Why: GitLab action_name is snake_case (review_requested); swap _ for space so the row reads like a sentence. */} + {todo.actionName.replace(/_/g, ' ')} + + {todo.targetTitle} + + {todo.projectPath} + + + {todo.updatedAt ? new Date(todo.updatedAt).toLocaleDateString() : ''} + + + + +
+ ))} +
+
+
+ ) +} diff --git a/src/renderer/src/components/TaskPageJiraConnectDialog.tsx b/src/renderer/src/components/TaskPageJiraConnectDialog.tsx new file mode 100644 index 00000000000..219e33c4637 --- /dev/null +++ b/src/renderer/src/components/TaskPageJiraConnectDialog.tsx @@ -0,0 +1,10 @@ +import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' +import { JiraConnectDialog } from '@/components/jira-connect-dialog' +export function TaskPageJiraConnectDialog({ + model +}: { + model: TaskPageComposerActionsModel +}): React.JSX.Element | null { + const { jiraConnectOpen, setJiraConnectOpen } = model + return +} diff --git a/src/renderer/src/components/TaskPageJiraContent.tsx b/src/renderer/src/components/TaskPageJiraContent.tsx new file mode 100644 index 00000000000..4f2f5d0aa2b --- /dev/null +++ b/src/renderer/src/components/TaskPageJiraContent.tsx @@ -0,0 +1,158 @@ +import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' +import { LoaderCircle } from 'lucide-react' +import { JiraIcon } from '@/components/icons/JiraIcon' +import { translate } from '@/i18n/i18n' +import { Button } from '@/components/ui/button' +import { TaskPageJiraSortControls } from './task-page-jira-sort-controls' +import { TaskPageJiraErrorBanner } from './task-page-linear-jira-list-model' +import { TaskPageJiraIssueList } from '@/components/task-page-jira-issue-list' +import { formatRelativeTime } from './task-page-source-context' +import { getJiraStatusTone } from '@/components/task-page-jira-status-tone' +import JiraIssueWorkspace from '@/components/JiraIssueWorkspace' +import { TaskPageLinearContent } from './TaskPageLinearContent' +export function TaskPageJiraContent({ + model +}: { + model: TaskPageComposerActionsModel +}): React.JSX.Element | null { + const { + jiraStatus, + jiraStatusReady, + jiraConnected, + selectedJiraSiteId, + hideTaskSource, + taskSource, + closeTaskDetailPage, + selectedJiraIssue, + jiraDetailSourceContext, + openJiraDetailPage, + jiraIssues, + jiraLoading, + jiraError, + jiraErrorDetailsOpen, + setJiraErrorDetailsOpen, + jiraSearchInput, + jiraOrderBy, + jiraOrderDirection, + handleJiraSort, + displayedJiraIssues, + displayedJiraStatusOrder, + sortedJiraIssues, + setJiraConnectOpen, + handleUseJiraItem + } = model + return taskSource === 'jira' ? ( + !jiraStatusReady ? ( +
+ +
+ ) : !jiraConnected ? ( +
+ +

+ {translate('auto.components.TaskPage.a150c59da7', 'Connect your Jira site')} +

+

+ {translate( + 'auto.components.TaskPage.b518ae6307', + 'Browse, edit, create, and start work from Jira issues directly from here.' + )} +

+
+ + +
+
+ ) : ( +
+
+
+ {translate('auto.components.TaskPage.63b2abd3aa', 'Jira issues')} +
+
+ {displayedJiraIssues.length} {translate('auto.components.TaskPage.b7bae28b6a', 'shown')} +
+
+ + + +
+ {jiraStatus.credentialError ? ( +
+ {jiraStatus.credentialError} +
+ ) : null} + {!jiraStatus.credentialError && jiraError ? ( + + ) : null} + + {jiraLoading && jiraIssues.length === 0 ? ( +
+ {Array.from({ + length: 6 + }).map((_, i) => ( +
+
+
+
+ ))} +
+ ) : null} + + {!jiraLoading && jiraIssues.length === 0 && !jiraError && !jiraStatus.credentialError ? ( +
+

+ {translate('auto.components.TaskPage.eba87f2edb', 'No Jira issues found')} +

+

+ {jiraSearchInput + ? translate('auto.components.TaskPage.f51e254d35', 'Try a different JQL query.') + : translate( + 'auto.components.TaskPage.94d900518d', + 'No issues match the selected preset.' + )} +

+
+ ) : null} + + +
+ +
+ ) + ) : ( + + ) +} diff --git a/src/renderer/src/components/TaskPageJiraFilters.tsx b/src/renderer/src/components/TaskPageJiraFilters.tsx new file mode 100644 index 00000000000..b97fad08c46 --- /dev/null +++ b/src/renderer/src/components/TaskPageJiraFilters.tsx @@ -0,0 +1,183 @@ +import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' +import { cn } from '@/lib/utils' +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' +import { Button } from '@/components/ui/button' +import { useAppStore } from '@/store' +import { getJiraProjectSelectionKey } from '@/components/task-page-jira-project-selection' +import { translate } from '@/i18n/i18n' +import { LoaderCircle, Plus, RefreshCw, Search, X } from 'lucide-react' +import { Input } from '@/components/ui/input' +import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard' +export function TaskPageJiraFilters({ + model +}: { + model: TaskPageComposerActionsModel +}): React.JSX.Element | null { + const { + setTaskResumeState, + jiraPresets, + jiraLoading, + jiraSearchInput, + setJiraSearchInput, + setAppliedJiraSearch, + activeJiraPreset, + setActiveJiraPreset, + setJiraRefreshNonce, + jiraProjectsLoading, + setNewJiraIssueOpen, + setNewJiraIssueTitle, + setNewJiraIssueBody, + setNewJiraIssueProjectId, + setNewJiraIssueProjectQuery, + setNewJiraIssueProjectCommandValue, + setNewJiraIssueTypeId, + sortedAvailableJiraProjects + } = model + return ( +
+
+
+ {jiraPresets.map((preset) => { + const active = !jiraSearchInput && activeJiraPreset === preset.id + return ( + + ) + })} +
+
+ + + + + + {translate('auto.components.TaskPage.0c11ca0b6d', 'New Jira issue')} + + + + + + + + {translate('auto.components.TaskPage.2ff9fd71fd', 'Refresh Jira issues')} + + +
+
+
+
+ + setJiraSearchInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + if ( + shouldSuppressEnterSubmit( + { + isComposing: e.nativeEvent.isComposing, + shiftKey: e.shiftKey + }, + false + ) + ) { + return + } + e.preventDefault() + const trimmed = jiraSearchInput.trim() + setJiraSearchInput(trimmed) + setAppliedJiraSearch(trimmed) + setTaskResumeState({ + jiraQuery: trimmed + }) + setJiraRefreshNonce((n) => n + 1) + } + }} + placeholder={translate( + 'auto.components.TaskPage.99c2755218', + 'Jira JQL, e.g. project = ABC AND statusCategory != Done' + )} + className="h-8 rounded-md border-border/50 bg-background pl-8 pr-8 text-xs" + /> + {jiraSearchInput ? ( + + ) : null} +
+
+
+ ) +} diff --git a/src/renderer/src/components/TaskPageJiraIssueDialog.tsx b/src/renderer/src/components/TaskPageJiraIssueDialog.tsx new file mode 100644 index 00000000000..51e9a93827e --- /dev/null +++ b/src/renderer/src/components/TaskPageJiraIssueDialog.tsx @@ -0,0 +1,380 @@ +import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter +} from '@/components/ui/dialog' +import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' +import { translate } from '@/i18n/i18n' +import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' +import { Button } from '@/components/ui/button' +import { getJiraProjectPickerDisplayLabel as getJiraProjectDisplayLabel } from '@/components/jira-project-picker-filter' +import { ChevronDown, Check, LoaderCircle } from 'lucide-react' +import { + Command, + CommandInput, + CommandList, + CommandEmpty, + CommandItem +} from '@/components/ui/command' +import { getJiraProjectSelectionKey } from '@/components/task-page-jira-project-selection' +import { cn } from '@/lib/utils' +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem +} from '@/components/ui/select' +import { Input } from '@/components/ui/input' +import { getJiraCreateAllowedValueLabel } from '@/components/task-page-jira-create-fields' +export function TaskPageJiraIssueDialog({ + model +}: { + model: TaskPageComposerActionsModel +}): React.JSX.Element | null { + const { + submitShortcutLabel, + newJiraIssueOpen, + setNewJiraIssueOpen, + newJiraIssueTitle, + setNewJiraIssueTitle, + newJiraIssueBody, + setNewJiraIssueBody, + newJiraIssueProjectComboboxOpen, + newJiraIssueProjectQuery, + setNewJiraIssueProjectQuery, + newJiraIssueProjectCommandValue, + setNewJiraIssueProjectCommandValue, + newJiraIssueTypeId, + setNewJiraIssueTypeId, + newJiraIssueSubmitting, + newJiraIssueProjectSearchInputRef, + availableJiraIssueTypes, + jiraIssueTypesLoading, + jiraCreateFieldsLoading, + jiraCreateFieldsError, + newJiraIssueCustomFieldValues, + setNewJiraIssueCustomFieldValues, + includeJiraSiteNameInProjectLabel, + sortedAvailableJiraProjects, + filteredNewJiraIssueProjects, + newJiraIssueTargetProject, + newJiraIssueTargetProjectSelectionKey, + newJiraIssueTargetType, + visibleJiraCreateFields, + hasMissingJiraCreateField, + handleNewJiraIssueProjectComboboxOpenChange, + handleNewJiraIssueProjectSelect, + handleNewJiraIssueProjectTriggerKeyDown, + handleCreateNewJiraIssue + } = model + return ( + { + if (!newJiraIssueSubmitting) { + setNewJiraIssueOpen(open) + } + }} + > + { + if (isScreenSubmitShortcut(event)) { + event.preventDefault() + void handleCreateNewJiraIssue() + } + }} + > + + + {translate('auto.components.TaskPage.0c11ca0b6d', 'New Jira issue')} + + + {newJiraIssueTargetProject + ? translate( + 'auto.components.TaskPage.0f7b0d964a', + 'Creates a new issue in {{value0}}.', + { + value0: newJiraIssueTargetProject.key + } + ) + : translate( + 'auto.components.TaskPage.e178c0a953', + 'Choose a Jira project before creating the issue.' + )} + + +
+
+
+ + + + + + event.preventDefault()} + > + + + + + {translate('auto.components.TaskPage.93c57f15e5', 'No projects found.')} + + {filteredNewJiraIssueProjects.map((project) => { + const selectionKey = getJiraProjectSelectionKey(project) + const selected = selectionKey === newJiraIssueTargetProjectSelectionKey + return ( + handleNewJiraIssueProjectSelect(selectionKey)} + className="items-center gap-2 px-3 py-2 text-xs" + > + + + {getJiraProjectDisplayLabel( + project, + includeJiraSiteNameInProjectLabel + )} + + + ) + })} + + + + +
+
+ + +
+
+
+ + setNewJiraIssueTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.nativeEvent.isComposing) { + e.preventDefault() + void handleCreateNewJiraIssue() + } + }} + placeholder={translate('auto.components.TaskPage.578f730c16', 'Short summary')} + disabled={newJiraIssueSubmitting} + /> +
+
+ +