From 02ffd40edcbc02c95bc230490cb1a171137d6bf8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:23:12 -0700 Subject: [PATCH 1/9] fix(ssh): stop treating an unanswered native-deps probe as broken deps (#17979) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit probeRequiredNativeDeps mapped any thrown error to available:false, which both triggered the repair and fed resetDeps — so one dropped exec channel rm -rf'd node_modules/node-pty on a healthy relay and forced a node-gyp source build. Verdicts are now ok / blocked / unverifiable; only an answered probe may repair, and only an answered probe may name reset deps. --- src/main/ssh/ssh-relay-deploy.ts | 32 ++- ...sh-relay-native-deps-probe-verdict.test.ts | 226 ++++++++++++++++++ 2 files changed, 251 insertions(+), 7 deletions(-) create mode 100644 src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index c9d82cbd744..911d185b0fd 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -733,13 +733,25 @@ function missingNativeDepsFromProbe(output: string): RelayNativeDepName[] { return RELAY_NATIVE_DEP_NAMES.filter((name) => reported.includes(name)) } +/** + * `ok` — the probe answered and both deps loaded. `blocked` — the probe answered and named deps + * that failed to load. `unverifiable` — the probe never answered, which is evidence about the + * transport, not about the deps. + * + * Why `unverifiable` is not `blocked`: repairing on it does `rm -rf node_modules/node-pty` and a + * node-gyp source build (no Linux prebuild) against a relay that was never shown to be broken. + * Same verdict discipline as `src/main/orcad/node-pty-precondition.ts` and + * docs/reference/ssh-execution-boundary.md — loss of contact is not evidence. + */ +type RelayNativeDepsProbeStatus = 'ok' | 'blocked' | 'unverifiable' + async function probeRequiredNativeDeps( conn: SshConnection, remoteDir: string, hostPlatform: RemoteHostPlatform, nodePath: string, signal?: AbortSignal -): Promise<{ available: boolean; missing: RelayNativeDepName[] }> { +): Promise<{ status: RelayNativeDepsProbeStatus; missing: RelayNativeDepName[] }> { const escapedNode = shellEscape(nodePath) const probeJs = nativeDepsProbeJs('ORCA-NATIVE-DEPS-OK') try { @@ -757,11 +769,14 @@ async function probeRequiredNativeDeps( `(${escapedNode} -e ${shellEscape(probeJs)} 2>/dev/null || echo MISSING)` ) const probe = await execHostCommand(conn, hostPlatform, command, { signal }) - const available = probe.includes('ORCA-NATIVE-DEPS-OK') - return { available, missing: available ? [] : missingNativeDepsFromProbe(probe) } + return probe.includes('ORCA-NATIVE-DEPS-OK') + ? { status: 'ok', missing: [] } + : { status: 'blocked', missing: missingNativeDepsFromProbe(probe) } } catch { signal?.throwIfAborted() - return { available: false, missing: [...RELAY_NATIVE_DEP_NAMES] } + // Why: an unanswered probe says nothing about the deps; reporting MISSING here reset and + // recompiled healthy relays, turning one dropped exec channel into a multi-minute reconnect. + return { status: 'unverifiable', missing: [] } } } @@ -810,7 +825,8 @@ async function repairInstalledNativeDeps( lockResult === 'busy' || lockResult === 'error' ? await acquireRelayLaunchGcFence(conn, remoteDir, hostPlatform, signal) : undefined - if (initialProbe.available) { + // Why: only a probe that answered may trigger repair; an unverifiable one launches as-is and the next reconnect re-probes. + if (initialProbe.status !== 'blocked') { // Why: even a healthy reconnect stays fenced until launch liveness is observable, or cross-version GC can rename after this probe. if (lockResult !== 'acquired') { return { ownsInstallLock: false, gcClaimToken } @@ -847,7 +863,9 @@ async function repairInstalledNativeDeps( // Why: older complete relay dirs predate @parcel/watcher; re-probe under the lock so only one reconnect mutates the dir. const probe = await probeRequiredNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) let repairNamespace: RelayInstallNamespace | undefined - if (!probe.available) { + if (probe.status !== 'ok') { + // Why: the locked re-probe can only narrow the repair; when it can't answer, the initial probe's answered evidence still stands. + const resetDeps = probe.status === 'unverifiable' ? initialProbe.missing : probe.missing // Why: only stamp ownership once the locked recheck proves this connection is the one about to write. repairNamespace = await createRelayLaunchNamespace( conn, @@ -863,7 +881,7 @@ async function repairInstalledNativeDeps( hostPlatform, nodePath, signal, - probe.missing, + resetDeps, repairNamespace ) await finalizeInstall(conn, remoteDir, hostPlatform, { signal, releaseLock: false }) diff --git a/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts b/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts new file mode 100644 index 00000000000..c67270db4c3 --- /dev/null +++ b/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts @@ -0,0 +1,226 @@ +// Why: the repair path used to map ANY probe failure to "all deps missing", so one dropped exec +// channel rm -rf'd node-pty on a healthy relay and forced a node-gyp rebuild. Verdicts are +// ok / blocked / unverifiable — see docs/reference/ssh-execution-boundary.md. + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' + +vi.mock('electron', () => ({ + app: { getAppPath: () => '/mock/app' } +})) + +vi.mock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') +})) + +vi.mock('./relay-protocol', () => ({ + RELAY_VERSION: '0.1.0', + RELAY_REMOTE_DIR: '.orca-remote', + parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), + RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', + RELAY_SENTINEL_TIMEOUT_MS: 10_000 +})) + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + uploadDirectory: vi.fn().mockResolvedValue(undefined), + waitForSentinel: vi.fn().mockResolvedValue({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }), + isUnconfirmedSshCommandTermination: (error: unknown) => + error instanceof Error && + (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, + execCommand: vi.fn() +})) + +vi.mock('./ssh-remote-node-resolution', () => ({ + resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') +})) + +vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ + ...(await importOriginal()), + createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' +})) + +vi.mock('./ssh-relay-versioned-install', () => ({ + readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), + computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, + isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), + finalizeInstall: vi.fn().mockResolvedValue(undefined), + abandonInstall: vi.fn().mockResolvedValue(undefined), + gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-relay-install-lock', () => ({ + acquireInstallLock: vi.fn().mockResolvedValue(undefined), + RELAY_INSTALL_LOCK_NAME: '.install-lock' +})) + +vi.mock('./ssh-relay-repair-lock', () => ({ + tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') +})) + +vi.mock('./ssh-relay-gc-claim', () => ({ + releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), + tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), + waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-connection-utils', () => ({ + shellEscape: (s: string) => `'${s}'` +})) + +import { deployAndLaunchRelay } from './ssh-relay-deploy' +import { execCommand, uploadDirectory } from './ssh-relay-deploy-helpers' +import { parseUnameToRelayPlatform } from './relay-protocol' +import { finalizeInstall, isRelayAlreadyInstalled } from './ssh-relay-versioned-install' +import { + makeMockConnection, + type ExecResponse, + type SftpWriteCapture +} from './ssh-relay-native-deps-install-fixture' + +const NODE_PTY_RESET = "rm -rf 'node_modules/node-pty'" +const WATCHER_RESET = "rm -rf 'node_modules/@parcel/watcher'" + +describe('native-deps repair probe verdicts', () => { + const sftpCapture: SftpWriteCapture = { paths: [], contents: {}, execCallCountAtWrite: {} } + let warnSpy: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(execCommand).mockReset().mockResolvedValue('') + vi.mocked(uploadDirectory).mockResolvedValue(undefined) + sftpCapture.paths.length = 0 + for (const key of Object.keys(sftpCapture.contents)) { + delete sftpCapture.contents[key] + } + for (const key of Object.keys(sftpCapture.execCallCountAtWrite)) { + delete sftpCapture.execCallCountAtWrite[key] + } + vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + function feed(execResponses: ExecResponse[]): void { + const mockExec = vi.mocked(execCommand) + for (const response of execResponses) { + if (typeof response === 'string') { + mockExec.mockResolvedValueOnce(response) + } else { + mockExec.mockRejectedValueOnce(new Error(response.reject)) + } + } + } + + function execCommands(): string[] { + return vi.mocked(execCommand).mock.calls.map(([, command]) => command) + } + + function warnings(): string[] { + return warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + } + + it('launches an intact relay when the health probe never answers', async () => { + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + { reject: 'SSH channel closed unexpectedly' }, // health probe: unverifiable, not MISSING + '', // launch namespace marker + 'DEAD', + '', // publish the per-launch credential + 'READY' + ]) + + // Assert the repair-avoidance facts before the launch outcome so a regression names the defect + // rather than the fixture drift that follows from an unexpected repair. + const outcome = await deployAndLaunchRelay(conn).then( + (result) => result, + (err: Error) => err + ) + + const commands = execCommands() + expect(warnings().some((message) => message.includes('Repairing missing native deps'))).toBe( + false + ) + expect(commands.some((command) => command.includes(NODE_PTY_RESET))).toBe(false) + expect(commands.some((command) => command.includes(WATCHER_RESET))).toBe(false) + expect(commands.some((command) => command.includes('npm install'))).toBe(false) + // Exactly one probe: an unverifiable answer must not fall through to the locked re-probe. + expect(commands.filter((command) => command.includes('ORCA-NATIVE-DEPS-OK'))).toHaveLength(1) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + expect(outcome, 'lost contact must not abort the connection').not.toBeInstanceOf(Error) + }) + + it('still resets and repairs when the probe answers without the OK marker', async () => { + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + 'MISSING', // answered, no marker line: both deps are genuinely broken + 'MISSING', // re-probe under the repair lock + '', // SFTP-namespace install-owner marker (repair) + '', // npm install native deps + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + 'DEAD', + '', // publish the per-launch credential + 'READY' + ]) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + const install = execCommands().find((command) => command.includes('npm install')) ?? '' + expect(install).toContain(NODE_PTY_RESET) + expect(install).toContain(WATCHER_RESET) + expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) + }) + + it('skips repair entirely when the probe answers OK', async () => { + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + 'ORCA-NATIVE-DEPS-OK', + '', // launch namespace marker + 'DEAD', + '', // publish the per-launch credential + 'READY' + ]) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + expect(execCommands().some((command) => command.includes('npm install'))).toBe(false) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + }) + + it('keeps the answered reset scope when the locked re-probe cannot answer', async () => { + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + 'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING', // answered: only the watcher is broken + { reject: 'SSH channel closed unexpectedly' }, // re-probe under the lock: unverifiable + '', // SFTP-namespace install-owner marker (repair) + '', // npm install native deps + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + 'DEAD', + '', // publish the per-launch credential + 'READY' + ]) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + const install = execCommands().find((command) => command.includes('npm install')) ?? '' + expect(install).toContain(WATCHER_RESET) + // The unanswered re-probe must not widen the reset to a dep no probe ever reported broken. + expect(install).not.toContain(NODE_PTY_RESET) + }) +}) From 97d1a9001c3d4529dbd9441dcab93da2454639a7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 03:28:39 -0700 Subject: [PATCH 2/9] fix(task-page): restore behavior lost in the UI split Five regressions shipped in the oversized-UI-surfaces split because the ratchet tests guarding them still pointed at an earlier, orphaned split of the same file: - GitLab rows lost the target/currentTarget guard, so Enter on the nested open-in-browser button also opened the task detail. - GitLab row keys dropped the repo prefix; work-item ids collide across hosts. - GitHub Enterprise avatars lost their onError fallback in three slots, so an unauthenticated avatarUrl rendered a broken image (#8784, #13976). - Linear new-issue popovers lost the viewport-aware scroll container and reverted to a fixed inner scroller the outer cap clips. - Jira issue creation lost its catch, so a transport failure told the user nothing. Retargets the eight ratchet files at the live modules so these stay guarded. --- .../TaskPageGitHubAssigneesCell.tsx | 14 ++--- .../src/components/TaskPageGitHubAvatars.tsx | 25 +++----- .../TaskPageGitHubReviewerPicker.tsx | 14 ++--- .../src/components/TaskPageGitLabItemList.tsx | 5 +- .../TaskPageLinearIssueAttributes.tsx | 18 +++--- .../components/TaskPageLinearIssueDialog.tsx | 5 +- ...ture-interaction-writer-boundaries.test.ts | 58 +++++++++---------- ...b-enterprise-slug-routing-boundary.test.ts | 12 ++-- .../repro-8784-ghe-avatar-fallback.test.ts | 10 ++-- .../task-page-github-issue-creation.test.ts | 16 +++-- ...linear-issue-dialog-popover-scroll.test.ts | 5 +- .../task-page-task-creation-drafts.test.ts | 14 ++--- ...k-page-workspace-composer-boundary.test.ts | 13 +---- .../use-task-page-jira-issue-creation.ts | 8 +++ 14 files changed, 111 insertions(+), 106 deletions(-) diff --git a/src/renderer/src/components/TaskPageGitHubAssigneesCell.tsx b/src/renderer/src/components/TaskPageGitHubAssigneesCell.tsx index 3d8bad1ed6d..1c03a15fe49 100644 --- a/src/renderer/src/components/TaskPageGitHubAssigneesCell.tsx +++ b/src/renderer/src/components/TaskPageGitHubAssigneesCell.tsx @@ -19,6 +19,7 @@ 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' +import { GitHubUserAvatar } from '@/components/github/github-user-avatar' export function GHAssigneesCell({ item, repo, @@ -270,13 +271,12 @@ export function GHAssigneesCell({ ) : null} - {user.avatarUrl ? ( - - ) : ( - - {user.login.slice(0, 1).toUpperCase()} - - )} + {user.login} {user.name ? ( diff --git a/src/renderer/src/components/TaskPageGitHubAvatars.tsx b/src/renderer/src/components/TaskPageGitHubAvatars.tsx index 14b8bacaee8..26162f1ceb6 100644 --- a/src/renderer/src/components/TaskPageGitHubAvatars.tsx +++ b/src/renderer/src/components/TaskPageGitHubAvatars.tsx @@ -31,24 +31,13 @@ export function GitHubAssigneeAvatar({ }: { 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/TaskPageGitHubReviewerPicker.tsx b/src/renderer/src/components/TaskPageGitHubReviewerPicker.tsx index 2a18fcf15ad..d61d669c478 100644 --- a/src/renderer/src/components/TaskPageGitHubReviewerPicker.tsx +++ b/src/renderer/src/components/TaskPageGitHubReviewerPicker.tsx @@ -8,6 +8,7 @@ import { PopoverContent } from '@/components/ui/popover' import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' import type { Repo } from '../../../shared/repo-types' +import { GitHubUserAvatar } from '@/components/github/github-user-avatar' type ReviewerPickerProps = { actionableReviewerRows: GitHubAssignableUser[] @@ -81,13 +82,12 @@ export function TaskPageGitHubReviewerPicker({ {selected ? : null} - {reviewer.avatarUrl ? ( - - ) : ( - - {reviewer.login.slice(0, 1).toUpperCase()} - - )} + {reviewer.login} diff --git a/src/renderer/src/components/TaskPageGitLabItemList.tsx b/src/renderer/src/components/TaskPageGitLabItemList.tsx index 5799c131e8a..31c279fe8aa 100644 --- a/src/renderer/src/components/TaskPageGitLabItemList.tsx +++ b/src/renderer/src/components/TaskPageGitLabItemList.tsx @@ -71,12 +71,15 @@ export function TaskPageGitLabItemList({
{ useAppStore.getState().recordFeatureInteraction('gitlab-tasks') openGitLabDetailPage(item) }} onKeyDown={(e) => { + if (e.target !== e.currentTarget) { + return + } if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() useAppStore.getState().recordFeatureInteraction('gitlab-tasks') diff --git a/src/renderer/src/components/TaskPageLinearIssueAttributes.tsx b/src/renderer/src/components/TaskPageLinearIssueAttributes.tsx index 3e7862d432a..ba5dda2f65b 100644 --- a/src/renderer/src/components/TaskPageLinearIssueAttributes.tsx +++ b/src/renderer/src/components/TaskPageLinearIssueAttributes.tsx @@ -56,7 +56,7 @@ export function TaskPageLinearIssueAttributes({ - +
{translate('auto.components.TaskPage.154b0fa623', 'Status')}
@@ -65,7 +65,7 @@ export function TaskPageLinearIssueAttributes({
) : ( -
+
{newLinearStates.data.map((s) => ( - +
{translate('auto.components.TaskPage.d2a876ca53', 'Assignee')}
@@ -137,7 +137,7 @@ export function TaskPageLinearIssueAttributes({
) : ( -
+
- +
{translate('auto.components.TaskPage.c8d5bec5f7', 'Priority')}
@@ -263,7 +263,7 @@ export function TaskPageLinearIssueAttributes({ - +
{translate('auto.components.TaskPage.00022ec0ba', 'Project')}
@@ -272,7 +272,7 @@ export function TaskPageLinearIssueAttributes({
) : ( -
+
- +
{translate('auto.components.TaskPage.d0ca4aa1d0', 'Labels')}
@@ -332,7 +332,7 @@ export function TaskPageLinearIssueAttributes({
) : ( -
+
{newLinearLabels.data.map((l) => { const isSelected = newLinearIssueLabelIds.includes(l.id) return ( diff --git a/src/renderer/src/components/TaskPageLinearIssueDialog.tsx b/src/renderer/src/components/TaskPageLinearIssueDialog.tsx index 15edf732046..f0c5198ee47 100644 --- a/src/renderer/src/components/TaskPageLinearIssueDialog.tsx +++ b/src/renderer/src/components/TaskPageLinearIssueDialog.tsx @@ -65,7 +65,10 @@ export function TaskPageLinearIssueDialog({ - +
{translate('auto.components.TaskPage.4f3cb99f41', 'Switch Team')}
diff --git a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts index 9842a75f7d7..a7c915cd6c6 100644 --- a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts +++ b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts @@ -75,7 +75,7 @@ describe('feature interaction writer boundaries', () => { ).toContain(githubWriter) expect( sourceBetween( - componentSource('task-page/hooks/use-task-page-use-item-actions.ts'), + componentSource('use-task-page-workspace-actions.ts'), 'const handleOpenOrUseGitHubWorkItem', 'const openComposerForGitLabItem' ) @@ -84,25 +84,25 @@ describe('feature interaction writer boundaries', () => { it('threads GitHub task source context through inline task mutations', () => { const sections = [ - componentSource('task-page/github/github-status-cell.tsx'), - componentSource('task-page/github/github-assignees-cell.tsx'), - componentSource('task-page/github/pr-review-cell.tsx'), - componentSource('task-page/github/pr-merge-cell.tsx'), + componentSource('TaskPageGitHubStatusCell.tsx'), + componentSource('TaskPageGitHubAssigneesCell.tsx'), + componentSource('TaskPageGitHubReviewCell.tsx'), + componentSource('TaskPageGitHubMergeCell.tsx'), sourceBetween( - componentSource('task-page/hooks/use-task-page-create-github-submit.ts'), + componentSource('use-task-page-github-issue-creation.ts'), 'const handleCreateNewIssue', - 'return { handleCreateNewIssue }' + 'const nextModel' ) ] for (const section of sections) { expect(section).toContain('sourceContext') } - const rowSource = componentSource('task-page/github/github-work-item-row.tsx') - expect(rowSource).toContain( - "const rowSourceContext = getTaskPageRepoSourceContext(itemRepo, 'github')" - ) - expect(rowSource).toContain('sourceContext={rowSourceContext}') + const rowSource = componentSource('TaskPageGitHubRows.tsx') + // Rows inline the repo lookup per cell rather than hoisting one const. + expect( + rowSource.match(/sourceContext=\{getTaskPageRepoSourceContext\(itemRepo, 'github'\)\}/g) + ).toHaveLength(4) }) it('suppresses Tasks surface telemetry for in-page provider switches and detail opens', () => { @@ -120,7 +120,7 @@ describe('feature interaction writer boundaries', () => { 'const openRelatedLinearIssue' ), sourceBetween( - componentSource('task-page/chrome/task-page-source-toolbar.tsx'), + componentSource('TaskPageSourceBar.tsx'), 'taskSourceManuallyChangedRef.current = true', 'void updateSettings' ) @@ -165,16 +165,16 @@ describe('feature interaction writer boundaries', () => { expect( sourceBetween( - componentSource('task-page/gitlab/gitlab-work-item-list.tsx'), + componentSource('TaskPageGitLabItemList.tsx'), '{displayedGitLabItems.map((item) => (', 'handleUseGitLabItem(item)' ).match(/recordFeatureInteraction\('gitlab-tasks'\)/g) ).toHaveLength(2) expect( sourceBetween( - componentSource('task-page/hooks/use-task-page-use-item-actions.ts'), + componentSource('use-task-page-workspace-actions.ts'), 'const handleUseGitLabItem', - 'return {' + 'const nextModel' ) ).toContain(gitlabWriter) @@ -200,18 +200,18 @@ describe('feature interaction writer boundaries', () => { it('keeps nested GitLab row actions from also opening task details by keyboard', () => { const rowSection = sourceBetween( - componentSource('task-page/gitlab/gitlab-work-item-list.tsx'), - 'onKeyDown={(event) => {', + componentSource('TaskPageGitLabItemList.tsx'), + 'onKeyDown={(e) => {', 'className="grid w-full cursor-pointer' ) - expect(rowSection).toContain('event.target !== event.currentTarget') - expect(rowSection.indexOf('event.target !== event.currentTarget')).toBeLessThan( - rowSection.indexOf("event.key === 'Enter'") + expect(rowSection).toContain('e.target !== e.currentTarget') + expect(rowSection.indexOf('e.target !== e.currentTarget')).toBeLessThan( + rowSection.indexOf("e.key === 'Enter'") ) }) it('keys GitLab rows by repository and item identity across hosts', () => { - expect(componentSource('task-page/gitlab/gitlab-work-item-list.tsx')).toContain( + expect(componentSource('TaskPageGitLabItemList.tsx')).toContain( 'key={`${item.repoId}:${item.id}`}' ) }) @@ -225,22 +225,22 @@ describe('feature interaction writer boundaries', () => { const taskPageSections = [ sourceBetween( - componentSource('task-page/linear/linear-state-cell.tsx'), + componentSource('task-page-linear-issue-model.tsx'), 'export function LinearStateCell', 'return (' ), sourceBetween( - componentSource('task-page/hooks/use-task-page-linear-board.tsx'), + componentSource('use-task-page-linear-board.ts'), 'const handleLinearBoardDrop', 'const toggleLinearDisplayProperty' ), sourceBetween( - componentSource('task-page/hooks/use-task-page-create-linear-submits.tsx'), + componentSource('use-task-page-linear-issue-creation.ts'), 'const handleCreateNewLinearIssue', - 'return {' + 'const nextModel' ), sourceBetween( - componentSource('task-page/hooks/use-task-page-linear-actions.ts'), + componentSource('use-task-page-composer-actions.ts'), 'const handleUseLinearItem', 'const handleLinearWorkspaceChange' ) @@ -270,9 +270,9 @@ describe('feature interaction writer boundaries', () => { // marker) no longer exists in TaskPage. expect( sourceBetween( - componentSource('task-page/hooks/use-task-page-jira-actions.ts'), + componentSource('use-task-page-composer-actions.ts'), 'const handleUseJiraItem', - 'return {' + 'const nextModel' ) ).toContain(jiraWriter) }) diff --git a/src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts b/src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts index aa90a447780..deeda3ef506 100644 --- a/src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts +++ b/src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts @@ -16,8 +16,8 @@ function sourceBetween(source: string, startPattern: string, endPattern: string) describe('GitHub Enterprise slug routing boundaries', () => { it('keeps work-item URL hosts on TaskPage metadata and issue mutations', () => { - const statusSection = componentSource('task-page/github/github-status-cell.tsx') - const assigneeSection = componentSource('task-page/github/github-assignees-cell.tsx') + const statusSection = componentSource('TaskPageGitHubStatusCell.tsx') + const assigneeSection = componentSource('TaskPageGitHubAssigneesCell.tsx') expect(statusSection).toContain('host: githubProjectHost(parsedOwnerRepo.host)') expect(assigneeSection).toContain('parsed?.slug.host') @@ -25,11 +25,13 @@ describe('GitHub Enterprise slug routing boundaries', () => { }) it('uses URL-host fallback for TaskPage reviewer and merge mutations', () => { - const reviewSection = componentSource('task-page/github/pr-review-cell.tsx') - const mergeSection = componentSource('task-page/github/pr-merge-cell.tsx') + const reviewSection = componentSource('TaskPageGitHubReviewCell.tsx') + const reviewActionsSection = componentSource('task-page-github-reviewer-actions.ts') + const mergeSection = componentSource('TaskPageGitHubMergeCell.tsx') expect(reviewSection).toContain('resolveTaskPullRequestRepo(item)') - expect(reviewSection.match(/prRepo: reviewRepo/g)).toHaveLength(4) + expect(reviewSection).toContain('reviewRepo,') + expect(reviewActionsSection.match(/prRepo: reviewRepo/g)).toHaveLength(4) expect(mergeSection).toContain('const prRepo = resolveTaskPullRequestRepo(item)') expect(mergeSection).not.toContain('prRepo: item.prRepo ?? null') }) diff --git a/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts b/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts index c48cd53071a..8e3b831020d 100644 --- a/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts +++ b/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts @@ -47,7 +47,7 @@ describe('issue #8784 GHE avatar fallback (regression)', () => { expect(prPage).not.toMatch(/githubAvatarUrl\(workItem\.author\)/) const reviewChip = readFileSync( - join(__dirname, '../task-page/github/github-assignee-avatars.tsx'), + join(__dirname, '../TaskPageGitHubAvatars.tsx'), 'utf8' ) expect(reviewChip).toMatch(/GitHubUserAvatar/) @@ -77,20 +77,20 @@ describe('issue #8784 GHE avatar fallback (regression)', () => { displayName: 'option.name' }, { - file: 'task-page/github/github-assignee-avatars.tsx', + file: 'TaskPageGitHubAvatars.tsx', fn: 'GitHubAssigneeAvatar', login: 'assignee.login', displayName: 'assignee.name' }, { - file: 'task-page/github/github-assignees-cell.tsx', + file: 'TaskPageGitHubAssigneesCell.tsx', fn: 'GHAssigneesCell', login: 'user.login', displayName: 'user.name' }, { - file: 'task-page/github/pr-review-picker-panel.tsx', - fn: 'PRReviewPickerPanel', + file: 'TaskPageGitHubReviewerPicker.tsx', + fn: 'TaskPageGitHubReviewerPicker', login: 'reviewer.login', displayName: 'reviewer.name' } diff --git a/src/renderer/src/components/task-page-github-issue-creation.test.ts b/src/renderer/src/components/task-page-github-issue-creation.test.ts index 8dacd66f084..bacc3988bb8 100644 --- a/src/renderer/src/components/task-page-github-issue-creation.test.ts +++ b/src/renderer/src/components/task-page-github-issue-creation.test.ts @@ -3,8 +3,12 @@ import { readTaskPageSource } from './task-page-source-family.test-support' import { readFileSync } from 'node:fs' const taskPageSource = readTaskPageSource('use-task-page-github-issue-creation.ts') -const newIssueStateSource = readFileSync( - new URL('./task-page/hooks/use-task-page-github-new-issue-state.ts', import.meta.url), +const newIssueDraftSource = readFileSync( + new URL('./use-task-page-github-issue-draft.ts', import.meta.url), + 'utf8' +) +const newIssueRepoResetSource = readFileSync( + new URL('./task-page-new-issue-draft.ts', import.meta.url), 'utf8' ) @@ -18,8 +22,12 @@ function issueCreationSection(): string { describe('TaskPage GitHub issue creation', () => { it('keeps issue creation targeted to the first selected repo on a fresh mount', () => { - expect(newIssueStateSource).toContain('(selectedRepos[0]?.id ?? null)') - expect(newIssueStateSource).toContain('newIssueRepoId !== null') + expect(newIssueDraftSource).toContain( + 'selectedRepos.find((r) => r.id === newIssueRepoId) ?? selectedRepos[0] ?? null' + ) + // Why: a repo leaving the selection mid-draft must reset to the first selected repo. + expect(newIssueRepoResetSource).toContain('newIssueRepoId === null') + expect(newIssueRepoResetSource).toContain('return { repoId: selectedRepoIds[0] ?? null }') }) it('covers the complete remote oversized-body recovery timeout envelope', () => { diff --git a/src/renderer/src/components/task-page-linear-issue-dialog-popover-scroll.test.ts b/src/renderer/src/components/task-page-linear-issue-dialog-popover-scroll.test.ts index f9ebe0ac6fb..bcd9ee73d97 100644 --- a/src/renderer/src/components/task-page-linear-issue-dialog-popover-scroll.test.ts +++ b/src/renderer/src/components/task-page-linear-issue-dialog-popover-scroll.test.ts @@ -2,9 +2,8 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' const newLinearIssueDialogSources = [ - './task-page/dialogs/new-linear-issue-dialog.tsx', - './task-page/dialogs/new-linear-issue-more-pickers.tsx', - './task-page/dialogs/new-linear-issue-status-assignee.tsx' + './TaskPageLinearIssueDialog.tsx', + './TaskPageLinearIssueAttributes.tsx' ].map((path) => readFileSync(new URL(path, import.meta.url), 'utf8')) /** The extracted Linear "New Issue" dialog and its attribute pickers. */ diff --git a/src/renderer/src/components/task-page-task-creation-drafts.test.ts b/src/renderer/src/components/task-page-task-creation-drafts.test.ts index d69257347d7..9933050d1b8 100644 --- a/src/renderer/src/components/task-page-task-creation-drafts.test.ts +++ b/src/renderer/src/components/task-page-task-creation-drafts.test.ts @@ -7,19 +7,19 @@ import { const taskPageSource = readTaskPageSourceFamily() const linearCreateDialogsSource = readFileSync( - new URL('./task-page/hooks/use-task-page-linear-create-dialogs.ts', import.meta.url), + new URL('./use-task-page-linear-creation-state.ts', import.meta.url), 'utf8' ) const jiraCreateDialogSource = readFileSync( - new URL('./task-page/hooks/use-task-page-jira-create-dialog.tsx', import.meta.url), + new URL('./use-task-page-jira-creation-state.ts', import.meta.url), 'utf8' ) const jiraCreateSubmitSource = readFileSync( - new URL('./task-page/hooks/use-task-page-create-jira-submit.ts', import.meta.url), + new URL('./use-task-page-jira-issue-creation.ts', import.meta.url), 'utf8' ) const draftWriterSource = readFileSync( - new URL('./task-page/dialogs/task-creation-draft-writers.ts', import.meta.url), + new URL('./task-page-draft-storage.tsx', import.meta.url), 'utf8' ) const draftRetentionSource = [linearCreateDialogsSource, jiraCreateDialogSource].join('\n') @@ -49,11 +49,11 @@ describe('TaskPage Linear/Jira creation drafts', () => { it('restores dismissed typed text when each dialog reopens', () => { const linearFilters = readFileSync( - new URL('./task-page/chrome/task-page-linear-filters.tsx', import.meta.url), + new URL('./TaskPageLinearFilters.tsx', import.meta.url), 'utf8' ) const jiraFilters = readFileSync( - new URL('./task-page/chrome/task-page-jira-filters.tsx', import.meta.url), + new URL('./TaskPageJiraFilters.tsx', import.meta.url), 'utf8' ) expect(linearFilters).toContain("setNewLinearProjectName(draft?.name ?? '')") @@ -92,7 +92,7 @@ describe('TaskPage Linear/Jira creation drafts', () => { const jiraIssueSection = sectionBetween( jiraCreateSubmitSource, 'const handleCreateNewJiraIssue', - 'return { handleCreateNewJiraIssue }' + 'const nextModel' ) expect(jiraIssueSection).toContain('} catch (error) {') expect(jiraIssueSection).toContain( diff --git a/src/renderer/src/components/task-page-workspace-composer-boundary.test.ts b/src/renderer/src/components/task-page-workspace-composer-boundary.test.ts index 7d1c9625efb..22ef41a8bd3 100644 --- a/src/renderer/src/components/task-page-workspace-composer-boundary.test.ts +++ b/src/renderer/src/components/task-page-workspace-composer-boundary.test.ts @@ -9,10 +9,6 @@ import { const TASK_PAGE_SOURCE = readTaskPageSourceFamily() const WORKSPACE_ACTIONS_SOURCE = readTaskPageSource('use-task-page-workspace-actions.ts') const COMPOSER_ACTIONS_SOURCE = readTaskPageSource('use-task-page-composer-actions.ts') -const USE_ITEM_ACTIONS_SOURCE = readFileSync( - join(__dirname, 'task-page/hooks/use-task-page-use-item-actions.ts'), - 'utf8' -) const PROJECT_VIEW_SOURCE = readFileSync( join(__dirname, 'github-project', 'ProjectViewWrapper.tsx'), 'utf8' @@ -79,7 +75,7 @@ describe('TaskPage workspace creation source boundaries', () => { expect(section).toContain("recordFeatureInteraction('github-tasks')") expect(section).toContain('openComposerForItem(item)') expect(section).not.toContain('createGitHubWorkItemWorkspaceInBackground') - expect(USE_ITEM_ACTIONS_SOURCE).not.toContain('@/lib/github-work-item-background-create') + expect(WORKSPACE_ACTIONS_SOURCE).not.toContain('@/lib/github-work-item-background-create') expect(TASK_PAGE_SOURCE).not.toContain('@/lib/github-work-item-background-create') }) @@ -118,11 +114,8 @@ describe('TaskPage workspace creation source boundaries', () => { }) it('uses the shared composer handler from GitHub detail and start-new actions', () => { - const detail = readFileSync(join(__dirname, 'task-page/github/github-detail-host.tsx'), 'utf8') - const actions = readFileSync( - join(__dirname, 'task-page/github/github-work-item-row-actions.tsx'), - 'utf8' - ) + const detail = readFileSync(join(__dirname, 'TaskPageContent.tsx'), 'utf8') + const actions = readFileSync(join(__dirname, 'TaskPageGitHubRows.tsx'), 'utf8') expect(detail.match(/onUse=\{\(item\) => \{/g)).toHaveLength(2) expect(actions.match(/onSelect=\{\(\) => handleUseWorkItem\(item\)\}/g)).toHaveLength(2) }) diff --git a/src/renderer/src/components/use-task-page-jira-issue-creation.ts b/src/renderer/src/components/use-task-page-jira-issue-creation.ts index 0ca6118831f..c1760e1c11d 100644 --- a/src/renderer/src/components/use-task-page-jira-issue-creation.ts +++ b/src/renderer/src/components/use-task-page-jira-issue-creation.ts @@ -97,6 +97,14 @@ export function useTaskPageJiraIssueCreation(model: TaskPageLinearIssueCreationM } }) .catch(() => {}) + } catch (error) { + if (submitProviderRuntimeContextKey === providerRuntimeContextKeyRef.current) { + toast.error( + error instanceof Error + ? error.message + : translate('auto.components.TaskPage.aec5feeb69', 'Failed to create Jira issue.') + ) + } } finally { if (submitProviderRuntimeContextKey === providerRuntimeContextKeyRef.current) { setNewJiraIssueSubmitting(false) From bbcb7a199dff0ac130151f82b565cc4dfa9ce5cd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 03:28:53 -0700 Subject: [PATCH 3/9] fix(task-page): restore the STA-5949 GitHub list scroll restore The split left this logic inline in use-task-page-github-detail.ts at its pre-STA-5949 shape: a 5s deadline whose expiry overwrote the remembered offset with the committed 0 -- the permanent-loss bug the extracted module's header says was removed -- observing only the children rather than the container, and with no MutationObserver, so late-mounting rows never retriggered a retry. The list scroll handler also blanket-returned while a restore was pending instead of classifying echoes, dropping a user's scroll during an unreachable restore. Promotes the module to a live path and wires the hook and scroll handler to it, which also gives the ten behavioral cases in the scroll-restore suite something live to assert against. --- .../src/components/TaskPageGitHubList.tsx | 19 +++-- ...sk-page-github-list-scroll-restore.test.ts | 2 +- ...> task-page-github-list-scroll-restore.ts} | 0 .../github/github-work-item-table.tsx | 2 +- .../hooks/use-task-page-github-list-state.ts | 2 +- .../components/use-task-page-github-detail.ts | 73 +++++-------------- .../use-task-page-github-list-state.ts | 4 + 7 files changed, 39 insertions(+), 63 deletions(-) rename src/renderer/src/components/{task-page/github/github-list-scroll-restore.ts => task-page-github-list-scroll-restore.ts} (100%) diff --git a/src/renderer/src/components/TaskPageGitHubList.tsx b/src/renderer/src/components/TaskPageGitHubList.tsx index aa53374ffa7..569db75502a 100644 --- a/src/renderer/src/components/TaskPageGitHubList.tsx +++ b/src/renderer/src/components/TaskPageGitHubList.tsx @@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button' import { LoaderCircle } from 'lucide-react' import { TaskPageGitHubRows } from './TaskPageGitHubRows' import { PaginationBar } from './TaskPagePaginationBar' +import { supersedeGitHubListScrollRestore } from './task-page-github-list-scroll-restore' export function TaskPageGitHubList({ model }: { @@ -33,6 +34,7 @@ export function TaskPageGitHubList({ githubListScrollRef, githubListScrollTopRef, pendingGithubScrollRestoreRef, + githubListRestoreWriteRef, loadingTargetPage, taskListPositionRef, perRepoSourceState, @@ -61,14 +63,21 @@ export function TaskPageGitHubList({ }} onScroll={(event) => { const state = useAppStore.getState() - if ( - state.activeView !== 'tasks' || - state.taskPageData.openGitHubWorkItem || - pendingGithubScrollRestoreRef.current !== null - ) { + if (state.activeView !== 'tasks' || state.taskPageData.openGitHubWorkItem) { return } const scrollTop = event.currentTarget.scrollTop + // Why: a restore's own write must not be saved as the user's position, but a real + // user scroll has to supersede a pending restore or saving stays suppressed. + if ( + !supersedeGitHubListScrollRestore({ + scrollTop, + pendingRestoreRef: pendingGithubScrollRestoreRef, + restoreWriteRef: githubListRestoreWriteRef + }) + ) { + return + } githubListScrollTopRef.current = scrollTop taskListPositionRef.current = { contextKey: githubResumeContextKey, diff --git a/src/renderer/src/components/task-page-github-list-scroll-restore.test.ts b/src/renderer/src/components/task-page-github-list-scroll-restore.test.ts index 9f557a10b7d..785f503436f 100644 --- a/src/renderer/src/components/task-page-github-list-scroll-restore.test.ts +++ b/src/renderer/src/components/task-page-github-list-scroll-restore.test.ts @@ -6,7 +6,7 @@ import { startGitHubListScrollRestore, supersedeGitHubListScrollRestore, type GitHubListRestoreWrite -} from './task-page/github/github-list-scroll-restore' +} from './task-page-github-list-scroll-restore' type FakeResizeObserver = { targets: Set diff --git a/src/renderer/src/components/task-page/github/github-list-scroll-restore.ts b/src/renderer/src/components/task-page-github-list-scroll-restore.ts similarity index 100% rename from src/renderer/src/components/task-page/github/github-list-scroll-restore.ts rename to src/renderer/src/components/task-page-github-list-scroll-restore.ts diff --git a/src/renderer/src/components/task-page/github/github-work-item-table.tsx b/src/renderer/src/components/task-page/github/github-work-item-table.tsx index 0f6e1ab6e8e..c19a2d34201 100644 --- a/src/renderer/src/components/task-page/github/github-work-item-table.tsx +++ b/src/renderer/src/components/task-page/github/github-work-item-table.tsx @@ -27,7 +27,7 @@ import type { TaskPageGitHubWorkItemMutationRunner } from './github-work-item-mu import { supersedeGitHubListScrollRestore, type GitHubListRestoreWrite -} from './github-list-scroll-restore' +} from '@/components/task-page-github-list-scroll-restore' import { GithubWorkItemRows } from './github-work-item-rows' import { PaginationBar } from '../pagination/pagination-bar' diff --git a/src/renderer/src/components/task-page/hooks/use-task-page-github-list-state.ts b/src/renderer/src/components/task-page/hooks/use-task-page-github-list-state.ts index d85a7e3d0b4..19815e9da3f 100644 --- a/src/renderer/src/components/task-page/hooks/use-task-page-github-list-state.ts +++ b/src/renderer/src/components/task-page/hooks/use-task-page-github-list-state.ts @@ -9,7 +9,7 @@ import { sortWorkItemsByNumber } from '../../../../../shared/work-items' import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' import type { Repo } from '../../../../../shared/repo-types' import type { TaskViewPresetId } from '../../../../../shared/ui-chrome-types' -import type { GitHubListRestoreWrite } from '@/components/task-page/github/github-list-scroll-restore' +import type { GitHubListRestoreWrite } from '@/components/task-page-github-list-scroll-restore' import type { AppState } from '@/store/types' export function useTaskPageGitHubListState({ diff --git a/src/renderer/src/components/use-task-page-github-detail.ts b/src/renderer/src/components/use-task-page-github-detail.ts index 22e0b2a2f85..b2c61f64bde 100644 --- a/src/renderer/src/components/use-task-page-github-detail.ts +++ b/src/renderer/src/components/use-task-page-github-detail.ts @@ -11,6 +11,7 @@ import { import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import type { GitLabWorkItem } from '../../../shared/gitlab-types' import { getTaskPageRepoCacheInput, getTaskPageRepoSourceContext } from './task-page-source-context' +import { startGitHubListScrollRestore } from './task-page-github-list-scroll-restore' function getTaskPageScrollTop( scrollRef: React.RefObject, @@ -38,6 +39,7 @@ export function useTaskPageGitHubDetail(model: TaskPageGitHubListStateModel) { githubListScrollRef, githubListScrollTopRef, pendingGithubScrollRestoreRef, + githubListRestoreWriteRef, taskListPositionRef } = model // 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. @@ -73,67 +75,28 @@ export function useTaskPageGitHubDetail(model: TaskPageGitHubListStateModel) { ? (cachedDialogWorkItem ?? githubTaskDrawerWorkItem) : null useLayoutEffect(() => { - const scrollTop = pendingGithubScrollRestoreRef.current - const scrollElement = githubListScrollRef.current - if (scrollTop === null || !scrollElement || !pages[currentPage]) { + const target = pendingGithubScrollRestoreRef.current + if (target === null || !githubListScrollRef.current || !pages[currentPage]) { return } - let frame: number | null = null - let timeout: number | null = null - let observer: ResizeObserver | null = null - const clearScheduledRestore = (): void => { - if (frame !== null) { - window.cancelAnimationFrame(frame) - frame = null - } - if (timeout !== null) { - window.clearTimeout(timeout) - timeout = null - } - observer?.disconnect() - } - const restore = (): void => { - const committedScrollElement = githubListScrollRef.current - if (!committedScrollElement || pendingGithubScrollRestoreRef.current !== scrollTop) { - return - } - committedScrollElement.scrollTop = scrollTop - githubListScrollTopRef.current = scrollTop - taskListPositionRef.current = { - contextKey: githubResumeContextKey, - page: currentPage, - scrollTop - } - if (Math.abs(committedScrollElement.scrollTop - scrollTop) < 1) { - pendingGithubScrollRestoreRef.current = null - clearScheduledRestore() - } - } - observer = new ResizeObserver(restore) - for (const child of scrollElement.children) { - observer.observe(child) - } - restore() - if (pendingGithubScrollRestoreRef.current === scrollTop) { - frame = window.requestAnimationFrame(restore) - timeout = window.setTimeout(() => { - if (pendingGithubScrollRestoreRef.current === scrollTop) { - const committedScrollTop = getTaskPageScrollTop(githubListScrollRef, 0) - githubListScrollTopRef.current = committedScrollTop - taskListPositionRef.current = { - contextKey: githubResumeContextKey, - page: currentPage, - scrollTop: committedScrollTop - } - pendingGithubScrollRestoreRef.current = null + return startGitHubListScrollRestore({ + target, + scrollElementRef: githubListScrollRef, + pendingRestoreRef: pendingGithubScrollRestoreRef, + restoreWriteRef: githubListRestoreWriteRef, + onScrollTopApplied: (scrollTop) => { + githubListScrollTopRef.current = scrollTop + taskListPositionRef.current = { + contextKey: githubResumeContextKey, + page: currentPage, + scrollTop } - clearScheduledRestore() - }, 5_000) - } - return clearScheduledRestore + } + }) }, [ currentPage, dialogWorkItem, + githubListRestoreWriteRef, githubListScrollRef, githubListScrollTopRef, githubResumeContextKey, diff --git a/src/renderer/src/components/use-task-page-github-list-state.ts b/src/renderer/src/components/use-task-page-github-list-state.ts index 7a5e6b31253..03ff48702c7 100644 --- a/src/renderer/src/components/use-task-page-github-list-state.ts +++ b/src/renderer/src/components/use-task-page-github-list-state.ts @@ -10,6 +10,7 @@ import { } from '@/components/task-page-github-resume-cache' import { sortWorkItemsByNumber } from '../../../shared/work-items' import { useAppStore } from '@/store' +import type { GitHubListRestoreWrite } from './task-page-github-list-scroll-restore' import { getTaskPageRepoSourceContext } from './task-page-source-context' export function useTaskPageGitHubListState(model: TaskPageProviderStateModel) { const { @@ -99,6 +100,7 @@ export function useTaskPageGitHubListState(model: TaskPageProviderStateModel) { const githubListScrollRef = useRef(null) const githubListScrollTopRef = useRef(0) const pendingGithubScrollRestoreRef = useRef(null) + const githubListRestoreWriteRef = useRef(null) const [paginationLoading, setPaginationLoading] = useState(false) const [loadingTargetPage, setLoadingTargetPage] = useState(null) const [countedTotalPages, setCountedTotalPages] = useState(null) @@ -218,6 +220,7 @@ export function useTaskPageGitHubListState(model: TaskPageProviderStateModel) { githubListScrollRef: typeof githubListScrollRef githubListScrollTopRef: typeof githubListScrollTopRef pendingGithubScrollRestoreRef: typeof pendingGithubScrollRestoreRef + githubListRestoreWriteRef: typeof githubListRestoreWriteRef paginationLoading: typeof paginationLoading setPaginationLoading: typeof setPaginationLoading loadingTargetPage: typeof loadingTargetPage @@ -275,6 +278,7 @@ export function useTaskPageGitHubListState(model: TaskPageProviderStateModel) { nextModel.githubListScrollRef = githubListScrollRef nextModel.githubListScrollTopRef = githubListScrollTopRef nextModel.pendingGithubScrollRestoreRef = pendingGithubScrollRestoreRef + nextModel.githubListRestoreWriteRef = githubListRestoreWriteRef nextModel.paginationLoading = paginationLoading nextModel.setPaginationLoading = setPaginationLoading nextModel.loadingTargetPage = loadingTargetPage From fc68d2c3a245f62c400f461d4b174c2edf47ff62 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 03:30:45 -0700 Subject: [PATCH 4/9] refactor(preload): name bridge modules for what they expose The split named these -part-N, which says nothing. Renames each for the group of bridge methods it actually exposes and folds the single-method window-reveal module into the window-controls module it belongs with. Verified by walking the composed contextBridge surface before and after: 1060 keys, identical nesting and value types, zero delta. The bridge modules carry no satisfies annotation, so a dropped key here is a runtime error in the renderer rather than a typecheck failure. --- src/main/ipc/github-ipc-channel-parity.test.ts | 4 ++-- ...bridge-guest-registration-and-downloads.ts} | 2 +- ...er-bridge-page-interaction-and-sessions.ts} | 2 +- src/preload/api/browser-bridge.ts | 9 ++++++--- ....ts => gh-bridge-mutations-and-projects.ts} | 2 +- ... gh-bridge-pull-requests-and-work-items.ts} | 2 +- src/preload/api/gh-bridge.ts | 6 +++--- ...part-1.ts => pty-bridge-session-control.ts} | 2 +- ... => pty-bridge-stream-and-serialization.ts} | 2 +- src/preload/api/pty-bridge.ts | 6 +++--- ...ui-bridge-clipboard-and-window-controls.ts} | 5 ++++- src/preload/api/ui-bridge-part-5.ts | 7 ------- ...ts => ui-bridge-state-and-menu-commands.ts} | 2 +- ...s => ui-bridge-tab-and-browser-commands.ts} | 2 +- ... => ui-bridge-terminal-and-session-tabs.ts} | 2 +- src/preload/api/ui-bridge.ts | 18 ++++++++---------- 16 files changed, 35 insertions(+), 38 deletions(-) rename src/preload/api/{browser-bridge-part-1.ts => browser-bridge-guest-registration-and-downloads.ts} (99%) rename src/preload/api/{browser-bridge-part-2.ts => browser-bridge-page-interaction-and-sessions.ts} (99%) rename src/preload/api/{gh-bridge-part-2.ts => gh-bridge-mutations-and-projects.ts} (99%) rename src/preload/api/{gh-bridge-part-1.ts => gh-bridge-pull-requests-and-work-items.ts} (99%) rename src/preload/api/{pty-bridge-part-1.ts => pty-bridge-session-control.ts} (99%) rename src/preload/api/{pty-bridge-part-2.ts => pty-bridge-stream-and-serialization.ts} (99%) rename src/preload/api/{ui-bridge-part-4.ts => ui-bridge-clipboard-and-window-controls.ts} (98%) delete mode 100644 src/preload/api/ui-bridge-part-5.ts rename src/preload/api/{ui-bridge-part-1.ts => ui-bridge-state-and-menu-commands.ts} (99%) rename src/preload/api/{ui-bridge-part-2.ts => ui-bridge-tab-and-browser-commands.ts} (99%) rename src/preload/api/{ui-bridge-part-3.ts => ui-bridge-terminal-and-session-tabs.ts} (99%) diff --git a/src/main/ipc/github-ipc-channel-parity.test.ts b/src/main/ipc/github-ipc-channel-parity.test.ts index 73fc291f4ca..22a8b4c4178 100644 --- a/src/main/ipc/github-ipc-channel-parity.test.ts +++ b/src/main/ipc/github-ipc-channel-parity.test.ts @@ -88,8 +88,8 @@ describe('GitHub IPC channel parity', () => { // The preload facade now composes the two GitHub bridge owners; inspect // both owners so the channel census remains tied to the actual invokes. const preloadSource = [ - '../../preload/api/gh-bridge-part-1.ts', - '../../preload/api/gh-bridge-part-2.ts' + '../../preload/api/gh-bridge-pull-requests-and-work-items.ts', + '../../preload/api/gh-bridge-mutations-and-projects.ts' ] .map((relativePath) => readFileSync(new URL(relativePath, import.meta.url), 'utf8')) .join('\n') diff --git a/src/preload/api/browser-bridge-part-1.ts b/src/preload/api/browser-bridge-guest-registration-and-downloads.ts similarity index 99% rename from src/preload/api/browser-bridge-part-1.ts rename to src/preload/api/browser-bridge-guest-registration-and-downloads.ts index 47883976338..3714a3bca7c 100644 --- a/src/preload/api/browser-bridge-part-1.ts +++ b/src/preload/api/browser-bridge-guest-registration-and-downloads.ts @@ -7,7 +7,7 @@ import type { import { readBrowserClientHostIdArgument } from '../../shared/browser-client-host-id-argument' import { browserClientPageRendererRequests } from '../preload-runtime-support' -export const browserApiPart1 = { +export const browserGuestRegistrationAndDownloadsApi = { onClientPageRendererRequest: browserClientPageRendererRequests.subscribe, readClientHostId: (): string | null => readBrowserClientHostIdArgument(process.argv), registerGuest: (args: { diff --git a/src/preload/api/browser-bridge-part-2.ts b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts similarity index 99% rename from src/preload/api/browser-bridge-part-2.ts rename to src/preload/api/browser-bridge-page-interaction-and-sessions.ts index 07788c59d2e..0e959e0af4f 100644 --- a/src/preload/api/browser-bridge-part-2.ts +++ b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts @@ -1,6 +1,6 @@ import { ipcRenderer } from 'electron' -export const browserApiPart2 = { +export const browserPageInteractionAndSessionsApi = { onContextMenuRequested: ( callback: (event: { browserPageId: string diff --git a/src/preload/api/browser-bridge.ts b/src/preload/api/browser-bridge.ts index 9b72b1658d3..dca222c5843 100644 --- a/src/preload/api/browser-bridge.ts +++ b/src/preload/api/browser-bridge.ts @@ -1,4 +1,7 @@ -import { browserApiPart1 } from './browser-bridge-part-1' -import { browserApiPart2 } from './browser-bridge-part-2' +import { browserGuestRegistrationAndDownloadsApi } from './browser-bridge-guest-registration-and-downloads' +import { browserPageInteractionAndSessionsApi } from './browser-bridge-page-interaction-and-sessions' -export const browserApi = { ...browserApiPart1, ...browserApiPart2 } +export const browserApi = { + ...browserGuestRegistrationAndDownloadsApi, + ...browserPageInteractionAndSessionsApi +} diff --git a/src/preload/api/gh-bridge-part-2.ts b/src/preload/api/gh-bridge-mutations-and-projects.ts similarity index 99% rename from src/preload/api/gh-bridge-part-2.ts rename to src/preload/api/gh-bridge-mutations-and-projects.ts index c7ebc51b57e..3103cb432ec 100644 --- a/src/preload/api/gh-bridge-part-2.ts +++ b/src/preload/api/gh-bridge-mutations-and-projects.ts @@ -36,7 +36,7 @@ import type { } from '../../shared/github/project-request-types' import type { AppStarSource } from '../../shared/gh-star-source' -export const ghApiPart2 = { +export const ghMutationsAndProjectsApi = { setPRAutoMerge: (args: { repoPath: string repoId?: string diff --git a/src/preload/api/gh-bridge-part-1.ts b/src/preload/api/gh-bridge-pull-requests-and-work-items.ts similarity index 99% rename from src/preload/api/gh-bridge-part-1.ts rename to src/preload/api/gh-bridge-pull-requests-and-work-items.ts index 11ed8c32f79..a4f3e1d45ef 100644 --- a/src/preload/api/gh-bridge-part-1.ts +++ b/src/preload/api/gh-bridge-pull-requests-and-work-items.ts @@ -10,7 +10,7 @@ import type { GitHubWorkItem, ListWorkItemsResult } from '../../shared/github/wo import type { GitHubCreateIssueResult } from '../../shared/issue-mutation-types' import type { TaskSourceContext } from '../../shared/task-source-context' -export const ghApiPart1 = { +export const ghPullRequestsAndWorkItemsApi = { viewer: (): Promise => ipcRenderer.invoke('gh:viewer'), repoSlug: (args: { repoPath: string; repoId?: string }): Promise => ipcRenderer.invoke('gh:repoSlug', args), diff --git a/src/preload/api/gh-bridge.ts b/src/preload/api/gh-bridge.ts index c4bef718721..52c21a966a7 100644 --- a/src/preload/api/gh-bridge.ts +++ b/src/preload/api/gh-bridge.ts @@ -1,4 +1,4 @@ -import { ghApiPart1 } from './gh-bridge-part-1' -import { ghApiPart2 } from './gh-bridge-part-2' +import { ghPullRequestsAndWorkItemsApi } from './gh-bridge-pull-requests-and-work-items' +import { ghMutationsAndProjectsApi } from './gh-bridge-mutations-and-projects' -export const ghApi = { ...ghApiPart1, ...ghApiPart2 } +export const ghApi = { ...ghPullRequestsAndWorkItemsApi, ...ghMutationsAndProjectsApi } diff --git a/src/preload/api/pty-bridge-part-1.ts b/src/preload/api/pty-bridge-session-control.ts similarity index 99% rename from src/preload/api/pty-bridge-part-1.ts rename to src/preload/api/pty-bridge-session-control.ts index 40da8dbfa52..7e761738256 100644 --- a/src/preload/api/pty-bridge-part-1.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -16,7 +16,7 @@ import type { TerminalViewAttributes } from '../../shared/terminal-view-attribut import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics' import type { AgentKind, LaunchSource, RequestKind } from '../../shared/telemetry-events' -export const ptyApiPart1 = { +export const ptySessionControlApi = { spawn: (opts: { cols: number rows: number diff --git a/src/preload/api/pty-bridge-part-2.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts similarity index 99% rename from src/preload/api/pty-bridge-part-2.ts rename to src/preload/api/pty-bridge-stream-and-serialization.ts index 1686a621f97..8fc9c48bce1 100644 --- a/src/preload/api/pty-bridge-part-2.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -2,7 +2,7 @@ import { ipcRenderer } from 'electron' import type { PtyModelRestoreNeededEvent } from '../../shared/pty-model-restore-marker' import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' -export const ptyApiPart2 = { +export const ptyStreamAndSerializationApi = { inspectProcess: ( id: string ): Promise<{ diff --git a/src/preload/api/pty-bridge.ts b/src/preload/api/pty-bridge.ts index 314bd6f993f..df080692178 100644 --- a/src/preload/api/pty-bridge.ts +++ b/src/preload/api/pty-bridge.ts @@ -1,4 +1,4 @@ -import { ptyApiPart1 } from './pty-bridge-part-1' -import { ptyApiPart2 } from './pty-bridge-part-2' +import { ptySessionControlApi } from './pty-bridge-session-control' +import { ptyStreamAndSerializationApi } from './pty-bridge-stream-and-serialization' -export const ptyApi = { ...ptyApiPart1, ...ptyApiPart2 } +export const ptyApi = { ...ptySessionControlApi, ...ptyStreamAndSerializationApi } diff --git a/src/preload/api/ui-bridge-part-4.ts b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts similarity index 98% rename from src/preload/api/ui-bridge-part-4.ts rename to src/preload/api/ui-bridge-clipboard-and-window-controls.ts index 2c5e2683931..867bd80026d 100644 --- a/src/preload/api/ui-bridge-part-4.ts +++ b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts @@ -13,7 +13,7 @@ import type { NativeFileDropPayload } from '../../shared/native-file-drop' import type { ReadClipboardTextOptions } from '../../shared/clipboard-text' import { subscribeNativeFileDrop } from '../preload-runtime-support' -export const uiApiPart4 = { +export const uiClipboardAndWindowControlsApi = { onOpenDiffFromMobile: ( callback: (data: { worktreeId: string @@ -191,5 +191,8 @@ export const uiApiPart4 = { }, confirmWindowClose: (): void => { ipcRenderer.send('window:confirm-close') + }, + notifyWindowRevealed: (): void => { + ipcRenderer.send('ui:window-revealed') } } diff --git a/src/preload/api/ui-bridge-part-5.ts b/src/preload/api/ui-bridge-part-5.ts deleted file mode 100644 index 7c4c10eb1c6..00000000000 --- a/src/preload/api/ui-bridge-part-5.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ipcRenderer } from 'electron' - -export const uiApiPart5 = { - notifyWindowRevealed: (): void => { - ipcRenderer.send('ui:window-revealed') - } -} diff --git a/src/preload/api/ui-bridge-part-1.ts b/src/preload/api/ui-bridge-state-and-menu-commands.ts similarity index 99% rename from src/preload/api/ui-bridge-part-1.ts rename to src/preload/api/ui-bridge-state-and-menu-commands.ts index b402b44b248..25476165cfb 100644 --- a/src/preload/api/ui-bridge-part-1.ts +++ b/src/preload/api/ui-bridge-state-and-menu-commands.ts @@ -2,7 +2,7 @@ import { ipcRenderer } from 'electron' import type { PersistedUIState } from '../../shared/persisted-ui-state-types' import type { KeybindingActionId } from '../../shared/keybindings' -export const uiApiPart1 = { +export const uiStateAndMenuCommandsApi = { get: () => ipcRenderer.invoke('ui:get'), set: (args) => ipcRenderer.invoke('ui:set', args), setWithAck: (args) => ipcRenderer.invoke('ui:set', args), diff --git a/src/preload/api/ui-bridge-part-2.ts b/src/preload/api/ui-bridge-tab-and-browser-commands.ts similarity index 99% rename from src/preload/api/ui-bridge-part-2.ts rename to src/preload/api/ui-bridge-tab-and-browser-commands.ts index fdf8956622e..ccca9a24f5b 100644 --- a/src/preload/api/ui-bridge-part-2.ts +++ b/src/preload/api/ui-bridge-tab-and-browser-commands.ts @@ -7,7 +7,7 @@ import type { } from '../../shared/worktree/launch-types' import { browserFindSubscriptions } from '../preload-runtime-support' -export const uiApiPart2 = { +export const uiTabAndBrowserCommandsApi = { onRequestTabSetProfile: ( callback: (data: { requestId: string diff --git a/src/preload/api/ui-bridge-part-3.ts b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts similarity index 99% rename from src/preload/api/ui-bridge-part-3.ts rename to src/preload/api/ui-bridge-terminal-and-session-tabs.ts index 44669b67838..9de47cceb0c 100644 --- a/src/preload/api/ui-bridge-part-3.ts +++ b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts @@ -12,7 +12,7 @@ import type { RuntimeTerminalPresentation } from '../../shared/runtime-types' -export const uiApiPart3 = { +export const uiTerminalAndSessionTabsApi = { onCreateTerminal: ( callback: (data: { requestId?: string diff --git a/src/preload/api/ui-bridge.ts b/src/preload/api/ui-bridge.ts index 97e199215c6..200d49ba1c1 100644 --- a/src/preload/api/ui-bridge.ts +++ b/src/preload/api/ui-bridge.ts @@ -1,14 +1,12 @@ import type { PreloadApi } from '../api-types' -import { uiApiPart1 } from './ui-bridge-part-1' -import { uiApiPart2 } from './ui-bridge-part-2' -import { uiApiPart3 } from './ui-bridge-part-3' -import { uiApiPart4 } from './ui-bridge-part-4' -import { uiApiPart5 } from './ui-bridge-part-5' +import { uiStateAndMenuCommandsApi } from './ui-bridge-state-and-menu-commands' +import { uiTabAndBrowserCommandsApi } from './ui-bridge-tab-and-browser-commands' +import { uiTerminalAndSessionTabsApi } from './ui-bridge-terminal-and-session-tabs' +import { uiClipboardAndWindowControlsApi } from './ui-bridge-clipboard-and-window-controls' export const uiApi = { - ...uiApiPart1, - ...uiApiPart2, - ...uiApiPart3, - ...uiApiPart4, - ...uiApiPart5 + ...uiStateAndMenuCommandsApi, + ...uiTabAndBrowserCommandsApi, + ...uiTerminalAndSessionTabsApi, + ...uiClipboardAndWindowControlsApi } satisfies PreloadApi['ui'] From de8aaac34404d7de75b4f84270ffd711e14510b2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 03:30:51 -0700 Subject: [PATCH 5/9] refactor(mobile): name terminal WebView modules for their contents fragment-01..10 were arbitrary line-count slices of one template literal. Two seams fell mid-expression -- inside buildMouseClickInput and inside the touchmove listener -- so those pieces had no identity to name. Re-splits at real statement boundaries and names each for what it holds. The composed output is byte-identical: sha256 42cc000f..., 729776 bytes, verified before, after the regroup, and after formatting. Also fixes two ratchet tests that read fragment paths directly, one of which duplicated the composer's file list. --- ...rminal-webview-html-source.test-support.ts | 16 +- mobile/src/terminal/terminal-webview-html.ts | 48 ++--- .../{fragment-01.ts => document-shell.ts} | 2 +- ...ent-03.ts => fit-scale-and-write-queue.ts} | 3 +- .../terminal-webview-html/fragment-10.ts | 132 ------------- ...{fragment-05.ts => host-message-router.ts} | 43 +---- .../message-bridge-and-document-close.ts | 43 +++++ ....ts => mouse-report-and-scroll-routing.ts} | 174 ++++++++---------- ...2.ts => runtime-state-and-text-scaling.ts} | 3 +- .../{fragment-08.ts => selection-overlay.ts} | 3 +- .../selection-state-and-eviction.ts | 71 +++++++ ....ts => smooth-scroll-and-cell-geometry.ts} | 80 +------- ...agment-09.ts => surface-touch-gestures.ts} | 92 ++++++++- .../term-observers-and-mode-mirroring.ts | 67 +++++++ ...gment-04.ts => terminal-init-and-write.ts} | 2 +- .../terminal-webview-scroll-routing.test.ts | 12 +- .../terminal-webview-text-zoom.test.ts | 6 +- 17 files changed, 405 insertions(+), 392 deletions(-) rename mobile/src/terminal/terminal-webview-html/{fragment-01.ts => document-shell.ts} (98%) rename mobile/src/terminal/terminal-webview-html/{fragment-03.ts => fit-scale-and-write-queue.ts} (98%) delete mode 100644 mobile/src/terminal/terminal-webview-html/fragment-10.ts rename mobile/src/terminal/terminal-webview-html/{fragment-05.ts => host-message-router.ts} (77%) create mode 100644 mobile/src/terminal/terminal-webview-html/message-bridge-and-document-close.ts rename mobile/src/terminal/terminal-webview-html/{fragment-06.ts => mouse-report-and-scroll-routing.ts} (56%) rename mobile/src/terminal/terminal-webview-html/{fragment-02.ts => runtime-state-and-text-scaling.ts} (97%) rename mobile/src/terminal/terminal-webview-html/{fragment-08.ts => selection-overlay.ts} (97%) create mode 100644 mobile/src/terminal/terminal-webview-html/selection-state-and-eviction.ts rename mobile/src/terminal/terminal-webview-html/{fragment-07.ts => smooth-scroll-and-cell-geometry.ts} (54%) rename mobile/src/terminal/terminal-webview-html/{fragment-09.ts => surface-touch-gestures.ts} (62%) create mode 100644 mobile/src/terminal/terminal-webview-html/term-observers-and-mode-mirroring.ts rename mobile/src/terminal/terminal-webview-html/{fragment-04.ts => terminal-init-and-write.ts} (98%) diff --git a/mobile/src/terminal/terminal-webview-html-source.test-support.ts b/mobile/src/terminal/terminal-webview-html-source.test-support.ts index 2491591572f..25902305f41 100644 --- a/mobile/src/terminal/terminal-webview-html-source.test-support.ts +++ b/mobile/src/terminal/terminal-webview-html-source.test-support.ts @@ -2,10 +2,18 @@ import { readFileSync } from 'node:fs' const SOURCE_FILES = [ './terminal-webview-html.ts', - ...Array.from( - { length: 10 }, - (_, index) => `./terminal-webview-html/fragment-${String(index + 1).padStart(2, '0')}.ts` - ) + './terminal-webview-html/document-shell.ts', + './terminal-webview-html/runtime-state-and-text-scaling.ts', + './terminal-webview-html/fit-scale-and-write-queue.ts', + './terminal-webview-html/terminal-init-and-write.ts', + './terminal-webview-html/host-message-router.ts', + './terminal-webview-html/selection-state-and-eviction.ts', + './terminal-webview-html/term-observers-and-mode-mirroring.ts', + './terminal-webview-html/mouse-report-and-scroll-routing.ts', + './terminal-webview-html/smooth-scroll-and-cell-geometry.ts', + './terminal-webview-html/selection-overlay.ts', + './terminal-webview-html/surface-touch-gestures.ts', + './terminal-webview-html/message-bridge-and-document-close.ts' ] as const /** Reads the TypeScript source that assembles the in-WebView document. */ diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index b55421bbe4d..40b7a2db22c 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -1,28 +1,34 @@ -import { TERMINAL_HTML_FRAGMENT_01 } from './terminal-webview-html/fragment-01' -import { TERMINAL_HTML_FRAGMENT_02 } from './terminal-webview-html/fragment-02' -import { TERMINAL_HTML_FRAGMENT_03 } from './terminal-webview-html/fragment-03' -import { TERMINAL_HTML_FRAGMENT_04 } from './terminal-webview-html/fragment-04' -import { TERMINAL_HTML_FRAGMENT_05 } from './terminal-webview-html/fragment-05' -import { TERMINAL_HTML_FRAGMENT_06 } from './terminal-webview-html/fragment-06' -import { TERMINAL_HTML_FRAGMENT_07 } from './terminal-webview-html/fragment-07' -import { TERMINAL_HTML_FRAGMENT_08 } from './terminal-webview-html/fragment-08' -import { TERMINAL_HTML_FRAGMENT_09 } from './terminal-webview-html/fragment-09' -import { TERMINAL_HTML_FRAGMENT_10 } from './terminal-webview-html/fragment-10' +import { TERMINAL_HTML_DOCUMENT_SHELL } from './terminal-webview-html/document-shell' +import { TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING } from './terminal-webview-html/runtime-state-and-text-scaling' +import { TERMINAL_HTML_FIT_SCALE_AND_WRITE_QUEUE } from './terminal-webview-html/fit-scale-and-write-queue' +import { TERMINAL_HTML_INIT_AND_WRITE } from './terminal-webview-html/terminal-init-and-write' +import { TERMINAL_HTML_HOST_MESSAGE_ROUTER } from './terminal-webview-html/host-message-router' +import { TERMINAL_HTML_SELECTION_STATE_AND_EVICTION } from './terminal-webview-html/selection-state-and-eviction' +import { TERMINAL_HTML_OBSERVERS_AND_MODE_MIRRORING } from './terminal-webview-html/term-observers-and-mode-mirroring' +import { TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING } from './terminal-webview-html/mouse-report-and-scroll-routing' +import { TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY } from './terminal-webview-html/smooth-scroll-and-cell-geometry' +import { TERMINAL_HTML_SELECTION_OVERLAY } from './terminal-webview-html/selection-overlay' +import { TERMINAL_HTML_SURFACE_TOUCH_GESTURES } from './terminal-webview-html/surface-touch-gestures' +import { TERMINAL_HTML_MESSAGE_BRIDGE_AND_DOCUMENT_CLOSE } from './terminal-webview-html/message-bridge-and-document-close' export { MOBILE_TERMINAL_CARET_OPTIONS } from './terminal-webview-html/theme' -// Why: keep the document source stable while each script/style concern remains independently reviewable. +// Why: keep the document source stable while each script/style concern remains independently +// reviewable. Boundaries can only fall where the emitted document allows, so a few modules +// carry a second concern noted at the top of the file. export const XTERM_HTML = [ - TERMINAL_HTML_FRAGMENT_01, - TERMINAL_HTML_FRAGMENT_02, - TERMINAL_HTML_FRAGMENT_03, - TERMINAL_HTML_FRAGMENT_04, - TERMINAL_HTML_FRAGMENT_05, - TERMINAL_HTML_FRAGMENT_06, - TERMINAL_HTML_FRAGMENT_07, - TERMINAL_HTML_FRAGMENT_08, - TERMINAL_HTML_FRAGMENT_09, - TERMINAL_HTML_FRAGMENT_10 + TERMINAL_HTML_DOCUMENT_SHELL, + TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING, + TERMINAL_HTML_FIT_SCALE_AND_WRITE_QUEUE, + TERMINAL_HTML_INIT_AND_WRITE, + TERMINAL_HTML_HOST_MESSAGE_ROUTER, + TERMINAL_HTML_SELECTION_STATE_AND_EVICTION, + TERMINAL_HTML_OBSERVERS_AND_MODE_MIRRORING, + TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING, + TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY, + TERMINAL_HTML_SELECTION_OVERLAY, + TERMINAL_HTML_SURFACE_TOUCH_GESTURES, + TERMINAL_HTML_MESSAGE_BRIDGE_AND_DOCUMENT_CLOSE ].join('') export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML } diff --git a/mobile/src/terminal/terminal-webview-html/fragment-01.ts b/mobile/src/terminal/terminal-webview-html/document-shell.ts similarity index 98% rename from mobile/src/terminal/terminal-webview-html/fragment-01.ts rename to mobile/src/terminal/terminal-webview-html/document-shell.ts index 77dba06232a..d6e733cdd77 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-01.ts +++ b/mobile/src/terminal/terminal-webview-html/document-shell.ts @@ -1,7 +1,7 @@ import { colors } from '../../theme/mobile-theme' import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from '../terminal-webview-engine.generated' -export const TERMINAL_HTML_FRAGMENT_01 = ` +export const TERMINAL_HTML_DOCUMENT_SHELL = ` diff --git a/mobile/src/terminal/terminal-webview-html/fragment-03.ts b/mobile/src/terminal/terminal-webview-html/fit-scale-and-write-queue.ts similarity index 98% rename from mobile/src/terminal/terminal-webview-html/fragment-03.ts rename to mobile/src/terminal/terminal-webview-html/fit-scale-and-write-queue.ts index 075ee819ec6..074185d43a5 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-03.ts +++ b/mobile/src/terminal/terminal-webview-html/fit-scale-and-write-queue.ts @@ -1,6 +1,7 @@ import { TERMINAL_WEBVIEW_THEME_JS } from '../terminal-webview-theme-injected' -export const TERMINAL_HTML_FRAGMENT_03 = `${TERMINAL_WEBVIEW_THEME_JS} +// Also carries the DECSET mouse-mode scanner: emitted-document order pins it between these two concerns. +export const TERMINAL_HTML_FIT_SCALE_AND_WRITE_QUEUE = `${TERMINAL_WEBVIEW_THEME_JS} function getCellHeight() { if (!term || !term._core) return 15; diff --git a/mobile/src/terminal/terminal-webview-html/fragment-10.ts b/mobile/src/terminal/terminal-webview-html/fragment-10.ts deleted file mode 100644 index d461f4a2039..00000000000 --- a/mobile/src/terminal/terminal-webview-html/fragment-10.ts +++ /dev/null @@ -1,132 +0,0 @@ -export const TERMINAL_HTML_FRAGMENT_10 = ` updateTransform(); - } - - var deltaY = ts.lastY - y; - ts.lastTime = now; - if (shouldRouteScrollToTerminalInput()) { - updateTouchVelocity(deltaY, dt); - resetSmoothScrollOffset(); - var effectiveCellH = getCellHeight() * getTotalScale(); - ts.accumDelta += deltaY; - var lines = Math.trunc(ts.accumDelta / effectiveCellH); - if (lines !== 0) { - ts.accumDelta -= lines * effectiveCellH; - routeScrollLines(lines, x, y); - } - } else { - if (enqueueNormalBufferScrollDelta(deltaY)) { - updateTouchVelocity(deltaY, dt); - } else { - ts.velY = 0; - } - } - ts.lastX = x; - ts.lastY = y; - } - }, { capture: true, passive: false }); - - targetSurface.addEventListener('touchend', function(e) { - if (dispatcherShouldBlockSurface()) return; - if (!term) return; - - if (ts.isPinching && e.touches.length < 2) { - ts.isPinching = false; - // Why: a finished pinch snaps to the nearest preset and becomes the new - // font size (reflowing the grid), so pinch-to-zoom IS the in-terminal way - // to set the text size. The CSS pinch zoom (userScale) is reset; the real - // size change reflows columns and RN persists + resizes the PTY to match. - var target = snapToTextScalePreset(currentTextScale * userScale); - var changed = target !== currentTextScale; - userScale = 1; - panX = 0; panY = 0; - applyTextScale(target); - updateTransform(); - notify({ type: 'font-scale-changed', fontScale: target }); - if (changed) notify({ type: 'haptic', kind: 'selection' }); - if (e.touches.length === 1) { - ts.lastX = e.touches[0].clientX; - ts.lastY = e.touches[0].clientY; - ts.lastTime = Date.now(); - ts.velY = 0; - ts.accumDelta = 0; - } - return; - } - - if (e.touches.length === 0) { - var vel = ts.velY; - var FRICTION = 0.972; - var MIN_VEL = 0.012; - function momentumStep() { - vel *= FRICTION; - if (Math.abs(vel) < MIN_VEL) { ts.momentumId = null; return; } - var delta = vel * 16; - if (shouldRouteScrollToTerminalInput()) { - resetSmoothScrollOffset(); - var effectiveCellH = getCellHeight() * getTotalScale(); - ts.accumDelta += delta; - var lines = Math.trunc(ts.accumDelta / effectiveCellH); - if (lines !== 0) { - ts.accumDelta -= lines * effectiveCellH; - routeScrollLines(lines, ts.lastX, ts.lastY); - } - } else { - if (!applyNormalBufferScrollDelta(delta)) { - ts.momentumId = null; - return; - } - } - ts.momentumId = requestAnimationFrame(momentumStep); - } - if (Math.abs(vel) > MIN_VEL) { - ts.momentumId = requestAnimationFrame(momentumStep); - } - } - }, { capture: true, passive: true }); - } - - attachSurfaceEventHandlers(surface); - - function handleIncomingMessage(e) { - var msg; - try { - msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; - } catch (ex) { - return; - } - try { - handleMsg(msg); - } catch(ex) { - reportEngineError( - msg && msg.type === 'init' ? 'terminal init failed' : 'terminal message failed', - ex, - msg && msg.type === 'init' && !everReady - ); - } - } - - window.addEventListener('message', handleIncomingMessage); - - document.addEventListener('message', handleIncomingMessage); - - window.addEventListener('resize', function() { - // Why: viewport changed (keyboard open/close, orientation, RN container - // size update). Re-fit so the scale matches the new vpWidth — without - // this, opening the keyboard leaves the terminal at the old scale even - // though there's now less vertical room and the fit ratio may differ. - applyFitScale('window-resize'); - adjustRowsForViewport(); - repositionOverlay(); - clampPan(); - updateTransform(); - }); - - if (window.Terminal) { - notify({ type: 'web-ready' }); - } else { - reportEngineError('terminal engine missing', 'xterm failed to load', true); - } -})(); - - -` diff --git a/mobile/src/terminal/terminal-webview-html/fragment-05.ts b/mobile/src/terminal/terminal-webview-html/host-message-router.ts similarity index 77% rename from mobile/src/terminal/terminal-webview-html/fragment-05.ts rename to mobile/src/terminal/terminal-webview-html/host-message-router.ts index c8852c50d78..29cba25df36 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-05.ts +++ b/mobile/src/terminal/terminal-webview-html/host-message-router.ts @@ -1,6 +1,6 @@ import { TERMINAL_REFLOW_JS } from '../terminal-webview-reflow-injected' -export const TERMINAL_HTML_FRAGMENT_05 = ` ${TERMINAL_REFLOW_JS} +export const TERMINAL_HTML_HOST_MESSAGE_ROUTER = ` ${TERMINAL_REFLOW_JS} function notify(msg) { if (window.ReactNativeWebView) { @@ -186,45 +186,4 @@ export const TERMINAL_HTML_FRAGMENT_05 = ` ${TERMINAL_REFLOW_JS} } } - // ============================================================ - // SELECTION MODE (long-press → handles → Copy) - // ============================================================ - var WORD_RE = /[\\p{L}\\p{N}_./:@~+=?&#%-]/u; - var LONG_PRESS_MS = 500; - var LONG_PRESS_SLOP = 10; - // Why: a tap that opens a link/path must survive small finger jitter. The - // long-press slop (10px) only cancels the press-to-select timer; reusing it - // to gate the tap dropped any URL/file tap that wandered >10px — at fit scale - // a few screen px of jitter is a normal tap. Use a wider, time-bounded tap - // window so deliberate scrolls/pans still don't fire a tap. - var TAP_SLOP = 24; - var TAP_MAX_MS = 700; - var EDGE_SCROLL_PX = 40; - var EDGE_SCROLL_INTERVAL = 60; - - var selectionOverlay = document.getElementById('selection-overlay'); - var handleStart = document.getElementById('sel-handle-start'); - var handleEnd = document.getElementById('sel-handle-end'); - var selMenu = document.getElementById('sel-menu'); - var btnCopy = document.getElementById('sel-menu-copy'); - var btnSelAll = document.getElementById('sel-menu-all'); - - // mode: 'navigate' | 'select' - var selMode = 'navigate'; - var sel = null; // { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' } - var longPressTimer = null; - var longPressOrigin = null; // {x,y, identifier} - // Why: tap detection is tracked separately from the long-press timer so a - // small jitter that cancels the press-to-select timer does not also cancel - // the tap (which opens links/paths). {x,y,t,identifier} or null once the - // gesture is disqualified as a tap (moved too far or held too long). - var tapCandidate = null; - var edgeScrollTimer = null; - var edgeScrollDir = 0; - var edgeScrollClientX = 0; - var edgeScrollClientY = 0; - - // Eviction watchdog: linesEverWritten counts onLineFeed since last init. - // Once buffer is full, every onLineFeed evicts the top row in xterm and - // we mirror that by decrementing stored absolute rows. ` diff --git a/mobile/src/terminal/terminal-webview-html/message-bridge-and-document-close.ts b/mobile/src/terminal/terminal-webview-html/message-bridge-and-document-close.ts new file mode 100644 index 00000000000..49cd3b4d84f --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/message-bridge-and-document-close.ts @@ -0,0 +1,43 @@ +export const TERMINAL_HTML_MESSAGE_BRIDGE_AND_DOCUMENT_CLOSE = ` function handleIncomingMessage(e) { + var msg; + try { + msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; + } catch (ex) { + return; + } + try { + handleMsg(msg); + } catch(ex) { + reportEngineError( + msg && msg.type === 'init' ? 'terminal init failed' : 'terminal message failed', + ex, + msg && msg.type === 'init' && !everReady + ); + } + } + + window.addEventListener('message', handleIncomingMessage); + + document.addEventListener('message', handleIncomingMessage); + + window.addEventListener('resize', function() { + // Why: viewport changed (keyboard open/close, orientation, RN container + // size update). Re-fit so the scale matches the new vpWidth — without + // this, opening the keyboard leaves the terminal at the old scale even + // though there's now less vertical room and the fit ratio may differ. + applyFitScale('window-resize'); + adjustRowsForViewport(); + repositionOverlay(); + clampPan(); + updateTransform(); + }); + + if (window.Terminal) { + notify({ type: 'web-ready' }); + } else { + reportEngineError('terminal engine missing', 'xterm failed to load', true); + } +})(); + + +` diff --git a/mobile/src/terminal/terminal-webview-html/fragment-06.ts b/mobile/src/terminal/terminal-webview-html/mouse-report-and-scroll-routing.ts similarity index 56% rename from mobile/src/terminal/terminal-webview-html/fragment-06.ts rename to mobile/src/terminal/terminal-webview-html/mouse-report-and-scroll-routing.ts index 1a03331df34..3b7e7f24cf3 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-06.ts +++ b/mobile/src/terminal/terminal-webview-html/mouse-report-and-scroll-routing.ts @@ -1,100 +1,6 @@ -import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from '../terminal-keyboard-avoidance-metrics-injected' import { TERMINAL_MOUSE_REPORT_CELL_JS } from '../terminal-webview-mouse-report-cell-injected' -export const TERMINAL_HTML_FRAGMENT_06 = ` var linesEverWritten = 0; - - function resetEvictionCounter() { linesEverWritten = 0; } - - function isBufferFull() { - if (!term) return false; - return linesEverWritten >= 5000 + (term.rows || 0); - } - - function checkEviction() { - if (selMode !== 'select' || !sel) return; - var oldest = Math.min(sel.anchor.row, sel.focus.row); - if (oldest < 0) { - notify({ type: 'selection-evicted' }); - cancelSelect(); - } - } - - function logFeedAndEvict() { - linesEverWritten++; - if (initialOscLinkEvictionReady && isBufferFull()) initialOscLinkRowOffset += 1; - if (selMode === 'select' && sel && isBufferFull()) { - sel.anchor.row -= 1; - sel.focus.row -= 1; - checkEviction(); - repositionOverlay(); - } - } - - function emitModesIfChanged() { - if (!term) return; - var bp = !!(term.modes && term.modes.bracketedPasteMode); - var alt = false; - var mouseTrackingMode = getMouseTrackingMode(); - try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {} - if ( - bp !== lastEmittedModes.bracketedPasteMode || - alt !== lastEmittedModes.altScreen || - mouseTrackingMode !== lastEmittedModes.mouseTrackingMode || - sgrMouseMode !== lastEmittedModes.sgrMouseMode || - sgrMousePixelsMode !== lastEmittedModes.sgrMousePixelsMode - ) { - lastEmittedModes = { - bracketedPasteMode: bp, - altScreen: alt, - mouseTrackingMode: mouseTrackingMode, - sgrMouseMode: sgrMouseMode, - sgrMousePixelsMode: sgrMousePixelsMode - }; - notify({ - type: 'modes', - bracketedPasteMode: bp, - altScreen: alt, - mouseTrackingMode: mouseTrackingMode, - sgrMouseMode: sgrMouseMode, - sgrMousePixelsMode: sgrMousePixelsMode - }); - } - } - var lastEmittedModes = { - bracketedPasteMode: false, - altScreen: false, - mouseTrackingMode: 'none', - sgrMouseMode: false, - sgrMousePixelsMode: false - }; - - ${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS} - - function attachTermObservers() { - if (!term) return; - disposeTermObservers(); - try { termObserverDisposables.push(term.onLineFeed(logFeedAndEvict)); } catch (e) {} - try { - termObserverDisposables.push(term.onScroll(function() { updateScrollIndicator(false); })); - } catch (e) {} - // Why: emit modes on every parsed write so RN's mirror stays current - // without round-trip; covers \\x1b[?2004h/l and alt-screen toggles. - try { - if (term.onWriteParsed) { - termObserverDisposables.push(term.onWriteParsed(function() { - emitModesIfChanged(); - emitKeyboardAvoidanceMetrics(); - })); - } - } catch (e) {} - // Initial emit once buffer settles. - afterWritesDrained(function() { - emitModesIfChanged(); - emitKeyboardAvoidanceMetrics(); - }); - } - - function viewportToCell(clientX, clientY) { +export const TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING = ` function viewportToCell(clientX, clientY) { if (!term) return null; var cellW = getCellWidth(); var cellH = getCellHeight(); @@ -201,4 +107,82 @@ export const TERMINAL_HTML_FRAGMENT_06 = ` var linesEverWritten = 0; } if (sgrMouseMode) { // Why: xterm increments zero-based mouse cells before encoding reports. + var sgrCol = cell.col + 1; + var sgrRow = cell.row + 1; + if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; + var sgrPress = ESC + '[<0;' + sgrCol + ';' + sgrRow + 'M'; + if (mouseTrackingMode === 'x10') return sgrPress; + return sgrPress + ESC + '[<0;' + sgrCol + ';' + sgrRow + 'm'; + } + // Why: non-SGR click coordinates use printable ASCII bytes on the mobile + // bridge; unsafe wide-terminal cells must not turn into corrupted input. + var col = cell.col + 1 + 32; + var row = cell.row + 1 + 32; + if (col > 126 || row > 126) return ''; + var press = ESC + '[M' + String.fromCharCode(32) + String.fromCharCode(col) + String.fromCharCode(row); + if (mouseTrackingMode === 'x10') return press; + return press + ESC + '[M' + String.fromCharCode(35) + String.fromCharCode(col) + String.fromCharCode(row); + } + + function isClickMouseTrackingMode(mode) { + return mode !== 'none'; + } + + function isWheelMouseTrackingMode(mode) { + return mode !== 'none' && mode !== 'x10'; + } + + function shouldRouteScrollToTerminalInput() { + return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive(); + } + + function buildMouseWheelScrollInput(lines, clientX, clientY) { + var count = Math.min(Math.abs(lines), 32); + if (count === 0) return ''; + var sequence = buildMouseWheelSequence(lines, clientX, clientY); + if (!sequence) return ''; + return repeatSequence(sequence, count); + } + + function buildTuiScrollInput(lines, clientX, clientY) { + var count = Math.min(Math.abs(lines), 32); + if (count === 0) return ''; + var mouseTrackingMode = getMouseTrackingMode(); + var sequence = ''; + if (isWheelMouseTrackingMode(mouseTrackingMode)) { + sequence = buildMouseWheelSequence(lines, clientX, clientY); + } + if (!sequence) sequence = buildArrowScrollSequence(lines); + return repeatSequence(sequence, count); + } + + function routeScrollLines(lines, clientX, clientY) { + if (!term || lines === 0) return; + var mouseTrackingMode = getMouseTrackingMode(); + var alternateBufferActive = isAlternateBufferActive(); + if (isWheelMouseTrackingMode(mouseTrackingMode)) { + // Why: xterm sends wheel events to mouse-aware TUIs before considering + // scrollback, even if the app stays on the normal buffer. + var mouseInput = buildMouseWheelScrollInput(lines, clientX, clientY); + if (mouseInput) { + notify({ type: 'terminal-input', bytes: mouseInput }); + return; + } + // Why: default mouse encoding can be unrepresentable in our ASCII-safe + // RPC path on wide terminals. Send bounded arrows instead of local + // scrollback/no-op while a mouse-aware app owns scroll gestures. + var fallbackInput = buildTuiScrollInput(lines, clientX, clientY); + if (fallbackInput) notify({ type: 'terminal-input', bytes: fallbackInput }); + return; + } + if (alternateBufferActive) { + // Why: alternate-screen TUIs own their scroll state and xterm has no + // scrollback there, so mobile scroll gestures must become terminal input. + var input = buildTuiScrollInput(lines, clientX, clientY); + if (input) notify({ type: 'terminal-input', bytes: input }); + return; + } + term.scrollLines(lines); + } + ` diff --git a/mobile/src/terminal/terminal-webview-html/fragment-02.ts b/mobile/src/terminal/terminal-webview-html/runtime-state-and-text-scaling.ts similarity index 97% rename from mobile/src/terminal/terminal-webview-html/fragment-02.ts rename to mobile/src/terminal/terminal-webview-html/runtime-state-and-text-scaling.ts index 7d25b0fb6b2..44ce1d39042 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-02.ts +++ b/mobile/src/terminal/terminal-webview-html/runtime-state-and-text-scaling.ts @@ -3,7 +3,8 @@ import { TERMINAL_SURFACE_SWAP_JS } from '../terminal-webview-surface-swap-injec import { TERMINAL_TEXT_SCALES } from '../../storage/preferences' import { DEFAULT_TERMINAL_THEME } from './theme' -export const TERMINAL_HTML_FRAGMENT_02 = ` var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096; +// Also carries the scroll-indicator painter, which reads the scale state declared here. +export const TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING = ` var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096; var term = null; ${TERMINAL_QUERY_REPLY_JS} ${TERMINAL_SURFACE_SWAP_JS} var scrollIndicator = document.getElementById('scroll-indicator'); diff --git a/mobile/src/terminal/terminal-webview-html/fragment-08.ts b/mobile/src/terminal/terminal-webview-html/selection-overlay.ts similarity index 97% rename from mobile/src/terminal/terminal-webview-html/fragment-08.ts rename to mobile/src/terminal/terminal-webview-html/selection-overlay.ts index 29f0d929918..335407af51a 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-08.ts +++ b/mobile/src/terminal/terminal-webview-html/selection-overlay.ts @@ -1,7 +1,8 @@ import { TERMINAL_PATH_TAP_JS } from '../terminal-path-tap-injected' import { URL_TAP_WEBVIEW_JS } from '../terminal-webview-url-tap' -export const TERMINAL_HTML_FRAGMENT_08 = ` ${TERMINAL_PATH_TAP_JS} +// Opens with the path/url tap matchers: they land at this point in the emitted document. +export const TERMINAL_HTML_SELECTION_OVERLAY = ` ${TERMINAL_PATH_TAP_JS} ${URL_TAP_WEBVIEW_JS} function seedWordSelection(col, absRow) { diff --git a/mobile/src/terminal/terminal-webview-html/selection-state-and-eviction.ts b/mobile/src/terminal/terminal-webview-html/selection-state-and-eviction.ts new file mode 100644 index 00000000000..48c05b6ad85 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/selection-state-and-eviction.ts @@ -0,0 +1,71 @@ +export const TERMINAL_HTML_SELECTION_STATE_AND_EVICTION = ` // ============================================================ + // SELECTION MODE (long-press → handles → Copy) + // ============================================================ + var WORD_RE = /[\\p{L}\\p{N}_./:@~+=?&#%-]/u; + var LONG_PRESS_MS = 500; + var LONG_PRESS_SLOP = 10; + // Why: a tap that opens a link/path must survive small finger jitter. The + // long-press slop (10px) only cancels the press-to-select timer; reusing it + // to gate the tap dropped any URL/file tap that wandered >10px — at fit scale + // a few screen px of jitter is a normal tap. Use a wider, time-bounded tap + // window so deliberate scrolls/pans still don't fire a tap. + var TAP_SLOP = 24; + var TAP_MAX_MS = 700; + var EDGE_SCROLL_PX = 40; + var EDGE_SCROLL_INTERVAL = 60; + + var selectionOverlay = document.getElementById('selection-overlay'); + var handleStart = document.getElementById('sel-handle-start'); + var handleEnd = document.getElementById('sel-handle-end'); + var selMenu = document.getElementById('sel-menu'); + var btnCopy = document.getElementById('sel-menu-copy'); + var btnSelAll = document.getElementById('sel-menu-all'); + + // mode: 'navigate' | 'select' + var selMode = 'navigate'; + var sel = null; // { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' } + var longPressTimer = null; + var longPressOrigin = null; // {x,y, identifier} + // Why: tap detection is tracked separately from the long-press timer so a + // small jitter that cancels the press-to-select timer does not also cancel + // the tap (which opens links/paths). {x,y,t,identifier} or null once the + // gesture is disqualified as a tap (moved too far or held too long). + var tapCandidate = null; + var edgeScrollTimer = null; + var edgeScrollDir = 0; + var edgeScrollClientX = 0; + var edgeScrollClientY = 0; + + // Eviction watchdog: linesEverWritten counts onLineFeed since last init. + // Once buffer is full, every onLineFeed evicts the top row in xterm and + // we mirror that by decrementing stored absolute rows. + var linesEverWritten = 0; + + function resetEvictionCounter() { linesEverWritten = 0; } + + function isBufferFull() { + if (!term) return false; + return linesEverWritten >= 5000 + (term.rows || 0); + } + + function checkEviction() { + if (selMode !== 'select' || !sel) return; + var oldest = Math.min(sel.anchor.row, sel.focus.row); + if (oldest < 0) { + notify({ type: 'selection-evicted' }); + cancelSelect(); + } + } + + function logFeedAndEvict() { + linesEverWritten++; + if (initialOscLinkEvictionReady && isBufferFull()) initialOscLinkRowOffset += 1; + if (selMode === 'select' && sel && isBufferFull()) { + sel.anchor.row -= 1; + sel.focus.row -= 1; + checkEviction(); + repositionOverlay(); + } + } + +` diff --git a/mobile/src/terminal/terminal-webview-html/fragment-07.ts b/mobile/src/terminal/terminal-webview-html/smooth-scroll-and-cell-geometry.ts similarity index 54% rename from mobile/src/terminal/terminal-webview-html/fragment-07.ts rename to mobile/src/terminal/terminal-webview-html/smooth-scroll-and-cell-geometry.ts index 22c3372b26f..de9db152fbf 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-07.ts +++ b/mobile/src/terminal/terminal-webview-html/smooth-scroll-and-cell-geometry.ts @@ -1,82 +1,4 @@ -export const TERMINAL_HTML_FRAGMENT_07 = ` var sgrCol = cell.col + 1; - var sgrRow = cell.row + 1; - if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; - var sgrPress = ESC + '[<0;' + sgrCol + ';' + sgrRow + 'M'; - if (mouseTrackingMode === 'x10') return sgrPress; - return sgrPress + ESC + '[<0;' + sgrCol + ';' + sgrRow + 'm'; - } - // Why: non-SGR click coordinates use printable ASCII bytes on the mobile - // bridge; unsafe wide-terminal cells must not turn into corrupted input. - var col = cell.col + 1 + 32; - var row = cell.row + 1 + 32; - if (col > 126 || row > 126) return ''; - var press = ESC + '[M' + String.fromCharCode(32) + String.fromCharCode(col) + String.fromCharCode(row); - if (mouseTrackingMode === 'x10') return press; - return press + ESC + '[M' + String.fromCharCode(35) + String.fromCharCode(col) + String.fromCharCode(row); - } - - function isClickMouseTrackingMode(mode) { - return mode !== 'none'; - } - - function isWheelMouseTrackingMode(mode) { - return mode !== 'none' && mode !== 'x10'; - } - - function shouldRouteScrollToTerminalInput() { - return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive(); - } - - function buildMouseWheelScrollInput(lines, clientX, clientY) { - var count = Math.min(Math.abs(lines), 32); - if (count === 0) return ''; - var sequence = buildMouseWheelSequence(lines, clientX, clientY); - if (!sequence) return ''; - return repeatSequence(sequence, count); - } - - function buildTuiScrollInput(lines, clientX, clientY) { - var count = Math.min(Math.abs(lines), 32); - if (count === 0) return ''; - var mouseTrackingMode = getMouseTrackingMode(); - var sequence = ''; - if (isWheelMouseTrackingMode(mouseTrackingMode)) { - sequence = buildMouseWheelSequence(lines, clientX, clientY); - } - if (!sequence) sequence = buildArrowScrollSequence(lines); - return repeatSequence(sequence, count); - } - - function routeScrollLines(lines, clientX, clientY) { - if (!term || lines === 0) return; - var mouseTrackingMode = getMouseTrackingMode(); - var alternateBufferActive = isAlternateBufferActive(); - if (isWheelMouseTrackingMode(mouseTrackingMode)) { - // Why: xterm sends wheel events to mouse-aware TUIs before considering - // scrollback, even if the app stays on the normal buffer. - var mouseInput = buildMouseWheelScrollInput(lines, clientX, clientY); - if (mouseInput) { - notify({ type: 'terminal-input', bytes: mouseInput }); - return; - } - // Why: default mouse encoding can be unrepresentable in our ASCII-safe - // RPC path on wide terminals. Send bounded arrows instead of local - // scrollback/no-op while a mouse-aware app owns scroll gestures. - var fallbackInput = buildTuiScrollInput(lines, clientX, clientY); - if (fallbackInput) notify({ type: 'terminal-input', bytes: fallbackInput }); - return; - } - if (alternateBufferActive) { - // Why: alternate-screen TUIs own their scroll state and xterm has no - // scrollback there, so mobile scroll gestures must become terminal input. - var input = buildTuiScrollInput(lines, clientX, clientY); - if (input) notify({ type: 'terminal-input', bytes: input }); - return; - } - term.scrollLines(lines); - } - - function clampNormalScrollLines(lines) { +export const TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY = ` function clampNormalScrollLines(lines) { if (!term || !term.buffer || !term.buffer.active || lines === 0) return 0; var buffer = term.buffer.active; if (lines > 0) { diff --git a/mobile/src/terminal/terminal-webview-html/fragment-09.ts b/mobile/src/terminal/terminal-webview-html/surface-touch-gestures.ts similarity index 62% rename from mobile/src/terminal/terminal-webview-html/fragment-09.ts rename to mobile/src/terminal/terminal-webview-html/surface-touch-gestures.ts index 06ffa927c65..515d7fd9d26 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-09.ts +++ b/mobile/src/terminal/terminal-webview-html/surface-touch-gestures.ts @@ -2,7 +2,8 @@ import { TERMINAL_TAP_DISPATCH_JS } from '../terminal-webview-tap-dispatch-injec import { TERMINAL_WHEEL_SCROLL_JS } from '../terminal-webview-wheel-scroll-injected' import { TERMINAL_MOUSE_CLICK_DRAG_JS } from '../terminal-webview-mouse-click-drag-injected' -export const TERMINAL_HTML_FRAGMENT_09 = ` ${TERMINAL_TAP_DISPATCH_JS} +// Also wires the selection menu's Copy/Select All buttons, which sit here in the emitted document. +export const TERMINAL_HTML_SURFACE_TOUCH_GESTURES = ` ${TERMINAL_TAP_DISPATCH_JS} // External mouse / trackpad scroll: see // terminal-webview-wheel-scroll-injected.ts (extracted for max-lines). @@ -135,4 +136,93 @@ export const TERMINAL_HTML_FRAGMENT_09 = ` ${TERMINAL_TAP_DISPATCH_JS} if (term.element && term.element.scrollWidth * getTotalScale() > window.innerWidth + 1) { panX += x - ts.lastX; clampPan(); + updateTransform(); + } + + var deltaY = ts.lastY - y; + ts.lastTime = now; + if (shouldRouteScrollToTerminalInput()) { + updateTouchVelocity(deltaY, dt); + resetSmoothScrollOffset(); + var effectiveCellH = getCellHeight() * getTotalScale(); + ts.accumDelta += deltaY; + var lines = Math.trunc(ts.accumDelta / effectiveCellH); + if (lines !== 0) { + ts.accumDelta -= lines * effectiveCellH; + routeScrollLines(lines, x, y); + } + } else { + if (enqueueNormalBufferScrollDelta(deltaY)) { + updateTouchVelocity(deltaY, dt); + } else { + ts.velY = 0; + } + } + ts.lastX = x; + ts.lastY = y; + } + }, { capture: true, passive: false }); + + targetSurface.addEventListener('touchend', function(e) { + if (dispatcherShouldBlockSurface()) return; + if (!term) return; + + if (ts.isPinching && e.touches.length < 2) { + ts.isPinching = false; + // Why: a finished pinch snaps to the nearest preset and becomes the new + // font size (reflowing the grid), so pinch-to-zoom IS the in-terminal way + // to set the text size. The CSS pinch zoom (userScale) is reset; the real + // size change reflows columns and RN persists + resizes the PTY to match. + var target = snapToTextScalePreset(currentTextScale * userScale); + var changed = target !== currentTextScale; + userScale = 1; + panX = 0; panY = 0; + applyTextScale(target); + updateTransform(); + notify({ type: 'font-scale-changed', fontScale: target }); + if (changed) notify({ type: 'haptic', kind: 'selection' }); + if (e.touches.length === 1) { + ts.lastX = e.touches[0].clientX; + ts.lastY = e.touches[0].clientY; + ts.lastTime = Date.now(); + ts.velY = 0; + ts.accumDelta = 0; + } + return; + } + + if (e.touches.length === 0) { + var vel = ts.velY; + var FRICTION = 0.972; + var MIN_VEL = 0.012; + function momentumStep() { + vel *= FRICTION; + if (Math.abs(vel) < MIN_VEL) { ts.momentumId = null; return; } + var delta = vel * 16; + if (shouldRouteScrollToTerminalInput()) { + resetSmoothScrollOffset(); + var effectiveCellH = getCellHeight() * getTotalScale(); + ts.accumDelta += delta; + var lines = Math.trunc(ts.accumDelta / effectiveCellH); + if (lines !== 0) { + ts.accumDelta -= lines * effectiveCellH; + routeScrollLines(lines, ts.lastX, ts.lastY); + } + } else { + if (!applyNormalBufferScrollDelta(delta)) { + ts.momentumId = null; + return; + } + } + ts.momentumId = requestAnimationFrame(momentumStep); + } + if (Math.abs(vel) > MIN_VEL) { + ts.momentumId = requestAnimationFrame(momentumStep); + } + } + }, { capture: true, passive: true }); + } + + attachSurfaceEventHandlers(surface); + ` diff --git a/mobile/src/terminal/terminal-webview-html/term-observers-and-mode-mirroring.ts b/mobile/src/terminal/terminal-webview-html/term-observers-and-mode-mirroring.ts new file mode 100644 index 00000000000..b69547affb5 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/term-observers-and-mode-mirroring.ts @@ -0,0 +1,67 @@ +import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from '../terminal-keyboard-avoidance-metrics-injected' + +export const TERMINAL_HTML_OBSERVERS_AND_MODE_MIRRORING = ` function emitModesIfChanged() { + if (!term) return; + var bp = !!(term.modes && term.modes.bracketedPasteMode); + var alt = false; + var mouseTrackingMode = getMouseTrackingMode(); + try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {} + if ( + bp !== lastEmittedModes.bracketedPasteMode || + alt !== lastEmittedModes.altScreen || + mouseTrackingMode !== lastEmittedModes.mouseTrackingMode || + sgrMouseMode !== lastEmittedModes.sgrMouseMode || + sgrMousePixelsMode !== lastEmittedModes.sgrMousePixelsMode + ) { + lastEmittedModes = { + bracketedPasteMode: bp, + altScreen: alt, + mouseTrackingMode: mouseTrackingMode, + sgrMouseMode: sgrMouseMode, + sgrMousePixelsMode: sgrMousePixelsMode + }; + notify({ + type: 'modes', + bracketedPasteMode: bp, + altScreen: alt, + mouseTrackingMode: mouseTrackingMode, + sgrMouseMode: sgrMouseMode, + sgrMousePixelsMode: sgrMousePixelsMode + }); + } + } + var lastEmittedModes = { + bracketedPasteMode: false, + altScreen: false, + mouseTrackingMode: 'none', + sgrMouseMode: false, + sgrMousePixelsMode: false + }; + + ${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS} + + function attachTermObservers() { + if (!term) return; + disposeTermObservers(); + try { termObserverDisposables.push(term.onLineFeed(logFeedAndEvict)); } catch (e) {} + try { + termObserverDisposables.push(term.onScroll(function() { updateScrollIndicator(false); })); + } catch (e) {} + // Why: emit modes on every parsed write so RN's mirror stays current + // without round-trip; covers \\x1b[?2004h/l and alt-screen toggles. + try { + if (term.onWriteParsed) { + termObserverDisposables.push(term.onWriteParsed(function() { + emitModesIfChanged(); + emitKeyboardAvoidanceMetrics(); + })); + } + } catch (e) {} + // Initial emit once buffer settles. + afterWritesDrained(function() { + emitModesIfChanged(); + emitKeyboardAvoidanceMetrics(); + }); + } + +` diff --git a/mobile/src/terminal/terminal-webview-html/fragment-04.ts b/mobile/src/terminal/terminal-webview-html/terminal-init-and-write.ts similarity index 98% rename from mobile/src/terminal/terminal-webview-html/fragment-04.ts rename to mobile/src/terminal/terminal-webview-html/terminal-init-and-write.ts index c2c9863be79..90dba7cbdbc 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-04.ts +++ b/mobile/src/terminal/terminal-webview-html/terminal-init-and-write.ts @@ -1,7 +1,7 @@ import { TERMINAL_WEBGL_RECOVERY_JS } from '../terminal-webview-webgl-recovery-injected' import { MOBILE_TERMINAL_CARET_OPTIONS } from './theme' -export const TERMINAL_HTML_FRAGMENT_04 = `${TERMINAL_WEBGL_RECOVERY_JS} +export const TERMINAL_HTML_INIT_AND_WRITE = `${TERMINAL_WEBGL_RECOVERY_JS} function init(cols, rows, initialData, nextTheme, nextFontScale, preserveScroll, nextOscLinks) { if (typeof nextFontScale === 'number' && nextFontScale > 0) currentTextScale = nextFontScale; diff --git a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts index 962cfddaa4d..9218e5d6ad9 100644 --- a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts +++ b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' +import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support' // The in-WebView JS lives in terminal-webview-html.ts; the RN wrapper in // TerminalWebView.tsx. Concatenate both so assertions resolve regardless of file. @@ -8,16 +9,7 @@ const source = readFileSync(new URL('./terminal-webview-pending-messages.ts', import.meta.url), 'utf8') + readFileSync(new URL('./terminal-webview-url-tap.ts', import.meta.url), 'utf8') + readFileSync(new URL('./terminal-webview-tap-dispatch-injected.ts', import.meta.url), 'utf8') + - readFileSync(new URL('./terminal-webview-html.ts', import.meta.url), 'utf8') + - Array.from({ length: 10 }, (_, index) => - readFileSync( - new URL( - `./terminal-webview-html/fragment-${String(index + 1).padStart(2, '0')}.ts`, - import.meta.url - ), - 'utf8' - ) - ).join('') + readTerminalWebViewHtmlSource() const sessionSource = readFileSync( new URL('../session/use-mobile-session-terminal-input.ts', import.meta.url), 'utf8' diff --git a/mobile/src/terminal/terminal-webview-text-zoom.test.ts b/mobile/src/terminal/terminal-webview-text-zoom.test.ts index 0deee79f670..d775237ddcc 100644 --- a/mobile/src/terminal/terminal-webview-text-zoom.test.ts +++ b/mobile/src/terminal/terminal-webview-text-zoom.test.ts @@ -11,8 +11,8 @@ const terminalHtmlModuleSource = readFileSync( new URL('./terminal-webview-html.ts', import.meta.url), 'utf8' ) -const terminalHtmlFragmentSource = readFileSync( - new URL('./terminal-webview-html/fragment-01.ts', import.meta.url), +const terminalHtmlDocumentShellSource = readFileSync( + new URL('./terminal-webview-html/document-shell.ts', import.meta.url), 'utf8' ) // Read behavior from the assembled document; the module source only contains @@ -150,7 +150,7 @@ describe('TerminalWebView text zoom', () => { }) it('loads Unicode 11 before replaying mobile terminal bytes', () => { - expect(terminalHtmlFragmentSource).toContain('XTERM_ENGINE_JS') + expect(terminalHtmlDocumentShellSource).toContain('XTERM_ENGINE_JS') expect(terminalHtmlSource).toContain('window.Unicode11Addon.Unicode11Addon') const open = terminalHtmlSource.indexOf('term.open(surface)') const unicode = terminalHtmlSource.indexOf("term.unicode.activeVersion = '11'") From 8cc763405101cbdb0cade835d4cb5d01733ac387 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 03:30:59 -0700 Subject: [PATCH 6/9] refactor: name modules for their domain instead of 'helpers' Renames seven -helpers modules for the concept their functions operate on, and splits three that were genuine grab-bags -- each had a clean cleavage along its importers, which is the signal AGENTS.md describes for a file holding more than one responsibility. Leaves keybindings/definitions-core-1..4 alone: definitions.ts spreads them in order, so their concatenation order is the command palette order and regrouping them thematically would be a user-visible change. Records that reasoning in a comment so it is not re-litigated. --- src/main/ipc/filesystem-path-containment.ts | 8 + .../filesystem-download-handlers.ts | 10 +- .../filesystem-download-promotion.ts | 79 +++++++ .../filesystem-file-content-inspection.ts | 85 +++++++ .../ipc/filesystem/filesystem-file-helpers.ts | 173 --------------- ...lesystem-git-commit-generation-handlers.ts | 2 +- .../filesystem-git-diff-handlers.ts | 6 +- ...filesystem-git-model-discovery-handlers.ts | 2 +- ...em-git-pull-request-generation-handlers.ts | 2 +- .../filesystem/filesystem-git-url-handlers.ts | 2 +- .../filesystem/filesystem-handler-context.ts | 2 +- .../filesystem/filesystem-read-handlers.ts | 2 +- .../filesystem/filesystem-search-handlers.ts | 4 +- ...> filesystem-source-control-ai-targets.ts} | 8 - .../filesystem/git-remote/compare-handlers.ts | 2 +- .../sync-runtime-graph/graph-publication.ts | 2 +- .../mobile-session-browser-tabs.ts | 2 +- .../mobile-session-editor-tabs.ts | 2 +- .../mobile-session-group-projection.ts | 2 +- .../mobile-session-inputs.ts | 2 +- .../mobile-session-snapshots.ts | 2 +- ...-helpers.ts => mobile-session-surfaces.ts} | 0 .../mobile-session-terminal-tabs.ts | 2 +- .../apply-active-state.ts | 2 +- .../apply-preparation-browser.ts | 4 +- .../web-session-tabs-sync/layout-groups.ts | 2 +- ...er-helpers.ts => mirrored-browser-tabs.ts} | 2 +- ...ut-helpers.ts => tab-group-layout-tree.ts} | 0 .../slices/agent-status-authority-actions.ts | 4 +- .../slices/agent-status-capacity-eviction.ts | 99 +++++++++ .../slices/agent-status-cleanup-actions.ts | 6 +- .../agent-status-connection-worktree-scope.ts | 41 ++++ .../store/slices/agent-status-drop-actions.ts | 4 +- .../store/slices/agent-status-drop-reducer.ts | 8 +- .../slices/agent-status-launch-actions.ts | 6 +- .../slices/agent-status-launch-config.ts | 25 ++- .../store/slices/agent-status-live-actions.ts | 2 +- .../slices/agent-status-live-entry-builder.ts | 4 +- .../store/slices/agent-status-live-facts.ts | 9 +- .../store/slices/agent-status-live-reducer.ts | 7 +- .../store/slices/agent-status-map-helpers.ts | 210 ------------------ ...nt-status-migration-unsupported-entries.ts | 17 ++ .../agent-status-orchestration-actions.ts | 2 +- .../agent-status-orchestration-context.ts | 49 ++++ ...s => agent-status-pane-key-tab-binding.ts} | 96 +------- .../slices/agent-status-pane-keyed-records.ts | 76 +++++++ .../agent-status-provider-session-actions.ts | 6 +- .../slices/agent-status-recovery-actions.ts | 4 +- .../agent-status-recovery-collection.ts | 4 +- .../slices/agent-status-retention-actions.ts | 4 +- ...rs.ts => agent-status-sleeping-records.ts} | 2 +- .../agent-status-worktree-drop-actions.ts | 4 +- src/renderer/src/store/slices/agent-status.ts | 6 +- .../store/slices/ui/ui-slice-agent-actions.ts | 10 +- ...ice-agent-notification-acknowledgement.ts} | 7 - ...> ui-slice-contextual-tour-progression.ts} | 0 .../slices/ui/ui-slice-feature-actions.ts | 2 +- src/shared/keybindings/definitions-core-1.ts | 2 + 58 files changed, 566 insertions(+), 561 deletions(-) create mode 100644 src/main/ipc/filesystem/filesystem-download-promotion.ts create mode 100644 src/main/ipc/filesystem/filesystem-file-content-inspection.ts delete mode 100644 src/main/ipc/filesystem/filesystem-file-helpers.ts rename src/main/ipc/filesystem/{filesystem-worktree-helpers.ts => filesystem-source-control-ai-targets.ts} (95%) rename src/renderer/src/runtime/sync-runtime-graph/{mobile-session-tab-helpers.ts => mobile-session-surfaces.ts} (100%) rename src/renderer/src/runtime/web-session-tabs-sync/{browser-helpers.ts => mirrored-browser-tabs.ts} (99%) rename src/renderer/src/runtime/web-session-tabs-sync/{layout-helpers.ts => tab-group-layout-tree.ts} (100%) create mode 100644 src/renderer/src/store/slices/agent-status-capacity-eviction.ts create mode 100644 src/renderer/src/store/slices/agent-status-connection-worktree-scope.ts delete mode 100644 src/renderer/src/store/slices/agent-status-map-helpers.ts create mode 100644 src/renderer/src/store/slices/agent-status-migration-unsupported-entries.ts create mode 100644 src/renderer/src/store/slices/agent-status-orchestration-context.ts rename src/renderer/src/store/slices/{agent-status-pane-helpers.ts => agent-status-pane-key-tab-binding.ts} (62%) create mode 100644 src/renderer/src/store/slices/agent-status-pane-keyed-records.ts rename src/renderer/src/store/slices/{agent-status-recovery-helpers.ts => agent-status-sleeping-records.ts} (98%) rename src/renderer/src/store/slices/ui/{ui-slice-agent-helpers.ts => ui-slice-agent-notification-acknowledgement.ts} (88%) rename src/renderer/src/store/slices/ui/{ui-slice-tour-helpers.ts => ui-slice-contextual-tour-progression.ts} (100%) diff --git a/src/main/ipc/filesystem-path-containment.ts b/src/main/ipc/filesystem-path-containment.ts index b236c90a94e..32d1c00f0e1 100644 --- a/src/main/ipc/filesystem-path-containment.ts +++ b/src/main/ipc/filesystem-path-containment.ts @@ -71,3 +71,11 @@ export function validateGitRelativeFilePath(worktreePath: string, filePath: stri return normalizedRelativePath } + +export function validateFullGitObjectId(value: string, label: string): string { + const pattern = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ + if (!pattern.test(value)) { + throw new Error(`${label} must be a full git object id`) + } + return value +} diff --git a/src/main/ipc/filesystem/filesystem-download-handlers.ts b/src/main/ipc/filesystem/filesystem-download-handlers.ts index 51a90c36213..6b73df909da 100644 --- a/src/main/ipc/filesystem/filesystem-download-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-download-handlers.ts @@ -12,10 +12,16 @@ import { DOWNLOAD_SESSION_TTL_MS, inspectDownloadDestination, promoteDownloadedFile, - validateRequiredString, createSiblingTransferPath, type DownloadFileResult -} from './filesystem-file-helpers' +} from './filesystem-download-promotion' + +function validateRequiredString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`${label} is required`) + } + return value +} export function registerFilesystemDownloadHandlers(context: FilesystemHandlerContext): void { const { downloadSessions, closeDownloadSession, cleanupDownloadSessionsForSender } = context diff --git a/src/main/ipc/filesystem/filesystem-download-promotion.ts b/src/main/ipc/filesystem/filesystem-download-promotion.ts new file mode 100644 index 00000000000..9fbc8acddd2 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-download-promotion.ts @@ -0,0 +1,79 @@ +import { randomUUID } from 'node:crypto' +import { rename, rm, stat } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { isENOENT } from '../filesystem-path-containment' + +export function decodeDownloadedFileContent(content: string, encoding: 'utf8' | 'base64'): Buffer { + return encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf8') +} + +export function createSiblingTransferPath(destinationPath: string, suffix: string): string { + // Why: promotion renames must stay on the destination volume, so transfer paths remain siblings. + return join(dirname(destinationPath), `.${randomUUID()}.${suffix}`) +} + +export async function cleanupLocalTransferPath(filePath: string | null): Promise { + if (!filePath) { + return + } + await rm(filePath, { force: true }).catch(() => {}) +} + +export async function inspectDownloadDestination( + destinationPath: string +): Promise<{ existed: boolean }> { + try { + const destinationStat = await stat(destinationPath) + if (destinationStat.isDirectory()) { + throw new Error('Cannot download to a directory') + } + return { existed: true } + } catch (error) { + if (isENOENT(error)) { + return { existed: false } + } + throw error + } +} + +export async function assertDestinationStillUnclaimed(destinationPath: string): Promise { + try { + await stat(destinationPath) + } catch (error) { + if (isENOENT(error)) { + return + } + throw error + } + throw new Error('Destination file appeared before download completed') +} + +export async function promoteDownloadedFile( + tempPath: string, + destinationPath: string, + destinationExisted: boolean +): Promise { + if (!destinationExisted) { + await assertDestinationStillUnclaimed(destinationPath) + await rename(tempPath, destinationPath) + return + } + + const backupPath = createSiblingTransferPath(destinationPath, 'backup') + let backupCreated = false + try { + await rename(destinationPath, backupPath) + backupCreated = true + await rename(tempPath, destinationPath) + await cleanupLocalTransferPath(backupPath) + } catch (error) { + if (backupCreated) { + await rename(backupPath, destinationPath).catch(() => {}) + } + throw error + } +} + +export type DownloadFileResult = { canceled: true } | { canceled: false; destinationPath: string } + +export const DOWNLOAD_SESSION_TTL_MS = 30 * 60 * 1000 diff --git a/src/main/ipc/filesystem/filesystem-file-content-inspection.ts b/src/main/ipc/filesystem/filesystem-file-content-inspection.ts new file mode 100644 index 00000000000..b140532c372 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-file-content-inspection.ts @@ -0,0 +1,85 @@ +import { open } from 'node:fs/promises' +import type { FileHandle } from 'node:fs/promises' +import { localLogFileIdentity } from '../../ai-vault/local-log-tail-reader' + +// Why: Monaco degrades features on large files like VS Code, so a 5MB block would needlessly lock out ordinary JSON/log files. +export const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB +export const BINARY_PROBE_BYTES = 8192 +// Why: previewable binaries are base64 blobs (not parsed as text), and local IPC has no frame limit (unlike the relay's 10MB), so 50MB is safe. +export const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 // 50MB +export const PREVIEWABLE_BINARY_MIME_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon', + '.pdf': 'application/pdf' +} + +export async function readLocalLogSnapshot(filePath: string): Promise<{ + content: string + isBinary: boolean + fileIdentity?: string +}> { + const handle = await open(filePath, 'r') + try { + const stats = await handle.stat() + if (stats.size > MAX_TEXT_FILE_SIZE) { + throw new Error( + `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` + ) + } + const buffer = await handle.readFile() + if (buffer.byteLength > MAX_TEXT_FILE_SIZE) { + throw new Error( + `File too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` + ) + } + if (isBinaryBuffer(buffer)) { + return { content: '', isBinary: true } + } + return { + content: buffer.toString('utf8'), + isBinary: false, + fileIdentity: localLogFileIdentity(stats) + } + } finally { + await handle.close() + } +} + +/** Check if a buffer appears to be binary (contains null bytes in first 8KB). */ +export function isBinaryBuffer(buffer: Buffer): boolean { + const len = Math.min(buffer.length, BINARY_PROBE_BYTES) + for (let i = 0; i < len; i++) { + if (buffer[i] === 0) { + return true + } + } + return false +} + +export async function isBinaryFilePrefix(filePath: string): Promise { + const handle: FileHandle = await open(filePath, 'r') + try { + const probe = Buffer.alloc(BINARY_PROBE_BYTES) + const { bytesRead } = await handle.read(probe, 0, probe.length, 0) + return isBinaryBuffer(probe.subarray(0, bytesRead)) + } finally { + await handle.close() + } +} + +export function isDirectoryEntry(entry: { + isDirectory(): boolean + isSymbolicLink(): boolean +}): boolean { + // Why: following a symlink in readDir can touch macOS TCC-protected containers; treat links as file-like until explicitly opened. + if (entry.isSymbolicLink()) { + return false + } + return entry.isDirectory() +} diff --git a/src/main/ipc/filesystem/filesystem-file-helpers.ts b/src/main/ipc/filesystem/filesystem-file-helpers.ts deleted file mode 100644 index 53e2eb3ec30..00000000000 --- a/src/main/ipc/filesystem/filesystem-file-helpers.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { randomUUID } from 'node:crypto' -import { open, rename, rm, stat } from 'node:fs/promises' -import type { FileHandle } from 'node:fs/promises' -import { dirname, join } from 'node:path' -import { localLogFileIdentity } from '../../ai-vault/local-log-tail-reader' -import { isENOENT } from '../filesystem-path-containment' - -// Why: Monaco degrades features on large files like VS Code, so a 5MB block would needlessly lock out ordinary JSON/log files. -export const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB -export const BINARY_PROBE_BYTES = 8192 -export const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ -// 32 visible matches plus one truncation sentinel stays below the legacy frame ceiling. -export const QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT = 33 -// Why: previewable binaries are base64 blobs (not parsed as text), and local IPC has no frame limit (unlike the relay's 10MB), so 50MB is safe. -export const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 // 50MB -export const PREVIEWABLE_BINARY_MIME_TYPES: Record = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.svg': 'image/svg+xml', - '.webp': 'image/webp', - '.bmp': 'image/bmp', - '.ico': 'image/x-icon', - '.pdf': 'application/pdf' -} - -export async function readLocalLogSnapshot(filePath: string): Promise<{ - content: string - isBinary: boolean - fileIdentity?: string -}> { - const handle = await open(filePath, 'r') - try { - const stats = await handle.stat() - if (stats.size > MAX_TEXT_FILE_SIZE) { - throw new Error( - `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` - ) - } - const buffer = await handle.readFile() - if (buffer.byteLength > MAX_TEXT_FILE_SIZE) { - throw new Error( - `File too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` - ) - } - if (isBinaryBuffer(buffer)) { - return { content: '', isBinary: true } - } - return { - content: buffer.toString('utf8'), - isBinary: false, - fileIdentity: localLogFileIdentity(stats) - } - } finally { - await handle.close() - } -} - -export function validateRequiredString(value: unknown, label: string): string { - if (typeof value !== 'string' || value.trim() === '') { - throw new Error(`${label} is required`) - } - return value -} - -export function decodeDownloadedFileContent(content: string, encoding: 'utf8' | 'base64'): Buffer { - return encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf8') -} - -export function createSiblingTransferPath(destinationPath: string, suffix: string): string { - // Why: promotion renames must stay on the destination volume, so transfer paths remain siblings. - return join(dirname(destinationPath), `.${randomUUID()}.${suffix}`) -} - -export async function cleanupLocalTransferPath(filePath: string | null): Promise { - if (!filePath) { - return - } - await rm(filePath, { force: true }).catch(() => {}) -} - -export async function inspectDownloadDestination( - destinationPath: string -): Promise<{ existed: boolean }> { - try { - const destinationStat = await stat(destinationPath) - if (destinationStat.isDirectory()) { - throw new Error('Cannot download to a directory') - } - return { existed: true } - } catch (error) { - if (isENOENT(error)) { - return { existed: false } - } - throw error - } -} - -export async function assertDestinationStillUnclaimed(destinationPath: string): Promise { - try { - await stat(destinationPath) - } catch (error) { - if (isENOENT(error)) { - return - } - throw error - } - throw new Error('Destination file appeared before download completed') -} - -export async function promoteDownloadedFile( - tempPath: string, - destinationPath: string, - destinationExisted: boolean -): Promise { - if (!destinationExisted) { - await assertDestinationStillUnclaimed(destinationPath) - await rename(tempPath, destinationPath) - return - } - - const backupPath = createSiblingTransferPath(destinationPath, 'backup') - let backupCreated = false - try { - await rename(destinationPath, backupPath) - backupCreated = true - await rename(tempPath, destinationPath) - await cleanupLocalTransferPath(backupPath) - } catch (error) { - if (backupCreated) { - await rename(backupPath, destinationPath).catch(() => {}) - } - throw error - } -} - -/** Check if a buffer appears to be binary (contains null bytes in first 8KB). */ -export function isBinaryBuffer(buffer: Buffer): boolean { - const len = Math.min(buffer.length, BINARY_PROBE_BYTES) - for (let i = 0; i < len; i++) { - if (buffer[i] === 0) { - return true - } - } - return false -} - -export async function isBinaryFilePrefix(filePath: string): Promise { - const handle: FileHandle = await open(filePath, 'r') - try { - const probe = Buffer.alloc(BINARY_PROBE_BYTES) - const { bytesRead } = await handle.read(probe, 0, probe.length, 0) - return isBinaryBuffer(probe.subarray(0, bytesRead)) - } finally { - await handle.close() - } -} - -export function isDirectoryEntry(entry: { - isDirectory(): boolean - isSymbolicLink(): boolean -}): boolean { - // Why: following a symlink in readDir can touch macOS TCC-protected containers; treat links as file-like until explicitly opened. - if (entry.isSymbolicLink()) { - return false - } - return entry.isDirectory() -} - -export type DownloadFileResult = { canceled: true } | { canceled: false; destinationPath: string } - -export const DOWNLOAD_SESSION_TTL_MS = 30 * 60 * 1000 diff --git a/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts b/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts index 4e55a39f76a..ae029dc2f41 100644 --- a/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts @@ -23,7 +23,7 @@ import { getLocalAgentRuntimeTarget, getLocalTextGenerationTarget, getRepoForSourceControlAi -} from './filesystem-worktree-helpers' +} from './filesystem-source-control-ai-targets' export function registerFilesystemGitCommitGenerationHandlers( context: FilesystemHandlerContext diff --git a/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts b/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts index ff706fcb078..8434e02264b 100644 --- a/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts @@ -7,8 +7,10 @@ import { } from '../../providers/ssh-git-dispatch' import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' -import { validateGitRelativeFilePath } from '../filesystem-path-containment' -import { validateFullGitObjectId } from './filesystem-worktree-helpers' +import { + validateFullGitObjectId, + validateGitRelativeFilePath +} from '../filesystem-path-containment' import type { FilesystemHandlerContext } from './filesystem-handler-context' export function registerFilesystemGitDiffHandlers(context: FilesystemHandlerContext): void { diff --git a/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts b/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts index 0933c08587e..8de07853057 100644 --- a/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts @@ -12,7 +12,7 @@ import { getSshGitProvider } from '../../providers/ssh-git-dispatch' import { resolveModelDiscoveryLocalPath, getLocalAgentRuntimeTarget -} from './filesystem-worktree-helpers' +} from './filesystem-source-control-ai-targets' import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' import type { FilesystemHandlerContext } from './filesystem-handler-context' diff --git a/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts b/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts index b779fb44429..1c16a119b3b 100644 --- a/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts @@ -27,7 +27,7 @@ import { getLocalAgentRuntimeTarget, getLocalTextGenerationTarget, getRepoForSourceControlAi -} from './filesystem-worktree-helpers' +} from './filesystem-source-control-ai-targets' export function registerFilesystemGitPullRequestGenerationHandlers( context: FilesystemHandlerContext diff --git a/src/main/ipc/filesystem/filesystem-git-url-handlers.ts b/src/main/ipc/filesystem/filesystem-git-url-handlers.ts index cb14d98db60..4ed9b55057e 100644 --- a/src/main/ipc/filesystem/filesystem-git-url-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-url-handlers.ts @@ -6,7 +6,7 @@ import { SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from '../../providers/ssh-git-dispatch' import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' -import { validateFullGitObjectId } from './filesystem-worktree-helpers' +import { validateFullGitObjectId } from '../filesystem-path-containment' import type { FilesystemHandlerContext } from './filesystem-handler-context' export function registerFilesystemGitUrlHandlers(context: FilesystemHandlerContext): void { diff --git a/src/main/ipc/filesystem/filesystem-handler-context.ts b/src/main/ipc/filesystem/filesystem-handler-context.ts index 3aeb8320cc3..02488931e1e 100644 --- a/src/main/ipc/filesystem/filesystem-handler-context.ts +++ b/src/main/ipc/filesystem/filesystem-handler-context.ts @@ -3,7 +3,7 @@ import type { FileHandle } from 'node:fs/promises' import type { Store } from '../../persistence' import type { CommitMessageAgentEnvironmentResolvers } from '../../text-generation/commit-message-agent-environment' import type { SenderScopedRequestCancellations } from '../sender-scoped-request-cancellation' -import { cleanupLocalTransferPath } from './filesystem-file-helpers' +import { cleanupLocalTransferPath } from './filesystem-download-promotion' export type DownloadSession = { destinationPath: string diff --git a/src/main/ipc/filesystem/filesystem-read-handlers.ts b/src/main/ipc/filesystem/filesystem-read-handlers.ts index d1a86f1f748..938370a2816 100644 --- a/src/main/ipc/filesystem/filesystem-read-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-read-handlers.ts @@ -20,7 +20,7 @@ import { MAX_TEXT_FILE_SIZE, PREVIEWABLE_BINARY_MIME_TYPES, readLocalLogSnapshot -} from './filesystem-file-helpers' +} from './filesystem-file-content-inspection' export function registerFilesystemReadHandlers(context: FilesystemHandlerContext): void { const { store } = context diff --git a/src/main/ipc/filesystem/filesystem-search-handlers.ts b/src/main/ipc/filesystem/filesystem-search-handlers.ts index c2ec1531ead..f6dd8d57284 100644 --- a/src/main/ipc/filesystem/filesystem-search-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-search-handlers.ts @@ -27,7 +27,9 @@ import { searchWithGitGrep } from '../filesystem-search-git' import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' import { QuickOpenPathRanker } from '../../../shared/quick-open-path-search' import type { FilesystemHandlerContext } from './filesystem-handler-context' -import { QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT } from './filesystem-file-helpers' + +// 32 visible matches plus one truncation sentinel stays below the legacy frame ceiling. +const QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT = 33 export function registerFilesystemSearchHandlers(context: FilesystemHandlerContext): void { const { store, activeTextSearches } = context diff --git a/src/main/ipc/filesystem/filesystem-worktree-helpers.ts b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts similarity index 95% rename from src/main/ipc/filesystem/filesystem-worktree-helpers.ts rename to src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts index 18dd5c4f1f6..62b86e7361a 100644 --- a/src/main/ipc/filesystem/filesystem-worktree-helpers.ts +++ b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts @@ -175,11 +175,3 @@ export function getLocalTextGenerationTarget( ...(env ? { env } : {}) } } - -export function validateFullGitObjectId(value: string, label: string): string { - const pattern = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ - if (!pattern.test(value)) { - throw new Error(`${label} must be a full git object id`) - } - return value -} diff --git a/src/main/ipc/filesystem/git-remote/compare-handlers.ts b/src/main/ipc/filesystem/git-remote/compare-handlers.ts index a522ae10af2..a2d7d7a3cd5 100644 --- a/src/main/ipc/filesystem/git-remote/compare-handlers.ts +++ b/src/main/ipc/filesystem/git-remote/compare-handlers.ts @@ -10,7 +10,7 @@ import { } from '../../../providers/ssh-git-dispatch' import { resolveRegisteredWorktreePath } from '../../registered-worktree-roots-cache' import { getLocalGitOptionsForRegisteredWorktree } from '../../local-worktree-runtime-options' -import { validateFullGitObjectId } from '../filesystem-worktree-helpers' +import { validateFullGitObjectId } from '../../filesystem-path-containment' import type { FilesystemHandlerContext } from '../filesystem-handler-context' import type { GitAdmissionTier } from '../../../git/command-runner/git-exec-options' diff --git a/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts b/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts index c2a22267344..e17e5806914 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts @@ -15,7 +15,7 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { applyNativeChatLaunchDraftResolved } from '../native-chat-launch-draft-runtime-resolution' import { resolveTerminalLayoutRoot } from '../remote-terminal-layout-resolution' import { buildMobileSessionTabSnapshots } from './mobile-session-snapshots' -import { isWebOnlyMirroredTerminalTab } from './mobile-session-tab-helpers' +import { isWebOnlyMirroredTerminalTab } from './mobile-session-surfaces' import { resolveRuntimeTerminalTitle } from './sync-projections' import { collectAmbiguousTerminalTabIds, diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-browser-tabs.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-browser-tabs.ts index e1621493832..5e24fa4d4dd 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-browser-tabs.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-browser-tabs.ts @@ -2,7 +2,7 @@ import type { AppState } from '@/store/types' import type { RuntimeMobileSessionBrowserTab } from '../../../../shared/runtime-types' import type { Tab } from '../../../../shared/tab-types' import type { MobileSessionWorktreeInputs } from './types' -import { isUnifiedTabActiveInActiveGroup } from './mobile-session-tab-helpers' +import { isUnifiedTabActiveInActiveGroup } from './mobile-session-surfaces' export function buildMobileBrowserTab( inputs: MobileSessionWorktreeInputs, diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-editor-tabs.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-editor-tabs.ts index 3d10aa48dc4..6c6e32f820d 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-editor-tabs.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-editor-tabs.ts @@ -9,7 +9,7 @@ import { isFileActiveEditorSurface, isMobileFileDiffSource, isUnifiedTabActiveInActiveGroup -} from './mobile-session-tab-helpers' +} from './mobile-session-surfaces' export function buildMobileMarkdownTab( inputs: MobileSessionWorktreeInputs, diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-group-projection.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-group-projection.ts index f590bc6f226..65ad4acf721 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-group-projection.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-group-projection.ts @@ -6,7 +6,7 @@ import { type VisibleTabRef } from '../../components/tab-bar/group-tab-order' import type { MobileSessionWorktreeInputs } from './types' -import { isEditorSurfaceTab } from './mobile-session-tab-helpers' +import { isEditorSurfaceTab } from './mobile-session-surfaces' export function getEditorUnifiedTabsForWorktree( inputs: Pick diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts index 16c2f4d0da3..d69885a0f8c 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts @@ -21,7 +21,7 @@ import { captureMountedTerminalSurfaces, narrowRecordByKeys } from './mobile-ses import { getRuntimeLeafIdsForTerminal, resolveMobileTabWideAgentHintLeafId -} from './mobile-session-tab-helpers' +} from './mobile-session-surfaces' export function getOpenFileIndexes(openFiles: AppState['openFiles']): OpenFileIndexes { if (graphState.cachedOpenFileIndexesSource === openFiles && graphState.cachedOpenFileIndexes) { diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts index 91e89a9cb73..11061906bc5 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts @@ -22,7 +22,7 @@ import { isMobilePublishableBrowserWorkspace, isMobilePublishableOpenFile, isWebOnlyMirroredTerminalTab -} from './mobile-session-tab-helpers' +} from './mobile-session-surfaces' import { appendFallbackEditorTabsToGroups, buildMobileSessionGroupProjection, diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-tab-helpers.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-surfaces.ts similarity index 100% rename from src/renderer/src/runtime/sync-runtime-graph/mobile-session-tab-helpers.ts rename to src/renderer/src/runtime/sync-runtime-graph/mobile-session-surfaces.ts diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-terminal-tabs.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-terminal-tabs.ts index 0d364c92970..b1c7fe4b54f 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-terminal-tabs.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-terminal-tabs.ts @@ -16,7 +16,7 @@ import { getRuntimeLeafIdsForTerminal, resolveMobileTabWideAgentHintLeafId, isUnifiedTabActiveInActiveGroup -} from './mobile-session-tab-helpers' +} from './mobile-session-surfaces' import { resolveRuntimeTerminalTitle } from './sync-projections' import { resolveTerminalLayoutRoot } from '../remote-terminal-layout-resolution' diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-active-state.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-active-state.ts index 68c06e05c9d..ad5c8641abb 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-active-state.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-active-state.ts @@ -6,7 +6,7 @@ import { appendTabGroupLayout, pruneTabGroupLayout, tabGroupLayoutEqual -} from './layout-helpers' +} from './tab-group-layout-tree' type ActiveStateContext = ReturnType diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts index 6263d9d8467..f14b8a26747 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts @@ -1,10 +1,10 @@ import { buildWebSessionExistingTabIndex } from '../web-session-existing-tab-index' -import { chooseTargetGroupId, buildHostGroupIdByTabId } from './layout-helpers' +import { chooseTargetGroupId, buildHostGroupIdByTabId } from './tab-group-layout-tree' import { buildMirroredBrowserTabs, browserWorkspaceHasRemoteEnvironmentPage, browserWorkspaceHasClientHostedEnvironmentPage -} from './browser-helpers' +} from './mirrored-browser-tabs' import { buildMirroredEditorTabs } from './tab-builders' import { buildMirroredAgentTabs, isReadyBrowserTab, isReadyEditorTab } from './terminal-surfaces' import { hostSnapshotAffirmsClientHostedPages } from '../host-session-snapshot-authority' diff --git a/src/renderer/src/runtime/web-session-tabs-sync/layout-groups.ts b/src/renderer/src/runtime/web-session-tabs-sync/layout-groups.ts index ad5a1a0b5ef..d85b7c6954a 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/layout-groups.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/layout-groups.ts @@ -11,7 +11,7 @@ import { toWebTerminalSurfaceTabId } from '../web-runtime-session' import { clearHostSessionTabIdMappings, setHostSessionTabIdMapping } from './tracking-mappings' import { isWebSessionBrowserPlacementGroupReserved } from '../web-session-browser-placement' import { resolveWebSessionReorderedOrder } from '../web-session-reorder-intent' -import { mapHostRecentTabIds } from './layout-helpers' +import { mapHostRecentTabIds } from './tab-group-layout-tree' import { pushRecentTabId, sanitizeRecentTabIds } from './state-equality-core' export function buildHostToLocalTabIdMap({ diff --git a/src/renderer/src/runtime/web-session-tabs-sync/browser-helpers.ts b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-browser-tabs.ts similarity index 99% rename from src/renderer/src/runtime/web-session-tabs-sync/browser-helpers.ts rename to src/renderer/src/runtime/web-session-tabs-sync/mirrored-browser-tabs.ts index 8597547cc0c..8464ba504d7 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/browser-helpers.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-browser-tabs.ts @@ -8,7 +8,7 @@ import type { WebSessionTabsSyncState, MirroredBrowserTab } from './state' import { readBrowserClientHostId } from '../browser-client-host-identity' import { peekWebSessionBrowserPlacementGroup } from '../web-session-browser-placement' import { browserPageEqual } from './state-equality-tabs' -import { collectLayoutGroupIds } from './layout-helpers' +import { collectLayoutGroupIds } from './tab-group-layout-tree' import { buildBrowserUnifiedTab } from './tab-builders' import { isReadyBrowserTab } from './terminal-surfaces' diff --git a/src/renderer/src/runtime/web-session-tabs-sync/layout-helpers.ts b/src/renderer/src/runtime/web-session-tabs-sync/tab-group-layout-tree.ts similarity index 100% rename from src/renderer/src/runtime/web-session-tabs-sync/layout-helpers.ts rename to src/renderer/src/runtime/web-session-tabs-sync/tab-group-layout-tree.ts diff --git a/src/renderer/src/store/slices/agent-status-authority-actions.ts b/src/renderer/src/store/slices/agent-status-authority-actions.ts index cbd3b991a67..0d96ca524ed 100644 --- a/src/renderer/src/store/slices/agent-status-authority-actions.ts +++ b/src/renderer/src/store/slices/agent-status-authority-actions.ts @@ -10,12 +10,12 @@ import { boundRecentlyRetiredAgentStatusPaneKeys, movePaneKeyedRecord, removePaneKeys -} from './agent-status-map-helpers' +} from './agent-status-pane-keyed-records' import { getLeafIdFromPaneKey, getTabIdFromPaneKey, isRecentlyClosedAgentStatusTab -} from './agent-status-pane-helpers' +} from './agent-status-pane-key-tab-binding' export function createAgentStatusAuthorityActions( runtime: AgentStatusRuntime diff --git a/src/renderer/src/store/slices/agent-status-capacity-eviction.ts b/src/renderer/src/store/slices/agent-status-capacity-eviction.ts new file mode 100644 index 00000000000..fa82528a169 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-capacity-eviction.ts @@ -0,0 +1,99 @@ +import type { AppState } from '../types' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' +import type { RetainedAgentEntry } from './agent-status-contract' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' +import { getTabIdFromPaneKey } from './agent-status-pane-key-tab-binding' + +const MAX_RETAINED_AGENTS = 500 + +export function capRetainedAgents( + retained: Record, + maxEntries = MAX_RETAINED_AGENTS +): Record { + const keys = Object.keys(retained) + if (keys.length <= maxEntries) { + return retained + } + const capped: Record = {} + for (const key of keys.slice(keys.length - maxEntries)) { + capped[key] = retained[key] + } + return capped +} + +// Why: missed pane teardown can leak heavy live rows in any state and amplify every status-map copy (#9872). +export const MAX_LIVE_AGENT_STATUSES = 500 + +type PaneLiveness = 'live' | 'dead' | 'unprovable' + +// Why: only a rooted tab proves which leaves are mounted; rootless and headless rows may still be live (#2962). +export function classifyPaneKeyLiveness(state: AppState): (paneKey: string) => PaneLiveness { + const rootedLeafKeys = new Set() + const rootedTabIds = new Set() + for (const [tabId, layout] of Object.entries(state.terminalLayoutsByTabId)) { + if (!layout?.root) { + continue + } + rootedTabIds.add(tabId) + const stack: TerminalPaneLayoutNode[] = [layout.root] + while (stack.length > 0) { + const node = stack.pop()! + if (node.type === 'leaf') { + rootedLeafKeys.add(`${tabId}:${node.leafId}`) + } else { + stack.push(node.first, node.second) + } + } + } + return (paneKey) => { + if (rootedLeafKeys.has(paneKey)) { + return 'live' + } + const tabId = getTabIdFromPaneKey(paneKey) + return tabId !== null && rootedTabIds.has(tabId) ? 'dead' : 'unprovable' + } +} + +// Why: mutate the caller-owned spread so eviction does not allocate another heavy-map copy. +export function capLiveAgentStatusesInPlace( + freshLive: Record, + protectedPaneKey: string, + buildClassifier: () => (paneKey: string) => PaneLiveness, + now: number, + maxEntries = MAX_LIVE_AGENT_STATUSES +): string[] { + const keys = Object.keys(freshLive) + let overflow = keys.length - maxEntries + if (overflow <= 0) { + return [] + } + const classify = buildClassifier() + const evictedPaneKeys: string[] = [] + const sweep = (canEvict: (liveness: PaneLiveness, entry: AgentStatusEntry) => boolean): void => { + for (const key of keys) { + if (overflow <= 0) { + break + } + if (key === protectedPaneKey || !(key in freshLive)) { + continue + } + const liveness = classify(key) + if (liveness === 'live' || !canEvict(liveness, freshLive[key])) { + continue + } + delete freshLive[key] + overflow -= 1 + evictedPaneKeys.push(key) + } + } + // Prefer rows that are provably dead or too stale to represent a live agent. + sweep( + (liveness, entry) => liveness === 'dead' || now - entry.updatedAt > AGENT_STATUS_STALE_AFTER_MS + ) + // Shed fresh unprovable rows only when needed; rooted live panes make this a soft cap. + if (overflow > 0) { + sweep(() => true) + } + return evictedPaneKeys +} diff --git a/src/renderer/src/store/slices/agent-status-cleanup-actions.ts b/src/renderer/src/store/slices/agent-status-cleanup-actions.ts index 78047c13560..352a86526df 100644 --- a/src/renderer/src/store/slices/agent-status-cleanup-actions.ts +++ b/src/renderer/src/store/slices/agent-status-cleanup-actions.ts @@ -1,9 +1,7 @@ import type { AgentStatusSlice } from './agent-status-slice-contract' import type { AgentStatusRuntime } from './agent-status-runtime' -import { - collectWorktreeIdsForConnection, - pruneMigrationUnsupportedEntries -} from './agent-status-map-helpers' +import { collectWorktreeIdsForConnection } from './agent-status-connection-worktree-scope' +import { pruneMigrationUnsupportedEntries } from './agent-status-migration-unsupported-entries' /** Actions for removing transient rows and migration-era cache entries. */ export function createAgentStatusCleanupActions( diff --git a/src/renderer/src/store/slices/agent-status-connection-worktree-scope.ts b/src/renderer/src/store/slices/agent-status-connection-worktree-scope.ts new file mode 100644 index 00000000000..e4e94c16049 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-connection-worktree-scope.ts @@ -0,0 +1,41 @@ +import type { AppState } from '../types' +import { + getRepoExecutionHostId, + getWorktreeExecutionHostId +} from '../../../../shared/execution-host' + +// Why: relay/daemon teardown drops main's rows, but renderer entries whose connectionId stamp never +// matched (unstamped over SSH) survive and stay "fresh" 30 min (#9030). Resolve each worktree's host +// via the canonical hostId-first precedence and keep only ids UNAMBIGUOUSLY on this connection — a +// worktree id is `${repoId}::${path}` (no host component), so the same project mirrored at the same +// path on two hosts yields one shared id that must not clear another host's live rows. +export function collectWorktreeIdsForConnection( + state: AppState, + connectionId: string +): Set { + const hostIdsOnConnection = new Set( + state.repos + .filter((repo) => repo.connectionId === connectionId) + .map((repo) => getRepoExecutionHostId(repo)) + ) + if (hostIdsOnConnection.size === 0) { + return new Set() + } + const repoById = new Map(state.repos.map((repo) => [repo.id, repo] as const)) + const onConnection = new Set() + const onOtherHost = new Set() + for (const [repoId, worktrees] of Object.entries(state.worktreesByRepo)) { + const repo = repoById.get(repoId) + for (const worktree of worktrees) { + const bucket = hostIdsOnConnection.has(getWorktreeExecutionHostId(worktree, repo)) + ? onConnection + : onOtherHost + bucket.add(worktree.id) + } + } + // A worktree id that also lives on another host is ambiguous — leave it rather than hide a live row. + for (const id of onOtherHost) { + onConnection.delete(id) + } + return onConnection +} diff --git a/src/renderer/src/store/slices/agent-status-drop-actions.ts b/src/renderer/src/store/slices/agent-status-drop-actions.ts index fdaaa450b52..c918793a303 100644 --- a/src/renderer/src/store/slices/agent-status-drop-actions.ts +++ b/src/renderer/src/store/slices/agent-status-drop-actions.ts @@ -6,11 +6,11 @@ import type { import type { AgentStatusSlice } from './agent-status-slice-contract' import type { AgentStatusRuntime } from './agent-status-runtime' import { buildAgentStatusTabPrefixDropPatch } from './agent-status-drop-reducer' -import { pruneMigrationUnsupportedEntries } from './agent-status-map-helpers' +import { pruneMigrationUnsupportedEntries } from './agent-status-migration-unsupported-entries' import { retainedAgentEntryFromLive, shouldReplaceRetainedWithLive -} from './agent-status-pane-helpers' +} from './agent-status-pane-key-tab-binding' import { retireAgentPaneAuthorityAliasesByOwnerTab } from './agent-pane-authority' function removeAcknowledgement( diff --git a/src/renderer/src/store/slices/agent-status-drop-reducer.ts b/src/renderer/src/store/slices/agent-status-drop-reducer.ts index fc24f06add8..5d8387e9bf0 100644 --- a/src/renderer/src/store/slices/agent-status-drop-reducer.ts +++ b/src/renderer/src/store/slices/agent-status-drop-reducer.ts @@ -1,11 +1,11 @@ import type { AppState } from '../types' import type { DropAgentStatusByTabPrefixOptions } from './agent-status-contract' +import { pruneMigrationUnsupportedEntries } from './agent-status-migration-unsupported-entries' import { boundRecentlyClosedAgentStatusTabIds, - boundRecentlyRetiredAgentStatusPaneKeys, - pruneMigrationUnsupportedEntries -} from './agent-status-map-helpers' -import { findCompletedOrphanPaneKeysForTabClose } from './agent-status-pane-helpers' + boundRecentlyRetiredAgentStatusPaneKeys +} from './agent-status-pane-keyed-records' +import { findCompletedOrphanPaneKeysForTabClose } from './agent-status-pane-key-tab-binding' /** Slices that only the fold touched, so the batch commits as a MERGE. */ export function buildAgentStatusBatchPatch( diff --git a/src/renderer/src/store/slices/agent-status-launch-actions.ts b/src/renderer/src/store/slices/agent-status-launch-actions.ts index 67832edfab6..eb7837fe391 100644 --- a/src/renderer/src/store/slices/agent-status-launch-actions.ts +++ b/src/renderer/src/store/slices/agent-status-launch-actions.ts @@ -1,15 +1,15 @@ import type { AgentStatusSlice } from './agent-status-slice-contract' import type { AgentStatusRuntime } from './agent-status-runtime' import type { AgentLaunchConfigRegistryEntry } from './agent-status-contract' -import { copyLaunchConfig, sleepingRecordFromEntry } from './agent-status-recovery-helpers' +import { copyLaunchConfig, sleepingRecordFromEntry } from './agent-status-sleeping-records' import { getLaunchConfigForEntry, launchConfigRegistryEntriesEqual, normalizeLaunchConfigRegistrationMetadata, registryEntryMatchesStatus } from './agent-status-launch-config' -import { getLaunchConfigForStatusMetadata } from './agent-status-map-helpers' -import { findAgentPaneWorktreeId, getTabIdFromPaneKey } from './agent-status-pane-helpers' +import { getLaunchConfigForStatusMetadata } from './agent-status-launch-config' +import { findAgentPaneWorktreeId, getTabIdFromPaneKey } from './agent-status-pane-key-tab-binding' export function createAgentStatusLaunchActions( runtime: AgentStatusRuntime diff --git a/src/renderer/src/store/slices/agent-status-launch-config.ts b/src/renderer/src/store/slices/agent-status-launch-config.ts index ff265bc3ff6..89c544de87b 100644 --- a/src/renderer/src/store/slices/agent-status-launch-config.ts +++ b/src/renderer/src/store/slices/agent-status-launch-config.ts @@ -7,9 +7,10 @@ import { } from '../../../../shared/agent-session-resume' import type { AgentLaunchConfigRegistryEntry, - AgentLaunchConfigRegistrationMetadata + AgentLaunchConfigRegistrationMetadata, + AgentLaunchConfigStatusMetadata } from './agent-status-contract' -import { getLeafIdFromPaneKey, getTabIdFromPaneKey } from './agent-status-pane-helpers' +import { getLeafIdFromPaneKey, getTabIdFromPaneKey } from './agent-status-pane-key-tab-binding' import { launchConfigsEqual } from './agent-status-recovery-equivalence' export function normalizeLaunchConfigRegistrationMetadata( @@ -140,3 +141,23 @@ export function getLaunchConfigForEntry( ? sleepingRecord.launchConfig : undefined } + +export function getLaunchConfigForStatusMetadata( + state: AppState, + metadata: AgentLaunchConfigStatusMetadata +): SleepingAgentLaunchConfig | undefined { + const registryEntry = state.agentLaunchConfigByPaneKey[metadata.paneKey] + return registryEntryMatchesStatus({ + entry: registryEntry, + paneKey: metadata.paneKey, + agentType: metadata.agentType, + tabId: metadata.tabId ?? getTabIdFromPaneKey(metadata.paneKey) ?? undefined, + terminalHandle: metadata.terminalHandle, + launchToken: metadata.launchToken, + providerSession: metadata.providerSession, + existingProviderSession: metadata.existingProviderSession, + providerSessionChanged: metadata.providerSessionChanged ?? false + }) + ? registryEntry?.launchConfig + : undefined +} diff --git a/src/renderer/src/store/slices/agent-status-live-actions.ts b/src/renderer/src/store/slices/agent-status-live-actions.ts index f534c5efb54..d9b0ff2c3dc 100644 --- a/src/renderer/src/store/slices/agent-status-live-actions.ts +++ b/src/renderer/src/store/slices/agent-status-live-actions.ts @@ -16,7 +16,7 @@ import { agentStatusTabAlreadyHasProtectedOrGeneratedTitle, getTabIdFromPaneKey, isRecentlyClosedAgentStatusTab -} from './agent-status-pane-helpers' +} from './agent-status-pane-key-tab-binding' import { getAgentRowGeneratedTitleText, getOrcaDispatchTaskId, diff --git a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts index 14c74b8d23f..2afd35430f3 100644 --- a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts +++ b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts @@ -26,8 +26,8 @@ import type { AgentStatusTiming } from './agent-status-contract' import { registryEntryMatchesStatus } from './agent-status-launch-config' -import { findAgentPaneWorktreeId, getTabIdFromPaneKey } from './agent-status-pane-helpers' -import { mergeCurrentOrchestrationContext } from './agent-status-map-helpers' +import { findAgentPaneWorktreeId, getTabIdFromPaneKey } from './agent-status-pane-key-tab-binding' +import { mergeCurrentOrchestrationContext } from './agent-status-orchestration-context' import { deriveAgentStatusLiveFacts } from './agent-status-live-facts' export type AgentStatusLiveEntryBuild = { diff --git a/src/renderer/src/store/slices/agent-status-live-facts.ts b/src/renderer/src/store/slices/agent-status-live-facts.ts index 4dd2691699e..485296e240d 100644 --- a/src/renderer/src/store/slices/agent-status-live-facts.ts +++ b/src/renderer/src/store/slices/agent-status-live-facts.ts @@ -10,9 +10,12 @@ import type { import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time' import { isExplicitAgentStatusFresh } from '@/lib/agent-status' import { recordHibernationBoundaryResolved } from '@/lib/agent-hibernation-pane-age' -import { findAgentPaneWorktreeId, isAgentCompletionState } from './agent-status-pane-helpers' -import { pruneMigrationUnsupportedEntries } from './agent-status-map-helpers' -import { sleepingRecordFromEntry } from './agent-status-recovery-helpers' +import { + findAgentPaneWorktreeId, + isAgentCompletionState +} from './agent-status-pane-key-tab-binding' +import { pruneMigrationUnsupportedEntries } from './agent-status-migration-unsupported-entries' +import { sleepingRecordFromEntry } from './agent-status-sleeping-records' export type AgentStatusLiveFacts = { existingSleepingRecord: SleepingAgentSessionRecord | undefined diff --git a/src/renderer/src/store/slices/agent-status-live-reducer.ts b/src/renderer/src/store/slices/agent-status-live-reducer.ts index 2bae1cb0db0..96c8c0c97b2 100644 --- a/src/renderer/src/store/slices/agent-status-live-reducer.ts +++ b/src/renderer/src/store/slices/agent-status-live-reducer.ts @@ -1,6 +1,9 @@ import type { AppState } from '../types' -import { capLiveAgentStatusesInPlace, classifyPaneKeyLiveness } from './agent-status-pane-helpers' -import { removePaneKeys } from './agent-status-map-helpers' +import { + capLiveAgentStatusesInPlace, + classifyPaneKeyLiveness +} from './agent-status-capacity-eviction' +import { removePaneKeys } from './agent-status-pane-keyed-records' import { recoveryRecordMatches } from './agent-status-recovery-equivalence' import type { AgentStatusLiveEntryBuild } from './agent-status-live-entry-builder' import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' diff --git a/src/renderer/src/store/slices/agent-status-map-helpers.ts b/src/renderer/src/store/slices/agent-status-map-helpers.ts deleted file mode 100644 index 7ebf55bbad0..00000000000 --- a/src/renderer/src/store/slices/agent-status-map-helpers.ts +++ /dev/null @@ -1,210 +0,0 @@ -import type { AppState } from '../types' -import type { - AgentStatusOrchestrationContext, - MigrationUnsupportedPtyEntry -} from '../../../../shared/agent-status-types' -import { - getRepoExecutionHostId, - getWorktreeExecutionHostId -} from '../../../../shared/execution-host' -import { getTabIdFromPaneKey } from './agent-status-pane-helpers' -import { registryEntryMatchesStatus } from './agent-status-launch-config' -import type { AgentLaunchConfigStatusMetadata } from './agent-status-contract' -import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' - -export const RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024 -export const RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX = 1024 - -// delete-then-set for LRU recency, then evict oldest keys past the cap (Record iterates -// insertion order); safe because a status for a tab closed >MAX tabs ago cannot still arrive. -export function boundRecentlyClosedAgentStatusTabIds( - existing: Record, - tabId: string -): Record { - const next: Record = {} - for (const key of Object.keys(existing)) { - if (key !== tabId) { - next[key] = true - } - } - next[tabId] = true - const keys = Object.keys(next) - if (keys.length > RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX) { - for (const stale of keys.slice(0, keys.length - RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX)) { - delete next[stale] - } - } - return next -} - -export function boundRecentlyRetiredAgentStatusPaneKeys( - existing: Record, - paneKeys: readonly string[] -): Record { - const additions = new Set(paneKeys) - const next: Record = {} - for (const key of Object.keys(existing)) { - if (!additions.has(key)) { - next[key] = true - } - } - for (const paneKey of additions) { - next[paneKey] = true - } - const keys = Object.keys(next) - for (const stale of keys.slice(0, -RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX)) { - delete next[stale] - } - return next -} - -export function movePaneKeyedRecord( - record: Record, - fromPaneKey: string, - toPaneKey: string, - transform: (value: T) => T = (value) => value -): Record { - const value = record[fromPaneKey] - if (value === undefined || fromPaneKey === toPaneKey) { - return record - } - const next = { ...record } - delete next[fromPaneKey] - next[toPaneKey] = transform(value) - return next -} - -export function removePaneKeys( - record: Record, - paneKeys: ReadonlySet -): Record { - const matchingKeys = Object.keys(record).filter((key) => paneKeys.has(key)) - if (matchingKeys.length === 0) { - return record - } - const next = { ...record } - for (const key of matchingKeys) { - delete next[key] - } - return next -} - -export function getLaunchConfigForStatusMetadata( - state: AppState, - metadata: AgentLaunchConfigStatusMetadata -): SleepingAgentLaunchConfig | undefined { - const registryEntry = state.agentLaunchConfigByPaneKey[metadata.paneKey] - return registryEntryMatchesStatus({ - entry: registryEntry, - paneKey: metadata.paneKey, - agentType: metadata.agentType, - tabId: metadata.tabId ?? getTabIdFromPaneKey(metadata.paneKey) ?? undefined, - terminalHandle: metadata.terminalHandle, - launchToken: metadata.launchToken, - providerSession: metadata.providerSession, - existingProviderSession: metadata.existingProviderSession, - providerSessionChanged: metadata.providerSessionChanged ?? false - }) - ? registryEntry?.launchConfig - : undefined -} - -export function pruneMigrationUnsupportedEntries( - entries: Record, - predicate: (entry: MigrationUnsupportedPtyEntry) => boolean -): { next: Record; changed: boolean } { - let changed = false - const next: Record = {} - for (const [ptyId, entry] of Object.entries(entries)) { - if (predicate(entry)) { - changed = true - continue - } - next[ptyId] = entry - } - return { next: changed ? next : entries, changed } -} - -export function orchestrationContextsEqual( - a: AgentStatusOrchestrationContext, - b: AgentStatusOrchestrationContext -): boolean { - return ( - a.taskId === b.taskId && - a.dispatchId === b.dispatchId && - a.dispatchStatus === b.dispatchStatus && - a.taskTitle === b.taskTitle && - a.displayName === b.displayName && - a.parentTerminalHandle === b.parentTerminalHandle && - a.parentPaneKey === b.parentPaneKey && - a.coordinatorHandle === b.coordinatorHandle && - a.orchestrationRunId === b.orchestrationRunId - ) -} - -export function orchestrationMapsEqual( - a: Record, - b: Record -): boolean { - const aKeys = Object.keys(a) - const bKeys = Object.keys(b) - if (aKeys.length !== bKeys.length) { - return false - } - return aKeys.every((key) => b[key] !== undefined && orchestrationContextsEqual(a[key]!, b[key]!)) -} - -export function mergeCurrentOrchestrationContext( - existing: AgentStatusOrchestrationContext | undefined, - current: AgentStatusOrchestrationContext -): AgentStatusOrchestrationContext { - if (!existing) { - return current - } - const sameDispatch = - existing.taskId === current.taskId && existing.dispatchId === current.dispatchId - if (!sameDispatch) { - return current - } - if (current.dispatchStatus !== undefined) { - return orchestrationContextsEqual(existing, current) ? existing : current - } - const merged = { ...existing, ...current } - return orchestrationContextsEqual(existing, merged) ? existing : merged -} - -// Why: relay/daemon teardown drops main's rows, but renderer entries whose connectionId stamp never -// matched (unstamped over SSH) survive and stay "fresh" 30 min (#9030). Resolve each worktree's host -// via the canonical hostId-first precedence and keep only ids UNAMBIGUOUSLY on this connection — a -// worktree id is `${repoId}::${path}` (no host component), so the same project mirrored at the same -// path on two hosts yields one shared id that must not clear another host's live rows. -export function collectWorktreeIdsForConnection( - state: AppState, - connectionId: string -): Set { - const hostIdsOnConnection = new Set( - state.repos - .filter((repo) => repo.connectionId === connectionId) - .map((repo) => getRepoExecutionHostId(repo)) - ) - if (hostIdsOnConnection.size === 0) { - return new Set() - } - const repoById = new Map(state.repos.map((repo) => [repo.id, repo] as const)) - const onConnection = new Set() - const onOtherHost = new Set() - for (const [repoId, worktrees] of Object.entries(state.worktreesByRepo)) { - const repo = repoById.get(repoId) - for (const worktree of worktrees) { - const bucket = hostIdsOnConnection.has(getWorktreeExecutionHostId(worktree, repo)) - ? onConnection - : onOtherHost - bucket.add(worktree.id) - } - } - // A worktree id that also lives on another host is ambiguous — leave it rather than hide a live row. - for (const id of onOtherHost) { - onConnection.delete(id) - } - return onConnection -} diff --git a/src/renderer/src/store/slices/agent-status-migration-unsupported-entries.ts b/src/renderer/src/store/slices/agent-status-migration-unsupported-entries.ts new file mode 100644 index 00000000000..13f1b1375ad --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-migration-unsupported-entries.ts @@ -0,0 +1,17 @@ +import type { MigrationUnsupportedPtyEntry } from '../../../../shared/agent-status-types' + +export function pruneMigrationUnsupportedEntries( + entries: Record, + predicate: (entry: MigrationUnsupportedPtyEntry) => boolean +): { next: Record; changed: boolean } { + let changed = false + const next: Record = {} + for (const [ptyId, entry] of Object.entries(entries)) { + if (predicate(entry)) { + changed = true + continue + } + next[ptyId] = entry + } + return { next: changed ? next : entries, changed } +} diff --git a/src/renderer/src/store/slices/agent-status-orchestration-actions.ts b/src/renderer/src/store/slices/agent-status-orchestration-actions.ts index 2d038ebf29e..8a1672953bf 100644 --- a/src/renderer/src/store/slices/agent-status-orchestration-actions.ts +++ b/src/renderer/src/store/slices/agent-status-orchestration-actions.ts @@ -11,7 +11,7 @@ import { import { mergeCurrentOrchestrationContext, orchestrationMapsEqual -} from './agent-status-map-helpers' +} from './agent-status-orchestration-context' export function createAgentStatusOrchestrationActions( runtime: AgentStatusRuntime diff --git a/src/renderer/src/store/slices/agent-status-orchestration-context.ts b/src/renderer/src/store/slices/agent-status-orchestration-context.ts new file mode 100644 index 00000000000..312f191f496 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-orchestration-context.ts @@ -0,0 +1,49 @@ +import type { AgentStatusOrchestrationContext } from '../../../../shared/agent-status-types' + +export function orchestrationContextsEqual( + a: AgentStatusOrchestrationContext, + b: AgentStatusOrchestrationContext +): boolean { + return ( + a.taskId === b.taskId && + a.dispatchId === b.dispatchId && + a.dispatchStatus === b.dispatchStatus && + a.taskTitle === b.taskTitle && + a.displayName === b.displayName && + a.parentTerminalHandle === b.parentTerminalHandle && + a.parentPaneKey === b.parentPaneKey && + a.coordinatorHandle === b.coordinatorHandle && + a.orchestrationRunId === b.orchestrationRunId + ) +} + +export function orchestrationMapsEqual( + a: Record, + b: Record +): boolean { + const aKeys = Object.keys(a) + const bKeys = Object.keys(b) + if (aKeys.length !== bKeys.length) { + return false + } + return aKeys.every((key) => b[key] !== undefined && orchestrationContextsEqual(a[key]!, b[key]!)) +} + +export function mergeCurrentOrchestrationContext( + existing: AgentStatusOrchestrationContext | undefined, + current: AgentStatusOrchestrationContext +): AgentStatusOrchestrationContext { + if (!existing) { + return current + } + const sameDispatch = + existing.taskId === current.taskId && existing.dispatchId === current.dispatchId + if (!sameDispatch) { + return current + } + if (current.dispatchStatus !== undefined) { + return orchestrationContextsEqual(existing, current) ? existing : current + } + const merged = { ...existing, ...current } + return orchestrationContextsEqual(existing, merged) ? existing : merged +} diff --git a/src/renderer/src/store/slices/agent-status-pane-helpers.ts b/src/renderer/src/store/slices/agent-status-pane-key-tab-binding.ts similarity index 62% rename from src/renderer/src/store/slices/agent-status-pane-helpers.ts rename to src/renderer/src/store/slices/agent-status-pane-key-tab-binding.ts index 49e0fc53af6..2377cd52950 100644 --- a/src/renderer/src/store/slices/agent-status-pane-helpers.ts +++ b/src/renderer/src/store/slices/agent-status-pane-key-tab-binding.ts @@ -4,103 +4,9 @@ import type { AgentType, ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' -import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { DropAgentStatusByWorktreeOptions, RetainedAgentEntry } from './agent-status-contract' import type { AgentStatusTabPrefixDropState } from './agent-status-drop-reducer' -import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' - -const MAX_RETAINED_AGENTS = 500 - -export function capRetainedAgents( - retained: Record, - maxEntries = MAX_RETAINED_AGENTS -): Record { - const keys = Object.keys(retained) - if (keys.length <= maxEntries) { - return retained - } - const capped: Record = {} - for (const key of keys.slice(keys.length - maxEntries)) { - capped[key] = retained[key] - } - return capped -} - -// Why: missed pane teardown can leak heavy live rows in any state and amplify every status-map copy (#9872). -export const MAX_LIVE_AGENT_STATUSES = 500 - -type PaneLiveness = 'live' | 'dead' | 'unprovable' - -// Why: only a rooted tab proves which leaves are mounted; rootless and headless rows may still be live (#2962). -export function classifyPaneKeyLiveness(state: AppState): (paneKey: string) => PaneLiveness { - const rootedLeafKeys = new Set() - const rootedTabIds = new Set() - for (const [tabId, layout] of Object.entries(state.terminalLayoutsByTabId)) { - if (!layout?.root) { - continue - } - rootedTabIds.add(tabId) - const stack: TerminalPaneLayoutNode[] = [layout.root] - while (stack.length > 0) { - const node = stack.pop()! - if (node.type === 'leaf') { - rootedLeafKeys.add(`${tabId}:${node.leafId}`) - } else { - stack.push(node.first, node.second) - } - } - } - return (paneKey) => { - if (rootedLeafKeys.has(paneKey)) { - return 'live' - } - const tabId = getTabIdFromPaneKey(paneKey) - return tabId !== null && rootedTabIds.has(tabId) ? 'dead' : 'unprovable' - } -} - -// Why: mutate the caller-owned spread so eviction does not allocate another heavy-map copy. -export function capLiveAgentStatusesInPlace( - freshLive: Record, - protectedPaneKey: string, - buildClassifier: () => (paneKey: string) => PaneLiveness, - now: number, - maxEntries = MAX_LIVE_AGENT_STATUSES -): string[] { - const keys = Object.keys(freshLive) - let overflow = keys.length - maxEntries - if (overflow <= 0) { - return [] - } - const classify = buildClassifier() - const evictedPaneKeys: string[] = [] - const sweep = (canEvict: (liveness: PaneLiveness, entry: AgentStatusEntry) => boolean): void => { - for (const key of keys) { - if (overflow <= 0) { - break - } - if (key === protectedPaneKey || !(key in freshLive)) { - continue - } - const liveness = classify(key) - if (liveness === 'live' || !canEvict(liveness, freshLive[key])) { - continue - } - delete freshLive[key] - overflow -= 1 - evictedPaneKeys.push(key) - } - } - // Prefer rows that are provably dead or too stale to represent a live agent. - sweep( - (liveness, entry) => liveness === 'dead' || now - entry.updatedAt > AGENT_STATUS_STALE_AFTER_MS - ) - // Shed fresh unprovable rows only when needed; rooted live panes make this a soft cap. - if (overflow > 0) { - sweep(() => true) - } - return evictedPaneKeys -} export function paneKeyMatchesAnyTabPrefix(paneKey: string, tabPrefixes: string[]): boolean { for (const prefix of tabPrefixes) { diff --git a/src/renderer/src/store/slices/agent-status-pane-keyed-records.ts b/src/renderer/src/store/slices/agent-status-pane-keyed-records.ts new file mode 100644 index 00000000000..1e28710c564 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-pane-keyed-records.ts @@ -0,0 +1,76 @@ +export const RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024 +export const RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX = 1024 + +// delete-then-set for LRU recency, then evict oldest keys past the cap (Record iterates +// insertion order); safe because a status for a tab closed >MAX tabs ago cannot still arrive. +export function boundRecentlyClosedAgentStatusTabIds( + existing: Record, + tabId: string +): Record { + const next: Record = {} + for (const key of Object.keys(existing)) { + if (key !== tabId) { + next[key] = true + } + } + next[tabId] = true + const keys = Object.keys(next) + if (keys.length > RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX) { + for (const stale of keys.slice(0, keys.length - RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX)) { + delete next[stale] + } + } + return next +} + +export function boundRecentlyRetiredAgentStatusPaneKeys( + existing: Record, + paneKeys: readonly string[] +): Record { + const additions = new Set(paneKeys) + const next: Record = {} + for (const key of Object.keys(existing)) { + if (!additions.has(key)) { + next[key] = true + } + } + for (const paneKey of additions) { + next[paneKey] = true + } + const keys = Object.keys(next) + for (const stale of keys.slice(0, -RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX)) { + delete next[stale] + } + return next +} + +export function movePaneKeyedRecord( + record: Record, + fromPaneKey: string, + toPaneKey: string, + transform: (value: T) => T = (value) => value +): Record { + const value = record[fromPaneKey] + if (value === undefined || fromPaneKey === toPaneKey) { + return record + } + const next = { ...record } + delete next[fromPaneKey] + next[toPaneKey] = transform(value) + return next +} + +export function removePaneKeys( + record: Record, + paneKeys: ReadonlySet +): Record { + const matchingKeys = Object.keys(record).filter((key) => paneKeys.has(key)) + if (matchingKeys.length === 0) { + return record + } + const next = { ...record } + for (const key of matchingKeys) { + delete next[key] + } + return next +} diff --git a/src/renderer/src/store/slices/agent-status-provider-session-actions.ts b/src/renderer/src/store/slices/agent-status-provider-session-actions.ts index 36e1f71ab09..b8a88b9bca6 100644 --- a/src/renderer/src/store/slices/agent-status-provider-session-actions.ts +++ b/src/renderer/src/store/slices/agent-status-provider-session-actions.ts @@ -17,10 +17,10 @@ import { findAgentPaneWorktreeId, getTabIdFromPaneKey, isRecentlyClosedAgentStatusTab -} from './agent-status-pane-helpers' -import { removePaneKeys } from './agent-status-map-helpers' +} from './agent-status-pane-key-tab-binding' +import { removePaneKeys } from './agent-status-pane-keyed-records' import { registryEntryMatchesStatus } from './agent-status-launch-config' -import { copyLaunchConfig } from './agent-status-recovery-helpers' +import { copyLaunchConfig } from './agent-status-sleeping-records' export function createAgentStatusProviderSessionActions( runtime: AgentStatusRuntime diff --git a/src/renderer/src/store/slices/agent-status-recovery-actions.ts b/src/renderer/src/store/slices/agent-status-recovery-actions.ts index d3109b3cb88..1f7a88fbb7e 100644 --- a/src/renderer/src/store/slices/agent-status-recovery-actions.ts +++ b/src/renderer/src/store/slices/agent-status-recovery-actions.ts @@ -5,13 +5,13 @@ import { collectSleepingAgentSessionRecordsForWorktree } from './agent-status-re import { removeSleepingRecordsReplacedByManualWorktreeSleep, sleepingRecordFromEntry -} from './agent-status-recovery-helpers' +} from './agent-status-sleeping-records' import { recoveryRecordTargetsSameSession, sleepingRecordsEquivalentIgnoringCaptureTime } from './agent-status-recovery-equivalence' import { getLaunchConfigForEntry } from './agent-status-launch-config' -import { findAgentPaneWorktreeId } from './agent-status-pane-helpers' +import { findAgentPaneWorktreeId } from './agent-status-pane-key-tab-binding' import { isCompletedPiCompatibleAgentWithLiveRecoveryRecord } from '@/lib/live-resume-anchor-record' export function createAgentStatusRecoveryActions( diff --git a/src/renderer/src/store/slices/agent-status-recovery-collection.ts b/src/renderer/src/store/slices/agent-status-recovery-collection.ts index d5c0b1cd37b..4587f919d36 100644 --- a/src/renderer/src/store/slices/agent-status-recovery-collection.ts +++ b/src/renderer/src/store/slices/agent-status-recovery-collection.ts @@ -8,7 +8,7 @@ import { normalizePaneKeySet, paneKeyMatchesAnyTabPrefix, retainedAgentEntryFromLive -} from './agent-status-pane-helpers' +} from './agent-status-pane-key-tab-binding' import { carryOverAutomaticResumeBlock, isValidCompletedAgentHibernationEntry, @@ -17,7 +17,7 @@ import { normalizeSleepingAgentSessionCollectOptions, sleepingRecordFromEntry, type CollectSleepingAgentSessionRecordsOptions -} from './agent-status-recovery-helpers' +} from './agent-status-sleeping-records' import { getLaunchConfigForEntry } from './agent-status-launch-config' import { isCompletedPiCompatibleAgentWithLiveRecoveryRecord } from '@/lib/live-resume-anchor-record' diff --git a/src/renderer/src/store/slices/agent-status-retention-actions.ts b/src/renderer/src/store/slices/agent-status-retention-actions.ts index 884a4ca6fa4..ac7cb5b7b48 100644 --- a/src/renderer/src/store/slices/agent-status-retention-actions.ts +++ b/src/renderer/src/store/slices/agent-status-retention-actions.ts @@ -1,8 +1,8 @@ import type { RetainedAgentEntry } from './agent-status-contract' import type { AgentStatusSlice } from './agent-status-slice-contract' import type { AgentStatusRuntime } from './agent-status-runtime' -import { mergeCurrentOrchestrationContext } from './agent-status-map-helpers' -import { capRetainedAgents } from './agent-status-pane-helpers' +import { mergeCurrentOrchestrationContext } from './agent-status-orchestration-context' +import { capRetainedAgents } from './agent-status-capacity-eviction' export function createAgentStatusRetentionActions( runtime: AgentStatusRuntime diff --git a/src/renderer/src/store/slices/agent-status-recovery-helpers.ts b/src/renderer/src/store/slices/agent-status-sleeping-records.ts similarity index 98% rename from src/renderer/src/store/slices/agent-status-recovery-helpers.ts rename to src/renderer/src/store/slices/agent-status-sleeping-records.ts index 9b7a07c4471..e5887ac8626 100644 --- a/src/renderer/src/store/slices/agent-status-recovery-helpers.ts +++ b/src/renderer/src/store/slices/agent-status-sleeping-records.ts @@ -8,7 +8,7 @@ import { type SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' import type { TerminalTab } from '../../../../shared/terminal-tab-types' -import { findTabForAgentEntry } from './agent-status-pane-helpers' +import { findTabForAgentEntry } from './agent-status-pane-key-tab-binding' export function copyLaunchConfig(config: SleepingAgentLaunchConfig): SleepingAgentLaunchConfig { return { diff --git a/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts b/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts index f6820296735..4bafe7dc381 100644 --- a/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts +++ b/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts @@ -1,13 +1,13 @@ import type { DropAgentStatusByWorktreeOptions, RetainedAgentEntry } from './agent-status-contract' import type { AgentStatusSlice } from './agent-status-slice-contract' import type { AgentStatusRuntime } from './agent-status-runtime' -import { pruneMigrationUnsupportedEntries } from './agent-status-map-helpers' +import { pruneMigrationUnsupportedEntries } from './agent-status-migration-unsupported-entries' import { normalizePaneKeySet, paneKeyMatchesAnyTabPrefix, retainedAgentEntryFromLive, shouldReplaceRetainedWithLive -} from './agent-status-pane-helpers' +} from './agent-status-pane-key-tab-binding' export function createAgentStatusWorktreeDropActions( runtime: AgentStatusRuntime diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 8093c2be805..6a1ed10025c 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -51,16 +51,16 @@ export { collectSleepingAgentSessionRecordsForWorktree, collectHibernatedCompletionEvidenceForWorktree } from './agent-status-recovery-collection' -export { removeSleepingRecordsReplacedByManualWorktreeSleep } from './agent-status-recovery-helpers' +export { removeSleepingRecordsReplacedByManualWorktreeSleep } from './agent-status-sleeping-records' export { buildAgentStatusTabPrefixDropPatch, type AgentStatusTabPrefixDropState } from './agent-status-drop-reducer' -export { MAX_LIVE_AGENT_STATUSES } from './agent-status-pane-helpers' +export { MAX_LIVE_AGENT_STATUSES } from './agent-status-capacity-eviction' export { RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX, RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX -} from './agent-status-map-helpers' +} from './agent-status-pane-keyed-records' export const createAgentStatusSlice: StateCreator = ( storeSet, diff --git a/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts index fba107882a5..b1401e7d416 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts @@ -7,11 +7,17 @@ import { import { translate } from '@/i18n/i18n' import { collectAcknowledgedAgentNotificationId, - createAgentSendTargetModeInstanceId, latestAgentTurnTimestamp, resolvePaneKeyWorktreeIdFromTabs, usableTimestamp -} from './ui-slice-agent-helpers' +} from './ui-slice-agent-notification-acknowledgement' + +let agentSendTargetModeInstanceCounter = 0 + +function createAgentSendTargetModeInstanceId(): string { + agentSendTargetModeInstanceCounter += 1 + return `${Date.now()}:${agentSendTargetModeInstanceCounter}` +} export function createUiAgentActions( set: UISliceSet, diff --git a/src/renderer/src/store/slices/ui/ui-slice-agent-helpers.ts b/src/renderer/src/store/slices/ui/ui-slice-agent-notification-acknowledgement.ts similarity index 88% rename from src/renderer/src/store/slices/ui/ui-slice-agent-helpers.ts rename to src/renderer/src/store/slices/ui/ui-slice-agent-notification-acknowledgement.ts index b9b578d5476..e7b600a2ec8 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-agent-helpers.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-agent-notification-acknowledgement.ts @@ -53,10 +53,3 @@ export function latestAgentTurnTimestamp(entry: { } return latest } - -let agentSendTargetModeInstanceCounter = 0 - -export function createAgentSendTargetModeInstanceId(): string { - agentSendTargetModeInstanceCounter += 1 - return `${Date.now()}:${agentSendTargetModeInstanceCounter}` -} diff --git a/src/renderer/src/store/slices/ui/ui-slice-tour-helpers.ts b/src/renderer/src/store/slices/ui/ui-slice-contextual-tour-progression.ts similarity index 100% rename from src/renderer/src/store/slices/ui/ui-slice-tour-helpers.ts rename to src/renderer/src/store/slices/ui/ui-slice-contextual-tour-progression.ts diff --git a/src/renderer/src/store/slices/ui/ui-slice-feature-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-feature-actions.ts index 3cc57ce8361..27756409c93 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-feature-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-feature-actions.ts @@ -3,7 +3,7 @@ import { mergeFeatureInteractionState, mergeContextualTourSeenIds } from './ui-slice-hydration-values' -import { getContextualTourProgressionForFeatureInteraction } from './ui-slice-tour-helpers' +import { getContextualTourProgressionForFeatureInteraction } from './ui-slice-contextual-tour-progression' import type { FeatureInteractionState } from '../../../../../shared/feature-interactions' export function createUiFeatureActions(set: UISliceSet, get: UISliceGet): Partial { diff --git a/src/shared/keybindings/definitions-core-1.ts b/src/shared/keybindings/definitions-core-1.ts index b757a4127e4..fe8797c7684 100644 --- a/src/shared/keybindings/definitions-core-1.ts +++ b/src/shared/keybindings/definitions-core-1.ts @@ -1,3 +1,5 @@ +// Ordered slices of one registry: `definitions.ts` concatenates core-1..4 in order and that order +// is the palette order, so the boundaries follow line count, not theme — do not regroup thematically. import type { KeybindingDefinition } from './types' import { platformBindings } from './definitions-support' From 79990464d20f12aaa5116560479c81c603894e62 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:12:45 -0700 Subject: [PATCH 7/9] fix(store): merge the duplicated agent-status-launch-config import The rename collapsed agent-status-map-helpers into agent-status-launch-config, leaving two import statements for the same module. --- src/renderer/src/store/slices/agent-status-launch-actions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/store/slices/agent-status-launch-actions.ts b/src/renderer/src/store/slices/agent-status-launch-actions.ts index eb7837fe391..254ae37f4d1 100644 --- a/src/renderer/src/store/slices/agent-status-launch-actions.ts +++ b/src/renderer/src/store/slices/agent-status-launch-actions.ts @@ -4,11 +4,11 @@ import type { AgentLaunchConfigRegistryEntry } from './agent-status-contract' import { copyLaunchConfig, sleepingRecordFromEntry } from './agent-status-sleeping-records' import { getLaunchConfigForEntry, + getLaunchConfigForStatusMetadata, launchConfigRegistryEntriesEqual, normalizeLaunchConfigRegistrationMetadata, registryEntryMatchesStatus } from './agent-status-launch-config' -import { getLaunchConfigForStatusMetadata } from './agent-status-launch-config' import { findAgentPaneWorktreeId, getTabIdFromPaneKey } from './agent-status-pane-key-tab-binding' export function createAgentStatusLaunchActions( From 8fef5820ffac193c165e42e5307fc01f4459e97b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:41:42 -0700 Subject: [PATCH 8/9] fix(renderer): restore behavior the UI split dropped The oversized-UI-surfaces split was cut from a stale branch and reverted merged work. getClientCreationActionPolicy entered Terminal.tsx in #13909 and left in the split, taking six call sites with it, so every action-time creation gate in the terminal and floating surfaces was gone. Restores those and the other behavior the split dropped, each ported from the pre-split reference: - Cmd/Ctrl+S dispatched a bare Event with no detail, so the only listener always bailed on detail?.fileId and the chord never saved. Its resolver had been left orphaned, imported by nothing but its own test. - Terminal and floating create actions lost their availability gates, their toasts, and their catch handlers; one path throws on unavailable, so it was a silent unhandled rejection. - Both outermost workbench wrappers lost the browser guest paint retention branch, and the census entry covering them was deleted in the same commit. - The Space Analyzer header counted omitted items the list no longer rendered, and a worktree whose items were all omitted showed the empty state. - The terminal root lost its tab topology projection, so every tab-title update re-rendered it. - The titlebar tab bar stopped being passed clientHostedBrowserRows, leaving client-hosted pages uncloseable before a worktree has a layout. - Parking diagnostics lost their exempt-route counts and crash breadcrumb. - A suppressed inherited-terminal frame began buying a freshness scan the pre-split early return skipped. Adds regression tests for each, all verified to fail against the pre-fix code. Restores three deleted assertions whose invariants are still live, and replaces a concatenated source-boundary fixture with per-module pinning so a symbol is again asserted against the module that must own it. Deletes three orphaned trees the splits stranded: a duplicate ResourceUsage surface, cmd-j-match-relevance, and an agent-session claim-key module whose logic the record store already owns. Makes two non-recursive test walkers recursive, one of which silently skipped every nested CLI handler group. --- src/cli/handler-group-manifest.test.ts | 72 +++- .../runtime/agent-session-claim-key-state.ts | 46 --- .../TerminalSplitWorkspaceSurfaces.tsx | 13 +- .../src/components/TerminalSurface.tsx | 10 +- .../components/TerminalTitlebarTabs.test.tsx | 111 ++++++ .../src/components/TerminalTitlebarTabs.tsx | 18 +- ...rowser-guest-retention-site-census.test.ts | 4 + .../use-floating-terminal-create-actions.ts | 4 + .../use-floating-terminal-panel-shortcuts.ts | 7 + ...rkspaceNameField-source-boundaries.test.ts | 37 +- .../status-bar/ResourceUsageAppSection.tsx | 98 ----- .../status-bar/ResourceUsageKillDialog.tsx | 98 ----- .../status-bar/ResourceUsageMetrics.tsx | 130 ------ .../ResourceUsagePopoverContent.tsx | 376 ------------------ .../status-bar/ResourceUsageTree.tsx | 127 ------ .../status-bar/ResourceUsageTrigger.tsx | 95 ----- .../status-bar/ResourceUsageWorktreeRow.tsx | 260 ------------ .../status-bar/resource-usage-sort.ts | 47 --- .../use-resource-usage-foundation.ts | 213 ---------- .../use-resource-usage-projection.ts | 150 ------- .../workspace-space-breakdown-list.test.tsx | 111 ++++++ .../workspace-space-breakdown-list.tsx | 21 +- .../workspace-space-presentation.test.ts | 134 ++++++- .../terminal-workspace-editor-shortcuts.ts | 67 ++++ .../terminal-workspace-keydown.test.ts | 104 +++++ .../components/terminal-workspace-keydown.ts | 65 ++- .../use-terminal-create-actions.test.tsx | 119 ++++++ .../components/use-terminal-create-actions.ts | 46 ++- .../components/use-terminal-parking-pass.ts | 13 +- .../use-terminal-workspace-foundation.ts | 15 +- .../use-terminal-workspace-projection.ts | 5 + .../useSettingsNavigationMetadata.test.ts | 7 +- .../src/lib/client-creation-action-error.ts | 24 ++ src/renderer/src/lib/cmd-j-match-relevance.ts | 96 ----- .../mobile-session-surfaces.ts | 7 +- .../sync-runtime-graph/sync-projections.ts | 10 - .../store/slices/agent-status-live-actions.ts | 19 +- .../slices/agent-status-live-entry-builder.ts | 13 +- ...gent-status-live-freshness-request.test.ts | 70 ++++ .../store/slices/linear/linear-slice-scope.ts | 28 +- 40 files changed, 1005 insertions(+), 1885 deletions(-) delete mode 100644 src/main/runtime/agent-session-claim-key-state.ts create mode 100644 src/renderer/src/components/TerminalTitlebarTabs.test.tsx delete mode 100644 src/renderer/src/components/status-bar/ResourceUsageAppSection.tsx delete mode 100644 src/renderer/src/components/status-bar/ResourceUsageKillDialog.tsx delete mode 100644 src/renderer/src/components/status-bar/ResourceUsageMetrics.tsx delete mode 100644 src/renderer/src/components/status-bar/ResourceUsagePopoverContent.tsx delete mode 100644 src/renderer/src/components/status-bar/ResourceUsageTree.tsx delete mode 100644 src/renderer/src/components/status-bar/ResourceUsageTrigger.tsx delete mode 100644 src/renderer/src/components/status-bar/ResourceUsageWorktreeRow.tsx delete mode 100644 src/renderer/src/components/status-bar/resource-usage-sort.ts delete mode 100644 src/renderer/src/components/status-bar/use-resource-usage-foundation.ts delete mode 100644 src/renderer/src/components/status-bar/use-resource-usage-projection.ts create mode 100644 src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx create mode 100644 src/renderer/src/components/terminal-workspace-editor-shortcuts.ts create mode 100644 src/renderer/src/components/terminal-workspace-keydown.test.ts create mode 100644 src/renderer/src/components/use-terminal-create-actions.test.tsx create mode 100644 src/renderer/src/lib/client-creation-action-error.ts delete mode 100644 src/renderer/src/lib/cmd-j-match-relevance.ts create mode 100644 src/renderer/src/store/slices/agent-status-live-freshness-request.test.ts diff --git a/src/cli/handler-group-manifest.test.ts b/src/cli/handler-group-manifest.test.ts index d17c3446db1..3849f39a7c9 100644 --- a/src/cli/handler-group-manifest.test.ts +++ b/src/cli/handler-group-manifest.test.ts @@ -1,5 +1,5 @@ import { readdirSync } from 'node:fs' -import { join } from 'node:path' +import { join, relative, sep } from 'node:path' import { describe, expect, it } from 'vitest' import { buildHandlerRoutes, dispatch, type HandlerContext } from './dispatch' @@ -9,6 +9,32 @@ import { HANDLER_GROUPS, type HandlerGroup } from './handler-group-manifest' // group. These tests are the only thing standing between that trust and a // silently unreachable command, so they load every group for real. +// Why: __dirname works under both Vitest and the CommonJS tsc emit that +// build:cli type-checks this file against; import.meta.dirname does not. +const HANDLERS_DIR = join(__dirname, 'handlers') + +// Why: both the plural records and the single-command `*_HANDLER` ones that +// nested modules export get spread into a group, so both must route. +const HANDLER_RECORD_EXPORT = /_HANDLERS?$/ + +function listHandlerModules(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) { + return listHandlerModules(path) + } + return entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') ? [path] : [] + }) +} + +function isHandlerRecord(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + Object.values(value).every((entry) => typeof entry === 'function') + ) +} + describe('handler group manifest', () => { it('lists a loadable group for every entry', async () => { for (const group of HANDLER_GROUPS) { @@ -54,25 +80,35 @@ describe('handler group manifest', () => { }) // Why: dropping a group from the manifest silently unregisters its commands — - // scan the directory so a new or forgotten handler file fails here, not in prod. - it('registers every handler module that exports a handler group', async () => { - // Why: __dirname works under both Vitest and the CommonJS tsc emit that - // build:cli type-checks this file against; import.meta.dirname does not. - const dir = join(__dirname, 'handlers') - const modules = readdirSync(dir).filter( - (file) => file.endsWith('.ts') && !file.endsWith('.test.ts') - ) - const registered = new Set(HANDLER_GROUPS.map((group) => group.name)) - const missing: string[] = [] - for (const file of modules) { - const name = file.slice(0, -'.ts'.length) - const exports: Record = await import(join(dir, file)) - const exportsGroup = Object.keys(exports).some((key) => key.endsWith('_HANDLERS')) - if (exportsGroup && !registered.has(name)) { - missing.push(name) + // walk the tree so a new or forgotten handler file fails here, not in prod. + // Nested modules are spread into a parent group rather than registered under + // their own name, so routability, not file name, is the invariant that holds. + it('routes every command exported by a handler module', async () => { + const routes = buildHandlerRoutes(HANDLER_GROUPS) + const unroutable: string[] = [] + for (const file of listHandlerModules(HANDLERS_DIR)) { + const exports: Record = await import(file) + for (const [name, value] of Object.entries(exports)) { + if (!HANDLER_RECORD_EXPORT.test(name) || !isHandlerRecord(value)) { + continue + } + for (const key of Object.keys(value)) { + if (!routes.has(key)) { + unroutable.push(`${relative(HANDLERS_DIR, file)} ${name}: ${key}`) + } + } } } - expect(missing).toEqual([]) + expect(unroutable).toEqual([]) + }) + + it('finds the modules it is meant to guard', () => { + // Why: a walk that missed the tree would make the guard above vacuously pass. + const modules = listHandlerModules(HANDLERS_DIR) + expect(modules.length).toBeGreaterThanOrEqual(40) + expect( + modules.filter((file) => relative(HANDLERS_DIR, file).includes(sep)).length + ).toBeGreaterThanOrEqual(7) }) }) diff --git a/src/main/runtime/agent-session-claim-key-state.ts b/src/main/runtime/agent-session-claim-key-state.ts deleted file mode 100644 index f9fe77cbdc5..00000000000 --- a/src/main/runtime/agent-session-claim-key-state.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { classifyObservedAgentSessionSpawnToken } from '../../shared/agent-session-lease-adjudication' -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import type { AgentSessionStoreState } from './agent-session-record-store-file' - -export function isVerifiable( - state: AgentSessionStoreState, - keyId: string, - now: number, - retentionMs: number -): boolean { - const retired = state.retiredClaimKeys.find((entry) => entry.keyId === keyId) - return !retired || now - retired.retiredAt <= retentionMs -} - -export function markConflicted(record: AgentSessionRecord, now: number): AgentSessionRecord { - return { - ...record, - updatedAt: now, - // A conflicted key must remain conflicted after its observing process exits. - lease: { ...record.lease, claimStatus: 'conflicted', handoffStage: 'manual-recovery' } - } -} - -export function retire( - state: AgentSessionStoreState, - keyId: string, - now: number, - retentionMs: number -): void { - if (!state.retiredClaimKeys.some((entry) => entry.keyId === keyId)) { - state.retiredClaimKeys.push({ keyId, retiredAt: now }) - } - state.retiredClaimKeys = state.retiredClaimKeys.filter( - (entry) => now - entry.retiredAt <= retentionMs - ) -} - -export function listOrphanSpawnTokens( - records: readonly AgentSessionRecord[], - observedTokens: readonly string[] -): string[] { - const leases = records.map((record) => record.lease) - return observedTokens.filter( - (spawnToken) => classifyObservedAgentSessionSpawnToken({ spawnToken, leases }) === 'orphan' - ) -} diff --git a/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx b/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx index b6842fd95fe..5c3145360f6 100644 --- a/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx +++ b/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx @@ -1,3 +1,4 @@ +import { useAnyBrowserGuestNeedsPaint } from './browser-pane/host-guest/browser-guest-paint-retention' import { WorktreeSplitSurface } from './TerminalWorktreeSplitSurface' import type { TerminalController } from './use-terminal-controller' @@ -22,12 +23,22 @@ export function TerminalSplitWorkspaceSurfaces({ renderedActiveWorktreeId, workspaceSurfaces } = controller + // Why: this and TerminalSurface are both strict ancestors of every browser , so a + // remote controller needs each to drop `hidden` — the per-worktree surface hatch below cannot + // override an ancestor that stopped compositing. + const retainBrowserGuestPaint = useAnyBrowserGuestNeedsPaint(!effectiveActiveLayout) if (!anyMountedWorktreeHasLayout) { return null } return (
{workspaceSurfaces .filter((workspace) => mountedWorktreeIdsRef.current.has(workspace.id)) diff --git a/src/renderer/src/components/TerminalSurface.tsx b/src/renderer/src/components/TerminalSurface.tsx index 66553037b42..023f19f7311 100644 --- a/src/renderer/src/components/TerminalSurface.tsx +++ b/src/renderer/src/components/TerminalSurface.tsx @@ -1,4 +1,5 @@ import EditorAutosaveController from './editor/EditorAutosaveController' +import { useAnyBrowserGuestNeedsPaint } from './browser-pane/host-guest/browser-guest-paint-retention' import { TerminalTitlebarTabs } from './TerminalTitlebarTabs' import { TerminalSplitWorkspaceSurfaces } from './TerminalSplitWorkspaceSurfaces' import { TerminalLegacyWorkspaceSurface } from './TerminalLegacyWorkspaceSurface' @@ -11,10 +12,17 @@ export function TerminalSurface({ controller: TerminalController }): React.JSX.Element { const { renderedActiveWorktreeId } = controller + const retainBrowserGuestPaint = useAnyBrowserGuestNeedsPaint(!renderedActiveWorktreeId) return (
diff --git a/src/renderer/src/components/TerminalTitlebarTabs.test.tsx b/src/renderer/src/components/TerminalTitlebarTabs.test.tsx new file mode 100644 index 00000000000..e4f4162c97a --- /dev/null +++ b/src/renderer/src/components/TerminalTitlebarTabs.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ClientHostedBrowserRow } from '../../../shared/client-hosted-browser-rows' +import { + applyClientHostedBrowserRows, + getClientHostedBrowserRows +} from '@/lib/pane-manager/client-hosted-browser-row-state' +import { TerminalTitlebarTabs } from './TerminalTitlebarTabs' +import type { TerminalController } from './use-terminal-controller' + +const mocks = vi.hoisted(() => ({ + state: {} as Record, + tabBarProps: [] as Record[] +})) + +vi.mock('../store', () => ({ + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(mocks.state), { + getState: () => mocks.state + }) +})) +vi.mock('./tab-bar/TabBar', () => ({ + default: (props: Record) => { + mocks.tabBarProps.push(props) + return null + } +})) + +const WORKTREE_ID = 'repo-1::/repo/worktree' +const ROW: ClientHostedBrowserRow = { + browserPageId: 'page-1', + title: 'Client page', + url: 'https://example.com' +} as ClientHostedBrowserRow + +let titlebarTarget: HTMLElement +let container: HTMLElement + +function renderTitlebarTabs(): void { + const controller = { + activeBrowserTabId: null, + activeFileId: null, + activeTabId: null, + activeTabType: 'terminal', + effectiveActiveLayout: null, + expandedPaneByTabId: {}, + handleActivateBrowserTab: vi.fn(), + handleActivateTab: vi.fn(), + handleCloseAllFiles: vi.fn(), + handleCloseBrowserTab: vi.fn(), + handleCloseFile: vi.fn(), + handleCloseOthers: vi.fn(), + handleCloseTab: vi.fn(), + handleCloseTabsToLeft: vi.fn(), + handleCloseTabsToRight: vi.fn(), + handleDuplicateBrowserTab: vi.fn(), + handleNewBrowserTab: vi.fn(), + handleNewFile: vi.fn(), + handleNewSimulatorTab: vi.fn(), + handleNewTab: vi.fn(), + handleOpenEntry: vi.fn(), + handleTogglePaneExpand: vi.fn(), + makePreviewFilePermanent: vi.fn(), + mobileEmulatorEnabled: false, + pinFile: vi.fn(), + renderedActiveWorktreeId: WORKTREE_ID, + setActiveFile: vi.fn(), + setActiveTab: vi.fn(), + setActiveTabType: vi.fn(), + setTabColor: vi.fn(), + setTabCustomTitle: vi.fn(), + tabBarOrder: [], + titlebarTabsTarget: titlebarTarget, + worktreeBrowserTabs: [], + // Mirrors the projection hook's derivation so the assertion follows the real row store. + worktreeClientHostedBrowserRows: getClientHostedBrowserRows(WORKTREE_ID), + worktreeFiles: [] + } as unknown as TerminalController + const root = createRoot(container) + act(() => root.render()) + act(() => root.unmount()) +} + +describe('TerminalTitlebarTabs', () => { + beforeEach(() => { + mocks.tabBarProps = [] + mocks.state = { tabsByWorktree: {}, unifiedTabsByWorktree: {}, getActiveTab: () => null } + titlebarTarget = document.createElement('div') + container = document.createElement('div') + document.body.append(titlebarTarget, container) + }) + + afterEach(() => { + applyClientHostedBrowserRows({ worktreeId: WORKTREE_ID, rows: [] }) + titlebarTarget.remove() + container.remove() + }) + + it('forwards client-hosted browser rows to the titlebar tab bar', () => { + applyClientHostedBrowserRows({ worktreeId: WORKTREE_ID, rows: [ROW] }) + renderTitlebarTabs() + expect(mocks.tabBarProps.at(-1)?.clientHostedBrowserRows).toEqual([ROW]) + }) + + it('passes no rows when the worktree has none', () => { + renderTitlebarTabs() + expect(mocks.tabBarProps.at(-1)?.clientHostedBrowserRows).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/TerminalTitlebarTabs.tsx b/src/renderer/src/components/TerminalTitlebarTabs.tsx index a275da9d801..c2a5d8218e4 100644 --- a/src/renderer/src/components/TerminalTitlebarTabs.tsx +++ b/src/renderer/src/components/TerminalTitlebarTabs.tsx @@ -1,8 +1,20 @@ import { createPortal } from 'react-dom' +import type { TerminalTab } from '../../../shared/terminal-tab-types' import { useAppStore } from '../store' import TabBar from './tab-bar/TabBar' import type { TerminalController } from './use-terminal-controller' +const EMPTY_TERMINAL_TABS: TerminalTab[] = [] + +// Why: keeps title-only tab updates a leaf subscription so the Terminal root, +// which reads the topology projection, does not re-render on every rename. +function LiveTerminalTabBar( + props: Omit, 'tabs'> +): React.JSX.Element { + const tabs = useAppStore((state) => state.tabsByWorktree[props.worktreeId] ?? EMPTY_TERMINAL_TABS) + return +} + export function TerminalTitlebarTabs({ controller }: { @@ -41,17 +53,16 @@ export function TerminalTitlebarTabs({ setTabColor, setTabCustomTitle, tabBarOrder, - tabs, titlebarTabsTarget, worktreeBrowserTabs, + worktreeClientHostedBrowserRows, worktreeFiles } = controller if (!renderedActiveWorktreeId || effectiveActiveLayout || !titlebarTabsTarget) { return null } return createPortal( - ([ ['components/TerminalWorkbenchContainer.tsx', ['useAnyBrowserGuestNeedsPaint']], + // The two outermost workbench wrappers: strict ancestors of every guest, so the per-worktree + // surface hatch below cannot rescue a guest either one parked with `hidden`. + ['components/TerminalSurface.tsx', ['useAnyBrowserGuestNeedsPaint']], + ['components/TerminalSplitWorkspaceSurfaces.tsx', ['useAnyBrowserGuestNeedsPaint']], ['components/TerminalWorktreeSplitSurface.tsx', ['useBrowserGuestPaintRetention']], [ 'components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx', diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts index 781948111c1..a60cdc7d93e 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts @@ -3,6 +3,7 @@ import { toast } from 'sonner' import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-visible-id' import { getConnectionId } from '@/lib/connection-context' import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown' +import { ensureClientCreationActionAllowed } from '@/lib/client-creation-action-error' import { detectLanguage } from '@/lib/language-detect' import { extractIpcErrorMessage } from '@/lib/ipc-error' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' @@ -74,6 +75,9 @@ export function useFloatingTerminalCreateActions({ ) const createFloatingBrowserTab = useCallback(() => { + if (!ensureClientCreationActionAllowed(FLOATING_TERMINAL_WORKTREE_ID, 'managed-browser')) { + return + } const url = browserDefaultUrl ?? 'about:blank' createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, url, { title: translate( diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts index 67e0bc79db3..b597810c0fd 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, type KeyboardEvent as ReactKeyboardEvent } from 'react' import { isTerminalPaneCloseChord } from '@/components/terminal-pane/terminal-shortcut-policy' +import { ensureClientCreationActionAllowed } from '@/lib/client-creation-action-error' import { matchFloatingWorkspacePanelOwnedAction, matchFloatingWorkspacePanelShortcut @@ -7,6 +8,7 @@ import { import { isFloatingWorkspaceTerminalInputTarget } from '@/lib/floating-workspace-terminal-actions' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { useAppStore } from '@/store' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import type { KeybindingContext, KeybindingMatchOptions } from '../../../../shared/keybindings' import type { FloatingPanelShortcutInput, @@ -123,6 +125,11 @@ export function useFloatingTerminalPanelShortcuts({ if (resolution.action === 'tab.newTerminal') { createFloatingTerminalTab() } else if (resolution.action === 'tab.newBrowser') { + if ( + !ensureClientCreationActionAllowed(FLOATING_TERMINAL_WORKTREE_ID, 'managed-browser') + ) { + return 'handled' + } createFloatingBrowserTab() } else if (resolution.action === 'tab.newMarkdown') { createFloatingMarkdownTab() diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts index e1d58f70ded..4382cd1aeb0 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts @@ -21,23 +21,6 @@ const COPY_SOURCE = readSource('smart-workspace-name-field-copy.ts') const INPUT_SOURCE = readSource('smart-workspace-name-input-surface.tsx') const SURFACE_SOURCE = readSource('smart-workspace-name-field-surface.tsx') const DIALOG_SOURCE = readSource('smart-workspace-cross-repo-dialog.tsx') -const FIELD_SOURCES = [ - MODEL_SOURCE, - CONTROLLER_SOURCE, - FOUNDATION_SOURCE, - AVAILABILITY_SOURCE, - FOCUS_SOURCE, - STATE_SOURCE, - GITHUB_SOURCE, - GITLAB_SOURCE, - SECONDARY_SEARCH_SOURCE, - ACTIONS_SOURCE, - PRESENTATION_SOURCE, - COPY_SOURCE, - INPUT_SOURCE, - SURFACE_SOURCE, - DIALOG_SOURCE -].join('\n') function sourceBetween(source: string, startPattern: string, endPattern: string): string { const start = source.indexOf(startPattern) @@ -72,7 +55,7 @@ describe('SmartWorkspaceNameField repo-backed source boundaries', () => { expect(availableModesSection).toContain("item.id === 'jira'") expect(availableModesSection).toContain('return jiraSourceConnected') expect(availableModesSection).toContain('branchesEnabled && !repoBackedSourcesDisabled') - expect(FIELD_SOURCES).toContain('repoBackedSourcesDisabled') + expect(CONTROLLER_SOURCE).toContain('repoBackedSourcesDisabled') expect(CONTROLLER_SOURCE).toContain('foundation.gitlabSourceAvailable') const jiraLookupSection = sourceBetween( @@ -107,8 +90,13 @@ describe('SmartWorkspaceNameField repo-backed source boundaries', () => { }) it('searches repo-backed task sources through implicit repo targets instead of a menu', () => { - expect(FIELD_SOURCES).not.toContain('RepoBackedSourceMenu') - expect(FIELD_SOURCES).not.toContain('repoBackedSourceOptions') + // The menu declared a prop, derived a visibility flag, and rendered a control; implicit repo + // targets replaced all three, so each former host is pinned separately. + expect(MODEL_SOURCE).not.toContain('repoBackedSourceOptions') + expect(CONTROLLER_SOURCE).not.toContain('repoBackedSourceOptions') + expect(FOUNDATION_SOURCE).not.toContain('repoBackedSourceOptions') + expect(SURFACE_SOURCE).not.toContain('RepoBackedSourceMenu') + expect(INPUT_SOURCE).not.toContain('RepoBackedSourceMenu') expect(MODEL_SOURCE).toContain('repoBackedSearchRepos?: readonly RepoOption[]') const targetSection = sourceBetween( @@ -125,26 +113,27 @@ describe('SmartWorkspaceNameField repo-backed source boundaries', () => { expect(CONTROLLER_SOURCE).toContain('foundation.repoBackedSearchTargets.length > 0') expect(GITHUB_SOURCE).toContain('fetchWorkItemsAcrossRepos') expect(GITHUB_SOURCE).toContain('repoBackedSearchTargets.map') + expect(GITLAB_SOURCE).toContain('repoBackedSearchTargets.map') }) it('does not fan decisive Linear URLs out to unrelated providers', () => { const githubGate = sourceBetween( - FIELD_SOURCES, + CONTROLLER_SOURCE, 'const shouldQueryGithub =', 'const shouldQueryLinear =' ) const branchGate = sourceBetween( - FIELD_SOURCES, + SECONDARY_SEARCH_SOURCE, 'const branchSearchRequest = useMemo', 'useEffect(() => {\n if (!branchSearchRequest)' ) const gitlabGate = sourceBetween( - FIELD_SOURCES, + CONTROLLER_SOURCE, 'const shouldQueryGitlab =', 'useSmartWorkspaceGitlabSearch({' ) - expect(FIELD_SOURCES).toContain( + expect(PRESENTATION_SOURCE).toContain( "linearUrlIntent !== null && (mode === 'smart' || mode === 'linear')" ) expect(githubGate).toContain('!linearUrlIntentOwnsInput') diff --git a/src/renderer/src/components/status-bar/ResourceUsageAppSection.tsx b/src/renderer/src/components/status-bar/ResourceUsageAppSection.tsx deleted file mode 100644 index 6f74cba2d5e..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageAppSection.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react' -import { ChevronDown, ChevronRight } from 'lucide-react' -import type { AppMemory, UsageValues } from '../../../../shared/process-stats-types' -import { translate } from '@/i18n/i18n' -import { - ResourceUsageMetricPair, - ResourceUsageSparkline, - ROW_TRAILING_GUTTER_CLS -} from './ResourceUsageMetrics' - -function AppSubRow({ label, values }: { label: string; values: UsageValues }): React.JSX.Element { - return ( -
- {label} -
- - -
-
- ) -} - -export function ResourceUsageAppSection({ - app, - isCollapsed, - onToggle -}: { - app: AppMemory - isCollapsed: boolean - onToggle: () => void -}): React.JSX.Element { - return ( -
-
- -
- - {translate('auto.components.status.bar.ResourceUsageStatusSegment.288a4dd177', 'Orca')} - -
- - - -
-
-
- {!isCollapsed && ( -
- - - {(app.other.cpu > 0 || app.other.memory > 0) && ( - - )} -
- )} -
- ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsageKillDialog.tsx b/src/renderer/src/components/status-bar/ResourceUsageKillDialog.tsx deleted file mode 100644 index fcb6beb6f2e..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageKillDialog.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react' -import { LoaderCircle } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { translate } from '@/i18n/i18n' -import type { ResourceUsageActions } from './use-resource-usage-actions' -import type { ResourceUsageFoundation } from './use-resource-usage-foundation' - -export function ResourceUsageKillDialog({ - foundation, - actions -}: { - foundation: ResourceUsageFoundation - actions: ResourceUsageActions -}): React.JSX.Element { - const { killConfirm, killing, setKillConfirm } = foundation - const { runKillConfirmed } = actions - - return ( - { - if (next) { - return - } - if (killing) { - return - } - setKillConfirm(null) - }} - > - { - if (killing) { - event.preventDefault() - } - }} - onEscapeKeyDown={(event) => { - if (killing) { - event.preventDefault() - } - }} - > - - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.e9a5d3c2b1f0', - 'Kill {{value0}}?', - { - value0: - killConfirm?.label ?? - translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.138b99bd80', - 'this session' - ) - } - )} - - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.67c4ecda49', - "Force-quits this terminal. Any unsaved work in the pane is lost. This can't be undone." - )} - - - - - - - - - ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsageMetrics.tsx b/src/renderer/src/components/status-bar/ResourceUsageMetrics.tsx deleted file mode 100644 index 6353c41b761..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageMetrics.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import React, { memo, useMemo } from 'react' -import { cn } from '@/lib/utils' -import type { Metric } from './resource-usage-merge-types' - -export const METRIC_COLUMNS_CLS = 'flex items-center shrink-0 tabular-nums' -export const CPU_COLUMN_CLS = 'w-12 text-right' -export const MEM_COLUMN_CLS = 'w-16 text-right' -export const ROW_TRAILING_GUTTER_CLS = 'w-5 shrink-0 flex items-center justify-end' - -export function formatMemory(bytes: number): string { - if (bytes < 1024 * 1024) { - return `${Math.round(bytes / 1024)} KB` - } - if (bytes < 1024 * 1024 * 1024) { - return `${(bytes / (1024 * 1024)).toFixed(1)} MB` - } - return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB` -} - -export function formatCpu(percent: number): string { - return `${percent.toFixed(1)}%` -} - -function formatMetricCpu(value: Metric): string { - return value === null ? '—' : formatCpu(value) -} - -function formatMetricMemory(value: Metric): string { - return value === null ? '—' : formatMemory(value) -} - -type SparklineProps = { - samples: number[] - width?: number - height?: number -} - -function SparklineImpl({ samples, width = 48, height = 14 }: SparklineProps): React.JSX.Element { - const points = useMemo(() => { - const safe = Array.isArray(samples) ? samples : [] - if (safe.length < 2) { - const midY = (height / 2).toFixed(1) - return `0,${midY} ${width},${midY}` - } - - let min = safe[0] - let max = safe[0] - for (const value of safe) { - if (value < min) { - min = value - } - if (value > max) { - max = value - } - } - const range = max - min || 1 - const stepX = width / (safe.length - 1) - const out: string[] = [] - for (let index = 0; index < safe.length; index++) { - const x = (index * stepX).toFixed(1) - const y = (height - ((safe[index] - min) / range) * height).toFixed(1) - out.push(`${x},${y}`) - } - return out.join(' ') - }, [samples, width, height]) - - return ( - - - - ) -} - -export const ResourceUsageSparkline = memo(SparklineImpl, (left, right) => { - if (left.width !== right.width || left.height !== right.height) { - return false - } - const leftSamples = Array.isArray(left.samples) ? left.samples : [] - const rightSamples = Array.isArray(right.samples) ? right.samples : [] - if (leftSamples === rightSamples) { - return true - } - if (leftSamples.length !== rightSamples.length) { - return false - } - for (let index = 0; index < leftSamples.length; index++) { - if (leftSamples[index] !== rightSamples[index]) { - return false - } - } - return true -}) - -export function ResourceUsageMetricPair({ - cpu, - memory, - size = 'base' -}: { - cpu: Metric - memory: Metric - size?: 'base' | 'small' -}): React.JSX.Element { - const textClassName = size === 'small' ? 'text-[11px]' : 'text-xs' - const muted = cpu === null && memory === null - return ( -
- {formatMetricCpu(cpu)} - {formatMetricMemory(memory)} -
- ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsagePopoverContent.tsx b/src/renderer/src/components/status-bar/ResourceUsagePopoverContent.tsx deleted file mode 100644 index 64ca6049363..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsagePopoverContent.tsx +++ /dev/null @@ -1,376 +0,0 @@ -import React from 'react' -import { AlertTriangle, ChevronRight, MemoryStick, RotateCw, Trash2 } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { PopoverContent } from '@/components/ui/popover' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { translate } from '@/i18n/i18n' -import { cn } from '@/lib/utils' -import { WorkspaceSpaceCompactPanel } from './WorkspaceSpaceCompactPanel' -import { ResourceUsageAppSection } from './ResourceUsageAppSection' -import { - CPU_COLUMN_CLS, - formatCpu, - formatMemory, - MEM_COLUMN_CLS, - METRIC_COLUMNS_CLS, - ROW_TRAILING_GUTTER_CLS -} from './ResourceUsageMetrics' -import { ResourceUsageTree } from './ResourceUsageTree' -import { STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS } from './status-bar-context-menu-policy' -import type { ResourceUsageActions } from './use-resource-usage-actions' -import type { ResourceUsageFoundation } from './use-resource-usage-foundation' -import type { ResourceUsageProjection } from './use-resource-usage-projection' - -export function ResourceUsagePopoverContent({ - foundation, - projection, - actions -}: { - foundation: ResourceUsageFoundation - projection: ResourceUsageProjection - actions: ResourceUsageActions -}): React.JSX.Element { - const { - sortOption, - setSortOption, - daemonActions, - resourceSnapshot, - setPopoverBodyNode, - collapsedRepos, - collapsedWorktrees, - activeWorktreeId, - appCollapsed, - setAppCollapsed - } = foundation - const { - daemonUnreachable, - sessionsOnlyError, - totalCpu, - totalMemory, - memoryMetricCopy, - orphanCount, - unifiedRepos - } = projection - const { - toggleRepo, - toggleWorktree, - navigateToWorktree, - navigateToTab, - deleteWorktree, - handleKillSession, - handleOpenWorkspaceCleanup, - handleKillOrphans, - openSpaceResults - } = actions - - return ( - event.preventDefault()} - // Why: xterm focus must not dismiss the resource manager after tab activation. - onFocusOutside={(event) => event.preventDefault()} - > -
-
- - - {translate('auto.components.status.bar.StatusBar.d1e1a7a6bf', 'Resource Manager')} - -
- -
- - - - - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.c9382662bb', - 'Restart daemon' - )} - - - - - - - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.bd19fd7a59', - 'Kill all sessions' - )} - - -
-
- - {daemonUnreachable && ( -
- -
-
- {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.f8e0d794b4', - 'Daemon is not responding' - )} -
-
- {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.f85af9cda6', - 'Resource snapshots and terminal sessions are unavailable.' - )} -
-
- -
- )} - - {!daemonUnreachable && sessionsOnlyError && ( -
- - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.e7cf14ec78', - 'Terminal sessions unavailable. The list may be stale.' - )} - -
- )} - - {resourceSnapshot && ( -
-
- - - - {formatCpu(totalCpu)} - - - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.1fedf94eae', - 'Combined CPU load. Values above 100% mean more than one core is working at once.' - )} - - - · - - - - {formatMemory(totalMemory)}{' '} - - {memoryMetricCopy.summaryLabel} - - - - - {memoryMetricCopy.description} - - -
- {orphanCount > 0 && ( - - {orphanCount === 1 - ? translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.30ff2c3c31', - '{{value0}} orphan', - { value0: orphanCount } - ) - : translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.b8f4a2c1d0e3', - '{{value0}} orphans', - { value0: orphanCount } - )} - - )} -
- )} - - {/* Why: fixed height prevents list expansion and polling from moving the popover. */} -
- {(unifiedRepos.length > 0 || resourceSnapshot) && ( -
- -
-
- - -
- -
-
- )} - -
- {unifiedRepos.length > 0 && ( - - )} - - {unifiedRepos.length === 0 && resourceSnapshot && ( -
- {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.27a74f91f0', - 'Nothing running right now' - )} -
- )} - - {resourceSnapshot && ( - setAppCollapsed((value) => !value)} - /> - )} - - {!resourceSnapshot && !daemonUnreachable && ( -
- {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.888dad8c55', - 'Loading…' - )} -
- )} -
-
- -
- - {orphanCount > 0 ? ( - - ) : null} -
- - -
- ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsageTree.tsx b/src/renderer/src/components/status-bar/ResourceUsageTree.tsx deleted file mode 100644 index 3914bab48df..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageTree.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import React, { useMemo } from 'react' -import { ChevronDown, ChevronRight } from 'lucide-react' -import { useWorktreeMap } from '../../store/selectors' -import { translate } from '@/i18n/i18n' -import type { - UnifiedProjectGroup, - UnifiedSessionRow, - UnifiedWorktreeRow -} from './resource-usage-merge-types' -import type { ResourceUsageSortOption } from './resource-usage-sort' -import { sortResourceUsageProjectGroups, sortResourceUsageWorktrees } from './resource-usage-sort' -import { ResourceUsageMetricPair, ROW_TRAILING_GUTTER_CLS } from './ResourceUsageMetrics' -import { ResourceUsageWorktreeRow } from './ResourceUsageWorktreeRow' - -export function ResourceUsageTree({ - repos, - sortOption, - collapsedRepos, - toggleRepo, - collapsedWorktrees, - activeWorktreeId, - toggleWorktree, - navigateToWorktree, - navigateToTab, - onDelete, - onKillSession -}: { - repos: UnifiedProjectGroup[] - sortOption: ResourceUsageSortOption - collapsedRepos: Set - toggleRepo: (repoId: string) => void - collapsedWorktrees: Set - activeWorktreeId: string | null - toggleWorktree: (worktreeId: string) => void - navigateToWorktree: (worktreeId: string) => void - navigateToTab: (tabId: string, paneKey: string | null) => void - onDelete: (worktreeId: string) => void - onKillSession: (session: UnifiedSessionRow) => void -}): React.JSX.Element { - const worktreeById = useWorktreeMap() - const sortedRepos = useMemo(() => { - const grouped = sortResourceUsageProjectGroups(repos, sortOption) - return grouped.map((repo) => ({ - ...repo, - worktrees: sortResourceUsageWorktrees(repo.worktrees, sortOption) - })) - }, [repos, sortOption]) - const renderWorktree = (worktree: UnifiedWorktreeRow): React.JSX.Element => { - const storeRecord = worktreeById.get(worktree.worktreeId) ?? null - return ( - toggleWorktree(worktree.worktreeId)} - onNavigate={() => navigateToWorktree(worktree.worktreeId)} - onDelete={() => onDelete(worktree.worktreeId)} - onKillSession={onKillSession} - navigateToTab={navigateToTab} - /> - ) - } - - if (sortedRepos.length === 1) { - return <>{sortedRepos[0].worktrees.map(renderWorktree)} - } - - return ( - <> - {sortedRepos.map((group) => { - const repoCollapsed = collapsedRepos.has(group.repoId) - return ( -
-
- -
- - - {group.repoName} - - {group.hasRemoteChildren && ( - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.21cacb16d1', - '· remote' - )} - - )} - -
- - -
-
-
- {!repoCollapsed && ( -
{group.worktrees.map(renderWorktree)}
- )} -
- ) - })} - - ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsageTrigger.tsx b/src/renderer/src/components/status-bar/ResourceUsageTrigger.tsx deleted file mode 100644 index 567be1d8a4b..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageTrigger.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import React from 'react' -import { AlertTriangle, MemoryStick, Terminal } from 'lucide-react' -import { PopoverTrigger } from '@/components/ui/popover' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { translate } from '@/i18n/i18n' -import { STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS } from './status-bar-context-menu-policy' -import type { ResourceUsageProjection } from './use-resource-usage-projection' - -export function ResourceUsageTrigger({ - iconOnly, - spaceScanReady, - projection -}: { - iconOnly: boolean - spaceScanReady: boolean - projection: ResourceUsageProjection -}): React.JSX.Element { - const { - daemonUnreachable, - resourceManagerAriaLabel, - memBadgeLabel, - triggerSessionCount, - orphanCount, - resourceManagerTooltipLines - } = projection - - return ( - - - - - - - -
- {resourceManagerTooltipLines.map((line) => ( -
- {line.text} -
- ))} -
-
-
- ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsageWorktreeRow.tsx b/src/renderer/src/components/status-bar/ResourceUsageWorktreeRow.tsx deleted file mode 100644 index 741863d11f1..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageWorktreeRow.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import React from 'react' -import { ChevronDown, ChevronRight, Globe, Trash2, X } from 'lucide-react' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { cn } from '@/lib/utils' -import { translate } from '@/i18n/i18n' -import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types' -import type { Worktree } from '../../../../shared/worktree/types' -import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants' -import { UNATTRIBUTED_REPO_ID } from './mergeSnapshotAndSessions' -import type { UnifiedSessionRow, UnifiedWorktreeRow } from './resource-usage-merge-types' -import { isResourceSessionActivationKey } from './resource-session-navigation' -import { - ResourceUsageMetricPair, - ResourceUsageSparkline, - ROW_TRAILING_GUTTER_CLS -} from './ResourceUsageMetrics' - -export function ResourceUsageSessionRow({ - session, - worktreeId, - onNavigate, - onKill -}: { - session: UnifiedSessionRow - worktreeId: string - onNavigate: (tabId: string, paneKey: string | null) => void - onKill: (session: UnifiedSessionRow) => void -}): React.JSX.Element { - const clickable = session.tabId !== null && session.bound - const handleClick = (): void => { - if (clickable && session.tabId) { - onNavigate(session.tabId, session.paneKey) - } - } - - return ( -
{ - if (isResourceSessionActivationKey(event.key)) { - event.preventDefault() - handleClick() - } - } - : undefined - } - data-worktree-id={worktreeId} - > - - - {session.label} - - - {/* Why: the shared gutter aligns columns while keeping orphan kills visible. */} - - - -
- ) -} - -function BrowserRow({ browser }: { browser: BrowserWorkspace }): React.JSX.Element { - const label = browser.title?.trim() || browser.label?.trim() || browser.url - return ( -
- - {label} - - -
- ) -} - -export function ResourceUsageWorktreeRow({ - worktree, - storeRecord, - activeWorktreeId, - isCollapsed, - onToggle, - onNavigate, - onDelete, - onKillSession, - navigateToTab -}: { - worktree: UnifiedWorktreeRow - storeRecord: Worktree | null - activeWorktreeId: string | null - isCollapsed: boolean - onToggle: () => void - onNavigate: () => void - onDelete: () => void - onKillSession: (session: UnifiedSessionRow) => void - navigateToTab: (tabId: string, paneKey: string | null) => void -}): React.JSX.Element { - const hasResources = worktree.sessions.length > 0 || worktree.browsers.length > 0 - const isSynthetic = - worktree.worktreeId === ORPHAN_WORKTREE_ID || worktree.repoId === UNATTRIBUTED_REPO_ID - const isNavigable = !isSynthetic - const showWorktreeActions = - !isSynthetic && storeRecord !== null && worktree.worktreeId !== activeWorktreeId - const isMainWorktree = storeRecord?.isMainWorktree ?? false - const rowLabel = storeRecord?.displayName?.trim() || worktree.worktreeName - - return ( -
-
- {hasResources ? ( - - ) : ( - - )} - -
-
- - - - {showWorktreeActions && ( -
- - - - - - {isMainWorktree - ? translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.946724a70a', - 'The main workspace cannot be deleted.' - ) - : translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.a82253b458', - 'Delete workspace.' - )} - - -
- )} -
- - -
-
- {!isCollapsed && - worktree.sessions.map((session) => ( - - ))} - {!isCollapsed && - worktree.browsers.map((browser) => )} -
- ) -} diff --git a/src/renderer/src/components/status-bar/resource-usage-sort.ts b/src/renderer/src/components/status-bar/resource-usage-sort.ts deleted file mode 100644 index e66e5c8e458..00000000000 --- a/src/renderer/src/components/status-bar/resource-usage-sort.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { Metric, UnifiedProjectGroup, UnifiedWorktreeRow } from './resource-usage-merge-types' - -export type ResourceUsageSortOption = 'memory' | 'cpu' | 'name' - -function compareMetricDesc(left: Metric, right: Metric): number { - // Why: remote null metrics stay behind sampled rows for every sort direction. - if (left === null && right === null) { - return 0 - } - if (left === null) { - return 1 - } - if (right === null) { - return -1 - } - return right - left -} - -export function sortResourceUsageWorktrees( - list: UnifiedWorktreeRow[], - sort: ResourceUsageSortOption -): UnifiedWorktreeRow[] { - const copy = [...list] - if (sort === 'memory') { - copy.sort((left, right) => compareMetricDesc(left.memory, right.memory)) - } else if (sort === 'cpu') { - copy.sort((left, right) => compareMetricDesc(left.cpu, right.cpu)) - } else { - copy.sort((left, right) => left.worktreeName.localeCompare(right.worktreeName)) - } - return copy -} - -export function sortResourceUsageProjectGroups( - groups: UnifiedProjectGroup[], - sort: ResourceUsageSortOption -): UnifiedProjectGroup[] { - const copy = [...groups] - if (sort === 'memory') { - copy.sort((left, right) => compareMetricDesc(left.memory, right.memory)) - } else if (sort === 'cpu') { - copy.sort((left, right) => compareMetricDesc(left.cpu, right.cpu)) - } else { - copy.sort((left, right) => left.repoName.localeCompare(right.repoName)) - } - return copy -} diff --git a/src/renderer/src/components/status-bar/use-resource-usage-foundation.ts b/src/renderer/src/components/status-bar/use-resource-usage-foundation.ts deleted file mode 100644 index bfb0314cfca..00000000000 --- a/src/renderer/src/components/status-bar/use-resource-usage-foundation.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useMountedRef } from '@/hooks/useMountedRef' -import { useAppStore } from '../../store' -import { useDaemonActions } from '../shared/useDaemonActions' -import type { UnifiedSessionRow } from './resource-usage-merge-types' -import type { ResourceUsageSortOption } from './resource-usage-sort' -import { - getResourceUsageAllWorktrees, - getResourceUsageBrowserTabsByWorktree, - getResourceUsageDeferredSshSessionIdsByTabId, - getResourceUsagePtyIdsByTabId, - getResourceUsageRepos, - getResourceUsageRuntimePaneTitlesByTabId, - getResourceUsageTerminalLayoutsByTabId, - getResourceUsageTabsByWorktree -} from './resource-usage-open-slices' -import { - resolveResourceUsageSpaceScanReady, - type ResourceUsageSpaceScanSnapshot -} from './resource-usage-space-scan-ready' -import type { ResourceSessionBindingInputs } from './resource-session-bindings' -import { useResourceSessionInventory } from './use-resource-session-inventory' - -const POLL_MS = 2_000 - -export function useResourceUsageFoundation() { - const snapshot = useAppStore((state) => state.memorySnapshot) - const memorySnapshotError = useAppStore((state) => state.memorySnapshotError) - const fetchSnapshot = useAppStore((state) => state.fetchMemorySnapshot) - const workspaceSessionReady = useAppStore((state) => state.workspaceSessionReady) - const setActiveView = useAppStore((state) => state.setActiveView) - const openModal = useAppStore((state) => state.openModal) - const openSpacePage = useAppStore((state) => state.openSpacePage) - const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction) - const activeView = useAppStore((state) => state.activeView) - const activeWorktreeId = useAppStore((state) => state.activeWorktreeId) - const workspaceSpaceScannedAt = useAppStore( - (state) => state.workspaceSpaceAnalysis?.scannedAt ?? null - ) - const workspaceSpaceScanning = useAppStore((state) => state.workspaceSpaceScanning) - const [open, setOpen] = useState(false) - const [sortOption, setSortOption] = useState('memory') - const [collapsedRepos, setCollapsedRepos] = useState>(new Set()) - const [collapsedWorktrees, setCollapsedWorktrees] = useState>(new Set()) - const [appCollapsed, setAppCollapsed] = useState(true) - const { - sessionInventory, - sessionsError, - refreshSessions, - clearSessionsError, - removeSession, - removeSessions - } = useResourceSessionInventory(workspaceSessionReady) - const sessions = sessionInventory.sessions - const [killConfirm, setKillConfirm] = useState(null) - const [killing, setKilling] = useState(false) - const [spaceScanSnapshot, setSpaceScanSnapshot] = useState( - () => ({ - ready: false, - previousScanning: workspaceSpaceScanning, - lastSeenScannedAt: workspaceSpaceScannedAt - }) - ) - // Why: title and binding maps churn; the closed trigger selects stable sentinels. - const runtimePaneTitlesByTabId = useAppStore((state) => - getResourceUsageRuntimePaneTitlesByTabId(state, open) - ) - const repos = useAppStore((state) => getResourceUsageRepos(state, open)) - const allWorktrees = useAppStore((state) => getResourceUsageAllWorktrees(state, open)) - const tabsByWorktree = useAppStore((state) => getResourceUsageTabsByWorktree(state, open)) - const browserTabsByWorktree = useAppStore((state) => - getResourceUsageBrowserTabsByWorktree(state, open) - ) - const ptyIdsByTabId = useAppStore((state) => getResourceUsagePtyIdsByTabId(state, open)) - const terminalLayoutsByTabId = useAppStore((state) => - getResourceUsageTerminalLayoutsByTabId(state, open) - ) - const deferredSshSessionIdsByTabId = useAppStore((state) => - getResourceUsageDeferredSshSessionIdsByTabId(state, open) - ) - const resourceSnapshot = snapshot - const resourceSessionBindings = useMemo( - () => ({ - ptyIdsByTabId, - tabsByWorktree, - terminalLayoutsByTabId, - deferredSshSessionIdsByTabId, - workspaceSessionReady - }), - [ - ptyIdsByTabId, - tabsByWorktree, - terminalLayoutsByTabId, - deferredSshSessionIdsByTabId, - workspaceSessionReady - ] - ) - const popoverBodyRef = useRef(null) - const popoverBodyFocusFrameRef = useRef(null) - const mountedRef = useMountedRef() - const cancelPopoverBodyFocusFrame = useCallback((): void => { - if (popoverBodyFocusFrameRef.current === null) { - return - } - cancelAnimationFrame(popoverBodyFocusFrameRef.current) - popoverBodyFocusFrameRef.current = null - }, []) - const setPopoverBodyNode = useCallback( - (node: HTMLDivElement | null): void => { - if (!node) { - cancelPopoverBodyFocusFrame() - } - popoverBodyRef.current = node - }, - [cancelPopoverBodyFocusFrame] - ) - const daemonActions = useDaemonActions({ - onRestartSettled: () => { - clearSessionsError() - void fetchSnapshot() - void refreshSessions() - } - }) - const nextSpaceScanSnapshot = resolveResourceUsageSpaceScanReady({ - snapshot: spaceScanSnapshot, - open, - activeView, - scannedAt: workspaceSpaceScannedAt, - scanning: workspaceSpaceScanning - }) - if ( - nextSpaceScanSnapshot.ready !== spaceScanSnapshot.ready || - nextSpaceScanSnapshot.previousScanning !== spaceScanSnapshot.previousScanning || - nextSpaceScanSnapshot.lastSeenScannedAt !== spaceScanSnapshot.lastSeenScannedAt - ) { - setSpaceScanSnapshot(nextSpaceScanSnapshot) - } - const spaceScanReady = nextSpaceScanSnapshot.ready - - // Why: seed RAM after session restore so the closed badge does not require a click. - useEffect(() => { - if (workspaceSessionReady) { - void fetchSnapshot() - } - }, [workspaceSessionReady, fetchSnapshot]) - - useEffect(() => { - if (!open) { - return - } - void fetchSnapshot() - void refreshSessions() - const memTimer = window.setInterval(() => { - void fetchSnapshot() - }, POLL_MS) - return () => { - window.clearInterval(memTimer) - } - }, [open, fetchSnapshot, refreshSessions]) - - useEffect(() => { - if (!open) { - clearSessionsError() - } - }, [open, clearSessionsError]) - - return { - snapshot, - memorySnapshotError, - workspaceSessionReady, - setActiveView, - openModal, - openSpacePage, - recordFeatureInteraction, - activeWorktreeId, - open, - setOpen, - sortOption, - setSortOption, - collapsedRepos, - setCollapsedRepos, - collapsedWorktrees, - setCollapsedWorktrees, - appCollapsed, - setAppCollapsed, - sessionInventory, - sessionsError, - refreshSessions, - removeSession, - removeSessions, - sessions, - killConfirm, - setKillConfirm, - killing, - setKilling, - runtimePaneTitlesByTabId, - repos, - allWorktrees, - tabsByWorktree, - browserTabsByWorktree, - resourceSnapshot, - resourceSessionBindings, - popoverBodyRef, - popoverBodyFocusFrameRef, - mountedRef, - cancelPopoverBodyFocusFrame, - setPopoverBodyNode, - daemonActions, - spaceScanReady - } -} - -export type ResourceUsageFoundation = ReturnType diff --git a/src/renderer/src/components/status-bar/use-resource-usage-projection.ts b/src/renderer/src/components/status-bar/use-resource-usage-projection.ts deleted file mode 100644 index 5b8145d58e0..00000000000 --- a/src/renderer/src/components/status-bar/use-resource-usage-projection.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { useEffect, useMemo, useState } from 'react' -import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' -import { countEstimatedInactiveWorkspaces } from '../workspace-cleanup/inactive-workspace-estimate' -import { mergeSnapshotAndSessions } from './mergeSnapshotAndSessions' -import { countUnboundDaemonSessions } from './resource-session-bindings' -import { - getResourceManagerAriaLabel, - getResourceManagerTooltipLines -} from './resource-manager-terminal-copy' -import { getResourceMemoryMetricCopy } from './resource-memory-metric-copy' -import { formatMemory } from './ResourceUsageMetrics' -import type { ResourceUsageFoundation } from './use-resource-usage-foundation' - -export function useResourceUsageProjection(foundation: ResourceUsageFoundation) { - const { - repos, - allWorktrees, - open, - resourceSnapshot, - sessions, - resourceSessionBindings, - runtimePaneTitlesByTabId, - browserTabsByWorktree, - workspaceSessionReady, - sessionInventory, - sessionsError, - memorySnapshotError, - snapshot, - spaceScanReady - } = foundation - - const repoDisplayNameById = useMemo(() => { - const map = new Map() - for (const repo of repos) { - const display = repo.displayName?.trim() - if (display) { - map.set(repo.id, display) - } - } - return map - }, [repos]) - - // Why: connectionId is the only honest signal that a repo runs over SSH. - const repoConnectionIdById = useMemo(() => { - const map = new Map() - for (const repo of repos) { - map.set(repo.id, repo.connectionId ?? null) - } - return map - }, [repos]) - - const repoRuntimeScopedById = useMemo(() => { - const map = new Map() - for (const repo of repos) { - const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) - map.set(repo.id, parsed?.kind === 'runtime') - } - return map - }, [repos]) - - const repoById = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos]) - const worktreeById = useMemo( - () => new Map(allWorktrees.map((worktree) => [worktree.id, worktree])), - [allWorktrees] - ) - const [oldWorkspaceCount, setOldWorkspaceCount] = useState(0) - useEffect(() => { - setOldWorkspaceCount(countEstimatedInactiveWorkspaces(allWorktrees, repoById, Date.now())) - }, [allWorktrees, repoById]) - - // Why: the closed segment must not merge on keystroke-driven store updates. - const unifiedRepos = useMemo( - () => - open - ? mergeSnapshotAndSessions(resourceSnapshot, sessions, { - ...resourceSessionBindings, - runtimePaneTitlesByTabId, - repoDisplayNameById, - repoConnectionIdById, - repoRuntimeScopedById, - browserTabsByWorktree, - worktreeById - }) - : [], - [ - open, - resourceSnapshot, - sessions, - resourceSessionBindings, - runtimePaneTitlesByTabId, - repoDisplayNameById, - repoConnectionIdById, - repoRuntimeScopedById, - browserTabsByWorktree, - worktreeById - ] - ) - - const orphanCount = useMemo(() => { - if (!open || !workspaceSessionReady) { - return 0 - } - return countUnboundDaemonSessions(sessions, resourceSessionBindings) - }, [open, sessions, resourceSessionBindings, workspaceSessionReady]) - - const triggerSessionCount = sessionInventory.count - const memoryMetricCopy = getResourceMemoryMetricCopy( - resourceSnapshot?.processMemoryMetric ?? 'rss' - ) - const { totalMemory, totalCpu, memBadgeLabel } = useMemo(() => { - const memory = resourceSnapshot?.totalMemory ?? 0 - const cpu = resourceSnapshot?.totalCpu ?? 0 - return { - totalMemory: memory, - totalCpu: cpu, - memBadgeLabel: resourceSnapshot ? formatMemory(memory) : '—' - } - }, [resourceSnapshot]) - - const daemonUnreachable = sessionsError && (memorySnapshotError !== null || snapshot === null) - const sessionsOnlyError = sessionsError && memorySnapshotError === null - const resourceManagerTooltipLines = getResourceManagerTooltipLines({ - memoryLabel: resourceSnapshot - ? `${memBadgeLabel} · ${memoryMetricCopy.summaryLabel}` - : memBadgeLabel, - sessionCount: triggerSessionCount, - spaceScanReady - }) - const resourceManagerAriaLabel = getResourceManagerAriaLabel({ - sessionCount: triggerSessionCount, - spaceScanReady - }) - - return { - oldWorkspaceCount, - unifiedRepos, - orphanCount, - triggerSessionCount, - memoryMetricCopy, - totalMemory, - totalCpu, - memBadgeLabel, - daemonUnreachable, - sessionsOnlyError, - resourceManagerTooltipLines, - resourceManagerAriaLabel - } -} - -export type ResourceUsageProjection = ReturnType diff --git a/src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx b/src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx new file mode 100644 index 00000000000..52f1f167e50 --- /dev/null +++ b/src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment happy-dom + +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { WorkspaceSpaceBreakdownList } from './workspace-space-breakdown-list' +import type { + WorkspaceSpaceItem, + WorkspaceSpaceWorktree +} from '../../../../shared/workspace-space-types' + +function item(name: string, sizeBytes: number): WorkspaceSpaceItem { + return { name, path: `/workspace/${name}`, kind: 'directory', sizeBytes } +} + +function worktree(overrides: Partial): WorkspaceSpaceWorktree { + return { + worktreeId: 'wt', + repoId: 'repo', + repoDisplayName: 'repo', + repoPath: '/repo', + displayName: 'workspace', + path: '/workspace', + branch: 'refs/heads/main', + isMainWorktree: false, + isRemote: false, + isSparse: false, + canDelete: true, + lastActivityAt: 0, + status: 'ok', + error: null, + scannedAt: 0, + sizeBytes: 0, + reclaimableBytes: 0, + skippedEntryCount: 0, + topLevelItems: [], + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0, + ...overrides + } +} + +function renderedRowNames(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll('span.font-medium')).map( + (node) => node.textContent ?? '' + ) +} + +afterEach(cleanup) + +describe('WorkspaceSpaceBreakdownList', () => { + it('renders one row per counted top-level item, including the omitted aggregate', () => { + const { container } = render( + + ) + + const names = renderedRowNames(container) + expect(names).toEqual(['node_modules', 'src', 'Other top-level items (7)']) + // The header count labels this list: the 7 omitted items are one aggregate row. + expect(container.textContent).toContain('9 top-level items') + expect(names.length - 1 + 7).toBe(9) + }) + + it('scales the size bars against the omitted aggregate when it is the largest item', () => { + const { container } = render( + + ) + + const widths = Array.from(container.querySelectorAll('div[style]')).map( + (node) => node.style.width + ) + expect(widths).toEqual(['25%', '100%']) + }) + + it('omits the aggregate row when nothing was omitted', () => { + const { container } = render( + + ) + + expect(renderedRowNames(container)).toEqual(['src']) + expect(container.textContent).toContain('1 top-level items') + }) + + it('shows the omitted aggregate rather than an empty state when every item was omitted', () => { + const { container } = render( + + ) + + expect(container.textContent).not.toContain('No files found.') + expect(renderedRowNames(container)).toEqual(['Other top-level items (4)']) + }) +}) diff --git a/src/renderer/src/components/status-bar/workspace-space-breakdown-list.tsx b/src/renderer/src/components/status-bar/workspace-space-breakdown-list.tsx index cda1a638c04..01530e17ba8 100644 --- a/src/renderer/src/components/status-bar/workspace-space-breakdown-list.tsx +++ b/src/renderer/src/components/status-bar/workspace-space-breakdown-list.tsx @@ -48,8 +48,24 @@ export function WorkspaceSpaceBreakdownList({ ) } - const maxChildSize = getLargestWorkspaceSpaceItemSize(worktree.topLevelItems) + const maxChildSize = Math.max( + getLargestWorkspaceSpaceItemSize(worktree.topLevelItems), + worktree.omittedTopLevelSizeBytes + ) const topLevelItemCount = worktree.topLevelItems.length + worktree.omittedTopLevelItemCount + const omittedItem: WorkspaceSpaceItem | null = + worktree.omittedTopLevelItemCount > 0 + ? { + name: translate( + 'components.status.bar.workspaceSpace.otherTopLevelItems', + 'Other top-level items ({{value0}})', + { value0: worktree.omittedTopLevelItemCount } + ), + path: '', + kind: 'other', + sizeBytes: worktree.omittedTopLevelSizeBytes + } + : null return (
@@ -86,7 +102,7 @@ export function WorkspaceSpaceBreakdownList({ )}
- ) : worktree.topLevelItems.length === 0 ? ( + ) : topLevelItemCount === 0 ? (
{translate( 'auto.components.status.bar.WorkspaceSpaceManagerPanel.16988df079', @@ -99,6 +115,7 @@ export function WorkspaceSpaceBreakdownList({ {worktree.topLevelItems.slice(0, 12).map((item) => ( ))} + {omittedItem ? : null}
)} diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts index 666db63968f..08b4b4bc9d9 100644 --- a/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts +++ b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts @@ -19,7 +19,7 @@ import { sortWorkspaceSpaceRows } from './workspace-space-presentation' import { getWorkspaceSpaceGitStatusRefreshCandidates } from './workspace-space-git-status-order' -import { getWorkspaceDecisionDetails } from './WorkspaceSpaceManagerPanel' +import { getWorkspaceDecisionDetails } from './workspace-space-decision-details' import { getWorkspaceSpaceDeleteState, getWorkspaceSpaceGitStatusForScan @@ -452,6 +452,117 @@ describe('workspace space presentation helpers', () => { expect(details.reviewLabel).toBeNull() }) + it('hides only a matching suppressed GitHub review from workspace decisions', () => { + const matching = getWorkspaceDecisionDetails( + row({ branch: 'refs/heads/feature/local' }), + decisionInputs({ + hostedReviewCache: { + 'local::repo::feature/local': { + data: { + provider: 'github', + number: 12, + state: 'open', + status: 'success', + title: 'Suppressed PR' + } + } + }, + worktreeMap: new Map([ + [ + 'wt', + worktreeRecord({ + branch: 'refs/heads/feature/local', + linkedPR: null, + suppressedGitHubPR: 12 + }) + ] + ]) + }) + ) + const different = getWorkspaceDecisionDetails( + row({ branch: 'refs/heads/feature/local' }), + decisionInputs({ + hostedReviewCache: { + 'local::repo::feature/local': { + data: { + provider: 'github', + number: 13, + state: 'open', + status: 'success', + title: 'Different PR' + } + } + }, + worktreeMap: new Map([ + [ + 'wt', + worktreeRecord({ + branch: 'refs/heads/feature/local', + linkedPR: null, + suppressedGitHubPR: 12 + }) + ] + ]) + }) + ) + + expect(matching.reviewLabel).toBeNull() + expect(different.reviewLabel).toBe('PR #13 Open, success') + }) + + it('preserves explicit links and non-GitHub reviews in workspace decisions', () => { + const cachedReview = { + number: 12, + state: 'open', + status: 'success', + title: 'Review' + } + const explicit = getWorkspaceDecisionDetails( + row({ branch: 'refs/heads/feature/local' }), + decisionInputs({ + hostedReviewCache: { + 'local::repo::feature/local': { + data: { ...cachedReview, provider: 'github' } + } + }, + worktreeMap: new Map([ + [ + 'wt', + worktreeRecord({ + branch: 'refs/heads/feature/local', + linkedPR: 12, + suppressedGitHubPR: 12 + }) + ] + ]) + }) + ) + const gitLab = getWorkspaceDecisionDetails( + row({ branch: 'refs/heads/feature/local' }), + decisionInputs({ + hostedReviewCache: { + 'local::repo::feature/local': { + data: { ...cachedReview, provider: 'gitlab' } + } + }, + worktreeMap: new Map([ + [ + 'wt', + worktreeRecord({ + branch: 'refs/heads/feature/local', + linkedPR: null, + suppressedGitHubPR: 12, + linkedGitLabMR: 12 + }) + ] + ]) + }) + ) + + expect(explicit.reviewLabel).toBe('PR #12 Open, success') + expect(gitLab.reviewLabel).toBe('PR #12 Open, success') + }) + it('counts migration-unsupported agent entries by worktree id', () => { const count = countWorkspaceSpaceActiveAgents({ worktreeId: 'wt', @@ -484,6 +595,27 @@ describe('workspace space presentation helpers', () => { ).toEqual(rows.map((item) => item.worktreeId)) }) + it('orders git-status refreshes active first, then visible, then the rest', () => { + const rows = [ + row({ worktreeId: 'rest-a', executionHostId: 'local' }), + row({ worktreeId: 'visible-a', executionHostId: 'local' }), + row({ worktreeId: 'active', executionHostId: 'ssh:builder' }), + row({ worktreeId: 'visible-b', executionHostId: 'local' }), + row({ worktreeId: 'rest-b', executionHostId: 'local' }) + ] + const visibleWorktreeIdentities = new Set( + [rows[1], rows[3]].map(getWorkspaceSpaceWorktreeIdentity) + ) + + expect( + getWorkspaceSpaceGitStatusRefreshCandidates(rows, { + activeWorktreeId: 'active', + activeExecutionHostId: 'ssh:builder', + visibleWorktreeIdentities + }).map((item) => item.worktreeId) + ).toEqual(['active', 'visible-a', 'visible-b', 'rest-a', 'rest-b']) + }) + it('resolves inspected worktree ids from the current scan rows', () => { const rows = [ row({ worktreeId: 'errored', status: 'error' }), diff --git a/src/renderer/src/components/terminal-workspace-editor-shortcuts.ts b/src/renderer/src/components/terminal-workspace-editor-shortcuts.ts new file mode 100644 index 00000000000..22f88cdbf20 --- /dev/null +++ b/src/renderer/src/components/terminal-workspace-editor-shortcuts.ts @@ -0,0 +1,67 @@ +import type { KeybindingActionId } from '../../../shared/keybindings' +import { useAppStore } from '../store' +import { + ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, + type EditorRequestCmdSaveDetail +} from './editor/editor-autosave' +import { getEditorCmdSaveFileId } from './editor/editor-cmd-save-target' +import { isEventTargetInsideFloatingWorkspacePanel } from '@/lib/floating-workspace-terminal-actions' + +type EditorShortcutContext = { + event: KeyboardEvent + floatingWorkspaceFocused: boolean + matchShortcut: (actionId: KeybindingActionId) => boolean + notifyTerminalCapture: (actionId: KeybindingActionId) => void +} + +// Returns true only when the chord was consumed, so unclaimed editor chords still +// fall through to the remaining workspace shortcuts. +export function handleTerminalWorkspaceEditorShortcut({ + event, + floatingWorkspaceFocused, + matchShortcut, + notifyTerminalCapture +}: EditorShortcutContext): boolean { + // Save active editor file — fallback for when focus is outside the editor (tab bar/sidebar); editor-local handlers own save when the editor is focused. + if (!event.repeat && matchShortcut('editor.save')) { + const target = event.target as HTMLElement | null + const inEditor = + target?.closest('.monaco-editor, [contenteditable]') !== null || + target?.closest('textarea:not(.xterm-helper-textarea), input') !== null + if (!inEditor) { + const state = useAppStore.getState() + const floatingPanelOwnsEvent = + isEventTargetInsideFloatingWorkspacePanel(event.target) || floatingWorkspaceFocused + const requestedFileId = getEditorCmdSaveFileId(state, floatingPanelOwnsEvent) + if (requestedFileId) { + event.preventDefault() + notifyTerminalCapture('editor.save') + window.dispatchEvent( + new CustomEvent(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, { + detail: { fileId: requestedFileId } + }) + ) + return true + } + } + } + // Why: long/structured files need a discoverable unwrap path without Settings (#9974). + if (!event.repeat && matchShortcut('editor.toggleWordWrap')) { + const state = useAppStore.getState() + if (state.activeTabType === 'editor' && state.activeFileId) { + event.preventDefault() + notifyTerminalCapture('editor.toggleWordWrap') + // Why: diff surfaces use diffWordWrap; plain editors use editorWordWrap (#10086). + const activeFile = state.openFiles.find((file) => file.id === state.activeFileId) + if (activeFile?.mode === 'diff') { + const wrapOn = state.settings?.diffWordWrap === true + void state.updateSettings({ diffWordWrap: !wrapOn }) + } else { + const wrapOn = state.settings?.editorWordWrap !== false + void state.updateSettings({ editorWordWrap: !wrapOn }) + } + return true + } + } + return false +} diff --git a/src/renderer/src/components/terminal-workspace-keydown.test.ts b/src/renderer/src/components/terminal-workspace-keydown.test.ts new file mode 100644 index 00000000000..015b9708a11 --- /dev/null +++ b/src/renderer/src/components/terminal-workspace-keydown.test.ts @@ -0,0 +1,104 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { + ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, + type EditorRequestCmdSaveDetail +} from './editor/editor-autosave' +import { handleTerminalWorkspaceKeyDown } from './terminal-workspace-keydown' +import type { TerminalActivationController } from './use-terminal-activation-actions' + +const mocks = vi.hoisted(() => ({ + state: {} as Record, + floatingFocused: false, + targetInsideFloatingPanel: false +})) + +vi.mock('../store', () => ({ useAppStore: { getState: () => mocks.state } })) +vi.mock('../hooks/ipc-tab-switch', () => ({ + handleSwitchRecentTab: vi.fn(), + handleSwitchTab: vi.fn(), + handleSwitchTabAcrossAllTypes: vi.fn(), + handleSwitchTerminalTab: vi.fn() +})) +vi.mock('@/lib/floating-workspace-terminal-actions', () => ({ + createFloatingWorkspaceBrowserTab: vi.fn(), + createFloatingWorkspaceMarkdownTab: vi.fn(), + createFloatingWorkspaceTerminalTab: vi.fn(), + handleEmptyFloatingWorkspacePanelCloseShortcut: () => false, + isEventTargetInsideFloatingWorkspacePanel: () => mocks.targetInsideFloatingPanel, + isFloatingWorkspacePanelFocused: () => mocks.floatingFocused, + switchFloatingWorkspaceTab: vi.fn() +})) +vi.mock('@/lib/terminal-shortcut-capture-notification', () => ({ + showTerminalShortcutCaptureNotification: vi.fn() +})) +vi.mock('./terminal-agent-tab-shortcut', () => ({ + resolveTerminalAgentTabShortcut: () => ({ actionId: null, agent: null }) +})) + +const controller = { + activeWorktreeId: 'repo-1::/repo/worktree', + handleCloseAllFiles: vi.fn(), + handleCloseBrowserTab: vi.fn(), + handleCloseFile: vi.fn(), + handleNewAgentTab: vi.fn(), + handleNewBrowserTab: vi.fn(), + handleNewFile: vi.fn(), + handleNewSimulatorTab: vi.fn(), + handleNewTab: vi.fn(), + keybindings: undefined, + mobileEmulatorEnabled: false, + terminalShortcutPolicy: 'orca-first' +} as unknown as TerminalActivationController + +function pressCmdS(): (EditorRequestCmdSaveDetail | undefined)[] { + const details: (EditorRequestCmdSaveDetail | undefined)[] = [] + const listener = (event: Event): void => { + details.push((event as CustomEvent).detail ?? undefined) + } + window.addEventListener(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, listener) + const target = document.createElement('div') + document.body.appendChild(target) + const event = new KeyboardEvent('keydown', { key: 's', metaKey: true, cancelable: true }) + Object.defineProperty(event, 'target', { value: target }) + try { + handleTerminalWorkspaceKeyDown(event, controller, 'darwin') + } finally { + window.removeEventListener(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, listener) + target.remove() + } + return details +} + +describe('handleTerminalWorkspaceKeyDown editor.save', () => { + beforeEach(() => { + mocks.floatingFocused = false + mocks.targetInsideFloatingPanel = false + mocks.state = { + activeView: 'terminal', + activeTabType: 'editor', + activeFileId: 'file-1', + getActiveTab: () => null + } + }) + + it('dispatches the save request with the resolved file id', () => { + expect(pressCmdS()).toEqual([{ fileId: 'file-1' }]) + }) + + it('resolves the floating panel editor when the panel owns the event', () => { + mocks.targetInsideFloatingPanel = true + mocks.state.getActiveTab = (worktreeId: string) => + worktreeId === FLOATING_TERMINAL_WORKTREE_ID + ? { contentType: 'editor', entityId: 'floating-file' } + : null + expect(pressCmdS()).toEqual([{ fileId: 'floating-file' }]) + }) + + it('does not swallow the chord outside the workspace view', () => { + mocks.state.activeView = 'tasks' + expect(pressCmdS()).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/terminal-workspace-keydown.ts b/src/renderer/src/components/terminal-workspace-keydown.ts index 522a203ffff..dd6e1e70878 100644 --- a/src/renderer/src/components/terminal-workspace-keydown.ts +++ b/src/renderer/src/components/terminal-workspace-keydown.ts @@ -3,7 +3,6 @@ import type { KeybindingActionId } from '../../../shared/keybindings' import { keybindingMatchesAction } from '../../../shared/keybindings' import { matchesRecentTabSwitcherChord } from '../../../shared/window-shortcut-policy' import { useAppStore } from '../store' -import { ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT } from './editor/editor-autosave' import { handleSwitchRecentTab, handleSwitchTab, @@ -20,9 +19,15 @@ import { switchFloatingWorkspaceTab } from '@/lib/floating-workspace-terminal-actions' import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' +import { + ensureClientCreationActionAllowed, + showClientCreationActionError +} from '@/lib/client-creation-action-error' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { translate } from '@/i18n/i18n' import { getKeybindingContext } from './terminal-workspace-model' import { resolveTerminalAgentTabShortcut } from './terminal-agent-tab-shortcut' +import { handleTerminalWorkspaceEditorShortcut } from './terminal-workspace-editor-shortcuts' import type { TerminalActivationController } from './use-terminal-activation-actions' export function handleTerminalWorkspaceKeyDown( @@ -101,14 +106,26 @@ export function handleTerminalWorkspaceKeyDown( if (!event.repeat && matchShortcut('tab.reopenClosed')) { event.preventDefault() notifyTerminalCapture('tab.reopenClosed') - useAppStore.getState().reopenClosedTab(activeWorktreeId) + try { + useAppStore.getState().reopenClosedTab(activeWorktreeId) + } catch (error) { + showClientCreationActionError(error) + } return } if (!event.repeat && matchShortcut('tab.newBrowser')) { event.preventDefault() notifyTerminalCapture('tab.newBrowser') + const browserWorkspaceId = floatingWorkspaceFocused + ? FLOATING_TERMINAL_WORKTREE_ID + : activeWorktreeId + if (!ensureClientCreationActionAllowed(browserWorkspaceId, 'managed-browser')) { + return + } if (floatingWorkspaceFocused) { - void createFloatingWorkspaceBrowserTab(useAppStore.getState()) + void createFloatingWorkspaceBrowserTab(useAppStore.getState()).catch( + showClientCreationActionError + ) return } handleNewBrowserTab() @@ -117,41 +134,23 @@ export function handleTerminalWorkspaceKeyDown( if (!event.repeat && mobileEmulatorEnabled && matchShortcut('tab.newSimulator')) { event.preventDefault() notifyTerminalCapture('tab.newSimulator') + if (!ensureClientCreationActionAllowed(activeWorktreeId, 'mobile-emulator')) { + return + } if (!floatingWorkspaceFocused) { handleNewSimulatorTab() } return } - if (!event.repeat && matchShortcut('editor.save')) { - const target = event.target as HTMLElement | null - const inEditor = - target?.closest('.monaco-editor, [contenteditable]') !== null || - target?.closest('textarea:not(.xterm-helper-textarea), input') !== null - if (!inEditor) { - const state = useAppStore.getState() - if (state.activeTabType === 'editor' && state.activeFileId) { - event.preventDefault() - notifyTerminalCapture('editor.save') - window.dispatchEvent(new Event(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT)) - return - } - } - } - if (!event.repeat && matchShortcut('editor.toggleWordWrap')) { - const state = useAppStore.getState() - if (state.activeTabType === 'editor' && state.activeFileId) { - event.preventDefault() - notifyTerminalCapture('editor.toggleWordWrap') - const activeFile = state.openFiles.find((file) => file.id === state.activeFileId) - if (activeFile?.mode === 'diff') { - const wrapOn = state.settings?.diffWordWrap === true - void state.updateSettings({ diffWordWrap: !wrapOn }) - } else { - const wrapOn = state.settings?.editorWordWrap !== false - void state.updateSettings({ editorWordWrap: !wrapOn }) - } - return - } + if ( + handleTerminalWorkspaceEditorShortcut({ + event, + floatingWorkspaceFocused, + matchShortcut, + notifyTerminalCapture + }) + ) { + return } if (!event.repeat && matchShortcut('tab.newMarkdown')) { event.preventDefault() diff --git a/src/renderer/src/components/use-terminal-create-actions.test.tsx b/src/renderer/src/components/use-terminal-create-actions.test.tsx new file mode 100644 index 00000000000..b12fd7730c6 --- /dev/null +++ b/src/renderer/src/components/use-terminal-create-actions.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment happy-dom + +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ClientCreationActionAvailability } from '@/lib/client-creation-action-policy' +import { useTerminalCreateActions } from './use-terminal-create-actions' +import type { TerminalColdActivationController } from './terminal-cold-activation' + +const mocks = vi.hoisted(() => ({ + browserAvailability: { + state: 'enabled', + provider: 'local-client' + } as ClientCreationActionAvailability, + simulatorAvailability: { + state: 'enabled', + provider: 'local-client' + } as ClientCreationActionAvailability, + state: {} as Record, + toastError: vi.fn(), + createBrowserTab: vi.fn(), + openNewBrowserTabInActiveWorkspace: vi.fn(), + openMobileEmulatorTab: vi.fn() +})) + +vi.mock('../store', () => ({ useAppStore: { getState: () => mocks.state } })) +vi.mock('sonner', () => ({ + toast: { error: (...args: unknown[]) => mocks.toastError(...args), message: vi.fn() } +})) +vi.mock('@/lib/client-creation-action-policy', () => ({ + getClientCreationActionPolicy: () => ({ + 'managed-browser': mocks.browserAvailability, + 'mobile-emulator': mocks.simulatorAvailability + }) +})) +vi.mock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: vi.fn() })) +vi.mock('@/runtime/web-runtime-session', () => ({ + createWebRuntimeSessionBrowserTab: vi.fn(), + createWebRuntimeSessionTerminal: vi.fn(), + isWebRuntimeSessionActive: () => false +})) +vi.mock('@/lib/open-mobile-emulator-tab', () => ({ + openMobileEmulatorTab: (...args: unknown[]) => mocks.openMobileEmulatorTab(...args) +})) +vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: vi.fn() })) +vi.mock('@/lib/duplicate-browser-tab-options', () => ({ + buildDuplicatedBrowserTabOptions: () => ({}) +})) +vi.mock('@/runtime/remote-browser-tab-ownership', () => ({ + browserWorkspaceHasRemoteOwner: () => false +})) +vi.mock('./tab-bar/tab-create-entry-action', () => ({ openTabBarEntry: vi.fn() })) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('./terminal-workspace-model', () => ({ + getActiveWorktreeRuntimeEnvironmentId: () => null +})) + +const WORKTREE_ID = 'repo-1::/repo/worktree' + +function renderActions() { + return renderHook(() => + useTerminalCreateActions({ + activeWorktreeId: WORKTREE_ID, + createBrowserTab: mocks.createBrowserTab, + createTab: vi.fn(), + openNewBrowserTabInActiveWorkspace: mocks.openNewBrowserTabInActiveWorkspace, + openNewMarkdownInActiveWorkspace: vi.fn(), + openNewTerminalTabInActiveWorkspace: vi.fn(), + setActiveTabType: vi.fn(), + setTabBarOrder: vi.fn() + } as unknown as TerminalColdActivationController) + ).result.current +} + +describe('useTerminalCreateActions creation gates', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.browserAvailability = { state: 'enabled', provider: 'local-client' } + mocks.simulatorAvailability = { state: 'enabled', provider: 'local-client' } + mocks.state = { + activeGroupIdByWorktree: {}, + groupsByWorktree: {}, + browserDefaultUrl: 'about:blank', + browserTabsByWorktree: { [WORKTREE_ID]: [{ id: 'browser-1', url: 'https://example.com' }] } + } + }) + + it('toasts instead of creating a browser tab when the provider forbids it', () => { + mocks.browserAvailability = { state: 'hidden', reason: 'no browser here' } + renderActions().handleNewBrowserTab() + expect(mocks.toastError).toHaveBeenCalledWith('no browser here') + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + }) + + it('toasts instead of duplicating a browser tab when the provider forbids it', () => { + mocks.browserAvailability = { state: 'hidden', reason: 'no browser here' } + renderActions().handleDuplicateBrowserTab('browser-1') + expect(mocks.toastError).toHaveBeenCalledWith('no browser here') + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + }) + + it('reports a rejected workspace browser open instead of leaving it unhandled', async () => { + mocks.state.activeGroupIdByWorktree = { [WORKTREE_ID]: 'group-1' } + mocks.openNewBrowserTabInActiveWorkspace.mockRejectedValue(new Error('runtime says no')) + const unhandled = vi.fn() + process.on('unhandledRejection', unhandled) + renderActions().handleNewBrowserTab() + await new Promise((resolve) => setTimeout(resolve, 0)) + process.off('unhandledRejection', unhandled) + expect(mocks.toastError).toHaveBeenCalledWith('runtime says no') + expect(unhandled).not.toHaveBeenCalled() + }) + + it('reports a rejected simulator open instead of leaving it unhandled', async () => { + mocks.openMobileEmulatorTab.mockRejectedValue(new Error('emulator says no')) + renderActions().handleNewSimulatorTab() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(mocks.toastError).toHaveBeenCalledWith('emulator says no') + }) +}) diff --git a/src/renderer/src/components/use-terminal-create-actions.ts b/src/renderer/src/components/use-terminal-create-actions.ts index 57bbdf2c1be..927e8e52b04 100644 --- a/src/renderer/src/components/use-terminal-create-actions.ts +++ b/src/renderer/src/components/use-terminal-create-actions.ts @@ -12,6 +12,8 @@ import { openMobileEmulatorTab } from '@/lib/open-mobile-emulator-tab' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import { buildDuplicatedBrowserTabOptions } from '@/lib/duplicate-browser-tab-options' import { browserWorkspaceHasRemoteOwner } from '@/runtime/remote-browser-tab-ownership' +import { getClientCreationActionPolicy } from '@/lib/client-creation-action-policy' +import { showClientCreationActionError } from '@/lib/client-creation-action-error' import { openTabBarEntry, type TabCreateEntryArgs } from './tab-bar/tab-create-entry-action' import { translate } from '@/i18n/i18n' import { getActiveWorktreeRuntimeEnvironmentId } from './terminal-workspace-model' @@ -122,7 +124,7 @@ export function useTerminalCreateActions(controller: TerminalColdActivationContr void openMobileEmulatorTab(activeWorktreeId, { placement: 'rightSplit', targetGroupId: targetGroupId ?? undefined - }) + }).catch(showClientCreationActionError) }, [activeWorktreeId]) const handleNewBrowserTab = useCallback(() => { @@ -133,22 +135,31 @@ export function useTerminalCreateActions(controller: TerminalColdActivationContr useAppStore.getState().activeGroupIdByWorktree[activeWorktreeId] ?? useAppStore.getState().groupsByWorktree[activeWorktreeId]?.[0]?.id if (targetGroupId) { - void openNewBrowserTabInActiveWorkspace(targetGroupId) + void openNewBrowserTabInActiveWorkspace(targetGroupId).catch(showClientCreationActionError) return } - const defaultUrl = useAppStore.getState().browserDefaultUrl ?? 'about:blank' + const state = useAppStore.getState() + const browserAvailability = getClientCreationActionPolicy(state, activeWorktreeId)[ + 'managed-browser' + ] + if (browserAvailability.state !== 'enabled') { + toast.error(browserAvailability.reason) + return + } + const defaultUrl = state.browserDefaultUrl ?? 'about:blank' const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) - if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { + if (browserAvailability.provider === 'paired-runtime' && runtimeEnvironmentId) { void createWebRuntimeSessionBrowserTab({ worktreeId: activeWorktreeId, environmentId: runtimeEnvironmentId, url: defaultUrl - }) + }).catch(showClientCreationActionError) return } createBrowserTab(activeWorktreeId, defaultUrl, { title: translate('auto.components.Terminal.37da0d736f', 'New Browser Tab'), - focusAddressBar: true + focusAddressBar: true, + ...(runtimeEnvironmentId ? { browserRuntimeEnvironmentId: null } : {}) }) }, [activeWorktreeId, createBrowserTab, openNewBrowserTabInActiveWorkspace]) @@ -168,8 +179,16 @@ export function useTerminalCreateActions(controller: TerminalColdActivationContr return } const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) + const browserAvailability = getClientCreationActionPolicy(state, activeWorktreeId)[ + 'managed-browser' + ] + if (browserAvailability.state !== 'enabled') { + toast.error(browserAvailability.reason) + return + } if ( - isWebRuntimeSessionActive(runtimeEnvironmentId) && + browserAvailability.provider === 'paired-runtime' && + runtimeEnvironmentId && browserWorkspaceHasRemoteOwner(state, source.id, runtimeEnvironmentId) ) { void createWebRuntimeSessionBrowserTab({ @@ -177,12 +196,17 @@ export function useTerminalCreateActions(controller: TerminalColdActivationContr environmentId: runtimeEnvironmentId, url: source.url, profileId: source.sessionProfileId - }) + }).catch(showClientCreationActionError) return } - createBrowserTab(activeWorktreeId, source.url, { - ...buildDuplicatedBrowserTabOptions(source) - }) + try { + createBrowserTab(activeWorktreeId, source.url, { + ...buildDuplicatedBrowserTabOptions(source), + ...(runtimeEnvironmentId ? { browserRuntimeEnvironmentId: null } : {}) + }) + } catch (error) { + showClientCreationActionError(error) + } }, [activeWorktreeId, createBrowserTab] ) diff --git a/src/renderer/src/components/use-terminal-parking-pass.ts b/src/renderer/src/components/use-terminal-parking-pass.ts index b5065c9355a..01733c3c09c 100644 --- a/src/renderer/src/components/use-terminal-parking-pass.ts +++ b/src/renderer/src/components/use-terminal-parking-pass.ts @@ -2,11 +2,14 @@ import { useEffect } from 'react' import { useAppStore } from '../store' import { TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS, + countEvictionExemptTabRoutes, + formatEvictionExemptRouteCounts, hasPendingRetentionSpawnWork, selectForceParkEvictableTabIds, selectRetentionForceParkedTerminalWorktrees, type TerminalWorktreeRetentionCandidate } from './terminal-pane/terminal-hidden-worktree-retention' +import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' import { selectEvictionExemptTerminalTabIds } from './terminal-pane/terminal-eviction-exempt-tabs' import { captureForceParkedWorktreeBuffers } from './terminal-pane/force-park-buffer-capture' import { warnTerminalLifecycleAnomaly } from './terminal-pane/terminal-lifecycle-diagnostics' @@ -91,10 +94,18 @@ export function useTerminalParkingPass(controller: TerminalParkingFoundation): v const evictableTabIds = selectForceParkEvictableTabIds(forceParkedTabs, (tab) => exemptTabIds.has(tab.id) ) + // Why routed + breadcrumbed: only per-route counts in a field bundle + // can say whether fail-open ids or unresolved snapshot capability + // dominates the degenerate all-exempt force-park (which frees no heap). if (evictableTabIds.length === 0 && forceParkedTabs.length > 0) { + const exemptRouteCounts = countEvictionExemptTabRoutes(forceParkedTabs, worktreeId) warnTerminalLifecycleAnomaly('retention force-park freed no panes', { worktreeId, - reason: `exemptTabs=${forceParkedTabs.length}` + reason: `exemptTabs=${forceParkedTabs.length} ${formatEvictionExemptRouteCounts(exemptRouteCounts)}` + }) + recordRendererCrashBreadcrumb('terminal_force_park_freed_no_panes', { + exemptTabs: forceParkedTabs.length, + ...exemptRouteCounts }) } if ( diff --git a/src/renderer/src/components/use-terminal-workspace-foundation.ts b/src/renderer/src/components/use-terminal-workspace-foundation.ts index 68ea6fcb8d8..9b365e9d885 100644 --- a/src/renderer/src/components/use-terminal-workspace-foundation.ts +++ b/src/renderer/src/components/use-terminal-workspace-foundation.ts @@ -1,13 +1,21 @@ import { useMemo, useRef } from 'react' import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import type { TerminalTab } from '../../../shared/terminal-tab-types' import { useAppStore } from '../store' import { useWorktreeMap } from '../store/selectors' import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-execution-host' +import type { WorktreeTabBucketProjection } from '@/lib/worktree-tab-bucket-projection' import { projectWorkspaceSurfaces } from './workspace-surface-projection' import { selectPairedRuntimeParkingEnvironmentIds } from './terminal-pane/terminal-hidden-view-parking' +import { createTerminalWorktreeTopologyProjection } from './terminal-pane/terminal-hidden-worktree-retention' import { isMainTerminalSideEffectAuthorityForPty } from './terminal-pane/terminal-side-effect-facts-handler' export function useTerminalWorkspaceFoundation() { + const terminalTopologyProjectionRef = useRef | null>(null) + terminalTopologyProjectionRef.current ??= createTerminalWorktreeTopologyProjection() const mountedWorktreeIdsRef = useRef(new Set()) const browserGuestWorktreeRecencyRef = useRef([]) const measurableBackgroundWorktreeIdsRef = useRef(new Set()) @@ -39,7 +47,12 @@ export function useTerminalWorkspaceFoundation() { [worktreesById, folderWorkspaces, renderedActiveWorktreeId, activeFolderSurfaceHostId] ) const activeView = useAppStore((state) => state.activeView) - const tabsByWorktree = useAppStore((state) => state.tabsByWorktree) + // Why: terminal titles are leaf chrome. The root host only subscribes to + // mount/parking semantics; a real transition publishes fresh tab objects, + // while LiveTerminalTabBar reads title-only updates from the active bucket. + const tabsByWorktree = useAppStore((state) => + terminalTopologyProjectionRef.current!.project(state.tabsByWorktree) + ) const pendingStartupByTabId = useAppStore((state) => state.pendingStartupByTabId) const terminalParkingEnabled = useAppStore( (state) => state.settings?.terminalHiddenViewParking !== false diff --git a/src/renderer/src/components/use-terminal-workspace-projection.ts b/src/renderer/src/components/use-terminal-workspace-projection.ts index 50672658c8f..a42031e7b11 100644 --- a/src/renderer/src/components/use-terminal-workspace-projection.ts +++ b/src/renderer/src/components/use-terminal-workspace-projection.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo } from 'react' import { useAppStore } from '../store' import { hasFeatureInteraction } from '../../../shared/feature-interactions' import { setForegroundTerminalTabIds } from '@/lib/foreground-terminal-tabs' +import { useClientHostedBrowserRows } from '@/lib/pane-manager/client-hosted-browser-row-state' import { useTerminalProviderSnapshotCapability } from './terminal/use-terminal-provider-snapshot-capability' import { getEffectiveLayoutForWorktree as getEffectiveLayout } from './terminal/split-group-mount' import { useContextualTour } from './contextual-tours/use-contextual-tour' @@ -65,6 +66,9 @@ export function useTerminalWorkspaceProjection(controller: TerminalWorkspaceStor const worktreeBrowserTabs = renderedActiveWorktreeId ? (browserTabsByWorktree[renderedActiveWorktreeId] ?? []) : [] + // Why: this strip only renders before the worktree has a layout, which is exactly when a paired + // client can have opened a page the host never has. Without a row here it stays uncloseable. + const worktreeClientHostedBrowserRows = useClientHostedBrowserRows(renderedActiveWorktreeId ?? '') const getEffectiveLayoutForWorktree = useCallback( (worktreeId: string) => getEffectiveLayout(worktreeId, layoutByWorktree, groupsByWorktree, activeGroupIdByWorktree), @@ -100,6 +104,7 @@ export function useTerminalWorkspaceProjection(controller: TerminalWorkspaceStor titlebarTabsTarget, worktreeFiles, worktreeBrowserTabs, + worktreeClientHostedBrowserRows, getEffectiveLayoutForWorktree, effectiveActiveLayout, activeWorktreeBrowserTabIdsKey, diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts index bf12b2c0c28..12ca698f192 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts @@ -1,5 +1,5 @@ import { readdirSync, readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { basename, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { buildSettingsNavigationMetadata } from './useSettingsNavigationMetadata' import type { Repo } from '../../../shared/repo-types' @@ -410,10 +410,11 @@ describe('settings navigation metadata', () => { const testDir = import.meta.dirname // Why: the section tables live in sibling settings-navigation-* modules, so reading only the // hook would scan a file that no longer holds the imports this guard exists to police. + // Walk recursively so a later split that nests the modules cannot shrink this guard. const sourceFiles = [ 'useSettingsNavigationMetadata.ts', - ...readdirSync(testDir).filter( - (name) => name.startsWith('settings-navigation-') && name.endsWith('.ts') + ...readdirSync(testDir, { recursive: true, encoding: 'utf8' }).filter( + (name) => basename(name).startsWith('settings-navigation-') && name.endsWith('.ts') ) ] expect(sourceFiles.length).toBeGreaterThan(1) diff --git a/src/renderer/src/lib/client-creation-action-error.ts b/src/renderer/src/lib/client-creation-action-error.ts new file mode 100644 index 00000000000..e0c70afd22e --- /dev/null +++ b/src/renderer/src/lib/client-creation-action-error.ts @@ -0,0 +1,24 @@ +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { + getClientCreationActionPolicy, + type ClientCreationAction +} from './client-creation-action-policy' + +export function showClientCreationActionError(error: unknown): void { + toast.error(error instanceof Error ? error.message : String(error)) +} + +// Why: action paths must surface the policy's reason; the visibility gate alone +// leaves shortcut-driven creation failing silently. +export function ensureClientCreationActionAllowed( + worktreeId: string | null, + action: ClientCreationAction +): boolean { + const availability = getClientCreationActionPolicy(useAppStore.getState(), worktreeId)[action] + if (availability.state !== 'enabled') { + toast.error(availability.reason) + return false + } + return true +} diff --git a/src/renderer/src/lib/cmd-j-match-relevance.ts b/src/renderer/src/lib/cmd-j-match-relevance.ts deleted file mode 100644 index 79b830783a4..00000000000 --- a/src/renderer/src/lib/cmd-j-match-relevance.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { - resolveWorktreeBranchLabel, - resolveWorktreeDisplayName -} from './worktree-default-display-name' -import type { MatchRange, PaletteSearchResult } from './worktree-palette-search' -import type { Worktree } from '../../../shared/worktree/types' - -/** A missing match sorts after every concrete field match. */ -export const NO_MATCH_RELEVANCE = Number.MAX_SAFE_INTEGER - -export type PaletteRelevanceFieldTier = 0 | 1 | 2 - -export type PaletteRelevanceField = { - text: string - ranges: readonly MatchRange[] - tier: PaletteRelevanceFieldTier -} - -const NON_WORD_CHARACTER = /[^\p{L}\p{M}\p{N}]/u -const POSITION_RANKS = 4 - -function positionRank(text: string, range: MatchRange): number { - if (range.start === 0) { - return range.end >= text.trimEnd().length ? 0 : 1 - } - return NON_WORD_CHARACTER.test(text[range.start - 1] ?? '') ? 2 : 3 -} - -export function scorePaletteRelevance(fields: readonly PaletteRelevanceField[]): number { - let best = NO_MATCH_RELEVANCE - for (const field of fields) { - for (const range of field.ranges) { - best = Math.min(best, field.tier * POSITION_RANKS + positionRank(field.text, range)) - } - } - return best -} - -export function getWorktreeMatchRelevance( - match: PaletteSearchResult, - worktree: Worktree, - repoName: string -): number { - return scorePaletteRelevance([ - { - text: resolveWorktreeDisplayName(worktree), - ranges: match.displayNameRanges, - tier: 0 - }, - { - text: resolveWorktreeBranchLabel(worktree), - ranges: match.branchRanges, - tier: 1 - }, - { - text: match.supportingText?.text ?? '', - ranges: match.supportingText?.matchRanges ?? [], - tier: 2 - }, - { text: repoName, ranges: match.repoRanges, tier: 2 } - ]) -} - -/** Structural shape shared by browser, simulator, and workspace-tab results. */ -export type OpenTabRelevanceInput = { - title: string - titleRanges: readonly MatchRange[] - secondaryText: string - secondaryRanges: readonly MatchRange[] - worktreeName: string - worktreeRanges: readonly MatchRange[] - repoName: string - repoRanges: readonly MatchRange[] - workspaceLabel?: string | null - workspaceRanges?: readonly MatchRange[] - typeAliasMatch?: { text: string; ranges: readonly MatchRange[] } | null -} - -export function getOpenTabMatchRelevance(result: OpenTabRelevanceInput): number { - return scorePaletteRelevance([ - { text: result.title, ranges: result.titleRanges, tier: 0 }, - { text: result.secondaryText, ranges: result.secondaryRanges, tier: 1 }, - { - text: result.typeAliasMatch?.text ?? '', - ranges: result.typeAliasMatch?.ranges ?? [], - tier: 1 - }, - { - text: result.workspaceLabel ?? '', - ranges: result.workspaceRanges ?? [], - tier: 2 - }, - { text: result.worktreeName, ranges: result.worktreeRanges, tier: 2 }, - { text: result.repoName, ranges: result.repoRanges, tier: 2 } - ]) -} diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-surfaces.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-surfaces.ts index 9012a0fc0d1..216d2dd0448 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-surfaces.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-surfaces.ts @@ -91,10 +91,15 @@ export function isMobilePublishableOpenFile(file: AppState['openFiles'][number]) ) } +/** + * Why a workspace document is held back: it is served to one desktop guest through a grant no + * mobile client holds, so there is nothing on the other side that could render it — and the wire + * has no tab kind for it, so an old client would take it for an ordinary browser tab and offer + * navigation for a page that has no URL. Host and phone parity ships behind capability negotiation. + */ export function isMobilePublishableBrowserWorkspace( workspace: NonNullable[number] ): boolean { - // Document previews are served through a desktop-only grant. return !workspace.docLocation } diff --git a/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts b/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts index 911348fae64..0627bccee06 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts @@ -2,12 +2,10 @@ import type { AppState } from '@/store/types' import { resolveTerminalTabTitle } from '../../../../shared/tab-title-resolution' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { - EMPTY_AGENT_STATUS_BY_PANE_KEY, EMPTY_BROWSER_PAGES_BY_WORKSPACE, EMPTY_BROWSER_TABS_BY_WORKTREE, graphState } from './graph-state' -import { buildRuntimeMobileAgentStatusProjection } from './agent-status-projection' export function getBrowserTabsByWorktree(state: AppState): AppState['browserTabsByWorktree'] { // Some callers/tests build partial pre-browser states; treat missing slices as empty. @@ -147,11 +145,3 @@ export function stableHashString(value: string): string { } return `draft:${value.length}:${(hash >>> 0).toString(16)}` } - -export function buildRuntimeMobileAgentStatusProjectionForState( - agentStatusByPaneKey: AppState['agentStatusByPaneKey'] | undefined -): string { - return buildRuntimeMobileAgentStatusProjection( - agentStatusByPaneKey ?? EMPTY_AGENT_STATUS_BY_PANE_KEY - ) -} diff --git a/src/renderer/src/store/slices/agent-status-live-actions.ts b/src/renderer/src/store/slices/agent-status-live-actions.ts index d9b0ff2c3dc..a9407da5c13 100644 --- a/src/renderer/src/store/slices/agent-status-live-actions.ts +++ b/src/renderer/src/store/slices/agent-status-live-actions.ts @@ -9,7 +9,8 @@ import type { import { resolveAgentPaneAuthorityKey } from './agent-pane-authority' import { buildAgentStatusLiveEntry, - type AgentStatusLiveEntryBuild + type AgentStatusLiveEntryBuild, + type AgentStatusLiveEntryRejection } from './agent-status-live-entry-builder' import { reduceAgentStatusLiveUpdate } from './agent-status-live-reducer' import { @@ -49,7 +50,7 @@ export function createAgentStatusLiveActions( ) { return } - let built: AgentStatusLiveEntryBuild | null = null + let built: AgentStatusLiveEntryBuild | AgentStatusLiveEntryRejection | null = null set((state) => { built = buildAgentStatusLiveEntry({ state, @@ -61,14 +62,16 @@ export function createAgentStatusLiveActions( metadata, updatedAt }) - return built ? reduceAgentStatusLiveUpdate(state, built, updatedAt) : state + return built.entry ? reduceAgentStatusLiveUpdate(state, built, updatedAt) : state }) // Zustand's updater runs synchronously, but TypeScript cannot observe the closure assignment. - const builtResult = built as AgentStatusLiveEntryBuild | null - if (!builtResult) { - // Keep standalone calls' deferred freshness contract even when a stale - // event is rejected by the reducer. - requestFreshness(false) + const builtResult = built as AgentStatusLiveEntryBuild | AgentStatusLiveEntryRejection | null + if (!builtResult?.entry) { + // Keep standalone calls' deferred freshness contract when a stale event is rejected, but a + // suppressed inherited-terminal frame returns without buying the deferred O(entries) scan. + if (builtResult?.reason !== 'suppressed-inherited-terminal') { + requestFreshness(false) + } return } const { entry } = builtResult diff --git a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts index 2afd35430f3..3ef257edb6b 100644 --- a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts +++ b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts @@ -52,6 +52,11 @@ export type AgentStatusLiveEntryBuild = { boundaryResolved: boolean } +export type AgentStatusLiveEntryRejection = { + entry: null + reason: 'stale' | 'suppressed-inherited-terminal' +} + export type AgentStatusLiveEntryArgs = { state: AppState paneKey: string @@ -63,14 +68,14 @@ export type AgentStatusLiveEntryArgs = { updatedAt: number } -/** Build one accepted live row and the derived map-update facts. */ +/** Build one accepted live row and the derived map-update facts, or say why the frame was rejected. */ export function buildAgentStatusLiveEntry( args: AgentStatusLiveEntryArgs -): AgentStatusLiveEntryBuild | null { +): AgentStatusLiveEntryBuild | AgentStatusLiveEntryRejection { const { state, paneKey, payload, terminalTitle, timing, routing, metadata, updatedAt } = args const existing = state.agentStatusByPaneKey[paneKey] if (existing && updatedAt < existing.updatedAt) { - return null + return { entry: null, reason: 'stale' } } const effectiveTitle = terminalTitle ?? existing?.terminalTitle let history: AgentStateHistoryEntry[] = existing?.stateHistory ?? [] @@ -141,7 +146,7 @@ export function buildAgentStatusLiveEntry( incomingState: payload.state }) ) { - return null + return { entry: null, reason: 'suppressed-inherited-terminal' } } const runtimeOrchestration = state.runtimeAgentOrchestrationByPaneKey[paneKey] const runtimeMergedOrchestration = runtimeOrchestration diff --git a/src/renderer/src/store/slices/agent-status-live-freshness-request.test.ts b/src/renderer/src/store/slices/agent-status-live-freshness-request.test.ts new file mode 100644 index 00000000000..233a7c0121b --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-live-freshness-request.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AppState } from '../types' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { AgentStatusPayload } from './agent-status-contract' +import type { AgentStatusRuntime } from './agent-status-runtime' +import { createAgentStatusLiveActions } from './agent-status-live-actions' + +const NOW = new Date('2026-04-09T12:00:00.000Z').getTime() +const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111' + +function existingEntry(overrides: Partial = {}): AgentStatusEntry { + return { + paneKey: PANE_KEY, + state: 'working', + prompt: 'parent turn', + updatedAt: NOW, + stateStartedAt: NOW, + stateHistory: [], + agentType: 'claude', + ...overrides + } +} + +function setup(existing: AgentStatusEntry) { + const state = { + agentStatusByPaneKey: { [PANE_KEY]: existing }, + recentlyRetiredAgentStatusPaneKeys: {}, + recentlyClosedAgentStatusTabIds: {} + } as unknown as AppState + const requestFreshness = vi.fn() + const runtime = { + get: () => state, + set: vi.fn((update) => { + if (typeof update === 'function') { + update(state) + } + }), + applyGeneratedTabTitleUpdate: vi.fn(), + requestFreshness, + transactAgentStatuses: vi.fn() + } as unknown as AgentStatusRuntime + return { requestFreshness, actions: createAgentStatusLiveActions(runtime) } +} + +function payload(overrides: Partial = {}): AgentStatusPayload { + return { state: 'done', prompt: 'child hook', ...overrides } as AgentStatusPayload +} + +describe('setAgentStatus freshness requests on rejected frames', () => { + it('skips the deferred freshness scan when an inherited terminal status is suppressed', () => { + // A nested child hook inherits ORCA_PANE_KEY, so its `done` is dropped while the parent works. + const { requestFreshness, actions } = setup(existingEntry()) + + actions.setAgentStatus(PANE_KEY, payload({ agentType: 'codex' }), undefined, { + updatedAt: NOW + 1 + }) + + expect(requestFreshness).not.toHaveBeenCalled() + }) + + it('still requests deferred freshness when a stale frame is rejected', () => { + const { requestFreshness, actions } = setup(existingEntry()) + + actions.setAgentStatus(PANE_KEY, payload({ agentType: 'claude' }), undefined, { + updatedAt: NOW - 1 + }) + + expect(requestFreshness).toHaveBeenCalledWith(false) + }) +}) diff --git a/src/renderer/src/store/slices/linear/linear-slice-scope.ts b/src/renderer/src/store/slices/linear/linear-slice-scope.ts index 42c1aadac03..6a36b740c64 100644 --- a/src/renderer/src/store/slices/linear/linear-slice-scope.ts +++ b/src/renderer/src/store/slices/linear/linear-slice-scope.ts @@ -10,11 +10,7 @@ import { type LinearIssueAttributeFilter } from '../../../../../shared/linear/issue-attribute-filter' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' -import { - getLinearCacheGeneration, - getLinearMutationGeneration, - linearRequestState -} from './linear-slice-request-state' +import { getLinearCacheGeneration, getLinearMutationGeneration } from './linear-slice-request-state' export function normalizeListAttributeFilter( attributeFilter?: LinearIssueAttributeFilter | null @@ -78,25 +74,3 @@ export function getLinearReadScope( export function scopedLinearCacheKey(scope: LinearReadScope, key: string): string { return scope.cachePrefix ? `${scope.cachePrefix}::${key}` : key } - -/** Read the current request generation without exposing mutable implementation details. */ -export function linearReadGenerationSnapshot(): { - cacheGeneration: number - mutationGeneration: number -} { - return { - cacheGeneration: getLinearCacheGeneration(), - mutationGeneration: getLinearMutationGeneration() - } -} - -/** Keep the status request handle private to the request-state module. */ -export function getInflightStatusRequest(): { contextKey: string; promise: Promise } | null { - return linearRequestState.inflightStatusRequest -} - -export function setInflightStatusRequest( - request: { contextKey: string; promise: Promise } | null -): void { - linearRequestState.inflightStatusRequest = request -} From 2be53521a7e01b4452141b8e6ae0cf0b39d67bdf Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:05:08 -0700 Subject: [PATCH 9/9] refactor(task-page): fold the task page into one provider-grouped tree The page had two competing splits: an Aug-25 folder split that the Aug-30 oversized-surfaces split stranded, and the 47 flat files that replaced it. The orphaned tree had no non-test importers, yet eight ratchet files still asserted against it, so their invariants stopped constraining shipping code -- which is how six regressions reached main unnoticed. Those ratchets were repointed and the regressions fixed earlier; this removes the tree they were guarding. Moves the live files into task-page/{github,gitlab,jira,linear} and drops the now-redundant prefix, matching the new-workspace sibling. Makes the source-family walker recursive first: it listed a single flat directory, so moving the files under it would have emptied the family and turned every ratchet built on it into a no-op without failing. --- .../git-diff-blob-concurrency-benchmark.mjs | 2 +- .../src/app-shell/AppWorkspaceShell.tsx | 2 +- src/renderer/src/components/TaskPage.tsx | 84 ---- .../src/components/TaskPageGitHubAvatars.tsx | 43 -- ...ture-interaction-writer-boundaries.test.ts | 18 +- ...b-enterprise-slug-routing-boundary.test.ts | 8 +- .../repro-8784-ghe-avatar-fallback.test.ts | 11 +- .../task-page-github-review-model.tsx | 6 +- ...-page-github-reviewer-suggestions.test.ts} | 2 +- ... => task-page-gitlab-task-filters.test.ts} | 2 +- ...linear-issue-dialog-popover-scroll.test.ts | 4 +- ...> task-page-linear-issue-grouping.test.ts} | 14 +- ... => task-page-repo-source-context.test.ts} | 4 +- ... task-page-repo-source-divergence.test.ts} | 2 +- .../task-page-source-family.test-support.ts | 35 +- .../task-page-source-switch-boundary.test.ts | 4 +- .../task-page-task-creation-drafts.test.ts | 4 +- ...age-task-source-host-availability.test.ts} | 6 +- ...k-page-workspace-composer-boundary.test.ts | 4 +- .../Content.tsx} | 10 +- .../Frame.tsx} | 6 +- .../ListChrome.tsx} | 8 +- .../PaginationBar.tsx} | 0 .../ProviderFilters.tsx} | 10 +- .../SourceBar.tsx} | 2 +- .../Surface.tsx} | 18 +- .../src/components/task-page/TaskPage.tsx | 84 ++++ .../chrome/task-page-github-item-filters.tsx | 303 -------------- .../chrome/task-page-github-mode-bar.tsx | 162 ------- .../chrome/task-page-gitlab-filters.tsx | 194 --------- .../chrome/task-page-jira-filters.tsx | 194 --------- .../chrome/task-page-linear-filters.tsx | 365 ---------------- .../chrome/task-page-source-toolbar.tsx | 286 ------------- .../dialogs/new-github-issue-dialog.tsx | 273 ------------ .../dialogs/new-jira-issue-custom-fields.tsx | 124 ------ .../dialogs/new-jira-issue-dialog.tsx | 369 ---------------- .../dialogs/new-linear-issue-dialog.tsx | 226 ---------- .../dialogs/new-linear-issue-more-pickers.tsx | 225 ---------- .../new-linear-issue-status-assignee.tsx | 190 --------- .../dialogs/new-linear-project-dialog.tsx | 228 ---------- .../dialogs/new-linear-project-fields.tsx | 332 --------------- .../dialogs/task-creation-draft-writers.ts | 34 -- .../dialogs/task-page-connect-dialogs.tsx | 72 ---- .../github/AssigneesCell.tsx} | 14 +- ...ithub-assignee-avatars.tsx => Avatars.tsx} | 9 +- .../github/ChecksCell.tsx} | 2 +- .../github/DuplicatePicker.tsx} | 2 +- .../github/Filters.tsx} | 4 +- .../github/IssueDialog.tsx} | 7 +- .../github/IssueSelectors.tsx} | 4 +- .../github/List.tsx} | 10 +- .../github/MergeCell.tsx} | 14 +- .../github/ModeControls.tsx} | 2 +- .../github/ReviewCell.tsx} | 18 +- .../github/ReviewerPicker.tsx} | 4 +- .../github/Rows.tsx} | 14 +- .../github/StatusCell.tsx} | 14 +- .../github/github-assignees-cell.tsx | 279 ------------- .../task-page/github/github-detail-host.tsx | 71 ---- .../github/github-issue-assignee-selector.tsx | 125 ------ .../github/github-issue-label-selector.tsx | 116 ----- .../github/github-reviewer-suggestions.ts | 72 ---- .../task-page/github/github-status-cell.tsx | 344 --------------- .../github/github-status-duplicate-picker.tsx | 155 ------- .../github/github-task-surface-classes.ts | 32 -- .../github-work-item-mutation-runner.ts | 19 - .../github/github-work-item-row-actions.tsx | 141 ------- .../task-page/github/github-work-item-row.tsx | 268 ------------ .../github/github-work-item-rows.tsx | 63 --- .../github/github-work-item-table.tsx | 395 ------------------ .../task-page/github/pr-checks-cell.tsx | 88 ---- .../task-page/github/pr-merge-cell.tsx | 254 ----------- .../task-page/github/pr-review-cell.tsx | 296 ------------- .../github/pr-review-picker-panel.tsx | 210 ---------- .../task-page/github/pr-review-picker.tsx | 268 ------------ .../gitlab/Dialog.tsx} | 2 +- .../gitlab/Filters.tsx} | 2 +- .../gitlab/ItemList.tsx} | 2 +- .../gitlab/TodoList.tsx} | 2 +- .../gitlab/gitlab-issue-pages.test.ts | 79 ---- .../task-page/gitlab/gitlab-issue-pages.ts | 41 -- .../task-page/gitlab/gitlab-task-filters.ts | 27 -- .../task-page/gitlab/gitlab-todos-list.tsx | 108 ----- .../gitlab/gitlab-work-item-list.tsx | 174 -------- .../hooks/github-list-fetch-settle.ts | 285 ------------- .../hooks/use-task-page-account-scopes.ts | 39 -- .../use-task-page-create-dialog-resets.ts | 130 ------ .../use-task-page-create-github-submit.ts | 218 ---------- .../hooks/use-task-page-create-jira-submit.ts | 178 -------- .../use-task-page-create-linear-submits.tsx | 371 ---------------- .../use-task-page-github-detail-openers.ts | 220 ---------- .../hooks/use-task-page-github-fetch.tsx | 383 ----------------- .../use-task-page-github-filtered-items.ts | 204 --------- .../hooks/use-task-page-github-list-state.ts | 177 -------- .../use-task-page-github-mutation-session.ts | 167 -------- .../use-task-page-github-new-issue-state.ts | 157 ------- .../hooks/use-task-page-github-page-loader.ts | 177 -------- .../use-task-page-github-quiet-revalidate.tsx | 356 ---------------- .../hooks/use-task-page-github-search.ts | 323 -------------- .../use-task-page-github-source-banners.ts | 152 ------- .../hooks/use-task-page-gitlab-fetch.ts | 231 ---------- .../hooks/use-task-page-gitlab-list-state.ts | 115 ----- .../hooks/use-task-page-jira-actions.ts | 72 ---- .../use-task-page-jira-create-dialog.tsx | 372 ----------------- .../use-task-page-jira-displayed-issues.ts | 78 ---- .../hooks/use-task-page-jira-fetch.ts | 217 ---------- .../hooks/use-task-page-jira-list-state.ts | 135 ------ .../hooks/use-task-page-jira-projects.ts | 73 ---- .../hooks/use-task-page-linear-actions.ts | 209 --------- .../use-task-page-linear-active-collection.ts | 178 -------- .../hooks/use-task-page-linear-board.tsx | 338 --------------- .../use-task-page-linear-create-dialogs.ts | 245 ----------- .../hooks/use-task-page-linear-fetch.ts | 231 ---------- .../use-task-page-linear-issue-attachments.ts | 74 ---- .../use-task-page-linear-issue-paging.ts | 271 ------------ ...use-task-page-linear-issue-team-options.ts | 171 -------- .../use-task-page-linear-issue-window.ts | 188 --------- .../hooks/use-task-page-linear-list-state.ts | 206 --------- ...use-task-page-linear-project-view-state.ts | 213 ---------- .../use-task-page-linear-search-persist.ts | 137 ------ .../hooks/use-task-page-linear-teams.ts | 74 ---- .../hooks/use-task-page-runtime-preflight.ts | 174 -------- .../use-task-page-selected-issue-state.ts | 217 ---------- .../hooks/use-task-page-session-resume.ts | 314 -------------- .../use-task-page-source-availability.ts | 173 -------- .../hooks/use-task-page-source-notices.ts | 208 --------- .../hooks/use-task-page-source-sync.ts | 71 ---- .../hooks/use-task-page-store-bindings.ts | 162 ------- .../hooks/use-task-page-task-source-state.ts | 17 - .../hooks/use-task-page-use-item-actions.ts | 130 ------ .../hooks/use-task-page-visible-sources.ts | 133 ------ .../jira/ConnectDialog.tsx} | 2 +- .../jira/Content.tsx} | 10 +- .../jira/Filters.tsx} | 2 +- .../jira/IssueDialog.tsx} | 2 +- .../task-page/jira/jira-error-banner.tsx | 52 --- .../task-page/jira/jira-issue-list-host.tsx | 184 -------- .../linear/ConnectDialog.tsx} | 2 +- .../linear/Content.tsx} | 12 +- .../linear/CustomViewList.tsx} | 2 +- .../linear/CustomViewProjects.tsx} | 2 +- .../linear/Filters.tsx} | 2 +- .../linear/IssueAttributes.tsx} | 2 +- .../linear/IssueBoard.tsx} | 10 +- .../linear/IssueBoardColumns.tsx} | 6 +- .../linear/IssueDialog.tsx} | 4 +- .../linear/IssueList.tsx} | 8 +- .../linear/IssueRows.tsx} | 4 +- .../linear/IssueTable.tsx} | 6 +- .../linear/IssueToolbar.tsx} | 2 +- .../linear/ProjectDialog.tsx} | 4 +- .../linear/ProjectFields.tsx} | 2 +- .../linear/ProjectList.tsx} | 2 +- .../linear/ProjectOverview.tsx} | 2 +- .../task-page/linear/linear-connect-empty.tsx | 49 --- .../linear/linear-custom-view-host.tsx | 170 -------- .../task-page/linear/linear-issue-board.tsx | 254 ----------- .../linear/linear-issue-empty-states.tsx | 183 -------- .../linear/linear-issue-grouped-list.tsx | 295 ------------- .../task-page/linear/linear-issue-grouping.ts | 164 -------- .../linear/linear-issue-list-host.tsx | 120 ------ .../task-page/linear/linear-issue-toolbar.tsx | 258 ------------ .../linear/linear-project-overview-host.tsx | 92 ---- .../linear/linear-project-table-host.tsx | 75 ---- .../task-page/linear/linear-state-cell.tsx | 176 -------- .../task-page/linear/linear-views-host.tsx | 154 ------- .../task-page/pagination/pagination-bar.tsx | 88 ---- .../src/components/task-page/relative-time.ts | 5 - .../task-page/source/repo-source-context.ts | 68 --- .../source/repo-source-divergence.ts | 20 - .../source/task-source-host-availability.ts | 35 -- .../source/use-task-page-repo-selection.ts | 108 ----- .../components/task-page/task-page-layout.tsx | 183 -------- .../task-page/task-page-list-limits.ts | 4 - .../components/task-page/workspace-seeds.ts | 31 -- 175 files changed, 288 insertions(+), 19253 deletions(-) delete mode 100644 src/renderer/src/components/TaskPage.tsx delete mode 100644 src/renderer/src/components/TaskPageGitHubAvatars.tsx rename src/renderer/src/components/{task-page/github/github-reviewer-suggestions.test.ts => task-page-github-reviewer-suggestions.test.ts} (98%) rename src/renderer/src/components/{task-page/gitlab/gitlab-task-filters.test.ts => task-page-gitlab-task-filters.test.ts} (91%) rename src/renderer/src/components/{task-page/linear/linear-issue-grouping.test.ts => task-page-linear-issue-grouping.test.ts} (96%) rename src/renderer/src/components/{task-page/source/repo-source-context.test.ts => task-page-repo-source-context.test.ts} (93%) rename src/renderer/src/components/{task-page/source/repo-source-divergence.test.ts => task-page-repo-source-divergence.test.ts} (98%) rename src/renderer/src/components/{task-page/source/task-source-host-availability.test.ts => task-page-task-source-host-availability.test.ts} (91%) rename src/renderer/src/components/{TaskPageContent.tsx => task-page/Content.tsx} (87%) rename src/renderer/src/components/{TaskPageFrame.tsx => task-page/Frame.tsx} (73%) rename src/renderer/src/components/{TaskPageListChrome.tsx => task-page/ListChrome.tsx} (80%) rename src/renderer/src/components/{TaskPagePaginationBar.tsx => task-page/PaginationBar.tsx} (100%) rename src/renderer/src/components/{TaskPageProviderFilters.tsx => task-page/ProviderFilters.tsx} (68%) rename src/renderer/src/components/{TaskPageSourceBar.tsx => task-page/SourceBar.tsx} (99%) rename src/renderer/src/components/{TaskPageSurface.tsx => task-page/Surface.tsx} (50%) create mode 100644 src/renderer/src/components/task-page/TaskPage.tsx delete mode 100644 src/renderer/src/components/task-page/chrome/task-page-github-item-filters.tsx delete mode 100644 src/renderer/src/components/task-page/chrome/task-page-github-mode-bar.tsx delete mode 100644 src/renderer/src/components/task-page/chrome/task-page-gitlab-filters.tsx delete mode 100644 src/renderer/src/components/task-page/chrome/task-page-jira-filters.tsx delete mode 100644 src/renderer/src/components/task-page/chrome/task-page-linear-filters.tsx delete mode 100644 src/renderer/src/components/task-page/chrome/task-page-source-toolbar.tsx delete mode 100644 src/renderer/src/components/task-page/dialogs/new-github-issue-dialog.tsx delete mode 100644 src/renderer/src/components/task-page/dialogs/new-jira-issue-custom-fields.tsx delete mode 100644 src/renderer/src/components/task-page/dialogs/new-jira-issue-dialog.tsx delete mode 100644 src/renderer/src/components/task-page/dialogs/new-linear-issue-dialog.tsx delete mode 100644 src/renderer/src/components/task-page/dialogs/new-linear-issue-more-pickers.tsx delete mode 100644 src/renderer/src/components/task-page/dialogs/new-linear-issue-status-assignee.tsx delete mode 100644 src/renderer/src/components/task-page/dialogs/new-linear-project-dialog.tsx delete mode 100644 src/renderer/src/components/task-page/dialogs/new-linear-project-fields.tsx delete mode 100644 src/renderer/src/components/task-page/dialogs/task-creation-draft-writers.ts delete mode 100644 src/renderer/src/components/task-page/dialogs/task-page-connect-dialogs.tsx rename src/renderer/src/components/{TaskPageGitHubAssigneesCell.tsx => task-page/github/AssigneesCell.tsx} (95%) rename src/renderer/src/components/task-page/github/{github-assignee-avatars.tsx => Avatars.tsx} (99%) rename src/renderer/src/components/{TaskPageGitHubChecksCell.tsx => task-page/github/ChecksCell.tsx} (96%) rename src/renderer/src/components/{TaskPageGitHubDuplicatePicker.tsx => task-page/github/DuplicatePicker.tsx} (98%) rename src/renderer/src/components/{TaskPageGitHubFilters.tsx => task-page/github/Filters.tsx} (98%) rename src/renderer/src/components/{TaskPageGitHubIssueDialog.tsx => task-page/github/IssueDialog.tsx} (97%) rename src/renderer/src/components/{TaskPageGitHubIssueSelectors.tsx => task-page/github/IssueSelectors.tsx} (98%) rename src/renderer/src/components/{TaskPageGitHubList.tsx => task-page/github/List.tsx} (97%) rename src/renderer/src/components/{TaskPageGitHubMergeCell.tsx => task-page/github/MergeCell.tsx} (94%) rename src/renderer/src/components/{TaskPageGitHubModeControls.tsx => task-page/github/ModeControls.tsx} (98%) rename src/renderer/src/components/{TaskPageGitHubReviewCell.tsx => task-page/github/ReviewCell.tsx} (95%) rename src/renderer/src/components/{TaskPageGitHubReviewerPicker.tsx => task-page/github/ReviewerPicker.tsx} (98%) rename src/renderer/src/components/{TaskPageGitHubRows.tsx => task-page/github/Rows.tsx} (97%) rename src/renderer/src/components/{TaskPageGitHubStatusCell.tsx => task-page/github/StatusCell.tsx} (96%) delete mode 100644 src/renderer/src/components/task-page/github/github-assignees-cell.tsx delete mode 100644 src/renderer/src/components/task-page/github/github-detail-host.tsx delete mode 100644 src/renderer/src/components/task-page/github/github-issue-assignee-selector.tsx delete mode 100644 src/renderer/src/components/task-page/github/github-issue-label-selector.tsx delete mode 100644 src/renderer/src/components/task-page/github/github-reviewer-suggestions.ts delete mode 100644 src/renderer/src/components/task-page/github/github-status-cell.tsx delete mode 100644 src/renderer/src/components/task-page/github/github-status-duplicate-picker.tsx delete mode 100644 src/renderer/src/components/task-page/github/github-task-surface-classes.ts delete mode 100644 src/renderer/src/components/task-page/github/github-work-item-mutation-runner.ts delete mode 100644 src/renderer/src/components/task-page/github/github-work-item-row-actions.tsx delete mode 100644 src/renderer/src/components/task-page/github/github-work-item-row.tsx delete mode 100644 src/renderer/src/components/task-page/github/github-work-item-rows.tsx delete mode 100644 src/renderer/src/components/task-page/github/github-work-item-table.tsx delete mode 100644 src/renderer/src/components/task-page/github/pr-checks-cell.tsx delete mode 100644 src/renderer/src/components/task-page/github/pr-merge-cell.tsx delete mode 100644 src/renderer/src/components/task-page/github/pr-review-cell.tsx delete mode 100644 src/renderer/src/components/task-page/github/pr-review-picker-panel.tsx delete mode 100644 src/renderer/src/components/task-page/github/pr-review-picker.tsx rename src/renderer/src/components/{TaskPageGitLabDialog.tsx => task-page/gitlab/Dialog.tsx} (90%) rename src/renderer/src/components/{TaskPageGitLabFilters.tsx => task-page/gitlab/Filters.tsx} (98%) rename src/renderer/src/components/{TaskPageGitLabItemList.tsx => task-page/gitlab/ItemList.tsx} (98%) rename src/renderer/src/components/{TaskPageGitLabTodoList.tsx => task-page/gitlab/TodoList.tsx} (98%) delete mode 100644 src/renderer/src/components/task-page/gitlab/gitlab-issue-pages.test.ts delete mode 100644 src/renderer/src/components/task-page/gitlab/gitlab-issue-pages.ts delete mode 100644 src/renderer/src/components/task-page/gitlab/gitlab-task-filters.ts delete mode 100644 src/renderer/src/components/task-page/gitlab/gitlab-todos-list.tsx delete mode 100644 src/renderer/src/components/task-page/gitlab/gitlab-work-item-list.tsx delete mode 100644 src/renderer/src/components/task-page/hooks/github-list-fetch-settle.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-account-scopes.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-create-dialog-resets.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-create-github-submit.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-create-jira-submit.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-create-linear-submits.tsx delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-github-detail-openers.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-github-fetch.tsx delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-github-filtered-items.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-github-list-state.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-github-mutation-session.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-github-new-issue-state.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-github-page-loader.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-github-quiet-revalidate.tsx delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-github-search.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-github-source-banners.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-gitlab-fetch.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-gitlab-list-state.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-jira-actions.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-jira-create-dialog.tsx delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-jira-displayed-issues.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-jira-fetch.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-jira-list-state.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-jira-projects.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-linear-actions.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-linear-active-collection.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-linear-board.tsx delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-linear-create-dialogs.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-linear-fetch.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-linear-issue-attachments.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-linear-issue-paging.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-linear-issue-team-options.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-linear-issue-window.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-linear-list-state.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-linear-project-view-state.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-linear-search-persist.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-linear-teams.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-runtime-preflight.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-selected-issue-state.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-session-resume.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-source-availability.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-source-notices.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-source-sync.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-store-bindings.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-task-source-state.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-use-item-actions.ts delete mode 100644 src/renderer/src/components/task-page/hooks/use-task-page-visible-sources.ts rename src/renderer/src/components/{TaskPageJiraConnectDialog.tsx => task-page/jira/ConnectDialog.tsx} (79%) rename src/renderer/src/components/{TaskPageJiraContent.tsx => task-page/jira/Content.tsx} (94%) rename src/renderer/src/components/{TaskPageJiraFilters.tsx => task-page/jira/Filters.tsx} (98%) rename src/renderer/src/components/{TaskPageJiraIssueDialog.tsx => task-page/jira/IssueDialog.tsx} (99%) delete mode 100644 src/renderer/src/components/task-page/jira/jira-error-banner.tsx delete mode 100644 src/renderer/src/components/task-page/jira/jira-issue-list-host.tsx rename src/renderer/src/components/{TaskPageLinearConnectDialog.tsx => task-page/linear/ConnectDialog.tsx} (87%) rename src/renderer/src/components/{TaskPageLinearContent.tsx => task-page/linear/Content.tsx} (85%) rename src/renderer/src/components/{TaskPageLinearCustomViewList.tsx => task-page/linear/CustomViewList.tsx} (96%) rename src/renderer/src/components/{TaskPageLinearCustomViewProjects.tsx => task-page/linear/CustomViewProjects.tsx} (97%) rename src/renderer/src/components/{TaskPageLinearFilters.tsx => task-page/linear/Filters.tsx} (99%) rename src/renderer/src/components/{TaskPageLinearIssueAttributes.tsx => task-page/linear/IssueAttributes.tsx} (99%) rename src/renderer/src/components/{TaskPageLinearIssueBoard.tsx => task-page/linear/IssueBoard.tsx} (94%) rename src/renderer/src/components/{TaskPageLinearIssueBoardColumns.tsx => task-page/linear/IssueBoardColumns.tsx} (97%) rename src/renderer/src/components/{TaskPageLinearIssueDialog.tsx => task-page/linear/IssueDialog.tsx} (97%) rename src/renderer/src/components/{TaskPageLinearIssueList.tsx => task-page/linear/IssueList.tsx} (95%) rename src/renderer/src/components/{TaskPageLinearIssueRows.tsx => task-page/linear/IssueRows.tsx} (53%) rename src/renderer/src/components/{TaskPageLinearIssueTable.tsx => task-page/linear/IssueTable.tsx} (98%) rename src/renderer/src/components/{TaskPageLinearIssueToolbar.tsx => task-page/linear/IssueToolbar.tsx} (98%) rename src/renderer/src/components/{TaskPageLinearProjectDialog.tsx => task-page/linear/ProjectDialog.tsx} (97%) rename src/renderer/src/components/{TaskPageLinearProjectFields.tsx => task-page/linear/ProjectFields.tsx} (99%) rename src/renderer/src/components/{TaskPageLinearProjectList.tsx => task-page/linear/ProjectList.tsx} (96%) rename src/renderer/src/components/{TaskPageLinearProjectOverview.tsx => task-page/linear/ProjectOverview.tsx} (96%) delete mode 100644 src/renderer/src/components/task-page/linear/linear-connect-empty.tsx delete mode 100644 src/renderer/src/components/task-page/linear/linear-custom-view-host.tsx delete mode 100644 src/renderer/src/components/task-page/linear/linear-issue-board.tsx delete mode 100644 src/renderer/src/components/task-page/linear/linear-issue-empty-states.tsx delete mode 100644 src/renderer/src/components/task-page/linear/linear-issue-grouped-list.tsx delete mode 100644 src/renderer/src/components/task-page/linear/linear-issue-grouping.ts delete mode 100644 src/renderer/src/components/task-page/linear/linear-issue-list-host.tsx delete mode 100644 src/renderer/src/components/task-page/linear/linear-issue-toolbar.tsx delete mode 100644 src/renderer/src/components/task-page/linear/linear-project-overview-host.tsx delete mode 100644 src/renderer/src/components/task-page/linear/linear-project-table-host.tsx delete mode 100644 src/renderer/src/components/task-page/linear/linear-state-cell.tsx delete mode 100644 src/renderer/src/components/task-page/linear/linear-views-host.tsx delete mode 100644 src/renderer/src/components/task-page/pagination/pagination-bar.tsx delete mode 100644 src/renderer/src/components/task-page/relative-time.ts delete mode 100644 src/renderer/src/components/task-page/source/repo-source-context.ts delete mode 100644 src/renderer/src/components/task-page/source/repo-source-divergence.ts delete mode 100644 src/renderer/src/components/task-page/source/task-source-host-availability.ts delete mode 100644 src/renderer/src/components/task-page/source/use-task-page-repo-selection.ts delete mode 100644 src/renderer/src/components/task-page/task-page-layout.tsx delete mode 100644 src/renderer/src/components/task-page/task-page-list-limits.ts delete mode 100644 src/renderer/src/components/task-page/workspace-seeds.ts diff --git a/config/scripts/git-diff-blob-concurrency-benchmark.mjs b/config/scripts/git-diff-blob-concurrency-benchmark.mjs index 602efd00e1b..8b7e9503e4a 100644 --- a/config/scripts/git-diff-blob-concurrency-benchmark.mjs +++ b/config/scripts/git-diff-blob-concurrency-benchmark.mjs @@ -87,7 +87,7 @@ const parent = `${head}~1` const CANDIDATES = [ 'src/main/git/status.ts', 'src/shared/agent-hook-listener.ts', - 'src/renderer/src/components/TaskPage.tsx' + 'src/renderer/src/components/task-page/TaskPage.tsx' ] const files = [] diff --git a/src/renderer/src/app-shell/AppWorkspaceShell.tsx b/src/renderer/src/app-shell/AppWorkspaceShell.tsx index af0df59ccd6..3ce49d84bf6 100644 --- a/src/renderer/src/app-shell/AppWorkspaceShell.tsx +++ b/src/renderer/src/app-shell/AppWorkspaceShell.tsx @@ -16,7 +16,7 @@ const Landing = lazy(() => import('../components/Landing')) const WorktreeCreationPanel = lazy( () => import('../components/worktree-creation/WorktreeCreationPanel') ) -const TaskPage = lazy(() => import('../components/TaskPage')) +const TaskPage = lazy(() => import('../components/task-page/TaskPage')) const AutomationsPage = lazy(() => import('../components/automations/AutomationsPage')) const ActivityPrototypePage = lazy(() => import('../components/activity/ActivityPrototypePage')) const Settings = lazy(() => import('../components/settings/Settings')) diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx deleted file mode 100644 index fcd84c20856..00000000000 --- a/src/renderer/src/components/TaskPage.tsx +++ /dev/null @@ -1,84 +0,0 @@ -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 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/TaskPageGitHubAvatars.tsx b/src/renderer/src/components/TaskPageGitHubAvatars.tsx deleted file mode 100644 index 26162f1ceb6..00000000000 --- a/src/renderer/src/components/TaskPageGitHubAvatars.tsx +++ /dev/null @@ -1,43 +0,0 @@ -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 { - return ( - - ) -} diff --git a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts index a7c915cd6c6..4a02840098e 100644 --- a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts +++ b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts @@ -84,10 +84,10 @@ describe('feature interaction writer boundaries', () => { it('threads GitHub task source context through inline task mutations', () => { const sections = [ - componentSource('TaskPageGitHubStatusCell.tsx'), - componentSource('TaskPageGitHubAssigneesCell.tsx'), - componentSource('TaskPageGitHubReviewCell.tsx'), - componentSource('TaskPageGitHubMergeCell.tsx'), + componentSource('task-page/github/StatusCell.tsx'), + componentSource('task-page/github/AssigneesCell.tsx'), + componentSource('task-page/github/ReviewCell.tsx'), + componentSource('task-page/github/MergeCell.tsx'), sourceBetween( componentSource('use-task-page-github-issue-creation.ts'), 'const handleCreateNewIssue', @@ -98,7 +98,7 @@ describe('feature interaction writer boundaries', () => { for (const section of sections) { expect(section).toContain('sourceContext') } - const rowSource = componentSource('TaskPageGitHubRows.tsx') + const rowSource = componentSource('task-page/github/Rows.tsx') // Rows inline the repo lookup per cell rather than hoisting one const. expect( rowSource.match(/sourceContext=\{getTaskPageRepoSourceContext\(itemRepo, 'github'\)\}/g) @@ -120,7 +120,7 @@ describe('feature interaction writer boundaries', () => { 'const openRelatedLinearIssue' ), sourceBetween( - componentSource('TaskPageSourceBar.tsx'), + componentSource('task-page/SourceBar.tsx'), 'taskSourceManuallyChangedRef.current = true', 'void updateSettings' ) @@ -165,7 +165,7 @@ describe('feature interaction writer boundaries', () => { expect( sourceBetween( - componentSource('TaskPageGitLabItemList.tsx'), + componentSource('task-page/gitlab/ItemList.tsx'), '{displayedGitLabItems.map((item) => (', 'handleUseGitLabItem(item)' ).match(/recordFeatureInteraction\('gitlab-tasks'\)/g) @@ -200,7 +200,7 @@ describe('feature interaction writer boundaries', () => { it('keeps nested GitLab row actions from also opening task details by keyboard', () => { const rowSection = sourceBetween( - componentSource('TaskPageGitLabItemList.tsx'), + componentSource('task-page/gitlab/ItemList.tsx'), 'onKeyDown={(e) => {', 'className="grid w-full cursor-pointer' ) @@ -211,7 +211,7 @@ describe('feature interaction writer boundaries', () => { }) it('keys GitLab rows by repository and item identity across hosts', () => { - expect(componentSource('TaskPageGitLabItemList.tsx')).toContain( + expect(componentSource('task-page/gitlab/ItemList.tsx')).toContain( 'key={`${item.repoId}:${item.id}`}' ) }) diff --git a/src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts b/src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts index deeda3ef506..469991fc55e 100644 --- a/src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts +++ b/src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts @@ -16,8 +16,8 @@ function sourceBetween(source: string, startPattern: string, endPattern: string) describe('GitHub Enterprise slug routing boundaries', () => { it('keeps work-item URL hosts on TaskPage metadata and issue mutations', () => { - const statusSection = componentSource('TaskPageGitHubStatusCell.tsx') - const assigneeSection = componentSource('TaskPageGitHubAssigneesCell.tsx') + const statusSection = componentSource('task-page/github/StatusCell.tsx') + const assigneeSection = componentSource('task-page/github/AssigneesCell.tsx') expect(statusSection).toContain('host: githubProjectHost(parsedOwnerRepo.host)') expect(assigneeSection).toContain('parsed?.slug.host') @@ -25,9 +25,9 @@ describe('GitHub Enterprise slug routing boundaries', () => { }) it('uses URL-host fallback for TaskPage reviewer and merge mutations', () => { - const reviewSection = componentSource('TaskPageGitHubReviewCell.tsx') + const reviewSection = componentSource('task-page/github/ReviewCell.tsx') const reviewActionsSection = componentSource('task-page-github-reviewer-actions.ts') - const mergeSection = componentSource('TaskPageGitHubMergeCell.tsx') + const mergeSection = componentSource('task-page/github/MergeCell.tsx') expect(reviewSection).toContain('resolveTaskPullRequestRepo(item)') expect(reviewSection).toContain('reviewRepo,') diff --git a/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts b/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts index 8e3b831020d..8b33b3dc40c 100644 --- a/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts +++ b/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts @@ -46,10 +46,7 @@ describe('issue #8784 GHE avatar fallback (regression)', () => { // Why: author chip must not ignore API avatar_url and only pass login. expect(prPage).not.toMatch(/githubAvatarUrl\(workItem\.author\)/) - const reviewChip = readFileSync( - join(__dirname, '../TaskPageGitHubAvatars.tsx'), - 'utf8' - ) + const reviewChip = readFileSync(join(__dirname, '../task-page/github/Avatars.tsx'), 'utf8') expect(reviewChip).toMatch(/GitHubUserAvatar/) // Why: list chip must not hardcode github.com/{login}.png. expect(reviewChip).not.toMatch(/github\.com\/\$\{reviewer\.login\}\.png/) @@ -77,19 +74,19 @@ describe('issue #8784 GHE avatar fallback (regression)', () => { displayName: 'option.name' }, { - file: 'TaskPageGitHubAvatars.tsx', + file: 'task-page/github/Avatars.tsx', fn: 'GitHubAssigneeAvatar', login: 'assignee.login', displayName: 'assignee.name' }, { - file: 'TaskPageGitHubAssigneesCell.tsx', + file: 'task-page/github/AssigneesCell.tsx', fn: 'GHAssigneesCell', login: 'user.login', displayName: 'user.name' }, { - file: 'TaskPageGitHubReviewerPicker.tsx', + file: 'task-page/github/ReviewerPicker.tsx', fn: 'TaskPageGitHubReviewerPicker', login: 'reviewer.login', displayName: 'reviewer.name' diff --git a/src/renderer/src/components/task-page-github-review-model.tsx b/src/renderer/src/components/task-page-github-review-model.tsx index 5a3bc1ce053..db4b9e4c5ce 100644 --- a/src/renderer/src/components/task-page-github-review-model.tsx +++ b/src/renderer/src/components/task-page-github-review-model.tsx @@ -41,10 +41,12 @@ export function mergeReviewerSuggestions( byLogin.set(key, user) continue } - if (!existing.avatarUrl && user.avatarUrl) { + // Why: seeds carry login only; backfill display fields from the metadata query. + if ((!existing.avatarUrl && user.avatarUrl) || (!existing.name && user.name)) { byLogin.set(key, { ...existing, - avatarUrl: user.avatarUrl + avatarUrl: existing.avatarUrl || user.avatarUrl, + name: existing.name ?? user.name }) } } diff --git a/src/renderer/src/components/task-page/github/github-reviewer-suggestions.test.ts b/src/renderer/src/components/task-page-github-reviewer-suggestions.test.ts similarity index 98% rename from src/renderer/src/components/task-page/github/github-reviewer-suggestions.test.ts rename to src/renderer/src/components/task-page-github-reviewer-suggestions.test.ts index a4dbc006031..7e3f0cec98a 100644 --- a/src/renderer/src/components/task-page/github/github-reviewer-suggestions.test.ts +++ b/src/renderer/src/components/task-page-github-reviewer-suggestions.test.ts @@ -4,7 +4,7 @@ import { mergeReviewerSuggestions, resolveTaskPullRequestRepo, sameOptionalGitHubOwnerRepo -} from './github-reviewer-suggestions' +} from './task-page-github-review-model' describe('sameOptionalGitHubOwnerRepo', () => { it('treats two missing values as equal', () => { diff --git a/src/renderer/src/components/task-page/gitlab/gitlab-task-filters.test.ts b/src/renderer/src/components/task-page-gitlab-task-filters.test.ts similarity index 91% rename from src/renderer/src/components/task-page/gitlab/gitlab-task-filters.test.ts rename to src/renderer/src/components/task-page-gitlab-task-filters.test.ts index efa44bd02d2..55dec516171 100644 --- a/src/renderer/src/components/task-page/gitlab/gitlab-task-filters.test.ts +++ b/src/renderer/src/components/task-page-gitlab-task-filters.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { isGitLabIssueFilter, isGitLabMRFilter } from './gitlab-task-filters' +import { isGitLabIssueFilter, isGitLabMRFilter } from './task-page-source-context' describe('isGitLabMRFilter', () => { it('accepts MR statuses including all', () => { diff --git a/src/renderer/src/components/task-page-linear-issue-dialog-popover-scroll.test.ts b/src/renderer/src/components/task-page-linear-issue-dialog-popover-scroll.test.ts index bcd9ee73d97..cea31abe302 100644 --- a/src/renderer/src/components/task-page-linear-issue-dialog-popover-scroll.test.ts +++ b/src/renderer/src/components/task-page-linear-issue-dialog-popover-scroll.test.ts @@ -2,8 +2,8 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' const newLinearIssueDialogSources = [ - './TaskPageLinearIssueDialog.tsx', - './TaskPageLinearIssueAttributes.tsx' + './task-page/linear/IssueDialog.tsx', + './task-page/linear/IssueAttributes.tsx' ].map((path) => readFileSync(new URL(path, import.meta.url), 'utf8')) /** The extracted Linear "New Issue" dialog and its attribute pickers. */ diff --git a/src/renderer/src/components/task-page/linear/linear-issue-grouping.test.ts b/src/renderer/src/components/task-page-linear-issue-grouping.test.ts similarity index 96% rename from src/renderer/src/components/task-page/linear/linear-issue-grouping.test.ts rename to src/renderer/src/components/task-page-linear-issue-grouping.test.ts index e0e120dcd91..59356b33f07 100644 --- a/src/renderer/src/components/task-page/linear/linear-issue-grouping.test.ts +++ b/src/renderer/src/components/task-page-linear-issue-grouping.test.ts @@ -1,14 +1,16 @@ import { describe, expect, it } from 'vitest' -import type { LinearIssue } from '../../../../../shared/linear/issue-types' +import type { LinearIssue } from '../../../shared/linear/issue-types' +import { + findLinearWorkflowStateForStatus, + getLinearStatusSectionState, + mergeLinearCollectionResults +} from './task-page-linear-issue-model' import { compareLinearIssues, - findLinearWorkflowStateForStatus, getLinearIssueGridTemplate, getLinearPriorityRank, - getLinearStatusSectionState, - groupLinearIssues, - mergeLinearCollectionResults -} from './linear-issue-grouping' + groupLinearIssues +} from './task-page-linear-jira-list-model' function issue( overrides: Partial & Pick diff --git a/src/renderer/src/components/task-page/source/repo-source-context.test.ts b/src/renderer/src/components/task-page-repo-source-context.test.ts similarity index 93% rename from src/renderer/src/components/task-page/source/repo-source-context.test.ts rename to src/renderer/src/components/task-page-repo-source-context.test.ts index e2cd60dfae2..4a90e04f352 100644 --- a/src/renderer/src/components/task-page/source/repo-source-context.test.ts +++ b/src/renderer/src/components/task-page-repo-source-context.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { buildGitLabProviderIdentity, getTaskPageRepoCacheInput } from './repo-source-context' -import type { Repo } from '../../../../../shared/repo-types' +import { buildGitLabProviderIdentity, getTaskPageRepoCacheInput } from './task-page-source-context' +import type { Repo } from '../../../shared/repo-types' describe('buildGitLabProviderIdentity', () => { it('splits namespace and project and builds the web URL', () => { diff --git a/src/renderer/src/components/task-page/source/repo-source-divergence.test.ts b/src/renderer/src/components/task-page-repo-source-divergence.test.ts similarity index 98% rename from src/renderer/src/components/task-page/source/repo-source-divergence.test.ts rename to src/renderer/src/components/task-page-repo-source-divergence.test.ts index eecbab425de..e80633c3d01 100644 --- a/src/renderer/src/components/task-page/source/repo-source-divergence.test.ts +++ b/src/renderer/src/components/task-page-repo-source-divergence.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { TaskPageRepoSourceState } from '@/components/task-page-cache-selectors' -import { hasDivergentSources, hasUpstreamCandidateDivergence } from './repo-source-divergence' +import { hasDivergentSources, hasUpstreamCandidateDivergence } from './task-page-draft-storage' function state( sources: Partial> diff --git a/src/renderer/src/components/task-page-source-family.test-support.ts b/src/renderer/src/components/task-page-source-family.test-support.ts index 11dd0634272..2253f11295e 100644 --- a/src/renderer/src/components/task-page-source-family.test-support.ts +++ b/src/renderer/src/components/task-page-source-family.test-support.ts @@ -7,7 +7,8 @@ import { readHookDefinitions } from './refactor-hook-contract.test-support' -const TASK_PAGE_SOURCE_PATTERN = /^(?:TaskPage.*\.tsx|use-task-page-.*\.ts|task-page-.*\.tsx?)$/ +const TASK_PAGE_FLAT_SOURCE_PATTERN = /^(?:use-task-page-.*\.ts|task-page-.*\.tsx?)$/ +const TASK_PAGE_DIRECTORY = 'task-page' const TASK_PAGE_EXTRACTED_LOWERCASE_FILES = new Set([ 'task-page-draft-storage.tsx', 'task-page-github-landing-refresh-run.tsx', @@ -19,18 +20,34 @@ const TASK_PAGE_EXTRACTED_LOWERCASE_FILES = new Set([ 'task-page-source-context.tsx' ]) -export const TASK_PAGE_SOURCE_FILES = readdirSync(__dirname) - .filter( - (name) => - TASK_PAGE_SOURCE_PATTERN.test(name) && - !name.includes('.test.') && - !name.includes('.test-support.') +function isSourceFile(name: string): boolean { + return !name.includes('.test.') && !name.includes('.test-support.') +} + +// Why: the components/task-page tree nests by provider, so a flat readdir would silently +// return an empty family and turn every ratchet built on it into a no-op. +function readTaskPageDirectory(relativeDirectory: string): string[] { + return readdirSync(join(__dirname, relativeDirectory), { withFileTypes: true }).flatMap( + (entry) => { + const relativePath = `${relativeDirectory}/${entry.name}` + if (entry.isDirectory()) { + return readTaskPageDirectory(relativePath) + } + return /\.tsx?$/.test(entry.name) && isSourceFile(entry.name) ? [relativePath] : [] + } ) - .sort() +} + +export const TASK_PAGE_SOURCE_FILES = [ + ...readdirSync(__dirname).filter( + (name) => TASK_PAGE_FLAT_SOURCE_PATTERN.test(name) && isSourceFile(name) + ), + ...readTaskPageDirectory(TASK_PAGE_DIRECTORY) +].sort() export const TASK_PAGE_REFACTOR_SOURCE_FILES = TASK_PAGE_SOURCE_FILES.filter( (name) => - /^TaskPage.*\.tsx$/.test(name) || + (name.startsWith(`${TASK_PAGE_DIRECTORY}/`) && name.endsWith('.tsx')) || /^use-task-page-.*\.ts$/.test(name) || TASK_PAGE_EXTRACTED_LOWERCASE_FILES.has(name) ) diff --git a/src/renderer/src/components/task-page-source-switch-boundary.test.ts b/src/renderer/src/components/task-page-source-switch-boundary.test.ts index 5465926518c..ae3bc60df21 100644 --- a/src/renderer/src/components/task-page-source-switch-boundary.test.ts +++ b/src/renderer/src/components/task-page-source-switch-boundary.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' import { readTaskPageSource } from './task-page-source-family.test-support' -const CONTENT_SOURCE = readTaskPageSource('TaskPageContent.tsx') -const SOURCE_BAR_SOURCE = readTaskPageSource('TaskPageSourceBar.tsx') +const CONTENT_SOURCE = readTaskPageSource('task-page/Content.tsx') +const SOURCE_BAR_SOURCE = readTaskPageSource('task-page/SourceBar.tsx') const SOURCE_CONTEXT_SOURCE = readTaskPageSource('task-page-source-context.tsx') const RUNTIME_HOSTS_SOURCE = readTaskPageSource('use-task-page-runtime-hosts.ts') const GITHUB_DETAIL_SOURCE = readTaskPageSource('use-task-page-github-detail.ts') diff --git a/src/renderer/src/components/task-page-task-creation-drafts.test.ts b/src/renderer/src/components/task-page-task-creation-drafts.test.ts index 9933050d1b8..969c1b0c373 100644 --- a/src/renderer/src/components/task-page-task-creation-drafts.test.ts +++ b/src/renderer/src/components/task-page-task-creation-drafts.test.ts @@ -49,11 +49,11 @@ describe('TaskPage Linear/Jira creation drafts', () => { it('restores dismissed typed text when each dialog reopens', () => { const linearFilters = readFileSync( - new URL('./TaskPageLinearFilters.tsx', import.meta.url), + new URL('./task-page/linear/Filters.tsx', import.meta.url), 'utf8' ) const jiraFilters = readFileSync( - new URL('./TaskPageJiraFilters.tsx', import.meta.url), + new URL('./task-page/jira/Filters.tsx', import.meta.url), 'utf8' ) expect(linearFilters).toContain("setNewLinearProjectName(draft?.name ?? '')") diff --git a/src/renderer/src/components/task-page/source/task-source-host-availability.test.ts b/src/renderer/src/components/task-page-task-source-host-availability.test.ts similarity index 91% rename from src/renderer/src/components/task-page/source/task-source-host-availability.test.ts rename to src/renderer/src/components/task-page-task-source-host-availability.test.ts index e0a0d595e8e..3fe0261beaf 100644 --- a/src/renderer/src/components/task-page/source/task-source-host-availability.test.ts +++ b/src/renderer/src/components/task-page-task-source-host-availability.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' -import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../../../shared/protocol-version' -import type { ExecutionHostRegistryEntry } from '../../../../../shared/execution-host-registry' -import { getTaskSourceHostAvailabilityForHost } from './task-source-host-availability' +import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry' +import { getTaskSourceHostAvailabilityForHost } from './task-page-source-context' // Why: a complete typed entry keeps these cases honest when the registry shape changes. function hostEntry( diff --git a/src/renderer/src/components/task-page-workspace-composer-boundary.test.ts b/src/renderer/src/components/task-page-workspace-composer-boundary.test.ts index 22ef41a8bd3..a16128eee76 100644 --- a/src/renderer/src/components/task-page-workspace-composer-boundary.test.ts +++ b/src/renderer/src/components/task-page-workspace-composer-boundary.test.ts @@ -114,8 +114,8 @@ describe('TaskPage workspace creation source boundaries', () => { }) it('uses the shared composer handler from GitHub detail and start-new actions', () => { - const detail = readFileSync(join(__dirname, 'TaskPageContent.tsx'), 'utf8') - const actions = readFileSync(join(__dirname, 'TaskPageGitHubRows.tsx'), 'utf8') + const detail = readFileSync(join(__dirname, 'task-page/Content.tsx'), 'utf8') + const actions = readFileSync(join(__dirname, 'task-page/github/Rows.tsx'), 'utf8') expect(detail.match(/onUse=\{\(item\) => \{/g)).toHaveLength(2) expect(actions.match(/onSelect=\{\(\) => handleUseWorkItem\(item\)\}/g)).toHaveLength(2) }) diff --git a/src/renderer/src/components/TaskPageContent.tsx b/src/renderer/src/components/task-page/Content.tsx similarity index 87% rename from src/renderer/src/components/TaskPageContent.tsx rename to src/renderer/src/components/task-page/Content.tsx index 7c6419c6742..ebe40e3ff28 100644 --- a/src/renderer/src/components/TaskPageContent.tsx +++ b/src/renderer/src/components/task-page/Content.tsx @@ -1,11 +1,11 @@ -import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' +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' +import { TaskPageGitHubList } from './github/List' +import { TaskPageGitLabTodoList } from './gitlab/TodoList' +import { TaskPageGitLabItemList } from './gitlab/ItemList' +import { TaskPageJiraContent } from './jira/Content' export function TaskPageContent({ model }: { diff --git a/src/renderer/src/components/TaskPageFrame.tsx b/src/renderer/src/components/task-page/Frame.tsx similarity index 73% rename from src/renderer/src/components/TaskPageFrame.tsx rename to src/renderer/src/components/task-page/Frame.tsx index 07d86bc07e2..5bcd2b05530 100644 --- a/src/renderer/src/components/TaskPageFrame.tsx +++ b/src/renderer/src/components/task-page/Frame.tsx @@ -1,6 +1,6 @@ -import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' -import { TaskPageListChrome } from './TaskPageListChrome' -import { TaskPageContent } from './TaskPageContent' +import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import { TaskPageListChrome } from './ListChrome' +import { TaskPageContent } from './Content' export function TaskPageFrame({ model }: { diff --git a/src/renderer/src/components/TaskPageListChrome.tsx b/src/renderer/src/components/task-page/ListChrome.tsx similarity index 80% rename from src/renderer/src/components/TaskPageListChrome.tsx rename to src/renderer/src/components/task-page/ListChrome.tsx index cf3e441b428..b103fae7624 100644 --- a/src/renderer/src/components/TaskPageListChrome.tsx +++ b/src/renderer/src/components/task-page/ListChrome.tsx @@ -1,9 +1,9 @@ -import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' +import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' import { cn } from '@/lib/utils' -import { TaskPageSourceBar } from './TaskPageSourceBar' +import { TaskPageSourceBar } from './SourceBar' import { AlertCircle } from 'lucide-react' -import { TaskPageGitHubModeControls } from './TaskPageGitHubModeControls' -import { TaskPageProviderFilters } from './TaskPageProviderFilters' +import { TaskPageGitHubModeControls } from './github/ModeControls' +import { TaskPageProviderFilters } from './ProviderFilters' export function TaskPageListChrome({ model }: { diff --git a/src/renderer/src/components/TaskPagePaginationBar.tsx b/src/renderer/src/components/task-page/PaginationBar.tsx similarity index 100% rename from src/renderer/src/components/TaskPagePaginationBar.tsx rename to src/renderer/src/components/task-page/PaginationBar.tsx diff --git a/src/renderer/src/components/TaskPageProviderFilters.tsx b/src/renderer/src/components/task-page/ProviderFilters.tsx similarity index 68% rename from src/renderer/src/components/TaskPageProviderFilters.tsx rename to src/renderer/src/components/task-page/ProviderFilters.tsx index 99dd252a051..41da2eec700 100644 --- a/src/renderer/src/components/TaskPageProviderFilters.tsx +++ b/src/renderer/src/components/task-page/ProviderFilters.tsx @@ -1,8 +1,8 @@ -import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' -import { TaskPageGitHubFilters } from './TaskPageGitHubFilters' -import { TaskPageLinearFilters } from './TaskPageLinearFilters' -import { TaskPageJiraFilters } from './TaskPageJiraFilters' -import { TaskPageGitLabFilters } from './TaskPageGitLabFilters' +import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import { TaskPageGitHubFilters } from './github/Filters' +import { TaskPageLinearFilters } from './linear/Filters' +import { TaskPageJiraFilters } from './jira/Filters' +import { TaskPageGitLabFilters } from './gitlab/Filters' export function TaskPageProviderFilters({ model }: { diff --git a/src/renderer/src/components/TaskPageSourceBar.tsx b/src/renderer/src/components/task-page/SourceBar.tsx similarity index 99% rename from src/renderer/src/components/TaskPageSourceBar.tsx rename to src/renderer/src/components/task-page/SourceBar.tsx index b60c5eb0b66..8bcedea82a5 100644 --- a/src/renderer/src/components/TaskPageSourceBar.tsx +++ b/src/renderer/src/components/task-page/SourceBar.tsx @@ -1,4 +1,4 @@ -import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' +import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/TaskPageSurface.tsx b/src/renderer/src/components/task-page/Surface.tsx similarity index 50% rename from src/renderer/src/components/TaskPageSurface.tsx rename to src/renderer/src/components/task-page/Surface.tsx index d8dfd1f570d..790dab21bcd 100644 --- a/src/renderer/src/components/TaskPageSurface.tsx +++ b/src/renderer/src/components/task-page/Surface.tsx @@ -1,12 +1,12 @@ -import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' -import { TaskPageFrame } from './TaskPageFrame' -import { TaskPageGitHubIssueDialog } from './TaskPageGitHubIssueDialog' -import { TaskPageLinearProjectDialog } from './TaskPageLinearProjectDialog' -import { TaskPageLinearIssueDialog } from './TaskPageLinearIssueDialog' -import { TaskPageJiraIssueDialog } from './TaskPageJiraIssueDialog' -import { TaskPageGitLabDialog } from './TaskPageGitLabDialog' -import { TaskPageLinearConnectDialog } from './TaskPageLinearConnectDialog' -import { TaskPageJiraConnectDialog } from './TaskPageJiraConnectDialog' +import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import { TaskPageFrame } from './Frame' +import { TaskPageGitHubIssueDialog } from './github/IssueDialog' +import { TaskPageLinearProjectDialog } from './linear/ProjectDialog' +import { TaskPageLinearIssueDialog } from './linear/IssueDialog' +import { TaskPageJiraIssueDialog } from './jira/IssueDialog' +import { TaskPageGitLabDialog } from './gitlab/Dialog' +import { TaskPageLinearConnectDialog } from './linear/ConnectDialog' +import { TaskPageJiraConnectDialog } from './jira/ConnectDialog' export function TaskPageSurface({ model }: { diff --git a/src/renderer/src/components/task-page/TaskPage.tsx b/src/renderer/src/components/task-page/TaskPage.tsx new file mode 100644 index 00000000000..c80e7aa8f38 --- /dev/null +++ b/src/renderer/src/components/task-page/TaskPage.tsx @@ -0,0 +1,84 @@ +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 './Surface' + +export default function TaskPage(): React.JSX.Element { + 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/task-page/chrome/task-page-github-item-filters.tsx b/src/renderer/src/components/task-page/chrome/task-page-github-item-filters.tsx deleted file mode 100644 index cf6b3b0585f..00000000000 --- a/src/renderer/src/components/task-page/chrome/task-page-github-item-filters.tsx +++ /dev/null @@ -1,303 +0,0 @@ -import React from 'react' -import { LoaderCircle, Plus, RefreshCw, Search, X } from 'lucide-react' - -import { useAppStore } from '@/store' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' -import IssueSourceIndicator from '@/components/github/IssueSourceIndicator' -import IssueSourceSelector, { issueSourceChipClass } from '@/components/github/IssueSourceSelector' -import PRFilterDropdowns, { type PRFilterChange } from '@/components/github/PRFilterDropdowns' -import { - getGitHubTaskKindPresets, - type GitHubTaskKind -} from '@/components/task-page-localized-options' -import { resolveNewIssueOpenSeed } from '@/components/task-page-new-issue-draft' -import type { TaskPageRepoSourceState } from '@/components/task-page-cache-selectors' -import { translate } from '@/i18n/i18n' -import { cn } from '@/lib/utils' -import type { - GitHubAssignableUser, - GitHubOwnerRepo -} from '../../../../../shared/github/pull-request-types' -import type { GlobalSettings } from '../../../../../shared/global-settings-types' -import type { IssueSourcePreference, Repo } from '../../../../../shared/repo-types' -import type { ParsedTaskQuery } from '../../../../../shared/task-query' -import type { TaskResumeState, TaskViewPresetId } from '../../../../../shared/ui-chrome-types' -import { - hasDivergentSources, - hasUpstreamCandidateDivergence -} from '@/components/task-page/source/repo-source-divergence' - -export type TaskPageGithubItemFiltersProps = { - activeGithubTaskKind: GitHubTaskKind - activeTaskPreset: TaskViewPresetId | null - setTaskSearchInput: (value: string) => void - setAppliedTaskSearch: (value: string) => void - setActiveTaskPreset: (preset: TaskViewPresetId | null) => void - setTaskResumeState: (updates: Partial) => void - setTaskRefreshNonce: React.Dispatch> - handleSetDefaultTaskPreset: (presetId: TaskViewPresetId) => void - appliedTaskQuery: ParsedTaskQuery - loadedGitHubAuthorLogins: string[] - primaryGithubFilterSlug: GitHubOwnerRepo | null - settings: Pick | null - applyPRFilterChange: (change: PRFilterChange) => void - taskSearchInputRef: React.RefObject - taskSearchInput: string - handleTaskSearchChange: (event: React.ChangeEvent) => void - handleTaskSearchKeyDown: (event: React.KeyboardEvent) => void - appliedTaskSearch: string - handleResetGithubTaskSearch: () => void - selectedRepos: Repo[] - setNewIssueTitle: (value: string) => void - setNewIssueBody: (value: string) => void - setNewIssueLabels: (labels: string[]) => void - setNewIssueAssignees: (assignees: GitHubAssignableUser[]) => void - setNewIssueRepoId: (repoId: string | null) => void - setNewIssueOpen: (open: boolean) => void - newIssueTargetRepo: Repo | null - handleRefreshGithubTasks: () => void - githubTasksBusy: boolean - perRepoSourceState: TaskPageRepoSourceState[] - setIssueSourcePreference: ( - repoId: string, - repoPath: string, - preference: IssueSourcePreference - ) => Promise -} - -export function TaskPageGithubItemFilters({ - 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 -}: TaskPageGithubItemFiltersProps): React.JSX.Element { - 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/task-page/chrome/task-page-github-mode-bar.tsx b/src/renderer/src/components/task-page/chrome/task-page-github-mode-bar.tsx deleted file mode 100644 index bd875ed948c..00000000000 --- a/src/renderer/src/components/task-page/chrome/task-page-github-mode-bar.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import React from 'react' -import { ExternalLink } from 'lucide-react' -import { toast } from 'sonner' - -import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import TaskProjectSourceCombobox from '@/components/task-project-source-combobox' -import { normalizeTaskRepoSelection } from '@/components/task-page-default-repo-selection' -import type { TaskProjectPickerGroup } from '@/components/task-page-default-repo-selection' -import type { GitHubModeButton, GitHubTaskKind } from '@/components/task-page-localized-options' -import { translate } from '@/i18n/i18n' -import { cn } from '@/lib/utils' -import type { GlobalSettings } from '../../../../../shared/global-settings-types' -import type { Repo } from '../../../../../shared/repo-types' -import type { TaskResumeState } from '../../../../../shared/ui-chrome-types' - -export type TaskPageGithubModeBarProps = { - projectModeVisible: boolean - githubModeButtons: GitHubModeButton[] - githubMode: 'items' | 'project' - activeGithubTaskKind: GitHubTaskKind - setGithubMode: (mode: 'items' | 'project') => void - setTaskResumeState: (updates: Partial) => void - handleSelectGithubTaskKind: (kind: GitHubTaskKind) => void - taskPickerGroups: TaskProjectPickerGroup[] - repoSelection: ReadonlySet - getTaskPickerRepoHostLabel: (repo: Repo) => string | null | undefined - eligibleRepos: Repo[] - setRepoSelection: (next: Set) => void - updateSettings: (updates: Partial) => Promise - taskPickerRepos: Repo[] - selectedGitHubRepoExternalLink: { url: string; label: string } | null -} - -export function TaskPageGithubModeBar({ - projectModeVisible, - githubModeButtons, - githubMode, - activeGithubTaskKind, - setGithubMode, - setTaskResumeState, - handleSelectGithubTaskKind, - taskPickerGroups, - repoSelection, - getTaskPickerRepoHostLabel, - eligibleRepos, - setRepoSelection, - updateSettings, - taskPickerRepos, - selectedGitHubRepoExternalLink -}: TaskPageGithubModeBarProps): React.JSX.Element { - return ( -
- {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' - )} - - -
- ) -} diff --git a/src/renderer/src/components/task-page/chrome/task-page-gitlab-filters.tsx b/src/renderer/src/components/task-page/chrome/task-page-gitlab-filters.tsx deleted file mode 100644 index 3552d414fa7..00000000000 --- a/src/renderer/src/components/task-page/chrome/task-page-gitlab-filters.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import React from 'react' -import { LoaderCircle, RefreshCw } from 'lucide-react' -import { toast } from 'sonner' - -import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import TaskProjectSourceCombobox from '@/components/task-project-source-combobox' -import { normalizeTaskRepoSelection } from '@/components/task-page-default-repo-selection' -import type { TaskProjectPickerGroup } from '@/components/task-page-default-repo-selection' -import type { GitLabIssueFilter, GitLabTaskFilter } from '@/components/task-page-localized-options' -import { translate } from '@/i18n/i18n' -import { cn } from '@/lib/utils' -import type { GlobalSettings } from '../../../../../shared/global-settings-types' -import type { Repo } from '../../../../../shared/repo-types' - -export type TaskPageGitlabFiltersProps = { - gitlabView: 'issues' | 'mrs' | 'todos' - setGitlabView: (view: 'issues' | 'mrs' | 'todos') => void - taskPickerGroups: TaskProjectPickerGroup[] - repoSelection: ReadonlySet - getTaskPickerRepoHostLabel: (repo: Repo) => string | null | undefined - eligibleRepos: Repo[] - setRepoSelection: (next: Set) => void - updateSettings: (updates: Partial) => Promise - taskPickerRepos: Repo[] - gitLabIssueFilters: { id: GitLabIssueFilter; label: string }[] - gitLabMRFilters: { id: GitLabTaskFilter; label: string }[] - activeGitlabFilter: GitLabTaskFilter | GitLabIssueFilter - setGitlabFilter: (id: GitLabTaskFilter | GitLabIssueFilter) => void - setGitlabRefreshNonce: React.Dispatch> - gitlabLoading: boolean - gitlabTodosLoading: boolean -} - -export function TaskPageGitlabFilters({ - gitlabView, - setGitlabView, - taskPickerGroups, - repoSelection, - getTaskPickerRepoHostLabel, - eligibleRepos, - setRepoSelection, - updateSettings, - taskPickerRepos, - gitLabIssueFilters, - gitLabMRFilters, - activeGitlabFilter, - setGitlabFilter, - setGitlabRefreshNonce, - gitlabLoading, - gitlabTodosLoading -}: TaskPageGitlabFiltersProps): React.JSX.Element { - return ( - <> -
-
- {(['issues', 'mrs', 'todos'] as const).map((view) => { - const active = gitlabView === view - const label = - view === 'issues' - ? translate('auto.components.TaskPage.dfc0c79bd8', 'Issues') - : view === 'mrs' - ? translate( - 'auto.components.task.page.chrome.task.page.gitlab.filters.e157d7ce4d', - 'MRs' - ) - : translate( - 'auto.components.task.page.chrome.task.page.gitlab.filters.2328f6a40c', - '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/task-page/chrome/task-page-jira-filters.tsx b/src/renderer/src/components/task-page/chrome/task-page-jira-filters.tsx deleted file mode 100644 index c133a5b18bf..00000000000 --- a/src/renderer/src/components/task-page/chrome/task-page-jira-filters.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import React from 'react' -import { LoaderCircle, Plus, RefreshCw, Search, X } from 'lucide-react' - -import { useAppStore } from '@/store' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { getJiraProjectSelectionKey } from '@/components/task-page-jira-project-selection' -import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard' -import { translate } from '@/i18n/i18n' -import { cn } from '@/lib/utils' -import type { JiraProject } from '../../../../../shared/jira-types' -import type { TaskResumeState } from '../../../../../shared/ui-chrome-types' -import type { JiraPreset } from '@/components/task-page-localized-options' - -export type TaskPageJiraFiltersProps = { - jiraPresets: JiraPreset[] - jiraSearchInput: string - activeJiraPreset: JiraPreset['id'] - setJiraSearchInput: (value: string) => void - setAppliedJiraSearch: (value: string) => void - setActiveJiraPreset: (preset: JiraPreset['id']) => void - setTaskResumeState: (updates: Partial) => void - setJiraRefreshNonce: React.Dispatch> - sortedAvailableJiraProjects: JiraProject[] - setNewJiraIssueTitle: (value: string) => void - setNewJiraIssueBody: (value: string) => void - setNewJiraIssueProjectId: (id: string | null) => void - setNewJiraIssueProjectQuery: (value: string) => void - setNewJiraIssueProjectCommandValue: (value: string) => void - setNewJiraIssueTypeId: (id: string | null) => void - setNewJiraIssueOpen: (open: boolean) => void - jiraProjectsLoading: boolean - jiraLoading: boolean -} - -export function TaskPageJiraFilters({ - jiraPresets, - jiraSearchInput, - activeJiraPreset, - setJiraSearchInput, - setAppliedJiraSearch, - setActiveJiraPreset, - setTaskResumeState, - setJiraRefreshNonce, - sortedAvailableJiraProjects, - setNewJiraIssueTitle, - setNewJiraIssueBody, - setNewJiraIssueProjectId, - setNewJiraIssueProjectQuery, - setNewJiraIssueProjectCommandValue, - setNewJiraIssueTypeId, - setNewJiraIssueOpen, - jiraProjectsLoading, - jiraLoading -}: TaskPageJiraFiltersProps): React.JSX.Element { - 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/task-page/chrome/task-page-linear-filters.tsx b/src/renderer/src/components/task-page/chrome/task-page-linear-filters.tsx deleted file mode 100644 index 56c734aaf21..00000000000 --- a/src/renderer/src/components/task-page/chrome/task-page-linear-filters.tsx +++ /dev/null @@ -1,365 +0,0 @@ -import React from 'react' -import { LoaderCircle, Plus, RefreshCw, Search, X } from 'lucide-react' - -import { useAppStore } from '@/store' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import LinearIssueAttributeFilterDropdowns from '@/components/linear-issue-attribute-filter-dropdowns' -import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard' -import { translate } from '@/i18n/i18n' -import { cn } from '@/lib/utils' -import type { LinearIssueAttributeFilter } from '../../../../../shared/linear/issue-attribute-filter' -import type { LinearProjectSummary } from '../../../../../shared/linear/project-types' -import type { LinearTeam } from '../../../../../shared/linear/workspace-types' -import type { TaskResumeState } from '../../../../../shared/ui-chrome-types' -import type { TaskSourceContext } from '../../../../../shared/task-source-context' -import type { LinearMode } from '@/components/task-page-localized-options' -import type { RuntimeLinearSettings } from '@/runtime/runtime-linear-client' - -export type TaskPageLinearFiltersProps = { - linearModeOptions: { id: LinearMode; label: string }[] - linearMode: LinearMode - selectLinearMode: (mode: LinearMode) => void - selectedLinearProject: LinearProjectSummary | null - availableTeams: LinearTeam[] - setNewLinearProjectName: (value: string) => void - setNewLinearProjectDescription: (value: string) => void - setNewLinearProjectContent: (value: string) => void - setNewLinearProjectTeamId: (id: string | null) => void - setNewLinearProjectLeadId: (id: string | null) => void - setNewLinearProjectMemberIds: (ids: string[]) => void - setNewLinearProjectLabelIds: (ids: string[]) => void - setNewLinearProjectPriority: (priority: number) => void - setNewLinearProjectStartDate: (value: string) => void - setNewLinearProjectTargetDate: (value: string) => void - setNewLinearProjectOpen: (open: boolean) => void - setNewLinearIssueTitle: (value: string) => void - setNewLinearIssueBody: (value: string) => void - setNewLinearIssueTeamId: (id: string | null) => void - setNewLinearIssueProjectId: (id: string | null) => void - setNewLinearIssueOpen: (open: boolean) => void - setLinearRefreshNonce: React.Dispatch> - linearLoading: boolean - linearProjectsLoading: boolean - linearProjectDetailLoading: boolean - linearCustomViewsLoading: boolean - linearCustomViewContentsLoading: boolean - showLinearAttributeFilters: boolean - linearAttributeFilter: LinearIssueAttributeFilter - applyLinearAttributeFilter: (next: LinearIssueAttributeFilter) => void - linearAttributeFilterWorkspaceId: string | null - linearAttributePrimaryTeam: LinearTeam | null - linearTeamSelection: ReadonlySet - linearTeamOptions: LinearTeam[] - linearTaskSourceContext: TaskSourceContext | null - settings: RuntimeLinearSettings | null - linearSearchInput: string - setLinearSearchInput: (value: string) => void - setAppliedLinearSearch: (value: string) => void - setTaskResumeState: (updates: Partial) => void - linearProjectSearchInput: string - setLinearProjectSearchInput: (value: string) => void - setAppliedLinearProjectSearch: (value: string) => void -} - -export function TaskPageLinearFilters(props: TaskPageLinearFiltersProps): React.JSX.Element { - const { - 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 - } = props - - return ( -
-
-
- {linearModeOptions.map((mode) => { - const active = linearMode === mode.id - const buttonClassName = cn( - 'rounded-md border px-2 py-1 text-xs transition', - active - ? 'border-border/50 bg-foreground/90 text-background' - : 'border-border/50 bg-transparent text-foreground hover:bg-muted/50' - ) - if (mode.id === 'in-orca') { - return ( - - - - - - {translate( - 'auto.components.TaskPage.linearModeHasWorktreeTooltip', - 'Linear tickets linked to an Orca workspace' - )} - - - ) - } - return ( - - ) - })} -
-
- - - - - - {linearMode === 'projects' && !selectedLinearProject - ? translate('auto.components.TaskPage.1361275ec3', 'New Linear project') - : translate('auto.components.TaskPage.3feb524d42', 'New Linear issue')} - - - - - - - - {translate('auto.components.TaskPage.8964184a8b', 'Refresh Linear')} - - -
-
- - {linearMode === 'issues' || linearMode === 'in-orca' ? ( -
- {showLinearAttributeFilters ? ( - 0} - settings={linearTaskSourceContext ?? settings} - /> - ) : null} -
- - setLinearSearchInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - if ( - shouldSuppressEnterSubmit( - { isComposing: e.nativeEvent.isComposing, shiftKey: e.shiftKey }, - false - ) - ) { - return - } - e.preventDefault() - const trimmed = linearSearchInput.trim() - setLinearSearchInput(trimmed) - setAppliedLinearSearch(trimmed) - setTaskResumeState({ - linearQuery: trimmed, - linearMode: linearMode === 'in-orca' ? 'in-orca' : 'issues' - }) - if (linearMode !== 'in-orca') { - setLinearRefreshNonce((n) => n + 1) - } - } - }} - placeholder={ - linearMode === 'in-orca' - ? translate( - 'auto.components.TaskPage.linearHasWorktreeSearchPlaceholder', - 'Filter issues linked to an Orca workspace...' - ) - : translate('auto.components.TaskPage.eec0c5c079', 'Search Linear issues...') - } - className="h-8 rounded-md border-border/50 bg-background pl-8 pr-8 text-xs" - /> - {linearSearchInput ? ( - - ) : null} -
-
- ) : linearMode === 'projects' && !selectedLinearProject ? ( -
-
- - setLinearProjectSearchInput(e.target.value)} - placeholder={translate( - 'auto.components.TaskPage.0b65d3fb2c', - 'Search Linear projects...' - )} - className="h-8 rounded-md border-border/50 bg-background pl-8 pr-8 text-xs" - /> - {linearProjectSearchInput ? ( - - ) : null} -
-
- ) : null} -
- ) -} diff --git a/src/renderer/src/components/task-page/chrome/task-page-source-toolbar.tsx b/src/renderer/src/components/task-page/chrome/task-page-source-toolbar.tsx deleted file mode 100644 index 2f6909abc8b..00000000000 --- a/src/renderer/src/components/task-page/chrome/task-page-source-toolbar.tsx +++ /dev/null @@ -1,286 +0,0 @@ -import React from 'react' -import { AlertCircle, ExternalLink, X } from 'lucide-react' -import { toast } from 'sonner' - -import { Button } from '@/components/ui/button' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@/components/ui/select' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { LinearScopeSelector } from '@/components/linear-scope-selector' -import { translate } from '@/i18n/i18n' -import { cn } from '@/lib/utils' -import type { GlobalSettings } from '../../../../../shared/global-settings-types' -import type { JiraIssue, JiraSite } from '../../../../../shared/jira-types' -import type { - LinearTeam, - LinearWorkspace, - LinearWorkspaceSelection -} from '../../../../../shared/linear/workspace-types' -import type { TaskProvider } from '../../../../../shared/task-providers' -import type { SourceOption } from '@/components/task-page-localized-options' -import type { TaskPageJiraLoadError } from '@/components/task-page-jira-load-state' -import type { - TaskSourceAvailabilityNotice, - TaskSourceContextSummary -} from '@/components/task-source-context-summary' - -export type TaskPageSourceToolbarProps = { - closeTaskPage: () => void - visibleSourceOptions: SourceOption[] - taskSource: TaskProvider - taskSourceAvailabilityNoticeByProvider: Partial< - Record - > - taskSourceManuallyChangedRef: React.MutableRefObject - openTaskPage: ( - data?: { taskSource?: TaskProvider }, - options?: { recordTasksInteraction?: boolean } - ) => void - updateSettings: (updates: Partial) => Promise - taskSourceContextSummary: TaskSourceContextSummary - linearConnected: boolean - linearWorkspaces: LinearWorkspace[] - selectedLinearWorkspaceId: LinearWorkspaceSelection | null - linearTeamOptions: LinearTeam[] - linearTeamSelection: ReadonlySet - defaultLinearTeamSelection: string[] | null | undefined - onLinearWorkspaceChange: (workspaceId: LinearWorkspaceSelection) => void - onLinearTeamSelectionChange: (next: ReadonlySet, persisted: string[] | null) => void - onOpenLinearConnect: () => void - onLinearScopeOpen: () => void - selectedLinearTeamForExternalLink: LinearTeam | null - jiraConnected: boolean - jiraSites: JiraSite[] - selectedJiraSiteId: string | null - selectJiraSite: (siteId: string) => Promise - setSelectedJiraIssueKey: (key: string | null) => void - setSelectedJiraIssueFallback: (issue: JiraIssue | null) => void - setJiraIssues: (issues: JiraIssue[]) => void - setJiraError: (error: TaskPageJiraLoadError | null) => void - setJiraLoading: (loading: boolean) => void - taskSourceAvailabilityNotice: TaskSourceAvailabilityNotice | null -} - -export function TaskPageSourceToolbar({ - closeTaskPage, - visibleSourceOptions, - taskSource, - taskSourceAvailabilityNoticeByProvider, - taskSourceManuallyChangedRef, - openTaskPage, - updateSettings, - taskSourceContextSummary, - linearConnected, - linearWorkspaces, - selectedLinearWorkspaceId, - linearTeamOptions, - linearTeamSelection, - defaultLinearTeamSelection, - onLinearWorkspaceChange, - onLinearTeamSelectionChange, - onOpenLinearConnect, - onLinearScopeOpen, - selectedLinearTeamForExternalLink, - jiraConnected, - jiraSites, - selectedJiraSiteId, - selectJiraSite, - setSelectedJiraIssueKey, - setSelectedJiraIssueFallback, - setJiraIssues, - setJiraError, - setJiraLoading, - taskSourceAvailabilityNotice -}: TaskPageSourceToolbarProps): React.JSX.Element { - return ( - <> -
-
- {/* Why: Close is anchored left with the source icons for one compact band, clear of the app sidebar on the right. */} - - - - - - {translate('auto.components.TaskPage.4826fd1ad8', 'Close · Esc')} - - -
- {visibleSourceOptions.map((source) => { - const active = taskSource === source.id - const sourceAvailabilityNotice = - taskSourceAvailabilityNoticeByProvider[source.id] ?? null - const sourceDisabled = source.disabled || sourceAvailabilityNotice?.blocking - return ( - - - - - - {sourceAvailabilityNotice?.label ?? source.label} - - - ) - })} -
- {taskSourceContextSummary.label} -
-
- {taskSource === 'linear' && linearConnected ? ( -
- - - - - - - {selectedLinearTeamForExternalLink - ? translate('auto.components.TaskPage.246bd64aed', 'Open {{value0}} in Linear', { - value0: selectedLinearTeamForExternalLink.name - }) - : translate( - 'auto.components.TaskPage.2af3ab5c58', - 'Select one team to open in Linear' - )} - - -
- ) : null} - {taskSource === 'jira' && jiraConnected ? ( -
- {jiraSites.length > 1 ? ( - - ) : null} -
- ) : null} -
- - {taskSourceAvailabilityNotice ? ( -
- - {taskSourceAvailabilityNotice.label} -
- ) : null} - - ) -} diff --git a/src/renderer/src/components/task-page/dialogs/new-github-issue-dialog.tsx b/src/renderer/src/components/task-page/dialogs/new-github-issue-dialog.tsx deleted file mode 100644 index 400b972ed2f..00000000000 --- a/src/renderer/src/components/task-page/dialogs/new-github-issue-dialog.tsx +++ /dev/null @@ -1,273 +0,0 @@ -import React from 'react' -import { LoaderCircle } from 'lucide-react' - -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { Input } from '@/components/ui/input' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@/components/ui/select' -import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer' -import IssueSourceSelector from '@/components/github/IssueSourceSelector' -import { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator' -import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' -import type { TaskPageRepoSourceState } from '@/components/task-page-cache-selectors' -import { resolveUserRepoSwitchReset } from '@/components/task-page-new-issue-draft' -import type { MetadataListState } from '@/hooks/useMetadataListRequest' -import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' -import { translate } from '@/i18n/i18n' -import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types' -import type { IssueSourcePreference, Repo } from '../../../../../shared/repo-types' -import { GitHubIssueAssigneeSelector } from '../github/github-issue-assignee-selector' -import { GitHubIssueLabelSelector } from '../github/github-issue-label-selector' - -export type NewGithubIssueDialogProps = { - newIssueOpen: boolean - newIssueSubmitting: boolean - setNewIssueOpen: (open: boolean) => void - handleCreateNewIssue: () => Promise | void - newIssueTargetRepo: Repo | null - perRepoSourceState: TaskPageRepoSourceState[] - setIssueSourcePreference: ( - repoId: string, - repoPath: string, - preference: IssueSourcePreference - ) => Promise - selectedRepos: readonly Repo[] - newIssueRepoId: string | null - setNewIssueRepoId: (id: string | null) => void - setNewIssueLabels: (labels: string[]) => void - setNewIssueAssignees: (assignees: GitHubAssignableUser[]) => void - newIssueTitle: string - setNewIssueTitle: (value: string) => void - newIssueBody: string - setNewIssueBody: (value: string) => void - newIssueRepoLabels: MetadataListState - newIssueLabels: string[] - newIssueRepoAssignees: MetadataListState - newIssueAssignees: GitHubAssignableUser[] - submitShortcutLabel: string -} - -export function NewGithubIssueDialog({ - newIssueOpen, - newIssueSubmitting, - setNewIssueOpen, - handleCreateNewIssue, - newIssueTargetRepo, - perRepoSourceState, - setIssueSourcePreference, - selectedRepos, - newIssueRepoId, - setNewIssueRepoId, - setNewIssueLabels, - setNewIssueAssignees, - newIssueTitle, - setNewIssueTitle, - newIssueBody, - setNewIssueBody, - newIssueRepoLabels, - newIssueLabels, - newIssueRepoAssignees, - newIssueAssignees, - submitShortcutLabel -}: NewGithubIssueDialogProps): React.JSX.Element { - 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 ?? - translate( - 'auto.components.task.page.dialogs.new.github.issue.dialog.e02508846c', - '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/task-page/dialogs/new-jira-issue-custom-fields.tsx b/src/renderer/src/components/task-page/dialogs/new-jira-issue-custom-fields.tsx deleted file mode 100644 index 84ef5250c76..00000000000 --- a/src/renderer/src/components/task-page/dialogs/new-jira-issue-custom-fields.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import React from 'react' - -import { Input } from '@/components/ui/input' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@/components/ui/select' -import { - getJiraCreateAllowedValueLabel, - isJiraScalarUserCreateField -} from '@/components/task-page-jira-create-fields' -import { JiraUserPicker } from '@/components/jira-user-picker' -import { translate } from '@/i18n/i18n' -import type { GlobalSettings } from '../../../../../shared/global-settings-types' -import type { JiraCreateField, JiraUser } from '../../../../../shared/jira-types' -import type { TaskSourceContext } from '../../../../../shared/task-source-context' - -/** Renders the required create fields, choosing a picker, select, or text input per schema. */ -export function NewJiraIssueCustomFields({ - visibleJiraCreateFields, - newJiraIssueCustomFieldValues, - setNewJiraIssueCustomFieldValues, - newJiraIssueSubmitting, - jiraUserFieldSelections, - setJiraUserFieldSelections, - providerSettings, - siteId -}: { - visibleJiraCreateFields: JiraCreateField[] - newJiraIssueCustomFieldValues: Record - setNewJiraIssueCustomFieldValues: React.Dispatch>> - newJiraIssueSubmitting: boolean - jiraUserFieldSelections: Record - setJiraUserFieldSelections: React.Dispatch>> - providerSettings: TaskSourceContext | GlobalSettings | null - siteId?: string | null -}): React.JSX.Element | null { - if (visibleJiraCreateFields.length === 0) { - return null - } - return ( -
- {visibleJiraCreateFields.map((field) => { - const fieldValue = newJiraIssueCustomFieldValues[field.key] ?? '' - return ( -
- - {isJiraScalarUserCreateField(field) && !field.allowedValues?.length ? ( - { - setNewJiraIssueCustomFieldValues((prev) => ({ - ...prev, - [field.key]: user.accountId - })) - setJiraUserFieldSelections((prev) => ({ ...prev, [field.key]: user })) - }} - disabled={newJiraIssueSubmitting} - label={field.name} - /> - ) : field.allowedValues?.length && field.schema?.type !== 'array' ? ( - - ) : ( - - setNewJiraIssueCustomFieldValues((prev) => ({ - ...prev, - [field.key]: event.target.value - })) - } - type={field.schema?.type === 'number' ? 'number' : 'text'} - placeholder={ - field.schema?.type === 'array' - ? translate('auto.components.TaskPage.56cdb413a2', 'Comma-separated values') - : translate('auto.components.TaskPage.919a20dd5b', 'Enter {{value0}}', { - value0: field.name - }) - } - disabled={newJiraIssueSubmitting} - aria-label={field.name} - /> - )} -
- ) - })} -
- ) -} diff --git a/src/renderer/src/components/task-page/dialogs/new-jira-issue-dialog.tsx b/src/renderer/src/components/task-page/dialogs/new-jira-issue-dialog.tsx deleted file mode 100644 index 8462493fa9a..00000000000 --- a/src/renderer/src/components/task-page/dialogs/new-jira-issue-dialog.tsx +++ /dev/null @@ -1,369 +0,0 @@ -import React from 'react' -import { Check, ChevronDown, LoaderCircle } from 'lucide-react' - -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { Input } from '@/components/ui/input' -import { - Command, - CommandEmpty, - CommandInput, - CommandItem, - CommandList -} from '@/components/ui/command' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@/components/ui/select' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { Textarea } from '@/components/ui/textarea' -import { getJiraProjectPickerDisplayLabel as getJiraProjectDisplayLabel } from '@/components/jira-project-picker-filter' -import { NewJiraIssueCustomFields } from './new-jira-issue-custom-fields' -import { getJiraProjectSelectionKey } from '@/components/task-page-jira-project-selection' -import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' -import { translate } from '@/i18n/i18n' -import { cn } from '@/lib/utils' -import type { GlobalSettings } from '../../../../../shared/global-settings-types' -import type { - JiraCreateField, - JiraIssueType, - JiraProject, - JiraUser -} from '../../../../../shared/jira-types' -import type { TaskSourceContext } from '../../../../../shared/task-source-context' - -export type NewJiraIssueDialogProps = { - newJiraIssueOpen: boolean - newJiraIssueSubmitting: boolean - setNewJiraIssueOpen: (open: boolean) => void - handleCreateNewJiraIssue: () => Promise | void - newJiraIssueTargetProject: JiraProject | null - includeJiraSiteNameInProjectLabel: boolean - newJiraIssueProjectCommandValue: string - setNewJiraIssueProjectCommandValue: (value: string) => void - newJiraIssueProjectSearchInputRef: React.RefObject - newJiraIssueProjectQuery: string - setNewJiraIssueProjectQuery: (value: string) => void - filteredNewJiraIssueProjects: JiraProject[] - sortedAvailableJiraProjects: JiraProject[] - newJiraIssueTargetProjectSelectionKey: string | null - handleNewJiraIssueProjectSelect: (selectionKey: string) => void - handleNewJiraIssueProjectComboboxOpenChange: (open: boolean) => void - handleNewJiraIssueProjectTriggerKeyDown: (event: React.KeyboardEvent) => void - newJiraIssueProjectComboboxOpen: boolean - newJiraIssueTypeId: string | null - newJiraIssueTargetType: JiraIssueType | null - setNewJiraIssueTypeId: (id: string | null) => void - jiraIssueTypesLoading: boolean - availableJiraIssueTypes: JiraIssueType[] - newJiraIssueTitle: string - setNewJiraIssueTitle: (value: string) => void - newJiraIssueBody: string - setNewJiraIssueBody: (value: string) => void - jiraCreateFieldsLoading: boolean - jiraCreateFieldsError: string | null - visibleJiraCreateFields: JiraCreateField[] - newJiraIssueCustomFieldValues: Record - setNewJiraIssueCustomFieldValues: React.Dispatch>> - jiraUserFieldSelections: Record - setJiraUserFieldSelections: React.Dispatch>> - jiraProviderSettings: TaskSourceContext | GlobalSettings | null - hasMissingJiraCreateField: boolean - submitShortcutLabel: string -} - -/** Dialog for creating a Jira issue and opening a workspace for it. */ -export function NewJiraIssueDialog(props: NewJiraIssueDialogProps): React.JSX.Element { - const { - newJiraIssueOpen, - newJiraIssueSubmitting, - setNewJiraIssueOpen, - handleCreateNewJiraIssue, - newJiraIssueTargetProject, - includeJiraSiteNameInProjectLabel, - newJiraIssueProjectCommandValue, - setNewJiraIssueProjectCommandValue, - newJiraIssueProjectSearchInputRef, - newJiraIssueProjectQuery, - setNewJiraIssueProjectQuery, - filteredNewJiraIssueProjects, - sortedAvailableJiraProjects, - newJiraIssueTargetProjectSelectionKey, - handleNewJiraIssueProjectSelect, - handleNewJiraIssueProjectComboboxOpenChange, - handleNewJiraIssueProjectTriggerKeyDown, - newJiraIssueProjectComboboxOpen, - newJiraIssueTypeId, - newJiraIssueTargetType, - setNewJiraIssueTypeId, - jiraIssueTypesLoading, - availableJiraIssueTypes, - newJiraIssueTitle, - setNewJiraIssueTitle, - newJiraIssueBody, - setNewJiraIssueBody, - jiraCreateFieldsLoading, - jiraCreateFieldsError, - visibleJiraCreateFields, - newJiraIssueCustomFieldValues, - setNewJiraIssueCustomFieldValues, - jiraUserFieldSelections, - setJiraUserFieldSelections, - jiraProviderSettings, - hasMissingJiraCreateField, - submitShortcutLabel - } = props - 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} - /> -
-
- -