diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index a9c03d1b9d6..4375803952e 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -167,7 +167,6 @@ inline src/main/window/attach-main-window-services.ts inline src/main/window/createMainWindow.test.ts inline src/main/window/createMainWindow.ts inline src/main/workspace-space-analysis.ts -inline src/preload/api-types.ts inline src/preload/index.ts inline src/relay/agent-hook-server.ts inline src/relay/dispatcher.ts @@ -318,7 +317,7 @@ inline src/shared/agent-hook-listener.ts inline src/shared/automation-schedules.ts inline src/shared/commit-message-agent-spec.ts inline src/shared/constants.ts -inline src/shared/github-project-types.ts +inline src/shared/github/project-types.ts inline src/shared/keybindings.test.ts inline src/shared/keybindings.ts inline src/shared/marine-creatures.ts @@ -327,7 +326,6 @@ inline src/shared/runtime-types.ts inline src/shared/source-control-ai.ts inline src/shared/telemetry-events.ts inline src/shared/text-search.ts -inline src/shared/types.ts inline tests/e2e/helpers/terminal.ts inline tests/e2e/terminal-panes.spec.ts mobile-config app/h/*/files/*.tsx diff --git a/config/scripts/worktree-refresh-churn-benchmark.mjs b/config/scripts/worktree-refresh-churn-benchmark.mjs index 67265305322..5a165801ded 100644 --- a/config/scripts/worktree-refresh-churn-benchmark.mjs +++ b/config/scripts/worktree-refresh-churn-benchmark.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node import { performance } from 'node:perf_hooks' -import { planWorktreeSortOrderUpdates } from '../../src/shared/worktree-sort-order-update.ts' +import { planWorktreeSortOrderUpdates } from '../../src/shared/worktree/sort-order-update.ts' import { reuseEqualCatalogRows } from '../../src/renderer/src/store/slices/worktree-catalog-reconciliation.ts' const WORKTREE_COUNT = 655 diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index 1034cc16ced..9f83cddee95 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -5,6 +5,7 @@ "../src/renderer/src/**/*", "../src/renderer/src/**/*.tsx", "../src/preload/api-types.ts", + "../src/preload/api/**/*", "../src/shared/**/*", "../src/main/gitlab/mappers.ts", "../src/main/ipc/worktree-branch-name.ts", diff --git a/config/tsconfig.web.json b/config/tsconfig.web.json index 92a6b9f2831..5a43720b5aa 100644 --- a/config/tsconfig.web.json +++ b/config/tsconfig.web.json @@ -5,6 +5,7 @@ "../src/renderer/src/**/*", "../src/renderer/src/**/*.tsx", "../src/preload/api-types.ts", + "../src/preload/api/**/*", "../src/shared/**/*" ], "compilerOptions": { diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index 553490b4748..7ef451df8aa 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -119,11 +119,11 @@ import { isIterationCurrent, sortRows, type ProjectGroup -} from '../../../../src/shared/github-project-group-sort' +} from '../../../../src/shared/github/project-group-sort' import type { GitHubProjectSortDirection, GitHubProjectTable as SharedGitHubProjectTable -} from '../../../../src/shared/github-project-types' +} from '../../../../src/shared/github/project-types' import { CROSS_REPO_DISPLAY_LIMIT, isGitHubWorkItemsSshRemoteRequiredError, @@ -161,7 +161,7 @@ import type { HostedReviewDecision } from '../../../../src/shared/hosted-review' import { githubProjectHost, githubProjectIdentityKey as githubProjectKey -} from '../../../../src/shared/github-project-identity' +} from '../../../../src/shared/github/project-identity' type RepoSummary = { id: string diff --git a/mobile/src/agent-history/agent-history-session-worktree.ts b/mobile/src/agent-history/agent-history-session-worktree.ts index 0e859bbf5ee..4e2a6c7b6a6 100644 --- a/mobile/src/agent-history/agent-history-session-worktree.ts +++ b/mobile/src/agent-history/agent-history-session-worktree.ts @@ -1,6 +1,6 @@ import { isPathInsideOrEqual, isRuntimePathAbsolute } from '../../../src/shared/cross-platform-path' import { parseWslUncPath } from '../../../src/shared/wsl-paths' -import { splitWorktreeIdForFilesystem } from '../../../src/shared/worktree-id' +import { splitWorktreeIdForFilesystem } from '../../../src/shared/worktree/id' import type { AiVaultSession } from '../../../src/shared/ai-vault-types' import type { Worktree } from '../worktree/workspace-list-types' diff --git a/mobile/src/session/github-pr-rpc.ts b/mobile/src/session/github-pr-rpc.ts index a2583376662..a34ed49581c 100644 --- a/mobile/src/session/github-pr-rpc.ts +++ b/mobile/src/session/github-pr-rpc.ts @@ -9,7 +9,7 @@ import type { HostedReviewInfo } from '../../../src/shared/hosted-review' import { normalizeGitHubPRForBranchOutcome, type GitHubPRForBranchResponse -} from '../../../src/shared/github-pr-for-branch-outcome' +} from '../../../src/shared/github/pull-request-for-branch-outcome' import type { RpcClient } from '../transport/rpc-client' import type { RpcSuccess } from '../transport/types' import { mobileRepoSelectorFromWorktreeId } from '../source-control/mobile-pr-create' diff --git a/mobile/src/session/pr-actions-engine.ts b/mobile/src/session/pr-actions-engine.ts index ce19c9bbbf9..cc5bfde80a3 100644 --- a/mobile/src/session/pr-actions-engine.ts +++ b/mobile/src/session/pr-actions-engine.ts @@ -1,5 +1,5 @@ import type { GitHubPRMergeMethod, PRState } from '../../../src/shared/types' -import { githubRepoIdentityKey } from '../../../src/shared/github-repository-identity-key' +import { githubRepoIdentityKey } from '../../../src/shared/github/repository-identity-key' import { classifyPrSidebarFailure } from './mobile-pr-sidebar-state' import { createOptimisticField, type OptimisticField } from './optimistic-write-sequence' import type { GitHubPrMutationOutcome } from './github-pr-mutations' diff --git a/mobile/src/session/use-live-worktree-name.ts b/mobile/src/session/use-live-worktree-name.ts index a2bc6d1175c..f0e9fa32138 100644 --- a/mobile/src/session/use-live-worktree-name.ts +++ b/mobile/src/session/use-live-worktree-name.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useFocusEffect } from 'expo-router' import type { RuntimeClientEventStreamMessage } from '../../../src/shared/runtime-client-events' -import { getRepoIdFromWorktreeId } from '../../../src/shared/worktree-id' +import { getRepoIdFromWorktreeId } from '../../../src/shared/worktree/id' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState, RpcSuccess } from '../transport/types' import { getLiveWorktreeDisplayName, type WorktreeDisplayNameSource } from './worktree-display-name' diff --git a/mobile/src/tasks/github-project-reference.ts b/mobile/src/tasks/github-project-reference.ts index 00047f15f53..5ed7ef22ead 100644 --- a/mobile/src/tasks/github-project-reference.ts +++ b/mobile/src/tasks/github-project-reference.ts @@ -1,4 +1,4 @@ -import type { GitHubProjectIdentity } from '../../../src/shared/github-project-identity' +import type { GitHubProjectIdentity } from '../../../src/shared/github/project-identity' export type GitHubProjectOwnerType = GitHubProjectIdentity['ownerType'] export type GitHubProjectRef = GitHubProjectIdentity diff --git a/mobile/src/tasks/github-project-repo-match.ts b/mobile/src/tasks/github-project-repo-match.ts index 06a4f94e802..7a9ba339d1a 100644 --- a/mobile/src/tasks/github-project-repo-match.ts +++ b/mobile/src/tasks/github-project-repo-match.ts @@ -1,7 +1,7 @@ import { githubRepoIdentityKey, isDefaultGitHubHost -} from '../../../src/shared/github-repository-identity-key' +} from '../../../src/shared/github/repository-identity-key' export type GitHubProjectRepoMatch = { id: string diff --git a/mobile/src/tasks/smart-source-paste-intent.ts b/mobile/src/tasks/smart-source-paste-intent.ts index 00f4c0d9ee7..534d36fd0f4 100644 --- a/mobile/src/tasks/smart-source-paste-intent.ts +++ b/mobile/src/tasks/smart-source-paste-intent.ts @@ -9,7 +9,7 @@ import { parseGitLabIssueOrMRLink } from '../../../src/shared/new-workspace/gitl import { isSmartWorkspaceSourceQueryWithinLimit } from '../../../src/shared/new-workspace/smart-workspace-source-results' import type { RpcClient } from '../transport/rpc-client' import type { RpcSuccess } from '../transport/types' -import { githubRepoIdentityKey } from '../../../src/shared/github-repository-identity-key' +import { githubRepoIdentityKey } from '../../../src/shared/github/repository-identity-key' // A repo the picker can switch to for a cross-repo GitHub paste. Slug is derived // best-effort from the repo's remote metadata. diff --git a/src/cli/handlers/linear-list-issues.ts b/src/cli/handlers/linear-list-issues.ts index 8e4801c1ec5..51efa1b711e 100644 --- a/src/cli/handlers/linear-list-issues.ts +++ b/src/cli/handlers/linear-list-issues.ts @@ -1,7 +1,7 @@ import type { LinearMcpIssueListRequest, LinearMcpIssueListResult -} from '../../shared/linear-agent-access' +} from '../../shared/linear/agent-access' import type { CommandHandler } from '../dispatch' import { getOptionalNonNegativeIntegerFlag, diff --git a/src/cli/handlers/linear-relation-write.ts b/src/cli/handlers/linear-relation-write.ts index 7257780a86f..2a718442df7 100644 --- a/src/cli/handlers/linear-relation-write.ts +++ b/src/cli/handlers/linear-relation-write.ts @@ -2,7 +2,7 @@ import type { LinearIssueRelationship, LinearIssueRelationWriteRequest, LinearIssueRelationWriteResult -} from '../../shared/linear-agent-access' +} from '../../shared/linear/agent-access' import type { CommandHandler } from '../dispatch' import { getRequiredStringFlag } from '../flags' import { printResult } from '../format' diff --git a/src/cli/handlers/linear-save-issue.ts b/src/cli/handlers/linear-save-issue.ts index 7c64c6523b7..c946e0d15c9 100644 --- a/src/cli/handlers/linear-save-issue.ts +++ b/src/cli/handlers/linear-save-issue.ts @@ -1,7 +1,7 @@ import type { LinearSaveIssueRequest, LinearSaveIssueResult -} from '../../shared/linear-agent-access' +} from '../../shared/linear/agent-access' import type { CommandHandler } from '../dispatch' import { printResult } from '../format' import { formatLinearSaveIssue } from '../linear-format' diff --git a/src/cli/handlers/linear.ts b/src/cli/handlers/linear.ts index 68204fa7a9e..8a04fa4707a 100644 --- a/src/cli/handlers/linear.ts +++ b/src/cli/handlers/linear.ts @@ -19,8 +19,8 @@ import type { LinearTeamListResult, LinearTeamMembersResult, LinearTeamStatesResult -} from '../../shared/linear-agent-access' -import { clampLinearSearchLimit } from '../../shared/linear-agent-access' +} from '../../shared/linear/agent-access' +import { clampLinearSearchLimit } from '../../shared/linear/agent-access' import type { CommandHandler } from '../dispatch' import { printResult } from '../format' import { RuntimeClientError } from '../runtime-client' diff --git a/src/cli/handlers/worktree-linear-issue-link.ts b/src/cli/handlers/worktree-linear-issue-link.ts index c8e1020d847..01c90e4d1f9 100644 --- a/src/cli/handlers/worktree-linear-issue-link.ts +++ b/src/cli/handlers/worktree-linear-issue-link.ts @@ -2,7 +2,7 @@ import { buildLinearIssueLinkUpdates, LINEAR_ISSUE_LINK_CLEARED, type LinearIssueLinkUpdates -} from '../../shared/linear-links' +} from '../../shared/linear/links' import { RuntimeClientError } from '../runtime-client' export function getOptionalLinearIssueLinkFlag( diff --git a/src/cli/linear-format.test.ts b/src/cli/linear-format.test.ts index ab6a7681133..caa6b0bb7de 100644 --- a/src/cli/linear-format.test.ts +++ b/src/cli/linear-format.test.ts @@ -5,7 +5,7 @@ import type { LinearMcpIssueListResult, LinearProjectListResult, LinearSearchResult -} from '../shared/linear-agent-access' +} from '../shared/linear/agent-access' import { formatLinearCreate, formatLinearIssue, diff --git a/src/cli/linear-format.ts b/src/cli/linear-format.ts index d6aa5df0a95..969923d46ef 100644 --- a/src/cli/linear-format.ts +++ b/src/cli/linear-format.ts @@ -16,11 +16,11 @@ import type { LinearTeamMembersResult, LinearTeamStatesResult, LinearStatusSetResult -} from '../shared/linear-agent-access' +} from '../shared/linear/agent-access' import { formatLinearProjectListRows, linearProjectListWarningLines -} from '../shared/linear-project-list-format' +} from '../shared/linear/project-list-format' export function formatLinearIssue(result: LinearIssueContextResult): string { const issue = result.issue diff --git a/src/cli/linear-request-builders.ts b/src/cli/linear-request-builders.ts index 34d1ee9d473..4af1f91dd57 100644 --- a/src/cli/linear-request-builders.ts +++ b/src/cli/linear-request-builders.ts @@ -6,13 +6,13 @@ import type { LinearIssueRequest, LinearIssueTaskUpdateRequest, LinearWriteTargetRequest -} from '../shared/linear-agent-access' +} from '../shared/linear/agent-access' import { LINEAR_CHILDREN_MAX_DEPTH, LINEAR_WRITE_BODY_CAP, clampLinearIssueDepth -} from '../shared/linear-agent-access' -import { isLinearUuid } from '../shared/linear-uuid' +} from '../shared/linear/agent-access' +import { isLinearUuid } from '../shared/linear/uuid' import { getOptionalNonNegativeIntegerFlag, getOptionalStringFlag, diff --git a/src/cli/linear-save-issue-request.ts b/src/cli/linear-save-issue-request.ts index 786f2b252be..95111463cc8 100644 --- a/src/cli/linear-save-issue-request.ts +++ b/src/cli/linear-save-issue-request.ts @@ -1,4 +1,4 @@ -import type { LinearSaveIssueRequest } from '../shared/linear-agent-access' +import type { LinearSaveIssueRequest } from '../shared/linear/agent-access' import { getOptionalNullableNumberFlag, getOptionalStringFlag, diff --git a/src/main/agent-hooks/first-work-branch-rename-test-harness.ts b/src/main/agent-hooks/first-work-branch-rename-test-harness.ts index 1f30a6e6b2c..427e2539722 100644 --- a/src/main/agent-hooks/first-work-branch-rename-test-harness.ts +++ b/src/main/agent-hooks/first-work-branch-rename-test-harness.ts @@ -1,6 +1,6 @@ import type { vi } from 'vitest' import type { GlobalSettings, Repo } from '../../shared/types' -import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree-id' +import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree/id' import type { FirstWorkBranchRenameDeps, FirstWorkBranchRenameEvent diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index e8d8cd5f373..7690215eedb 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { GlobalSettings, Repo } from '../../shared/types' -import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree-id' +import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree/id' const { gitExecFileAsyncMock, diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index 02784cbef86..ea2614a344a 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -1,6 +1,6 @@ // On first agent work in a fresh workspace, replace the auto-generated creature branch (e.g. `you/Nautilus`) with a short work-derived name. import type { GlobalSettings, Repo } from '../../shared/types' -import { getRepoIdFromWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree-id' +import { getRepoIdFromWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { parsePaneKey } from '../../shared/stable-pane-id' import { diff --git a/src/main/agent-hooks/first-work-folder-rename.ts b/src/main/agent-hooks/first-work-folder-rename.ts index c7050310834..79b3f98493e 100644 --- a/src/main/agent-hooks/first-work-folder-rename.ts +++ b/src/main/agent-hooks/first-work-folder-rename.ts @@ -10,7 +10,7 @@ import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, getRepoIdFromWorktreeId, splitWorktreeIdForFilesystem -} from '../../shared/worktree-id' +} from '../../shared/worktree/id' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { planWorktreeFolderRename } from '../ipc/worktree-folder-rename-target' diff --git a/src/main/agent-hooks/restored-subagent-liveness-sweep.ts b/src/main/agent-hooks/restored-subagent-liveness-sweep.ts index 6fcea40a8cf..e44a47f11d0 100644 --- a/src/main/agent-hooks/restored-subagent-liveness-sweep.ts +++ b/src/main/agent-hooks/restored-subagent-liveness-sweep.ts @@ -5,7 +5,7 @@ import { type ExecutionHostId } from '../../shared/execution-host' import type { FolderWorkspace, ProjectGroup } from '../../shared/types' -import { getRepoIdFromWorktreeId } from '../../shared/worktree-id' +import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' import { parseWorkspaceKey } from '../../shared/workspace-scope' export type RestoredSubagentLivenessSweepDeps = { diff --git a/src/main/automations/run-target-resolution.ts b/src/main/automations/run-target-resolution.ts index 5ff447d6537..1168bf8fb6a 100644 --- a/src/main/automations/run-target-resolution.ts +++ b/src/main/automations/run-target-resolution.ts @@ -3,7 +3,7 @@ import type { Automation } from '../../shared/automations-types' import { getAutomationLegacyRepoId } from '../../shared/automation-run-identity' import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host' import type { ProjectHostSetup, Repo } from '../../shared/types' -import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' export type AutomationRunTargetResult = | { ok: true; cwd: string; repo: Repo; setup?: ProjectHostSetup } diff --git a/src/main/daemon/wsl-session-context.ts b/src/main/daemon/wsl-session-context.ts index fa69089f55b..0cf419872f9 100644 --- a/src/main/daemon/wsl-session-context.ts +++ b/src/main/daemon/wsl-session-context.ts @@ -1,4 +1,4 @@ -import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { isWslShellName } from '../../shared/local-windows-terminal-runtime' import { getDefaultWslDistro, parseWslPath } from '../wsl' import { parsePtySessionId } from './pty-session-id' diff --git a/src/main/git/status.ts b/src/main/git/status.ts index a51d1784ddf..31f364789de 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -46,7 +46,7 @@ import { removeSafeUntrackedDiscardTargets } from '../../shared/git-discard-path-safety' import { readBranchCompareHead } from '../../shared/git-branch-compare-head' -import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref' +import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref' import { resolveWorktreeBaseCommitOid } from './worktree-base-ref-probe' import { getLargeDiffRenderLimit } from '../../shared/large-diff-render-limit' import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe' diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 9ee8eb2e188..11439bff0ae 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -5,7 +5,7 @@ import { branchHasNoUnmergedChangesWithLazyTargetRefresh, getBranchCleanupTargetRefs } from '../../shared/git-branch-cleanup' -import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref' +import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref' import { withSpan } from '../observability/tracer' import { withWorktreeRemoveStageSpan } from '../observability/instrumentation' import { @@ -20,8 +20,8 @@ import type { LocalBaseRefUpdateSuggestion, RemoveWorktreeResult } from '../../shared/types' -import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree-removal' -import { isSubmoduleWorktreeRemovalRefusal } from '../../shared/worktree-submodule-removal' +import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal' +import { isSubmoduleWorktreeRemovalRefusal } from '../../shared/worktree/submodule-removal' import { decodeGitCQuotedPath } from '../../shared/git-cquoted-path' import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output' import { parseWslUncPath } from '../../shared/wsl-paths' diff --git a/src/main/github/auth-diagnose.ts b/src/main/github/auth-diagnose.ts index 8051af7f8b4..61aafdd7cf8 100644 --- a/src/main/github/auth-diagnose.ts +++ b/src/main/github/auth-diagnose.ts @@ -12,7 +12,7 @@ * uses stable field labels ("Token scopes:", "(GITHUB_TOKEN)", etc.). */ import { ghExecFileAsync } from '../git/runner' -import type { GhAuthDiagnostic, GhAuthAccount } from '../../shared/github-auth-types' +import type { GhAuthDiagnostic, GhAuthAccount } from '../../shared/github/auth-types' // Required scopes for ProjectV2 GraphQL access in Orca. `project` is the // scope that gates ProjectV2 reads/writes; the others are needed for the diff --git a/src/main/github/client-work-items-query-paging.test.ts b/src/main/github/client-work-items-query-paging.test.ts index 7af6b124c34..0e8b62bdfcf 100644 --- a/src/main/github/client-work-items-query-paging.test.ts +++ b/src/main/github/client-work-items-query-paging.test.ts @@ -118,7 +118,7 @@ import { _resetMergeQueueCacheForTests, _resetOwnerRepoCache } from './client' -import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github-work-items-query-bounds' +import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github/work-items-query-bounds' import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index 84119b3b2a2..f26ccfc12d9 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -112,7 +112,7 @@ import { _resetMergeQueueCacheForTests, _resetOwnerRepoCache } from './client' -import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github-work-items-query-bounds' +import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github/work-items-query-bounds' import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 068d3ccc419..2457c7f21f4 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -28,10 +28,10 @@ import { normalizeHostedReviewBaseRef, normalizeHostedReviewHeadRef } from '../../shared/hosted-review-refs' -import { normalizeGitHubPRMergeMethodSettings } from '../../shared/github-pr-merge-methods' +import { normalizeGitHubPRMergeMethodSettings } from '../../shared/github/pull-request-merge-methods' import { summarizeProviderChecks } from '../../shared/provider-check-summary' -import { isGitHubWorkItemsQueryTooLarge } from '../../shared/github-work-items-query-bounds' -import { classifyGitHubUnavailable } from '../../shared/github-api-availability' +import { isGitHubWorkItemsQueryTooLarge } from '../../shared/github/work-items-query-bounds' +import { classifyGitHubUnavailable } from '../../shared/github/api-availability' import { parseTaskQuery, type ParsedTaskQuery } from '../../shared/task-query' import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE, @@ -91,7 +91,7 @@ import { type GitHubRepoExecOptions, type GitHubApiRepository } from './github-api-repository' -import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key' +import { githubRepoIdentityKey } from '../../shared/github/repository-identity-key' export { _resetOwnerRepoCache } from './gh-utils' export { getIssue, @@ -127,7 +127,7 @@ import { import { GITHUB_CHECK_DETAILS_HOST_TIMEOUT_MS, GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE -} from '../../shared/github-check-details-deadline' +} from '../../shared/github/check-details-deadline' import { hydrateGitHubPRStack, mergeGitHubPRStack } from './github-pr-stack' type GhExecOptions = GitHubRepoExecOptions & { signal?: AbortSignal } diff --git a/src/main/github/gh-utils.test.ts b/src/main/github/gh-utils.test.ts index ca9098e53bf..ba19fc13fda 100644 --- a/src/main/github/gh-utils.test.ts +++ b/src/main/github/gh-utils.test.ts @@ -38,7 +38,7 @@ import { __resetLocalGitConfigSignatureCacheForTests, readLocalGitConfigSignature } from './local-git-config-signature' -import { GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN } from '../../shared/github-work-items-query-bounds' +import { GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN } from '../../shared/github/work-items-query-bounds' describe('github owner/repo resolution', () => { beforeEach(() => { diff --git a/src/main/github/github-api-repository.ts b/src/main/github/github-api-repository.ts index 3a61b9387da..24037c4f73c 100644 --- a/src/main/github/github-api-repository.ts +++ b/src/main/github/github-api-repository.ts @@ -2,7 +2,7 @@ import type { GitHubOwnerRepo, IssueSourcePreference } from '../../shared/types' import { githubRepoIdentityKey, isDefaultGitHubHost -} from '../../shared/github-repository-identity-key' +} from '../../shared/github/repository-identity-key' import { getOwnerRepoForRemote, ghRepoExecOptions, diff --git a/src/main/github/github-owner-repo-selection.ts b/src/main/github/github-owner-repo-selection.ts index 63b6d75678e..2e7c6aa0f4c 100644 --- a/src/main/github/github-owner-repo-selection.ts +++ b/src/main/github/github-owner-repo-selection.ts @@ -1,5 +1,5 @@ import type { IssueSourcePreference } from '../../shared/types' -import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key' +import { githubRepoIdentityKey } from '../../shared/github/repository-identity-key' import { getOwnerRepoForRemote, type LocalGitExecOptions, diff --git a/src/main/github/github-pr-stack.ts b/src/main/github/github-pr-stack.ts index 24d0773a91c..a11cf597769 100644 --- a/src/main/github/github-pr-stack.ts +++ b/src/main/github/github-pr-stack.ts @@ -6,7 +6,7 @@ import type { PRReviewDecision, PRState } from '../../shared/types' -import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key' +import { githubRepoIdentityKey } from '../../shared/github/repository-identity-key' import { ghExecFileAsync } from '../git/runner' import { githubHostExecOptions, diff --git a/src/main/github/github-remote-identity-parsing.ts b/src/main/github/github-remote-identity-parsing.ts index cc8e283c11c..02c21fb8983 100644 --- a/src/main/github/github-remote-identity-parsing.ts +++ b/src/main/github/github-remote-identity-parsing.ts @@ -1,13 +1,8 @@ +import { normalizeGitHubRemoteHost } from '../../shared/git-remote-host-alias' import type { GitHubOwnerRepo } from '../../shared/types' export type GitHubRemoteIdentity = GitHubOwnerRepo & { host: string } -export function normalizeGitHubRemoteHost(host: string): string { - const normalizedHost = host.toLowerCase() - // Why: GitHub documents ssh.github.com as SSH-over-HTTPS for github.com repos. - return normalizedHost === 'ssh.github.com' ? 'github.com' : normalizedHost -} - // Why: HTTP ports identify the GHES web/API endpoint; SSH and git ports are // transport-only and must not leak into gh's host identity. function hostFromRemoteUrl(url: URL): string { diff --git a/src/main/github/merged-pr-commit-membership.ts b/src/main/github/merged-pr-commit-membership.ts index 7350416ca6f..dd595da69f4 100644 --- a/src/main/github/merged-pr-commit-membership.ts +++ b/src/main/github/merged-pr-commit-membership.ts @@ -1,7 +1,7 @@ import { ghExecFileAsync } from './gh-utils' import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' import { githubHostExecOptions } from './github-api-repository' -import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key' +import { githubRepoIdentityKey } from '../../shared/github/repository-identity-key' import type { OwnerRepo } from './github-repository-identity' type GhExecOptions = Parameters[1] diff --git a/src/main/github/pr-refresh-error-classification.ts b/src/main/github/pr-refresh-error-classification.ts index aa9e226939d..12c42d092b2 100644 --- a/src/main/github/pr-refresh-error-classification.ts +++ b/src/main/github/pr-refresh-error-classification.ts @@ -1,5 +1,5 @@ import type { PRRefreshErrorType } from '../../shared/types' -import { classifyGitHubUnavailable } from '../../shared/github-api-availability' +import { classifyGitHubUnavailable } from '../../shared/github/api-availability' import { extractExecError } from '../git/exec-error' /** diff --git a/src/main/github/project-view.test.ts b/src/main/github/project-view.test.ts index 1339c5c0fdc..48486ae4a9e 100644 --- a/src/main/github/project-view.test.ts +++ b/src/main/github/project-view.test.ts @@ -11,7 +11,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import { GITHUB_PROJECT_REF_INPUT_MAX_BYTES, GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR -} from '../../shared/github-project-ref-input' +} from '../../shared/github/project-ref-input' import { PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES, _getProjectViewCacheSizesForTests, diff --git a/src/main/github/project-view.ts b/src/main/github/project-view.ts index db344de7133..cd763b2542e 100644 --- a/src/main/github/project-view.ts +++ b/src/main/github/project-view.ts @@ -46,12 +46,12 @@ import type { ListProjectViewsResult, ResolveProjectRefArgs, ResolveProjectRefResult -} from '../../shared/github-project-types' +} from '../../shared/github/project-types' import { GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR, isGitHubProjectRefInputTooLarge -} from '../../shared/github-project-ref-input' -import { githubProjectHost } from '../../shared/github-project-identity' +} from '../../shared/github/project-ref-input' +import { githubProjectHost } from '../../shared/github/project-identity' // Re-export the public API so existing `./project-view` call sites keep working; the split is internal-only. export { isValidOwnerSlug, isValidRepoSlug } from './project-view/internals' diff --git a/src/main/github/project-view/internals.ts b/src/main/github/project-view/internals.ts index 1dd794370c7..06f0f7d69cf 100644 --- a/src/main/github/project-view/internals.ts +++ b/src/main/github/project-view/internals.ts @@ -9,9 +9,9 @@ import { noteRepositoryRateLimitSpend, type RateLimitBucketKind } from '../rate-limit' -import type { GitHubProjectViewError } from '../../../shared/github-project-types' -import { githubProjectHost } from '../../../shared/github-project-identity' -import { isDefaultGitHubHost } from '../../../shared/github-repository-identity-key' +import type { GitHubProjectViewError } from '../../../shared/github/project-types' +import { githubProjectHost } from '../../../shared/github/project-identity' +import { isDefaultGitHubHost } from '../../../shared/github/repository-identity-key' import { isGitHubHostAuthenticatedForGlobalCli } from '../github-enterprise-repository' import { classifyProjectError, diff --git a/src/main/github/project-view/mutations.ts b/src/main/github/project-view/mutations.ts index 453a695baec..5f6044debbf 100644 --- a/src/main/github/project-view/mutations.ts +++ b/src/main/github/project-view/mutations.ts @@ -17,7 +17,7 @@ import { type GraphqlVars } from './internals' import { classifyProjectError, rateLimitedError } from './project-error-classification' -import { githubProjectHost } from '../../../shared/github-project-identity' +import { githubProjectHost } from '../../../shared/github/project-identity' import type { GitHubAssignableUser, GitHubWorkItemDetails, PRComment } from '../../../shared/types' import type { AddIssueCommentBySlugArgs, @@ -39,7 +39,7 @@ import type { UpdateIssueTypeBySlugArgs, UpdatePullRequestBySlugArgs, UpdateProjectItemFieldArgs -} from '../../../shared/github-project-types' +} from '../../../shared/github/project-types' function githubHostExecOptions(args: { host?: string }): { host: string } { return { host: githubProjectHost(args.host) } diff --git a/src/main/github/project-view/project-error-classification.ts b/src/main/github/project-view/project-error-classification.ts index 1468552b345..5db90f4305d 100644 --- a/src/main/github/project-view/project-error-classification.ts +++ b/src/main/github/project-view/project-error-classification.ts @@ -1,8 +1,8 @@ // Why: turns raw gh stderr/stdout into the typed GitHubProjectViewError the // renderer can act on (auth vs scope vs rate limit vs drift), shared by the // project-view read and mutation paths. -import type { GitHubProjectViewError } from '../../../shared/github-project-types' -import { githubProjectHost } from '../../../shared/github-project-identity' +import type { GitHubProjectViewError } from '../../../shared/github/project-types' +import { githubProjectHost } from '../../../shared/github/project-identity' export type GhGraphqlErrorShape = { type?: string diff --git a/src/main/github/rate-limit.ts b/src/main/github/rate-limit.ts index 2ca3f5cad43..4a48823164d 100644 --- a/src/main/github/rate-limit.ts +++ b/src/main/github/rate-limit.ts @@ -9,7 +9,7 @@ import type { GitHubRateLimitBucket, GitHubRateLimitSnapshot } from '../../shared/types' -import { isDefaultGitHubHost } from '../../shared/github-repository-identity-key' +import { isDefaultGitHubHost } from '../../shared/github/repository-identity-key' import { isWslUncPath } from '../../shared/wsl-paths' import { acquire, release } from './gh-utils' import { ghExecFileAsync } from '../git/runner' diff --git a/src/main/github/stacked-pr-creation.ts b/src/main/github/stacked-pr-creation.ts index e34752df6d9..2d060f65a83 100644 --- a/src/main/github/stacked-pr-creation.ts +++ b/src/main/github/stacked-pr-creation.ts @@ -2,7 +2,7 @@ import type { CreateStackedHostedReviewInput, CreateStackedHostedReviewResult } from '../../shared/hosted-review' -import { isDefaultGitHubHost } from '../../shared/github-repository-identity-key' +import { isDefaultGitHubHost } from '../../shared/github/repository-identity-key' import { normalizeHostedReviewBaseRef, normalizeHostedReviewHeadRef diff --git a/src/main/index.ts b/src/main/index.ts index b4e0e1b5ae4..6b7cafa79e4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -267,7 +267,7 @@ import { configureWindowsHostGitEnvironmentReadiness, setDefaultWslDistroOverride } from './git/runner' -import { getRepoIdFromWorktreeId } from '../shared/worktree-id' +import { getRepoIdFromWorktreeId } from '../shared/worktree/id' import { parseWorkspaceKey } from '../shared/workspace-scope' import { setMigrationUnsupportedPtyListener } from './agent-hooks/migration-unsupported-pty-state' import { AgentBrowserBridge } from './browser/agent-browser-bridge' diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 40701203e06..44239bdac74 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -135,7 +135,7 @@ import { import { listRepoWorktrees } from '../repo-worktrees' import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' import { buildReadDirErrorBreadcrumb, type ReadDirThrowSite } from './readdir-error-diagnostics' -import { splitWorktreeId } from '../../shared/worktree-id' +import { splitWorktreeId } from '../../shared/worktree/id' import { getRuntimePathBasename } from '../../shared/cross-platform-path' import type { LocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { registerLocalLogTailHandlers } from './local-log-tail' diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 91cfc94f061..eda32115b19 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -107,7 +107,7 @@ import type { UpdateIssueTypeBySlugArgs, UpdateProjectItemFieldArgs, UpdatePullRequestBySlugArgs -} from '../../shared/github-project-types' +} from '../../shared/github/project-types' import { appStarSourceSchema } from '../../shared/gh-star-source' import { track } from '../telemetry/client' import { getCohortAtEmit } from '../telemetry/cohort-classifier' diff --git a/src/main/ipc/linear.ts b/src/main/ipc/linear.ts index 2e70cd0564d..0905c691f4c 100644 --- a/src/main/ipc/linear.ts +++ b/src/main/ipc/linear.ts @@ -24,8 +24,8 @@ import { } from '../linear/projects' import { listTeams, getTeamStates, getTeamLabels, getTeamMembers } from '../linear/teams' import type { LinearListFilter } from '../linear/issues' -import { clampLinearIssueListLimit } from '../../shared/linear-issue-read-limits' -import { optionalParsedLinearIssueAttributeFilter } from '../../shared/linear-issue-attribute-filter' +import { clampLinearIssueListLimit } from '../../shared/linear/issue-read-limits' +import { optionalParsedLinearIssueAttributeFilter } from '../../shared/linear/issue-attribute-filter' import type { LinearCustomViewModel, LinearIssueUpdate, diff --git a/src/main/ipc/local-worktree-runtime-options.ts b/src/main/ipc/local-worktree-runtime-options.ts index 13a13e7f536..f9850a7a399 100644 --- a/src/main/ipc/local-worktree-runtime-options.ts +++ b/src/main/ipc/local-worktree-runtime-options.ts @@ -4,7 +4,7 @@ import { getLocalProjectWorktreeGitOptions, type LocalProjectWorktreeGitOptions } from '../project-runtime-git-options' -import { splitWorktreeId } from '../../shared/worktree-id' +import { splitWorktreeId } from '../../shared/worktree/id' import type { Repo } from '../../shared/types' function comparableLocalPath(value: string): string { diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index 7418396a199..031cdf63378 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -21,7 +21,7 @@ import type { NotificationSettings, NotificationSoundDataResult } from '../../shared/types' -import { getRepoIdFromWorktreeId } from '../../shared/worktree-id' +import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { buildNotificationOptions } from './notification-options' import { readNotificationAuthorizationStatus } from './notification-authorization-status' diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 6e28044a889..680bc78b4ec 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -148,7 +148,7 @@ import { type TerminalStartupCwdMissingDirFallback } from '../../shared/terminal-startup-cwd' import { isWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' -import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' import { agentSessionOwnerBindingsEqual, diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts index 713fe1b8a17..361f66bb21f 100644 --- a/src/main/ipc/remote-workspace.ts +++ b/src/main/ipc/remote-workspace.ts @@ -15,7 +15,7 @@ import type { } from '../../shared/remote-workspace-types' import type { SshTarget } from '../../shared/ssh-types' import type { WorkspaceSessionState } from '../../shared/types' -import { getRepoIdFromWorktreeId } from '../../shared/worktree-id' +import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' import { getRemoteWorkspaceNamespace } from './remote-workspace-namespace' import { registerRemoteWorkspaceNotificationHandler } from './remote-workspace-events' diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 94b55875345..d64809f8d2c 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -121,7 +121,7 @@ import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' import { normalizeCustomWorktreeVisibilitySources, normalizeWorktreeVisibilitySourcePreferences -} from '../../shared/worktree-visibility-sources' +} from '../../shared/worktree/visibility-sources' import { runWithGitReadCacheInvalidation } from '../git/status' import { isAdmissibleDirectSshAuthority } from '../../shared/ssh-retained-payload-admission' import { isCurrentSshProviderAuthority } from '../ssh/ssh-provider-authority' diff --git a/src/main/ipc/source-control-ai-linked-issue.ts b/src/main/ipc/source-control-ai-linked-issue.ts index fa000148fef..aa0696dd913 100644 --- a/src/main/ipc/source-control-ai-linked-issue.ts +++ b/src/main/ipc/source-control-ai-linked-issue.ts @@ -2,7 +2,7 @@ import { resolve } from 'node:path' import type { Store } from '../persistence' import { isLinkedIssueNumber } from '../../shared/source-control-ai-action-variables' import type { WorktreeMeta } from '../../shared/types' -import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' export type LinkedIssueLookupArgs = { worktreeId?: string diff --git a/src/main/ipc/watcher-removal-gate.test.ts b/src/main/ipc/watcher-removal-gate.test.ts index 221f5f724c9..677cb381d1f 100644 --- a/src/main/ipc/watcher-removal-gate.test.ts +++ b/src/main/ipc/watcher-removal-gate.test.ts @@ -6,7 +6,7 @@ import { TerminalRemovalInProgressError, WatcherRemovalInProgressError } from './watcher-removal-gate' -import { isWorktreeRemovalFenceError } from '../../shared/worktree-removal-fence-error' +import { isWorktreeRemovalFenceError } from '../../shared/worktree/removal-fence-error' describe('watcher removal gate', () => { it('waits for an existing install and rejects later equivalent-path installs', async () => { diff --git a/src/main/ipc/watcher-removal-gate.ts b/src/main/ipc/watcher-removal-gate.ts index de19acfdf46..ec4bef288ca 100644 --- a/src/main/ipc/watcher-removal-gate.ts +++ b/src/main/ipc/watcher-removal-gate.ts @@ -5,7 +5,7 @@ import { import { TERMINAL_REMOVAL_IN_PROGRESS_MESSAGE, WATCHER_REMOVAL_IN_PROGRESS_MESSAGE -} from '../../shared/worktree-removal-fence-error' +} from '../../shared/worktree/removal-fence-error' // Why: fence slots are identities, not a counter, so a removal that gives up waiting can drop exactly // the installs it waited on without a late finishInstall corrupting the count for a newer install. diff --git a/src/main/ipc/workspace-cleanup-disconnected-ssh.ts b/src/main/ipc/workspace-cleanup-disconnected-ssh.ts index bd549a9b6ee..f7128d26827 100644 --- a/src/main/ipc/workspace-cleanup-disconnected-ssh.ts +++ b/src/main/ipc/workspace-cleanup-disconnected-ssh.ts @@ -6,7 +6,7 @@ import { createWorkspaceCleanupFingerprint, type WorkspaceCleanupCandidate } from '../../shared/workspace-cleanup' -import { splitWorktreeId } from '../../shared/worktree-id' +import { splitWorktreeId } from '../../shared/worktree/id' import { getNewestWorkspaceCleanupDiffCommentAt, getWorkspaceCleanupInactivityReasonsForWorkspace, diff --git a/src/main/ipc/workspace-cleanup-scan.ts b/src/main/ipc/workspace-cleanup-scan.ts index 32794f61977..41f94a07814 100644 --- a/src/main/ipc/workspace-cleanup-scan.ts +++ b/src/main/ipc/workspace-cleanup-scan.ts @@ -5,7 +5,7 @@ import type { IGitProvider } from '../providers/types' import { isFolderRepo } from '../../shared/repo-kind' import type { GitWorktreeInfo, Repo, Worktree } from '../../shared/types' import { mergeWorktree } from './worktree-logic' -import { splitWorktreeId } from '../../shared/worktree-id' +import { splitWorktreeId } from '../../shared/worktree/id' import type { WorkspaceCleanupCandidate, WorkspaceCleanupScanError, diff --git a/src/main/ipc/worktree-folder-rename-target.ts b/src/main/ipc/worktree-folder-rename-target.ts index 5725b4efd75..9cdbe7188c2 100644 --- a/src/main/ipc/worktree-folder-rename-target.ts +++ b/src/main/ipc/worktree-folder-rename-target.ts @@ -1,6 +1,6 @@ import { posix } from 'node:path' import type { GlobalSettings } from '../../shared/types' -import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree-id' +import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree/id' import { computeWorktreePath } from './worktree-logic' type WorktreePathSettings = Pick diff --git a/src/main/ipc/worktree-git-status-ref-watch.ts b/src/main/ipc/worktree-git-status-ref-watch.ts index f8035b0510f..db24dd6ac3c 100644 --- a/src/main/ipc/worktree-git-status-ref-watch.ts +++ b/src/main/ipc/worktree-git-status-ref-watch.ts @@ -1,6 +1,6 @@ import { resolveRuntimePath } from '../../shared/cross-platform-path' import { isSafeGitStatusUpstreamRef } from '../../shared/git-status-upstream-ref' -import { getRepoIdFromWorktreeId } from '../../shared/worktree-id' +import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' import { pathRelativeToWorktreeWatchRoot, type WorktreeBaseWatchTarget diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index a056d657f02..45c6cbdff03 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -2,7 +2,7 @@ import { resolve, relative, isAbsolute, posix, sep, win32 } from 'node:path' import type { GlobalSettings, OrcaWorkspaceLayout, Repo } from '../../shared/types' import { isWindowsAbsolutePathLike, resolveRuntimePath } from '../../shared/cross-platform-path' import { isWslUncPath } from '../../shared/wsl-paths' -import { splitWorktreeId } from '../../shared/worktree-id' +import { splitWorktreeId } from '../../shared/worktree/id' import { replaceKnownEmojiWithShortcodes } from '../../shared/emoji-shortcode-catalog' import { getWslHome, parseWslPath } from '../wsl' diff --git a/src/main/ipc/worktree-push-target-cleanup.ts b/src/main/ipc/worktree-push-target-cleanup.ts index d7fdc7a04be..0fd1faf10ad 100644 --- a/src/main/ipc/worktree-push-target-cleanup.ts +++ b/src/main/ipc/worktree-push-target-cleanup.ts @@ -6,7 +6,7 @@ import type { Store } from '../persistence' import type { GitPushTarget } from '../../shared/types' import { parseGitHubOwnerRepo } from '../github/gh-utils' -import { getRepoIdFromWorktreeId } from '../../shared/worktree-id' +import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' import { iterateProcessOutputLines } from '../../shared/process-output-field-scanner' export type GitRemoteExec = ( diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index f83c5035976..8d9e70481cd 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -32,7 +32,7 @@ import { import { resolveLocalGitUsername, getSshGitUsername } from '../git/git-username' import { hasCommitObjectViaGitExec } from '../git/commit-object-ref' import { resolveWorktreeCreateBase } from '../worktree-create-base' -import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref' +import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref' import { getHostedReviewForBranch } from '../source-control/hosted-review' import type { ForgeProviderId } from '../source-control/forge-provider' import { validateGitPushTarget } from '../git/push-target-validation' @@ -88,7 +88,7 @@ import { } from './worktree-logic' import { findCreatedWorktree } from './created-worktree-reconciliation' import type { BranchPrefixSettings } from '../../shared/branch-prefix' -import { getRepoIdFromWorktreeId } from '../../shared/worktree-id' +import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope' import { cleanupUnusedWorktreePushTargetRemoteWithExec, diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index d72fb666665..a03396107ef 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -8,7 +8,7 @@ import { isFolderRepo } from '../../shared/repo-kind' import { readBranchRenameFailureOutputForDisplay } from '../agent-hooks/branch-rename-failure-output' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' -import { planWorktreeSortOrderUpdates } from '../../shared/worktree-sort-order-update' +import { planWorktreeSortOrderUpdates } from '../../shared/worktree/sort-order-update' import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection' import { TaskSourceContextSchema } from '../../shared/task-source-context-schema' import { WorkspaceLinkedItemSchema } from '../../shared/workspace-linked-item-schema' @@ -37,7 +37,7 @@ import type { WorkspaceLineage, WorktreeMeta } from '../../shared/types' -import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree-removal' +import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID, @@ -66,11 +66,11 @@ import { buildKnownOrcaWorkspaceLayouts, isLegacyRepoForExternalWorktreeVisibility, toDetectedWorktree -} from '../../shared/worktree-ownership' +} from '../../shared/worktree/ownership' import { createWorktreeVisibilitySourceMatcher, resolveCustomWorktreeVisibilitySources -} from '../../shared/worktree-visibility-sources' +} from '../../shared/worktree/visibility-sources' import { assertWorktreeCleanForRemoval, forceDeleteLocalBranch, @@ -239,7 +239,7 @@ import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, getRepoIdFromWorktreeId, getWorktreePathBasenameFromId -} from '../../shared/worktree-id' +} from '../../shared/worktree/id' import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' import { getLocalProjectGitExecOptions, diff --git a/src/main/linear/issue-activity-raw.ts b/src/main/linear/issue-activity-raw.ts index 992a175b701..c53e713e738 100644 --- a/src/main/linear/issue-activity-raw.ts +++ b/src/main/linear/issue-activity-raw.ts @@ -1,7 +1,7 @@ import type { LinearIssueActivityEntry, LinearIssueActivityValue -} from '../../shared/linear-issue-activity' +} from '../../shared/linear/issue-activity' import type { RawNamedEntity, RawPageInfo, RawUser } from './issue-context-raw' type RawActivityEntity = RawNamedEntity & { diff --git a/src/main/linear/issue-context-client.ts b/src/main/linear/issue-context-client.ts index c9e5085f3bb..1e3a45bd301 100644 --- a/src/main/linear/issue-context-client.ts +++ b/src/main/linear/issue-context-client.ts @@ -1,5 +1,5 @@ -import type { LinearSearchIssueSummary, LinearSearchResult } from '../../shared/linear-agent-access' -import { clampLinearSearchLimit } from '../../shared/linear-agent-access' +import type { LinearSearchIssueSummary, LinearSearchResult } from '../../shared/linear/agent-access' +import { clampLinearSearchLimit } from '../../shared/linear/agent-access' import type { LinearWorkspace } from '../../shared/types' import { acquire, diff --git a/src/main/linear/issue-context-current.ts b/src/main/linear/issue-context-current.ts index dbde6c8176f..e433435b421 100644 --- a/src/main/linear/issue-context-current.ts +++ b/src/main/linear/issue-context-current.ts @@ -1,4 +1,4 @@ -import { parseLinearIssueInput } from '../../shared/linear-links' +import { parseLinearIssueInput } from '../../shared/linear/links' import { getConnectedWorkspaces } from './issue-context-client' import { linearError } from './issue-context-errors' diff --git a/src/main/linear/issue-context-errors.ts b/src/main/linear/issue-context-errors.ts index 4e76835eb6a..1dde1b58537 100644 --- a/src/main/linear/issue-context-errors.ts +++ b/src/main/linear/issue-context-errors.ts @@ -1,4 +1,4 @@ -import type { LinearErrorCode, LinearIncludeErrorCode } from '../../shared/linear-agent-access' +import type { LinearErrorCode, LinearIncludeErrorCode } from '../../shared/linear/agent-access' export class LinearAgentAccessError extends Error { readonly code: LinearErrorCode diff --git a/src/main/linear/issue-context-fanout.ts b/src/main/linear/issue-context-fanout.ts index 484eec818f7..c28f03a2c9c 100644 --- a/src/main/linear/issue-context-fanout.ts +++ b/src/main/linear/issue-context-fanout.ts @@ -1,4 +1,4 @@ -import type { LinearErrorCode, LinearWorkspaceCandidate } from '../../shared/linear-agent-access' +import type { LinearErrorCode, LinearWorkspaceCandidate } from '../../shared/linear/agent-access' import type { LinearWorkspace } from '../../shared/types' import { getClients, getStatus, type LinearClientForWorkspace } from './client' import { diff --git a/src/main/linear/issue-context-includes.test.ts b/src/main/linear/issue-context-includes.test.ts index 05e5f2b9892..0d8d432a1c5 100644 --- a/src/main/linear/issue-context-includes.test.ts +++ b/src/main/linear/issue-context-includes.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { LinearIssueContextResult, LinearIssueRequest } from '../../shared/linear-agent-access' +import type { LinearIssueContextResult, LinearIssueRequest } from '../../shared/linear/agent-access' import type { ResolvedIssue } from './issue-context-client' import { ACTIVITY_QUERY } from './issue-activity-raw' import { diff --git a/src/main/linear/issue-context-includes.ts b/src/main/linear/issue-context-includes.ts index 13a8f4ae234..785bdd82f4f 100644 --- a/src/main/linear/issue-context-includes.ts +++ b/src/main/linear/issue-context-includes.ts @@ -7,7 +7,7 @@ import type { LinearIssueContextResult, LinearIssueInclude, LinearIssueRequest -} from '../../shared/linear-agent-access' +} from '../../shared/linear/agent-access' import { LINEAR_ATTACHMENTS_CAP, LINEAR_ACTIVITY_CAP, @@ -15,8 +15,8 @@ import { LINEAR_COMMENTS_CAP, LINEAR_COMMENT_BODY_CAP, clampLinearIssueDepth -} from '../../shared/linear-agent-access' -import { extractLinearInlineMedia } from '../../shared/linear-inline-media' +} from '../../shared/linear/agent-access' +import { extractLinearInlineMedia } from '../../shared/linear/inline-media' import type { ResolvedIssue } from './issue-context-client' import { getRequiredEntry, withLinearRead } from './issue-context-client' import { getPublicFileUrlClient } from './client' diff --git a/src/main/linear/issue-context-inline-media.test.ts b/src/main/linear/issue-context-inline-media.test.ts index 3b3b97994de..e3e484a4593 100644 --- a/src/main/linear/issue-context-inline-media.test.ts +++ b/src/main/linear/issue-context-inline-media.test.ts @@ -4,7 +4,7 @@ import type { LinearIssueCommentNode, LinearIssueContextResult, LinearIssueSummary -} from '../../shared/linear-agent-access' +} from '../../shared/linear/agent-access' import { collectInlineMedia } from './issue-context' function summary(overrides: Partial): LinearIssueSummary { diff --git a/src/main/linear/issue-context-pagination.ts b/src/main/linear/issue-context-pagination.ts index d8c5f239ac9..83118c41b72 100644 --- a/src/main/linear/issue-context-pagination.ts +++ b/src/main/linear/issue-context-pagination.ts @@ -1,4 +1,4 @@ -import { LINEAR_ISSUE_API_PAGE_SIZE_MAX } from '../../shared/linear-issue-read-limits' +import { LINEAR_ISSUE_API_PAGE_SIZE_MAX } from '../../shared/linear/issue-read-limits' export type LinearPageVariables = { first: number; after?: string } diff --git a/src/main/linear/issue-context-raw.ts b/src/main/linear/issue-context-raw.ts index 6f29e9e6a01..d421a17e017 100644 --- a/src/main/linear/issue-context-raw.ts +++ b/src/main/linear/issue-context-raw.ts @@ -2,7 +2,7 @@ import type { LinearCollectionMeta, LinearIssueSummary, LinearSearchIssueSummary -} from '../../shared/linear-agent-access' +} from '../../shared/linear/agent-access' export type RawIssueResponse = { issue?: RawIssue | null diff --git a/src/main/linear/issue-context-relations.ts b/src/main/linear/issue-context-relations.ts index fd39787d720..66e2fac9d3d 100644 --- a/src/main/linear/issue-context-relations.ts +++ b/src/main/linear/issue-context-relations.ts @@ -1,5 +1,5 @@ -import type { LinearCollectionMeta, LinearIssueRelation } from '../../shared/linear-agent-access' -import { LINEAR_RELATIONS_CAP } from '../../shared/linear-agent-access' +import type { LinearCollectionMeta, LinearIssueRelation } from '../../shared/linear/agent-access' +import { LINEAR_RELATIONS_CAP } from '../../shared/linear/agent-access' import type { ResolvedIssue } from './issue-context-client' import { getRequiredEntry, withLinearRead } from './issue-context-client' import { readConnectionPages } from './issue-context-pagination' diff --git a/src/main/linear/issue-context-workspaces.ts b/src/main/linear/issue-context-workspaces.ts index debdf646c4e..47ad620a0a3 100644 --- a/src/main/linear/issue-context-workspaces.ts +++ b/src/main/linear/issue-context-workspaces.ts @@ -1,4 +1,4 @@ -import type { LinearWorkspaceCandidate } from '../../shared/linear-agent-access' +import type { LinearWorkspaceCandidate } from '../../shared/linear/agent-access' import type { LinearWorkspace } from '../../shared/types' import { linearError } from './issue-context-errors' diff --git a/src/main/linear/issue-context.ts b/src/main/linear/issue-context.ts index 9116b9a591b..0cf5ea5fd0b 100644 --- a/src/main/linear/issue-context.ts +++ b/src/main/linear/issue-context.ts @@ -3,9 +3,9 @@ import type { LinearIssueChildNode, LinearIssueContextResult, LinearIssueRequest -} from '../../shared/linear-agent-access' -import { extractLinearInlineMedia } from '../../shared/linear-inline-media' -import { parseLinearIssueInput } from '../../shared/linear-links' +} from '../../shared/linear/agent-access' +import { extractLinearInlineMedia } from '../../shared/linear/inline-media' +import { parseLinearIssueInput } from '../../shared/linear/links' import { resolveIssue, searchLinearIssuesForAgents, diff --git a/src/main/linear/issue-list-filter.ts b/src/main/linear/issue-list-filter.ts index b85b7e3ba09..6190716ea0c 100644 --- a/src/main/linear/issue-list-filter.ts +++ b/src/main/linear/issue-list-filter.ts @@ -1,7 +1,7 @@ import { isEmptyLinearIssueAttributeFilter, type LinearIssueAttributeFilter -} from '../../shared/linear-issue-attribute-filter' +} from '../../shared/linear/issue-attribute-filter' import type { LinearListFilter } from './issues' // Why: attribute facets must merge into Linear's IssueFilter before the first-N diff --git a/src/main/linear/issue-relation-write.ts b/src/main/linear/issue-relation-write.ts index 6f9ce71c634..08f2a3bc91e 100644 --- a/src/main/linear/issue-relation-write.ts +++ b/src/main/linear/issue-relation-write.ts @@ -3,8 +3,8 @@ import { loadLinearSdk } from './linear-sdk' import type { LinearIssueRelationship, LinearIssueRelationWriteResult -} from '../../shared/linear-issue-relation-write' -import { LINEAR_ISSUE_API_PAGE_SIZE_MAX } from '../../shared/linear-issue-read-limits' +} from '../../shared/linear/issue-relation-write' +import { LINEAR_ISSUE_API_PAGE_SIZE_MAX } from '../../shared/linear/issue-read-limits' import { acquire, clearToken, getClients, isAuthError, release } from './client' import { linearError } from './issue-context-errors' import { diff --git a/src/main/linear/issues.ts b/src/main/linear/issues.ts index 1c8d7d8ee74..301bc66024a 100644 --- a/src/main/linear/issues.ts +++ b/src/main/linear/issues.ts @@ -14,11 +14,11 @@ import { loadLinearSdk } from './linear-sdk' import { LINEAR_ISSUE_API_PAGE_SIZE_MAX, clampLinearIssueListLimit -} from '../../shared/linear-issue-read-limits' +} from '../../shared/linear/issue-read-limits' import { isEmptyLinearIssueAttributeFilter, type LinearIssueAttributeFilter -} from '../../shared/linear-issue-attribute-filter' +} from '../../shared/linear/issue-attribute-filter' import { acquire, release, diff --git a/src/main/linear/linear-team-pages.ts b/src/main/linear/linear-team-pages.ts index 1da7fd6242f..abc1c485a8b 100644 --- a/src/main/linear/linear-team-pages.ts +++ b/src/main/linear/linear-team-pages.ts @@ -1,5 +1,5 @@ import type { LinearLabel, LinearMember, LinearTeam, LinearWorkflowState } from '../../shared/types' -import { buildLinearTeamUrl } from '../../shared/linear-links' +import { buildLinearTeamUrl } from '../../shared/linear/links' import type { LinearClientForWorkspace } from './client' const TEAM_PAGE_SIZE = 100 diff --git a/src/main/linear/mcp-issue-list.ts b/src/main/linear/mcp-issue-list.ts index 09c5fff3336..ff98278cc7d 100644 --- a/src/main/linear/mcp-issue-list.ts +++ b/src/main/linear/mcp-issue-list.ts @@ -1,7 +1,7 @@ import type { LinearMcpIssueListRequest, LinearMcpIssueListResult -} from '../../shared/linear-agent-access' +} from '../../shared/linear/agent-access' import { getClients, getStatus, type LinearClientForWorkspace } from './client' import { withLinearRead } from './issue-context-client' import { linearError } from './issue-context-errors' diff --git a/src/main/linear/projects.ts b/src/main/linear/projects.ts index 5945c3fc95b..d55d2f6e6a6 100644 --- a/src/main/linear/projects.ts +++ b/src/main/linear/projects.ts @@ -15,7 +15,7 @@ import type { import { LINEAR_ISSUE_API_PAGE_SIZE_MAX, clampLinearIssueListLimit -} from '../../shared/linear-issue-read-limits' +} from '../../shared/linear/issue-read-limits' import { acquire, clearToken, diff --git a/src/main/local-project-runtime-resolution.ts b/src/main/local-project-runtime-resolution.ts index d7588ae51a1..e86a2f31927 100644 --- a/src/main/local-project-runtime-resolution.ts +++ b/src/main/local-project-runtime-resolution.ts @@ -10,7 +10,7 @@ import { hasCachedWslAvailability, hasCachedWslDistros } from './wsl' -import { getRepoIdFromWorktreeId } from '../shared/worktree-id' +import { getRepoIdFromWorktreeId } from '../shared/worktree/id' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' function canResolveProjectRuntimeForRepo(store: Store): boolean { diff --git a/src/main/local-worktree-removal-recovery.ts b/src/main/local-worktree-removal-recovery.ts index 5a920a4ee6f..578743029f6 100644 --- a/src/main/local-worktree-removal-recovery.ts +++ b/src/main/local-worktree-removal-recovery.ts @@ -1,5 +1,5 @@ import type { GitWorktreeInfo, RemoveWorktreeResult } from '../shared/types' -import { assertWorktreeUnlockedForRemoval } from '../shared/worktree-removal' +import { assertWorktreeUnlockedForRemoval } from '../shared/worktree/removal' import { areWorktreePathsEqual, formatWorktreeRemovalError } from './ipc/worktree-logic' import { gitExecFileAsync } from './git/runner' import { listWorktreesStrict, type GitWorktreeExecOptions } from './git/worktree' diff --git a/src/main/memory/collector.ts b/src/main/memory/collector.ts index d2a8783a510..c1fe58989ad 100644 --- a/src/main/memory/collector.ts +++ b/src/main/memory/collector.ts @@ -21,7 +21,7 @@ import { basename } from 'node:path' import { exec } from 'node:child_process' import { promisify } from 'node:util' import os from 'node:os' -import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { getProcessOutputFields, iterateProcessOutputLines diff --git a/src/main/memory/hydrate-local-pty-registry.ts b/src/main/memory/hydrate-local-pty-registry.ts index 6608fd2641c..3388eeebed9 100644 --- a/src/main/memory/hydrate-local-pty-registry.ts +++ b/src/main/memory/hydrate-local-pty-registry.ts @@ -16,7 +16,7 @@ import type { SessionInfo } from '../daemon/types' import { listRegisteredPtys, registerPty } from './pty-registry' import { listRepoWorktrees } from '../repo-worktrees' import { parsePtySessionId } from '../../shared/pty-session-id-format' -import { splitWorktreeId } from '../../shared/worktree-id' +import { splitWorktreeId } from '../../shared/worktree/id' import type { Store } from '../persistence' // Why: macOS dock reactivation reruns setup; avoid repeating Git I/O and daemon RPC. diff --git a/src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts b/src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts index af14882b371..4d84e49b3c5 100644 --- a/src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts +++ b/src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts @@ -130,4 +130,48 @@ describe('Codex WSL scan gate', () => { }) ).rejects.toBe(refusal) }) + + it('reports the abort, not a refusal, when the hook-path probe races a caller abort', async () => { + const controller = new AbortController() + const abortReason = new Error('caller went away') + mocks.gate.mockImplementation(async (options: { operation?: string; path: string }) => { + if (options.operation === 'access') { + controller.abort(abortReason) + throw new WslTranscriptFsError('timeout', 'slow share') + } + return [] + }) + + await expect( + resolveSessionFilePath( + 'codex', + 'session-id', + { + transcriptPath: `${WSL_SESSIONS_DIR}\\2026\\rollout-1-session-id.jsonl`, + codexSessionsDirs: [LOCAL_SESSIONS_DIR] + }, + controller.signal + ) + ).rejects.toBe(abortReason) + }) + + // The scan layer's own waiter already wins this race; the resolver's post-catch + // signal check is the backstop if that ever stops holding. + it('reports the abort, not a refusal, when a scanned root is aborted mid-scan', async () => { + const controller = new AbortController() + const abortReason = new Error('caller went away') + mocks.gate.mockImplementation(async () => { + controller.abort(abortReason) + throw new WslTranscriptFsError('timeout', 'slow share') + }) + + await expect( + resolveSessionFilePath( + 'codex', + 'session-id', + { codexSessionsDirs: [WSL_SESSIONS_DIR] }, + controller.signal + ) + ).rejects.toBe(abortReason) + }) }) diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index f0376e01f02..a45850f2d46 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -108,6 +108,8 @@ export async function resolveSessionFilePath( return hostReadable } } catch (error) { + // A caller abort that races the refusal stays authoritative. + signal?.throwIfAborted() // Why: the id-based search may still hit; surface the refusal only when // it does not, so a stalled distro reads as unavailable, never "missing". unavailable = wslTranscriptFsRefusal(error) @@ -232,6 +234,8 @@ async function findCodexRolloutInDirs( return files } } catch (error) { + // A caller abort that races the refusal stays authoritative. + signal?.throwIfAborted() // Why: one stalled distro must not hide another root's hit. unavailable = wslTranscriptFsRefusal(error) } diff --git a/src/main/orca-profiles/profile-project-state-file.test.ts b/src/main/orca-profiles/profile-project-state-file.test.ts new file mode 100644 index 00000000000..223d1229555 --- /dev/null +++ b/src/main/orca-profiles/profile-project-state-file.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultPersistedState } from '../../shared/constants' +import type { PersistedState, Project, ProjectHostSetup, Repo } from '../../shared/types' +import { rebuildRepoBackedProjectState } from './profile-project-state-file' + +const upstreamIdentity = { + canonicalKey: 'git.example.com/acme/app-upstream', + remoteName: 'upstream', + remoteUrl: 'git@git.example.com:acme/app-upstream.git' +} + +const makeRepo = (overrides: Partial = {}): Repo => ({ + id: 'r1', + path: '/repo', + displayName: 'App', + badgeColor: '#fff', + addedAt: 1, + ...overrides +}) + +const makeProject = (overrides: Partial = {}): Project => ({ + id: 'project-1', + displayName: 'App', + badgeColor: '#737373', + sourceRepoIds: [], + createdAt: 1, + updatedAt: 1, + ...overrides +}) + +const makeSetup = (overrides: Partial = {}): ProjectHostSetup => ({ + id: 'setup-1', + projectId: 'project-1', + hostId: 'local', + repoId: '', + path: '/repo', + displayName: 'App', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 1, + ...overrides +}) + +function makeState(overrides: Partial): PersistedState { + return { ...getDefaultPersistedState('/home/test'), ...overrides } +} + +describe('rebuildRepoBackedProjectState', () => { + it('carries project state and independent setups across a repo remote identity change', () => { + const originProjectId = 'git:git.example.com/acme/app' + const rebuilt = rebuildRepoBackedProjectState( + makeState({ + repos: [makeRepo({ gitRemoteIdentity: upstreamIdentity })], + projects: [ + makeProject({ + id: originProjectId, + sourceRepoIds: ['r1'], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + ], + projectHostSetups: [ + makeSetup({ id: 'r1', projectId: originProjectId, repoId: 'r1' }), + makeSetup({ + id: 'app::gpu-vm', + projectId: originProjectId, + hostId: 'runtime:gpu-vm', + path: '/srv/app' + }) + ] + }) + ) + + expect(rebuilt.projects).toEqual([ + expect.objectContaining({ + id: 'git:git.example.com/acme/app-upstream', + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + ]) + expect(rebuilt.projectHostSetups.find((setup) => setup.id === 'app::gpu-vm')?.projectId).toBe( + 'git:git.example.com/acme/app-upstream' + ) + }) + + it('picks one predecessor project when several prior rows overlap the same repos', () => { + const sharedIdentity = { + canonicalKey: 'git.example.com/acme/shared', + remoteName: 'origin', + remoteUrl: 'git@git.example.com:acme/shared.git' + } + const rebuilt = rebuildRepoBackedProjectState( + makeState({ + repos: [ + makeRepo({ id: 'r1', path: '/left', gitRemoteIdentity: sharedIdentity }), + makeRepo({ id: 'r2', path: '/right', gitRemoteIdentity: sharedIdentity }) + ], + projects: [ + makeProject({ + id: 'git:git.example.com/acme/left', + sourceRepoIds: ['r1'], + updatedAt: 200, + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }), + makeProject({ + id: 'git:git.example.com/acme/right', + sourceRepoIds: ['r2'], + updatedAt: 100, + localWindowsRuntimePreference: { kind: 'windows-host' } + }) + ], + projectHostSetups: [ + makeSetup({ id: 'r1', projectId: 'git:git.example.com/acme/left', repoId: 'r1' }), + makeSetup({ id: 'r2', projectId: 'git:git.example.com/acme/right', repoId: 'r2' }) + ] + }) + ) + + // Equal repo overlap resolves by newest updatedAt; the loser's preference is never merged in. + expect(rebuilt.projects).toEqual([ + expect.objectContaining({ + id: 'git:git.example.com/acme/shared', + sourceRepoIds: ['r1', 'r2'], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + ]) + }) + + it('leaves an unclaimed prior project standing with its own independent setups', () => { + const rebuilt = rebuildRepoBackedProjectState( + makeState({ + repos: [makeRepo({ gitRemoteIdentity: upstreamIdentity })], + projects: [ + makeProject({ + id: 'cloud-project', + localWindowsRuntimePreference: { kind: 'windows-host' } + }) + ], + projectHostSetups: [ + makeSetup({ id: 'cloud-project::gpu-vm', projectId: 'cloud-project', path: '/srv/cloud' }) + ] + }) + ) + + expect(rebuilt.projects.map((project) => project.id)).toEqual([ + 'git:git.example.com/acme/app-upstream', + 'cloud-project' + ]) + expect(rebuilt.projectHostSetups.map((setup) => setup.projectId)).toEqual([ + 'git:git.example.com/acme/app-upstream', + 'cloud-project' + ]) + }) +}) diff --git a/src/main/orca-profiles/profile-project-state-file.ts b/src/main/orca-profiles/profile-project-state-file.ts index 4169fea6ffc..7e00533350f 100644 --- a/src/main/orca-profiles/profile-project-state-file.ts +++ b/src/main/orca-profiles/profile-project-state-file.ts @@ -5,6 +5,7 @@ import { dirname } from 'node:path' import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../shared/constants' import type { ExecutionHostId } from '../../shared/execution-host' import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection' +import { carryProjectStateThroughIdentityChange } from '../../shared/project-identity-succession' import type { PersistedState, Project, @@ -102,16 +103,22 @@ function isRepoBackedProjectHostSetup( export function rebuildRepoBackedProjectState(state: TransferProfileState): TransferProfileState { const projection = projectHostSetupProjectionFromRepos(state.repos) - const existingProjectsById = new Map(state.projects.map((project) => [project.id, project])) + const succession = carryProjectStateThroughIdentityChange(projection.projects, state.projects) const currentRepoIds = new Set(state.repos.map((repo) => repo.id)) const projectedProjectIds = new Set(projection.projects.map((project) => project.id)) const projectedSetupIds = new Set(projection.setups.map((setup) => setup.id)) - const independentSetups = state.projectHostSetups.filter((setup) => { - if (projectedSetupIds.has(setup.id)) { - return false - } - return !isRepoBackedProjectHostSetup(setup, currentRepoIds) - }) + const independentSetups = state.projectHostSetups + .filter((setup) => { + if (projectedSetupIds.has(setup.id)) { + return false + } + return !isRepoBackedProjectHostSetup(setup, currentRepoIds) + }) + // Why: follow the repo's project through a derived-id change so no ghost project row survives. + .map((setup) => { + const remappedProjectId = succession.remappedProjectIds.get(setup.projectId) + return remappedProjectId ? { ...setup, projectId: remappedProjectId } : setup + }) const independentProjectIds = new Set(independentSetups.map((setup) => setup.projectId)) const independentProjects = state.projects .filter( @@ -121,19 +128,9 @@ export function rebuildRepoBackedProjectState(state: TransferProfileState): Tran ...project, sourceRepoIds: project.sourceRepoIds.filter((repoId) => currentRepoIds.has(repoId)) })) - const projectedProjects = projection.projects.map((project) => { - const existingProject = existingProjectsById.get(project.id) - return existingProject?.localWindowsRuntimePreference - ? { - ...project, - localWindowsRuntimePreference: existingProject.localWindowsRuntimePreference, - updatedAt: Math.max(project.updatedAt, existingProject.updatedAt) - } - : project - }) return { ...state, - projects: [...projectedProjects, ...independentProjects], + projects: [...succession.projects, ...independentProjects], projectHostSetups: [...projection.setups, ...independentSetups] } } diff --git a/src/main/orca-profiles/profile-project-worktree-identity.ts b/src/main/orca-profiles/profile-project-worktree-identity.ts index 9bb4fa9fc27..58aa55da8b3 100644 --- a/src/main/orca-profiles/profile-project-worktree-identity.ts +++ b/src/main/orca-profiles/profile-project-worktree-identity.ts @@ -2,7 +2,7 @@ import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-p import { getRepoExecutionHostId } from '../../shared/execution-host' import type { Repo, WorkspaceKey } from '../../shared/types' import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope' -import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree-id' +import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree/id' export function repoPhysicalKey( repo: Pick diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index c19e4e64984..02989a1f398 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -595,6 +595,124 @@ describe('Store', () => { }) }) + it('carries project state and independent setups across a repo remote identity change', async () => { + const originProjectId = 'git:git.example.com/acme/app' + writeDataFile({ + ...getDefaultPersistedState(testState.dir), + repos: [ + makeRepo({ + id: 'r1', + path: '/repo', + displayName: 'App', + gitRemoteIdentity: { + canonicalKey: 'git.example.com/acme/app', + remoteName: 'origin', + remoteUrl: 'git@git.example.com:acme/app.git' + } + }) + ], + projects: [ + makeProject({ + id: originProjectId, + sourceRepoIds: ['r1'], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + ], + projectHostSetups: [ + makeProjectHostSetup({ id: 'r1', projectId: originProjectId, repoId: 'r1' }), + makeProjectHostSetup({ + id: 'app::gpu-vm', + projectId: originProjectId, + hostId: 'runtime:gpu-vm', + repoId: '', + path: '/srv/app' + }) + ] + }) + const store = await createStore() + + // A re-probe that now prefers the `upstream` remote rewrites the derived project id. + store.updateRepo('r1', { + gitRemoteIdentity: { + canonicalKey: 'git.example.com/acme/app-upstream', + remoteName: 'upstream', + remoteUrl: 'git@git.example.com:acme/app-upstream.git' + } + }) + + const upstreamProjectId = 'git:git.example.com/acme/app-upstream' + expect(store.getProjects().map((project) => project.id)).toEqual([upstreamProjectId]) + expect(store.getProjects()[0]?.localWindowsRuntimePreference).toEqual({ + kind: 'wsl', + distro: 'Ubuntu' + }) + expect( + store.getProjectHostSetups().find((setup) => setup.id === 'app::gpu-vm')?.projectId + ).toBe(upstreamProjectId) + }) + + it('picks one predecessor project when several prior rows overlap the same repos', async () => { + const sharedIdentity = { + canonicalKey: 'git.example.com/acme/shared', + remoteName: 'origin', + remoteUrl: 'git@git.example.com:acme/shared.git' + } + writeDataFile({ + ...getDefaultPersistedState(testState.dir), + repos: [ + makeRepo({ + id: 'r1', + path: '/left', + displayName: 'Left', + gitRemoteIdentity: sharedIdentity + }), + makeRepo({ + id: 'r2', + path: '/right', + displayName: 'Right', + gitRemoteIdentity: sharedIdentity + }) + ], + projects: [ + makeProject({ + id: 'git:git.example.com/acme/left', + sourceRepoIds: ['r1'], + updatedAt: 200, + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }), + makeProject({ + id: 'git:git.example.com/acme/right', + sourceRepoIds: ['r2'], + updatedAt: 100, + localWindowsRuntimePreference: { kind: 'windows-host' } + }) + ], + projectHostSetups: [ + makeProjectHostSetup({ + id: 'r1', + projectId: 'git:git.example.com/acme/left', + repoId: 'r1' + }), + makeProjectHostSetup({ + id: 'r2', + projectId: 'git:git.example.com/acme/right', + repoId: 'r2' + }) + ] + }) + + const store = await createStore() + + // Equal repo overlap resolves by newest updatedAt; the loser's preference is never merged in. + expect(store.getProjects()).toEqual([ + expect.objectContaining({ + id: 'git:git.example.com/acme/shared', + sourceRepoIds: ['r1', 'r2'], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + ]) + }) + it('migrates legacy WSL agent settings into the global Windows runtime default', async () => { writeDataFile({ schemaVersion: 1, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 85cdc145efa..cf6c1b6168c 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -79,6 +79,7 @@ import { normalizeProjectRuntimePreference } from '../shared/project-execution-runtime' import { projectHostSetupProjectionFromRepos } from '../shared/project-host-setup-projection' +import { carryProjectStateThroughIdentityChange } from '../shared/project-identity-succession' import { isPluginPanelTabKey } from '../shared/plugins/plugin-manifest' import type { GitRemoteIdentity } from '../shared/git-remote-identity' import { @@ -140,7 +141,7 @@ import { normalizeStatusBarUsageMode } from '../shared/status-bar-usage-mode' import { normalizeCustomWorktreeVisibilitySources, normalizeWorktreeVisibilitySourcePreferences -} from '../shared/worktree-visibility-sources' +} from '../shared/worktree/visibility-sources' import { isExistingPersistedProfile } from '../shared/project-order-manual-default-notice' import { resolveUsagePercentageDisplayChangeNoticeDismissed } from '../shared/usage-percentage-display-change-notice' import { normalizePRBotAuthorOverrides } from '../shared/pr-bot-author-overrides' @@ -172,7 +173,7 @@ import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, getRepoIdFromWorktreeId, getWorktreePathBasenameFromId -} from '../shared/worktree-id' +} from '../shared/worktree/id' import { isPathInsideOrEqual, isWindowsAbsolutePathLike, @@ -219,7 +220,7 @@ import { } from '../shared/workspace-statuses' import { clampMarkdownTocPanelWidth } from '../shared/markdown-toc-panel-width' import { clampCombinedDiffFileTreeWidth } from '../shared/combined-diff-file-tree-width' -import { isLegacyRepoForExternalWorktreeVisibility } from '../shared/worktree-ownership' +import { isLegacyRepoForExternalWorktreeVisibility } from '../shared/worktree/ownership' import { migrateExternalWorktreeVisibilityDefaults, normalizeWorktreeVisibilityDefaults @@ -2470,19 +2471,26 @@ function mergeProjectHostSetupCompatibilityState( repos: readonly Repo[] ): Pick { const projection = projectHostSetupProjectionFromRepos(repos) - const existingProjectsById = new Map( - (state.projects ?? []).map((project) => [project.id, project]) + const succession = carryProjectStateThroughIdentityChange( + projection.projects, + state.projects ?? [] ) const currentRepoIds = new Set(repos.map((repo) => repo.id)) const projectedProjectIds = new Set(projection.projects.map((project) => project.id)) const projectedSetupIds = new Set(projection.setups.map((setup) => setup.id)) // Why: legacy/repo-backed setup rows reuse the repo id; keep only independent rows so repo deletion leaves no ghosts. - const independentSetups = (state.projectHostSetups ?? []).filter((setup) => { - if (projectedSetupIds.has(setup.id)) { - return false - } - return !isRepoBackedProjectHostSetup(setup, currentRepoIds) - }) + const independentSetups = (state.projectHostSetups ?? []) + .filter((setup) => { + if (projectedSetupIds.has(setup.id)) { + return false + } + return !isRepoBackedProjectHostSetup(setup, currentRepoIds) + }) + // Why: follow the repo's project through a derived-id change so no ghost project row survives. + .map((setup) => { + const remappedProjectId = succession.remappedProjectIds.get(setup.projectId) + return remappedProjectId ? { ...setup, projectId: remappedProjectId } : setup + }) const independentProjectIds = new Set(independentSetups.map((setup) => setup.projectId)) const independentProjects = (state.projects ?? []) .filter( @@ -2492,18 +2500,8 @@ function mergeProjectHostSetupCompatibilityState( ...project, sourceRepoIds: project.sourceRepoIds.filter((repoId) => currentRepoIds.has(repoId)) })) - const projectedProjects = projection.projects.map((project) => { - const existingProject = existingProjectsById.get(project.id) - return existingProject?.localWindowsRuntimePreference - ? { - ...project, - localWindowsRuntimePreference: existingProject.localWindowsRuntimePreference, - updatedAt: Math.max(project.updatedAt, existingProject.updatedAt) - } - : project - }) return { - projects: [...projectedProjects, ...independentProjects], + projects: [...succession.projects, ...independentProjects], projectHostSetups: [...projection.setups, ...independentSetups] } } diff --git a/src/main/ports/ssh-advertised-url-enrichment.ts b/src/main/ports/ssh-advertised-url-enrichment.ts index c7c264d3eb4..4a6b17f46d8 100644 --- a/src/main/ports/ssh-advertised-url-enrichment.ts +++ b/src/main/ports/ssh-advertised-url-enrichment.ts @@ -7,7 +7,7 @@ import type { DetectedPort, EnrichedDetectedPort, PortForwardEntry } from '../../shared/ssh-types' import type { Store } from '../persistence' -import { splitWorktreeId } from '../../shared/worktree-id' +import { splitWorktreeId } from '../../shared/worktree/id' import { advertisedUrlWatcher, type AdvertisedUrl, diff --git a/src/main/ports/workspace-port-ownership.ts b/src/main/ports/workspace-port-ownership.ts index 9f7df2465cb..f9020d76682 100644 --- a/src/main/ports/workspace-port-ownership.ts +++ b/src/main/ports/workspace-port-ownership.ts @@ -1,6 +1,6 @@ import path from 'node:path' import type { Store } from '../persistence' -import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree-id' +import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { isFolderRepo } from '../../shared/repo-kind' import type { WorkspacePortKillRequest, diff --git a/src/main/providers/agent-foreground-context-paths.ts b/src/main/providers/agent-foreground-context-paths.ts index 7ff8a6d3e62..760b2933960 100644 --- a/src/main/providers/agent-foreground-context-paths.ts +++ b/src/main/providers/agent-foreground-context-paths.ts @@ -1,4 +1,4 @@ -import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' export function getAgentForegroundContextPaths(options: { cwd?: string diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index b4af3c907fb..4a852017f6a 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -12,7 +12,7 @@ import { resolveProcessCwd } from './process-cwd' import { existsSync } from 'node:fs' import * as pty from 'node-pty' import { getDefaultWslDistro, parseWslPath, isWslAvailableAsync } from '../wsl' -import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { isBracketedPasteSafeShell } from '../../shared/startup-command-submission' import { injectHistoryEnv, diff --git a/src/main/repo-git-remote-identity-enrichment.test.ts b/src/main/repo-git-remote-identity-enrichment.test.ts index 8d33eb8fffe..24f498b03fc 100644 --- a/src/main/repo-git-remote-identity-enrichment.test.ts +++ b/src/main/repo-git-remote-identity-enrichment.test.ts @@ -38,8 +38,7 @@ function makeRepo(overrides: Partial = {}): Repo { } } -function makeStore(repo: Repo): RepoIdentityStore & { updateRepo: ReturnType } { - const repos = [repo] +function makeStore(...repos: Repo[]): RepoIdentityStore & { updateRepo: ReturnType } { return { getRepos: () => repos, getRepo: (id) => repos.find((candidate) => candidate.id === id), @@ -65,6 +64,27 @@ function deferred(): { return { promise, resolve } } +const REFRESH_STARTUP_DELAY_MS = 5 * 60 * 1000 +const REFRESH_TTL_MS = 6 * 60 * 60 * 1000 + +const movedIdentity: GitRemoteIdentity = { + canonicalKey: 'git.company.test/platform/sample-app', + remoteName: 'upstream', + remoteUrl: 'git@git.company.test:platform/sample-app.git' +} + +/** Drains the sequential sweep: each flush only awaits the probe in flight at that moment. */ +async function drainEnrichmentSweep(): Promise { + for (let attempt = 0; attempt < 8; attempt += 1) { + await flushRepoGitRemoteIdentityEnrichmentForTests() + } +} + +async function sweep(store: RepoIdentityStore): Promise { + enrichMissingRepoGitRemoteIdentities(store) + await drainEnrichmentSweep() +} + afterEach(() => { vi.useRealTimers() vi.clearAllMocks() @@ -168,6 +188,147 @@ describe('enrichMissingRepoGitRemoteIdentities', () => { expect(store.updateRepo).toHaveBeenCalledWith('repo-1', { gitRemoteIdentity: remoteIdentity }) }) + it('does not re-probe a resolved identity before the refresh window elapses', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + vi.mocked(probeGitRemoteIdentity).mockResolvedValue(resolvedProbe) + const store = makeStore(makeRepo({ gitRemoteIdentity: remoteIdentity })) + + // Why two sweeps: the first only schedules the re-probe, so a restart cannot fan out at launch. + await sweep(store) + expect(probeGitRemoteIdentity).not.toHaveBeenCalled() + + vi.setSystemTime(1_000 + REFRESH_STARTUP_DELAY_MS + 1) + await sweep(store) + expect(probeGitRemoteIdentity).toHaveBeenCalledTimes(1) + + vi.setSystemTime(1_000 + REFRESH_STARTUP_DELAY_MS + REFRESH_TTL_MS - 1) + await sweep(store) + expect(probeGitRemoteIdentity).toHaveBeenCalledTimes(1) + }) + + it('re-probes a resolved identity after the refresh window elapses', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + vi.mocked(probeGitRemoteIdentity).mockResolvedValue(resolvedProbe) + const store = makeStore(makeRepo({ gitRemoteIdentity: remoteIdentity })) + + await sweep(store) + vi.setSystemTime(1_000 + REFRESH_STARTUP_DELAY_MS + 1) + await sweep(store) + vi.setSystemTime(1_000 + REFRESH_STARTUP_DELAY_MS + REFRESH_TTL_MS + 2) + await sweep(store) + + expect(probeGitRemoteIdentity).toHaveBeenCalledTimes(2) + }) + + it('overwrites a resolved identity when the re-probe finds a different canonical key', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ + status: 'resolved', + identity: movedIdentity + }) + const repo = makeRepo({ gitRemoteIdentity: remoteIdentity }) + const store = makeStore(repo) + const onChanged = vi.fn() + + await sweep(store) + vi.setSystemTime(1_000 + REFRESH_STARTUP_DELAY_MS + 1) + enrichMissingRepoGitRemoteIdentities(store, { onChanged }) + await drainEnrichmentSweep() + + expect(store.updateRepo).toHaveBeenCalledWith('repo-1', { gitRemoteIdentity: movedIdentity }) + expect(repo.gitRemoteIdentity).toEqual(movedIdentity) + expect(onChanged).toHaveBeenCalledTimes(1) + }) + + it('writes nothing when the re-probe returns the same canonical key', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ + status: 'resolved', + identity: { ...remoteIdentity, remoteUrl: 'https://git.company.test/team/sample-app.git' } + }) + const store = makeStore(makeRepo({ gitRemoteIdentity: remoteIdentity })) + const onChanged = vi.fn() + + await sweep(store) + vi.setSystemTime(1_000 + REFRESH_STARTUP_DELAY_MS + 1) + enrichMissingRepoGitRemoteIdentities(store, { onChanged }) + await drainEnrichmentSweep() + + expect(probeGitRemoteIdentity).toHaveBeenCalledTimes(1) + expect(store.updateRepo).not.toHaveBeenCalled() + expect(onChanged).not.toHaveBeenCalled() + }) + + it('keeps the existing identity when a re-probe cannot reach the host', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ status: 'unavailable' }) + const repo = makeRepo({ connectionId: 'builder', gitRemoteIdentity: remoteIdentity }) + const store = makeStore(repo) + + await sweep(store) + vi.setSystemTime(1_000 + REFRESH_STARTUP_DELAY_MS + 1) + await sweep(store) + + expect(probeGitRemoteIdentity).toHaveBeenCalledTimes(1) + expect(store.updateRepo).not.toHaveBeenCalled() + expect(repo.gitRemoteIdentity).toEqual(remoteIdentity) + }) + + it('keeps the existing identity when a re-probe reports no usable remote', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ status: 'no-remote' }) + const repo = makeRepo({ gitRemoteIdentity: remoteIdentity }) + const store = makeStore(repo) + + await sweep(store) + vi.setSystemTime(1_000 + REFRESH_STARTUP_DELAY_MS + 1) + await sweep(store) + + expect(store.updateRepo).not.toHaveBeenCalled() + expect(repo.gitRemoteIdentity).toEqual(remoteIdentity) + }) + + it('bounds re-probes per sweep so a large repo list drains gradually', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + vi.mocked(probeGitRemoteIdentity).mockResolvedValue(resolvedProbe) + const repos = Array.from({ length: 6 }, (_unused, index) => + makeRepo({ + id: `repo-${index}`, + path: `/workspace/sample-app-${index}`, + gitRemoteIdentity: remoteIdentity + }) + ) + const store = makeStore(...repos) + + await sweep(store) + vi.setSystemTime(1_000 + REFRESH_STARTUP_DELAY_MS + 1) + await sweep(store) + expect(probeGitRemoteIdentity).toHaveBeenCalledTimes(4) + + await sweep(store) + expect(probeGitRemoteIdentity).toHaveBeenCalledTimes(6) + }) + + it('does not re-probe folder workspaces', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + vi.mocked(probeGitRemoteIdentity).mockResolvedValue(resolvedProbe) + const store = makeStore(makeRepo({ kind: 'folder', gitRemoteIdentity: remoteIdentity })) + + await sweep(store) + vi.setSystemTime(1_000 + REFRESH_STARTUP_DELAY_MS + REFRESH_TTL_MS + 1) + await sweep(store) + + expect(probeGitRemoteIdentity).not.toHaveBeenCalled() + }) + it('does not write stale identity data after the repo path changes', async () => { const probe = deferred() vi.mocked(probeGitRemoteIdentity).mockReturnValue(probe.promise) diff --git a/src/main/repo-git-remote-identity-enrichment.ts b/src/main/repo-git-remote-identity-enrichment.ts index 9da00e68953..e48177f2e62 100644 --- a/src/main/repo-git-remote-identity-enrichment.ts +++ b/src/main/repo-git-remote-identity-enrichment.ts @@ -2,6 +2,16 @@ import type { Repo } from '../shared/types' import { probeGitRemoteIdentity } from './repo-git-remote-identity' const NO_IDENTITY_RETRY_TTL_MS = 5 * 60 * 1000 +// Why 6h: a resolved identity only changes when a remote is added or the project is renamed or +// transferred, so this trades a `git remote -v` per repo per quarter-day for identity gates that +// stop being frozen for the life of the repo record. +const RESOLVED_IDENTITY_REFRESH_TTL_MS = 6 * 60 * 60 * 1000 +// Why: the deadline map is per-process, so a restart would otherwise make every resolved repo due +// at once, on the first list call — the worst moment to spawn a subprocess per repo. +const RESOLVED_IDENTITY_REFRESH_STARTUP_DELAY_MS = 5 * 60 * 1000 +// Why: refreshes are speculative, so a large repo list drains across sweeps instead of stalling one +// list call behind N sequential probes (each an SSH round trip for off-host repos). +const MAX_IDENTITY_REFRESHES_PER_SWEEP = 4 type RepoIdentityStore = { getRepos(): Repo[] @@ -14,7 +24,7 @@ type EnrichmentOptions = { } const inFlightProbesByLocation = new Map>() -const noIdentityRetryAfterByLocation = new Map() +const probeRetryAfterByLocation = new Map() function getRepoLocationKey(repo: Pick): string { return `${repo.connectionId ?? 'local'}\0${repo.path}` @@ -24,28 +34,38 @@ function getCurrentRepo(store: RepoIdentityStore, id: string): Repo | undefined return store.getRepo?.(id) ?? store.getRepos().find((repo) => repo.id === id) } -function isSameUnenrichedRepo(snapshot: Repo, current: Repo | undefined): boolean { +function isSameProbedRepo(snapshot: Repo, current: Repo | undefined): current is Repo { return ( !!current && current.kind !== 'folder' && - !current.gitRemoteIdentity && current.path === snapshot.path && (current.connectionId ?? null) === (snapshot.connectionId ?? null) ) } +function shouldWriteProbedIdentity(current: Repo, probed: Repo['gitRemoteIdentity']): boolean { + const existing = current.gitRemoteIdentity + if (!existing) { + // Why: the no-remote marker is re-derived on every retry; skip the redundant + // write so repo-list consumers do not churn. + return !(probed === null && existing === null) + } + // Why: a resolved identity is replaced only by a probe that found a genuinely different repo. + // Failures and no-remote answers must never clear it — consumers read an absent identity as + // "unknown" and gate permissively, so losing one is worse than carrying a stale one. + return !!probed && probed.canonicalKey !== existing.canonicalKey +} + function writeIdentity( store: RepoIdentityStore, snapshot: Repo, gitRemoteIdentity: Repo['gitRemoteIdentity'] ): boolean { const current = getCurrentRepo(store, snapshot.id) - if (!isSameUnenrichedRepo(snapshot, current)) { - return false - } - // Why: the no-remote marker is re-derived on every retry; skip the redundant - // write so repo-list consumers do not churn. - if (gitRemoteIdentity === null && current?.gitRemoteIdentity === null) { + if ( + !isSameProbedRepo(snapshot, current) || + !shouldWriteProbedIdentity(current, gitRemoteIdentity) + ) { return false } return !!store.updateRepo(snapshot.id, { gitRemoteIdentity }) @@ -53,7 +73,7 @@ function writeIdentity( async function enrichRepoGitRemoteIdentity(store: RepoIdentityStore, repo: Repo): Promise { const locationKey = getRepoLocationKey(repo) - const retryAfter = noIdentityRetryAfterByLocation.get(locationKey) ?? 0 + const retryAfter = probeRetryAfterByLocation.get(locationKey) ?? 0 if (retryAfter > Date.now()) { return false } @@ -61,19 +81,24 @@ async function enrichRepoGitRemoteIdentity(store: RepoIdentityStore, repo: Repo) if (inFlight) { return inFlight } + // Why: a failed refresh of an already-resolved repo backs off on the long TTL — an SSH host that + // is down for the day must not re-probe every five minutes for an identity we already hold. + const missTtlMs = repo.gitRemoteIdentity + ? RESOLVED_IDENTITY_REFRESH_TTL_MS + : NO_IDENTITY_RETRY_TTL_MS const probe = (async () => { const result = await probeGitRemoteIdentity(repo.path, repo.connectionId) if (result.status !== 'resolved') { // Why: repos without a parseable remote are common; cache misses briefly so // list calls stay cheap while still allowing recent remote changes to land. - noIdentityRetryAfterByLocation.set(locationKey, Date.now() + NO_IDENTITY_RETRY_TTL_MS) + probeRetryAfterByLocation.set(locationKey, Date.now() + missTtlMs) // Why: only a probe that actually reached git settles "no usable remote". // An unreachable host leaves the identity unknown so consumers can keep // treating the repo as pending instead of ineligible. return result.status === 'no-remote' ? writeIdentity(store, repo, null) : false } - noIdentityRetryAfterByLocation.delete(locationKey) + probeRetryAfterByLocation.set(locationKey, Date.now() + RESOLVED_IDENTITY_REFRESH_TTL_MS) return writeIdentity(store, repo, result.identity) })().finally(() => { if (inFlightProbesByLocation.get(locationKey) === probe) { @@ -84,17 +109,45 @@ async function enrichRepoGitRemoteIdentity(store: RepoIdentityStore, repo: Repo) return probe } -async function enrichMissingRepoGitRemoteIdentitiesInBackground( - store: RepoIdentityStore, - options: EnrichmentOptions -): Promise { +function isIdentityRefreshDue(repo: Repo, now: number): boolean { + const locationKey = getRepoLocationKey(repo) + const dueAt = probeRetryAfterByLocation.get(locationKey) + if (dueAt === undefined) { + probeRetryAfterByLocation.set(locationKey, now + RESOLVED_IDENTITY_REFRESH_STARTUP_DELAY_MS) + return false + } + return dueAt <= now +} + +function selectEnrichmentCandidates(store: RepoIdentityStore): Repo[] { + const now = Date.now() + const repos = store.getRepos().filter((repo) => repo.kind !== 'folder') // Why: the settled `null` marker stays a candidate on purpose — a repo that // gains a remote later must still resolve. Do not tighten this to // `=== undefined`; the retry TTL already bounds the cost and `writeIdentity` // skips the redundant rewrite. - const candidates = store - .getRepos() - .filter((repo) => repo.kind !== 'folder' && !repo.gitRemoteIdentity) + const candidates = repos.filter((repo) => !repo.gitRemoteIdentity) + // Why re-probe at all: an identity resolved once is otherwise frozen for the life of the repo + // record, so a later `git remote add upstream` or a project rename/transfer keeps misjudging + // identity gates against the path the repo had when it was added. + let refreshes = 0 + for (const repo of repos) { + if (refreshes >= MAX_IDENTITY_REFRESHES_PER_SWEEP) { + break + } + if (repo.gitRemoteIdentity && isIdentityRefreshDue(repo, now)) { + candidates.push(repo) + refreshes += 1 + } + } + return candidates +} + +async function enrichMissingRepoGitRemoteIdentitiesInBackground( + store: RepoIdentityStore, + options: EnrichmentOptions +): Promise { + const candidates = selectEnrichmentCandidates(store) let changed = false for (const repo of candidates) { // Why: enrichment runs later; capture the location we probed so a mutable @@ -123,5 +176,5 @@ export async function flushRepoGitRemoteIdentityEnrichmentForTests(): Promise LINEAR_DUE_DATE_PATTERN.test(value), { diff --git a/src/main/runtime/rpc/methods/linear-issue-attribute-filter-schema.ts b/src/main/runtime/rpc/methods/linear-issue-attribute-filter-schema.ts index 8d9dde7ddc8..7b7176f48b8 100644 --- a/src/main/runtime/rpc/methods/linear-issue-attribute-filter-schema.ts +++ b/src/main/runtime/rpc/methods/linear-issue-attribute-filter-schema.ts @@ -4,7 +4,7 @@ import { LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS, LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_PRIORITIES, LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS -} from '../../../../shared/linear-issue-attribute-filter' +} from '../../../../shared/linear/issue-attribute-filter' // Why: keep ListIssues param validation co-located with shared limits without // pushing linear.ts past the max-lines ratchet. diff --git a/src/main/runtime/rpc/methods/repo-update-schema.ts b/src/main/runtime/rpc/methods/repo-update-schema.ts index 8ce5b916ffc..b613b35d8ba 100644 --- a/src/main/runtime/rpc/methods/repo-update-schema.ts +++ b/src/main/runtime/rpc/methods/repo-update-schema.ts @@ -6,7 +6,7 @@ import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source import { normalizeCustomWorktreeVisibilitySources, normalizeWorktreeVisibilitySourcePreferences -} from '../../../../shared/worktree-visibility-sources' +} from '../../../../shared/worktree/visibility-sources' export const RepoSourceControlAiOverrides = z .unknown() diff --git a/src/main/runtime/rpc/methods/worktree-visibility-defaults-schema.ts b/src/main/runtime/rpc/methods/worktree-visibility-defaults-schema.ts index 2bd263f301f..8bb7e1d081e 100644 --- a/src/main/runtime/rpc/methods/worktree-visibility-defaults-schema.ts +++ b/src/main/runtime/rpc/methods/worktree-visibility-defaults-schema.ts @@ -2,7 +2,7 @@ import { z } from 'zod' import { normalizeCustomWorktreeVisibilitySources, normalizeWorktreeVisibilitySourcePreferences -} from '../../../../shared/worktree-visibility-sources' +} from '../../../../shared/worktree/visibility-sources' export const WorktreeVisibilityDefaultsUpdate = z .object({ diff --git a/src/main/runtime/runtime-folder-workspace.test.ts b/src/main/runtime/runtime-folder-workspace.test.ts index f00da160330..06d97809c90 100644 --- a/src/main/runtime/runtime-folder-workspace.test.ts +++ b/src/main/runtime/runtime-folder-workspace.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { Repo, WorktreeMeta } from '../../shared/types' -import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id' +import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree/id' import { getRuntimeFolderWorkspaceInstanceId, getRuntimeFolderWorkspaceRootId, diff --git a/src/main/runtime/runtime-folder-workspace.ts b/src/main/runtime/runtime-folder-workspace.ts index fcbbacbd51d..63f548a1037 100644 --- a/src/main/runtime/runtime-folder-workspace.ts +++ b/src/main/runtime/runtime-folder-workspace.ts @@ -1,6 +1,6 @@ import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import type { Repo, Worktree, WorktreeMeta } from '../../shared/types' -import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id' +import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree/id' import { normalizeWorkspaceCreatorProvenance } from '../../shared/workspace-creator-provenance' export function getRuntimeFolderWorkspaceRootId(repo: Repo): string { diff --git a/src/main/runtime/unstopped-pty-verification.ts b/src/main/runtime/unstopped-pty-verification.ts index 068334eb98d..2dc9aac5960 100644 --- a/src/main/runtime/unstopped-pty-verification.ts +++ b/src/main/runtime/unstopped-pty-verification.ts @@ -3,7 +3,7 @@ import { UNSTOPPED_PTY_DETAIL_SEPARATOR, UNSTOPPED_PTY_LIVE_DETAIL_PREFIX, UNSTOPPED_PTY_REMOVAL_PREFIX -} from '../../shared/worktree-removal' +} from '../../shared/worktree/removal' import { settleBeforeDeadline } from './settle-before-deadline' // Floor for the verification window when the sweep ran on a very short budget. diff --git a/src/main/runtime/workspace-session-terminal-membership-authority.ts b/src/main/runtime/workspace-session-terminal-membership-authority.ts index 9dace4c8cb1..556d488edc2 100644 --- a/src/main/runtime/workspace-session-terminal-membership-authority.ts +++ b/src/main/runtime/workspace-session-terminal-membership-authority.ts @@ -5,7 +5,7 @@ import type { TerminalPaneLayoutNode, WorkspaceSessionState } from '../../shared/types' -import { getRepoIdFromWorktreeId } from '../../shared/worktree-id' +import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' import { pruneTabGroupLayoutAfterRetirement } from './mobile-session-terminal-retirement' function collectLeafIds(node: TerminalPaneLayoutNode | null, ids: Set): void { diff --git a/src/main/runtime/worktree-teardown-unstopped-pty.test.ts b/src/main/runtime/worktree-teardown-unstopped-pty.test.ts index ca09e01b2d8..31ba390c3ec 100644 --- a/src/main/runtime/worktree-teardown-unstopped-pty.test.ts +++ b/src/main/runtime/worktree-teardown-unstopped-pty.test.ts @@ -13,7 +13,7 @@ import { ABANDONED_SWEEP_GRACE_MS } from './forced-sweep-settlement' import { classifyWorktreeForceDeleteReason, isProvenLivePtyRemovalError -} from '../../shared/worktree-removal' +} from '../../shared/worktree/removal' import type { IPtyProvider, PtyProcessInfo } from '../providers/types' // Why: these tests advance fake timers *before* awaiting the teardown, so a diff --git a/src/main/runtime/worktree-teardown.ts b/src/main/runtime/worktree-teardown.ts index 993a263d348..ed0df69e8f7 100644 --- a/src/main/runtime/worktree-teardown.ts +++ b/src/main/runtime/worktree-teardown.ts @@ -2,13 +2,13 @@ import type { IPtyProvider } from '../providers/types' import type { OrcaRuntimeService } from './orca-runtime' import { listRegisteredPtys } from '../memory/pty-registry' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' -import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree-id' +import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { mapWithConcurrency } from '../../shared/map-with-concurrency' import { isUnstoppedPtyRemovalError, WORKTREE_TEARDOWN_FORCE_HINT, WORKTREE_TEARDOWN_TIMEOUT_PREFIX -} from '../../shared/worktree-removal' +} from '../../shared/worktree/removal' import { settleBeforeDeadline } from './settle-before-deadline' import { createWorktreeSweepTracker, settleSweepsForForcedRemoval } from './forced-sweep-settlement' import { diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index 0fa50ef3a32..2149c1ab000 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -21,7 +21,7 @@ import { isGiteaReviewCreationAuthenticated } from '../gitea/pull-request-creati import { isBitbucketReviewCreationAuthenticated } from '../bitbucket/pull-request-creation' import { getEnterpriseGitHubRepoSlug } from '../github/github-enterprise-repository' import { getRepoSlug } from '../github/client' -import { isDefaultGitHubHost } from '../../shared/github-repository-identity-key' +import { isDefaultGitHubHost } from '../../shared/github/repository-identity-key' import { acquire, ghExecFileAsync, gitExecFileAsync, release } from '../github/gh-utils' import { isNoUpstreamError, normalizeGitErrorMessage } from '../../shared/git-remote-error' import type { GitUpstreamStatus } from '../../shared/types' diff --git a/src/main/speech/model-manager-download-resume.test.ts b/src/main/speech/model-manager-download-resume.test.ts index 60645d1aa28..2fb4329e5c5 100644 --- a/src/main/speech/model-manager-download-resume.test.ts +++ b/src/main/speech/model-manager-download-resume.test.ts @@ -37,6 +37,7 @@ type ModelManagerInternals = { redirectCount?: number, resumeOffset?: number ) => Promise + getPartialDownloadBytes: (filePath: string) => number } type ScriptedResponse = { @@ -288,11 +289,13 @@ describe('ModelManager download resume', () => { const manager = new ModelManager(dir) as unknown as ModelManagerInternals const filePath = join(dir, 'model.bin') let bytesWritten = 0 + // Why: the ceiling costs 4096 iterations, so read progress from memory — + // real per-iteration file I/O stalls this test under parallel load. + vi.spyOn(manager, 'getPartialDownloadBytes').mockImplementation(() => bytesWritten) // Advances one byte per request against a total larger than the request // ceiling, so it makes forward progress forever without ever completing. const downloadFileMock = vi.spyOn(manager, 'downloadFile').mockImplementation(() => { bytesWritten += 1 - writeFileSync(filePath, Buffer.alloc(bytesWritten)) return Promise.resolve() }) diff --git a/src/main/ssh/ssh-remote-linear-activity-output.test.ts b/src/main/ssh/ssh-remote-linear-activity-output.test.ts index ffbd63a71bc..3cfb70c4686 100644 --- a/src/main/ssh/ssh-remote-linear-activity-output.test.ts +++ b/src/main/ssh/ssh-remote-linear-activity-output.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { LinearIssueContextResult } from '../../shared/linear-agent-access' +import type { LinearIssueContextResult } from '../../shared/linear/agent-access' import { formatRemoteLinearCli } from './ssh-remote-linear-output' describe('SSH Linear activity output', () => { diff --git a/src/main/ssh/ssh-remote-linear-list-issues.ts b/src/main/ssh/ssh-remote-linear-list-issues.ts index eb9213ada7c..98f10e8f36c 100644 --- a/src/main/ssh/ssh-remote-linear-list-issues.ts +++ b/src/main/ssh/ssh-remote-linear-list-issues.ts @@ -1,4 +1,4 @@ -import type { LinearMcpIssueListRequest } from '../../shared/linear-agent-access' +import type { LinearMcpIssueListRequest } from '../../shared/linear/agent-access' import type { RpcDispatcher } from '../runtime/rpc/dispatcher' import type { RpcResponse } from '../runtime/rpc/core' import { RemoteCliArgumentError, type ParsedRemoteCli } from './ssh-remote-cli-argument-error' diff --git a/src/main/ssh/ssh-remote-linear-output.ts b/src/main/ssh/ssh-remote-linear-output.ts index 4835791733c..0025248fb37 100644 --- a/src/main/ssh/ssh-remote-linear-output.ts +++ b/src/main/ssh/ssh-remote-linear-output.ts @@ -16,11 +16,11 @@ import type { LinearAttachResult, LinearCreateResult, LinearSaveIssueResult -} from '../../shared/linear-agent-access' +} from '../../shared/linear/agent-access' import { formatLinearProjectListRows, linearProjectListWarningLines -} from '../../shared/linear-project-list-format' +} from '../../shared/linear/project-list-format' import { isLinearAttachResult, isLinearCommentAddResult, diff --git a/src/main/ssh/ssh-remote-linear-read-cli.ts b/src/main/ssh/ssh-remote-linear-read-cli.ts index 123a279f0b6..d9ecfb27365 100644 --- a/src/main/ssh/ssh-remote-linear-read-cli.ts +++ b/src/main/ssh/ssh-remote-linear-read-cli.ts @@ -3,7 +3,7 @@ import { clampLinearIssueDepth, clampLinearSearchLimit, type LinearIssueInclude -} from '../../shared/linear-agent-access' +} from '../../shared/linear/agent-access' import type { RpcDispatcher } from '../runtime/rpc/dispatcher' import type { RpcResponse } from '../runtime/rpc/core' import { RemoteCliArgumentError, type ParsedRemoteCli } from './ssh-remote-cli-argument-error' diff --git a/src/main/ssh/ssh-remote-linear-relation-write.ts b/src/main/ssh/ssh-remote-linear-relation-write.ts index f89911958ff..e0ca568f8ed 100644 --- a/src/main/ssh/ssh-remote-linear-relation-write.ts +++ b/src/main/ssh/ssh-remote-linear-relation-write.ts @@ -1,4 +1,4 @@ -import type { LinearIssueRelationship } from '../../shared/linear-agent-access' +import type { LinearIssueRelationship } from '../../shared/linear/agent-access' import type { RpcResponse } from '../runtime/rpc/core' import type { RpcDispatcher } from '../runtime/rpc/dispatcher' import { diff --git a/src/main/ssh/ssh-remote-linear-result-guards.ts b/src/main/ssh/ssh-remote-linear-result-guards.ts index fd9bb2573c4..af05a6ed4da 100644 --- a/src/main/ssh/ssh-remote-linear-result-guards.ts +++ b/src/main/ssh/ssh-remote-linear-result-guards.ts @@ -15,7 +15,7 @@ import type { LinearTeamListResult, LinearTeamMembersResult, LinearTeamStatesResult -} from '../../shared/linear-agent-access' +} from '../../shared/linear/agent-access' export function isLinearIssueContextResult(result: unknown): result is LinearIssueContextResult { return ( diff --git a/src/main/ssh/ssh-remote-linear-write-support.ts b/src/main/ssh/ssh-remote-linear-write-support.ts index a341f0a93e8..b4967c90536 100644 --- a/src/main/ssh/ssh-remote-linear-write-support.ts +++ b/src/main/ssh/ssh-remote-linear-write-support.ts @@ -1,6 +1,6 @@ import type { RpcResponse } from '../runtime/rpc/core' import type { RpcDispatcher } from '../runtime/rpc/dispatcher' -import { isLinearUuid } from '../../shared/linear-uuid' +import { isLinearUuid } from '../../shared/linear/uuid' type ParsedRemoteCli = { commandPath: string[] diff --git a/src/main/usage-worktree-metadata.ts b/src/main/usage-worktree-metadata.ts index 53043124c94..8c4f3a94ba6 100644 --- a/src/main/usage-worktree-metadata.ts +++ b/src/main/usage-worktree-metadata.ts @@ -1,6 +1,6 @@ import { basename } from 'node:path' import type { Repo } from '../shared/types' -import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../shared/worktree-id' +import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../shared/worktree/id' import { isFolderRepo } from '../shared/repo-kind' import type { Store } from './persistence' diff --git a/src/main/worktree-create-base-prefetch.ts b/src/main/worktree-create-base-prefetch.ts index 8653c6866eb..fc43980202e 100644 --- a/src/main/worktree-create-base-prefetch.ts +++ b/src/main/worktree-create-base-prefetch.ts @@ -6,7 +6,7 @@ import { getBaseRefDefault } from './git/repo' import { getSshGitProvider } from './providers/ssh-git-dispatch' import { prefetchRemoteWorktreeCreateBase } from './ipc/worktree-remote' import { resolveWorktreeCreateBase } from './worktree-create-base' -import { resolveWorktreeAddBaseRef } from '../shared/worktree-base-ref' +import { resolveWorktreeAddBaseRef } from '../shared/worktree/base-ref' type RemoteTrackingBaseForPrefetch = { remote: string diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index dff4d95046b..d81d8c59f93 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1,2746 +1,130 @@ -/* eslint-disable max-lines -- Why: the preload contract is intentionally centralized in one declaration file so renderer and preload stay in lockstep when IPC surfaces change. */ -import type { - CreateHostedReviewArgs, - CreateHostedReviewResult, - CreateStackedHostedReviewArgs, - CreateStackedHostedReviewResult, - HostedReviewCreationEligibility, - HostedReviewCreationEligibilityArgs, - HostedReviewForBranchArgs, - HostedReviewInfo, - HostedReviewProvider -} from '../shared/hosted-review' -import type { - BitbucketConnectArgs, - BitbucketConnectionStatus -} from '../shared/bitbucket-credentials' -import type { NativeFileDropPayload } from '../shared/native-file-drop' -import type { ComputerAwakeStatus } from '../shared/computer-awake-mode' -import type { BrowserFindSource } from '../shared/browser-find-source' -import type { - DashboardRevealAgentArgs, - DashboardSleepWorkspaceArgs, - DashboardSnapshot, - DashboardSpawnAgentArgs -} from '../shared/dashboard-snapshot' -import type { - TerminalPreviewConnectResult, - TerminalPreviewDataPayload -} from '../shared/terminal-preview' -import type { - TerminalTabCloseRequest, - TerminalTabCloseResponse -} from '../shared/terminal-tab-close' -import type { TerminalTabCreateReply } from '../shared/terminal-reveal-identity' -import type { - LocalLogTailChangedPayload, - LocalLogTailReadArgs, - LocalLogTailReadResult, - LocalLogTailWatchArgs -} from '../shared/local-log-tail-types' -import type { ReadClipboardTextOptions } from '../shared/clipboard-text' -import type { AppIdentity } from '../shared/app-identity' -import type { ReleaseChannel } from '../shared/release-channel' -import type { - ForgetRemovedWorktreesForExecutionHostArgs, - ForgetRemovedWorktreesForExecutionHostResult, - HostQualifiedKnownWorktreeResult, - HostQualifiedDetectedWorktreeResult, - LegacyDetectedWorktreeRequest, - ListKnownWorktreesForExecutionHostArgs, - ListDetectedWorktreesArgs, - ProviderRequestId -} from '../shared/detected-worktree-provider-contract' -import type { - HostRepoCatalogSnapshot, - ListReposForExecutionHostArgs -} from '../shared/host-repo-catalog-contract' -import type { - HostLineageSnapshot, - ListDesktopLineageForHostArgs -} from '../shared/host-lineage-contract' -import type { - WriteTerminalRenderDesyncEvidenceArgs, - WriteTerminalRenderDesyncEvidenceResult -} from '../shared/terminal-render-desync-evidence' -import type { MobileRelayStatus } from '../shared/mobile-relay-status' -import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' -import type { RuntimePairingReach } from '../shared/runtime-pairing-reach' -import type { MobileRelayMintFailure } from '../shared/mobile-relay-mint-failure' -import type { VerifyAndAddRuntimeEnvironmentResult } from '../shared/remote-pairing-verification' -import type { - SshMutationExpectation, - SshConnectionState, - SshConfigHostListArgs, - SshConfigHostListResult, - SshConfigHostResolution, - SshConfigImportResult, - SshTargetAddResult, - SshTarget, - PortForwardEntry, - EnrichedDetectedPort -} from '../shared/ssh-types' -import type { - CreateLocalOrcaProfileArgs, - CreateLocalOrcaProfileResult, - CreateCloudLinkedOrcaProfileArgs, - CreateCloudLinkedOrcaProfileResult, - ConnectCurrentOrcaProfileResult, - FindOrcaProfileProjectsByPathArgs, - FindOrcaProfileProjectsByPathResult, - OrcaProfileListResult, - OrcaProfileAuthStatus, - RefreshCurrentOrcaProfileAuthResult, - SelectOrcaProfileOrgArgs, - SelectOrcaProfileOrgResult, - SignOutCurrentOrcaProfileResult, - SwitchOrcaProfileArgs, - SwitchOrcaProfileResult, - TransferOrcaProfileProjectArgs, - TransferOrcaProfileProjectResult, - OrcaProfileOrgInviteRevokeArgs, - OrcaProfileOrgMemberChangeRoleArgs, - OrcaProfileOrgMemberInviteArgs, - OrcaProfileOrgMemberMutationResult, - OrcaProfileOrgMemberRemoveArgs, - OrcaProfileOrgMembersListArgs, - OrcaProfileOrgMembersListResult -} from '../shared/orca-profiles' -import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry' -import type { TaskSourceContext } from '../shared/task-source-context' -import type { LinearIssueAttributeFilter } from '../shared/linear-issue-attribute-filter' -import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' -import type { StartupCommandDelivery } from '../shared/codex-startup-delivery' -import type { - AgentProviderSessionMetadata, - SleepingAgentLaunchConfig -} from '../shared/agent-session-resume' -import type { - PluginPanelActionOutcome, - PluginPanelEntry -} from '../shared/plugins/plugin-panel-bridge' -import type { PluginConsentRequest } from '../shared/plugins/plugin-consent-request' -import type { PluginLanguagePackRegistration } from '../shared/plugins/plugin-language-pack-artifact' -import type { PluginChangeEvent } from '../shared/plugins/plugin-change-event' -import type { PluginManifest } from '../shared/plugins/plugin-manifest' -import type { PluginMarketplaceGitSource } from '../shared/plugins/plugin-marketplace' -import type { - LocalhostWorktreeLabelResult, - LocalhostWorktreeLabelRoute -} from '../shared/localhost-worktree-labels' -import type { - FolderWorkspacePathStatus, - FolderWorkspacePathStatusRequest -} from '../shared/folder-workspace-path-status' -import type { - BaseRefDefaultResult, - BaseRefSearchResult, - BrowserCookieImportResult, - BrowserCertificateFailure, - BrowserCertificateProceedResult, - BrowserLoadError, - BrowserSessionProfile, - BrowserSessionProfileCreateOptions, - BrowserSessionProfileScope, - BrowserSessionProfileSource, - BrowserViewportOverride, - ClaudeRateLimitAccountsState, - ClassifiedError, - CodexRateLimitAccountsState, - AdoptProvisionedRootArgs, - CreateWorktreeArgs, - CreateWorktreeResult, - CustomPet, - DetectedWorktreeListResult, - DirEntry, - FilesystemPathFlavor, - ForceDeleteWorktreeBranchResult, - FsChangedPayload, - GhosttyImportPreview, - GlobalSettings, - GitBranchCompareResult, - GitCommitCompareResult, - GitConflictOperation, - GitDiffResult, - GitForkSyncExpectedUpstream, - GitForkSyncResult, - GitPushTarget, - GitStagingArea, - GitStatusResult, - GitUpstreamStatus, - GitHubAssignableUser, - GitHubCreateIssueResult, - GitHubPRFile, - GitHubPRFileContents, - GitHubPrStartPoint, - GitHubPRReviewCommentInput, - GitHubCommentResult, - GitHubOwnerRepo, - GitHubWorkItem, - GitHubWorkItemDetails, - GitHubViewer, - GitLabAssignableUser, - GitLabAuthDiagnostic, - GitLabCommentResult, - GitLabDiscussionResolveResult, - GitLabIssueInfo, - GitLabIssueUpdate, - GitLabJobTraceResult, - GitLabMRInlineCommentInput, - GitLabMRReviewersUpdateResult, - GitLabMRUpdate, - GitLabProjectRef, - GitLabRetryJobResult, - GitLabTodo, - GitLabViewer, - GitLabWorkItem, - GitLabWorkItemDetails, - GetGitLabRateLimitResult, - ListMergeRequestsResult, - MRInfo, - MRListState, - ListWorkItemsResult, - IssueInfo, - JiraComment, - JiraConnectionStatus, - JiraCreateField, - JiraCreateIssueArgs, - JiraIssue, - JiraIssueFilter, - JiraIssueType, - JiraProjectStatusOrder, - JiraIssueUpdate, - JiraPriority, - JiraProject, - JiraSiteSelection, - JiraTransition, - JiraUser, - JiraViewer, - LinearViewer, - LinearCollectionResult, - LinearConnectionStatus, - LinearCustomViewModel, - LinearCustomViewSummary, - LinearWorkspaceSelection, - LinearIssue, - LinearIssueUpdate, - LinearComment, - LinearWorkflowState, - LinearLabel, - LinearMember, - LinearProjectDetail, - LinearProjectSummary, - LinearTeam, - MarkdownDocument, - FloatingTerminalCwdRequest, - GitHubIssueUpdate, - GitHubReactionContent, - GitHubPRRefreshCandidate, - GitHubPRRefreshEnqueueResult, - GitHubPRRefreshEvent, - GitHubPRRefreshReason, - GetRateLimitResult, - NotificationDispatchRequest, - NotificationDispatchResult, - NotificationDeliveryProbeResult, - NotificationDismissResult, - NotificationPermissionStatusResult, - NotificationSoundResult, - OnboardingState, - OrcaHooks, - PathSource, - PersistedUIState, - PRCheckDetail, - PRCheckRunDetails, - PRComment, - PRInfo, - PRRefreshOutcome, - Project, - ProjectUpdateArgs, - Repo, - ProjectGroup, - ProjectHostSetup, - ProjectHostSetupCreateArgs, - ProjectHostSetupCreateResult, - ProjectHostSetupDeleteArgs, - ProjectHostSetupDeleteResult, - ProjectHostSetupExistingFolderArgs, - ProjectHostSetupResult, - ProjectHostSetupUpdateArgs, - ProjectHostSetupUpdateResult, - FolderWorkspace, - ProjectGroupImportResult, - ProjectGroupImportMode, - ShellHydrationFailureReason, - SparsePreset, - SearchOptions, - NestedRepoScanResult, - SearchResult, - StatsSummary, - MemorySnapshot, - TuiAgent, - ReleaseBuildListResult, - UpdateCheckOptions, - UpdateStatus, - Worktree, - WorktreeBaseStatusEvent, - WorktreeHeadIdentity, - WorktreeLineage, - WorkspaceLineage, - WorktreeMeta, - WorktreeRemoteBranchConflictEvent, - RemoveWorktreeResult, - WorktreeDefaultTabsLaunch, - WorktreeSetupLaunch, - WorktreeStartupLaunch, - WorkspaceSessionPatch, - WorkspaceSessionState, - LinuxPackageInstallInstructions -} from '../shared/types' -import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker' -import type { PtyListedSession } from '../shared/pty-listed-session' -import type { - PtyRendererDeliveryHealthReply, - PtyRendererDeliveryStateReport -} from '../shared/pty-renderer-delivery-health' -import type { TerminalViewAttributes } from '../shared/terminal-view-attributes' -import type { PtyMainDeliveryDiagnostics } from '../shared/pty-delivery-diagnostics' -import type { - WarpThemeImportPreview, - WarpThemeImportSource -} from '../shared/terminal-custom-themes' - -import type { SetupScriptImportCandidate } from '../shared/setup-script-imports' -import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' -import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' -import type { EphemeralVmRecipeDoctorResult } from '../shared/ephemeral-vm-recipes' -import type { EphemeralVmRecipeResultWarning } from '../shared/ephemeral-vm-recipe-diagnostics' -import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes' -import type { RuntimeAccessGrant } from '../shared/runtime-access-grants' -import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope' -import type { ExecutionHostId } from '../shared/execution-host' -import type { FeatureInteractionId } from '../shared/feature-interactions' -import type { - AddIssueCommentBySlugArgs, - ClearProjectItemFieldArgs, - DeleteIssueCommentBySlugArgs, - GetProjectViewTableArgs, - GetProjectViewTableResult, - GitHubProjectCommentMutationResult, - GitHubProjectMutationResult, - ListAccessibleProjectsArgs, - ListAccessibleProjectsResult, - ListAssignableUsersBySlugArgs, - ListAssignableUsersBySlugResult, - ListIssueTypesBySlugArgs, - ListIssueTypesBySlugResult, - ListLabelsBySlugArgs, - ListLabelsBySlugResult, - ListProjectViewsArgs, - ListProjectViewsResult, - ProjectWorkItemDetailsBySlugArgs, - ProjectWorkItemDetailsBySlugResult, - ResolveProjectRefArgs, - ResolveProjectRefResult, - UpdateIssueBySlugArgs, - UpdateIssueCommentBySlugArgs, - UpdateIssueTypeBySlugArgs, - UpdatePullRequestBySlugArgs, - UpdateProjectItemFieldArgs -} from '../shared/github-project-types' -import type { - RichMarkdownContextMenuCommandPayload, - RichMarkdownContextMenuTableTarget -} from '../shared/rich-markdown-context-menu' -import type { - BrowserSetGrabModeArgs, - BrowserSetGrabModeResult, - BrowserAwaitGrabSelectionArgs, - BrowserGrabResult, - BrowserCancelGrabArgs, - BrowserCaptureSelectionScreenshotArgs, - BrowserCaptureSelectionScreenshotResult, - BrowserExtractHoverArgs, - BrowserExtractHoverResult -} from '../shared/browser-grab-types' -import type { - BrowserContextMenuDismissedEvent, - BrowserContextMenuRequestedEvent, - BrowserDownloadFinishedEvent, - BrowserDownloadProgressEvent, - BrowserDownloadRequestedEvent, - BrowserPermissionDeniedEvent, - BrowserPopupEvent -} from '../shared/browser-guest-events' import type { ElectronAPI } from '@electron-toolkit/preload' -import type { BrowserSetAnnotationViewportBridgeArgs } from '../shared/browser-annotation-viewport-bridge' -import type { CliInstallStatus } from '../shared/cli-install-types' -import type { E2EConfig } from '../shared/e2e-config' -import type { AgentHookInstallStatus } from '../shared/agent-hook-types' -import type { CodexConfigSyncStatus } from '../shared/codex-config-sync-types' import type { - AgentStatusClearIpcPayload, - AgentStatusIpcPayload, - MigrationUnsupportedPtyEntry -} from '../shared/agent-status-types' -import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent' -import type { AgentQuestionAnsweredInferenceRequest } from '../shared/agent-question-answered-intent' -import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' + ClaudeAccountsApi, + CodexAccountsApi, + CodexConfigSyncApi, + GrokAccountsApi, + MinimaxCredentialsApi +} from './api/agent-account-api' +import type { AgentHooksApi, HooksApi } from './api/agent-hook-api' +import type { SkillsApi } from './api/agent-skill-api' +import type { AgentAwakeApi, AgentStatusApi, AgentTrustApi } from './api/agent-status-api' import type { - RuntimeBrowserDriverState, - RuntimeMobileSessionTabMove, - RuntimeStatus, - RuntimeSyncWindowGraphResult, - RuntimeSyncWindowGraph, - RuntimeTerminalCreateRequestPayload, - RuntimeTerminalDriverState, - RuntimeTerminalPresentation -} from '../shared/runtime-types' + ClaudeUsageApi, + CodexUsageApi, + OpenCodeUsageApi, + RateLimitsApi +} from './api/agent-usage-api' +import type { AiVaultApi } from './api/ai-vault-api' +import type { AppApi, E2EApi, PlatformApi } from './api/app-api' +import type { AutomationsApi } from './api/automation-api' +import type { BrowserApi } from './api/browser-api' +import type { CliApi } from './api/cli-install-api' +import type { CrashReportsApi, FeedbackApi } from './api/crash-report-api' +import type { DashboardApi, TerminalPreviewApi } from './api/dashboard-api' +import type { EmulatorApi } from './api/emulator-api' +import type { EphemeralVmApi } from './api/ephemeral-vm-api' +import type { ExportApi, FilesystemApi } from './api/filesystem-api' +import type { GitInspectionApi } from './api/git-inspection-api' +import type { GitOperationApi } from './api/git-operation-api' +import type { GithubPullRequestApi } from './api/github-pull-request-api' +import type { GithubWorkItemApi } from './api/github-work-item-api' +import type { GitLabApi } from './api/gitlab-api' +import type { BitbucketApi, HostedReviewApi } from './api/hosted-review-api' +import type { JiraApi } from './api/jira-api' +import type { LinearApi } from './api/linear-api' +import type { MobileApi } from './api/mobile-api' +import type { NativeChatApi } from './api/native-chat-api' +import type { OnboardingApi, StarNagApi } from './api/onboarding-api' +import type { OrcaProfileApi } from './api/orca-profile-api' import type { - CommitMessageAgentCapability, - CommitMessageModelCapability -} from '../shared/commit-message-agent-spec' -import type { ResolvedSourceControlAiGenerationParams } from '../shared/source-control-ai' -import type { SourceControlAiSettings } from '../shared/source-control-ai-types' -import type { - ShellOpenExternalEditorRequest, - ShellOpenExternalEditorResult, - ShellOpenLocalPathResult -} from '../shared/shell-open-types' -import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills' -import type { - SkillFreshnessInventory, - SkillUpdateRun, - SkillUpdateStartResult -} from '../shared/skill-freshness' -import type { - CrashReportBreadcrumbData, - CrashReportCopyDiagnosticsArgs, - CrashReportRecord, - CrashReportSubmitArgs, - CrashReportSubmitResult, - ReactErrorBoundaryReportArgs, - ReactErrorBoundaryReportResult -} from '../shared/crash-reporting' -import type { RendererHeapStatistics } from '../shared/renderer-heap-statistics' + ComputerUsePermissionsApi, + DeveloperPermissionsApi, + MacosTccPromptsApi, + NotificationsApi +} from './api/os-permission-api' +import type { PetApi } from './api/pet-api' +import type { PluginsApi } from './api/plugin-host-api' +import type { PreflightApi } from './api/preflight-api' +import type { PtyApi } from './api/pty-api' +import type { ProjectGroupsApi, ProjectsApi, RepositoryApi } from './api/repository-api' +import type { RuntimeApi } from './api/runtime-api' +import type { KeybindingsApi, SettingsApi } from './api/settings-api' +import type { ShellApi } from './api/shell-api' +import type { SpeechApi } from './api/speech-api' +import type { SshApi } from './api/ssh-api' +import type { DiagnosticsApi, MemoryApi, StatsApi, TelemetryApi } from './api/telemetry-api' +import type { UiCommandEventApi } from './api/ui-command-event-api' +import type { UiWindowApi } from './api/ui-window-api' +import type { UpdaterApi } from './api/updater-api' +import type { WorkspaceCleanupApi, WorkspaceSpaceApi } from './api/workspace-cleanup-api' +import type { LocalhostWorktreeLabelsApi, WorkspacePortsApi } from './api/workspace-port-api' +import type { WorkspaceSessionApi } from './api/workspace-session-api' +import type { FolderWorkspacesApi, SparsePresetsApi, WorktreeApi } from './api/worktree-api' -export type { - ShellOpenExternalEditorRequest, - ShellOpenExternalEditorResult, - ShellOpenLocalPathResult -} from '../shared/shell-open-types' - -type RuntimeEnvironmentSubscriptionHandle = { - unsubscribe: () => void - sendBinary: (bytes: Uint8Array) => void -} -import type { - RuntimeMobileMarkdownRequest, - RuntimeMobileMarkdownResponse -} from '../shared/mobile-markdown-document' -import type { - DeveloperPermissionId, - DeveloperPermissionRequestResult, - DeveloperPermissionState, - LocalNetworkConnectionTestResult -} from '../shared/developer-permissions-types' -import type { - ComputerUsePermissionId, - ComputerUsePermissionResetResult, - ComputerUsePermissionSetupResult, - ComputerUsePermissionStatusResult -} from '../shared/computer-use-permissions-types' -import type { ClaudeUsageBreakdownKind, ClaudeUsageSnapshot } from '../shared/claude-usage-types' -import type { - CodexRateLimitResetResult, - GrokAccountStatus, - RateLimitRuntimeTarget, - RateLimitState -} from '../shared/rate-limit-types' -import type { - SpeechErrorEvent, - SpeechLifecycleEvent, - SpeechModelManifest, - SpeechModelState, - SpeechTranscriptEvent -} from '../shared/speech-types' -import type { - WorkspaceSpaceAnalyzeResult, - WorkspaceSpaceScanProgress -} from '../shared/workspace-space-types' -import type { - WorkspacePortAdvertisedUrlChangedEvent, - WorkspacePortKillRequest, - WorkspacePortKillResult, - WorkspacePortScanRequest, - WorkspacePortScanResult -} from '../shared/workspace-ports' -import type { GhAuthDiagnostic } from '../shared/github-auth-types' -import type { CodexUsageBreakdownKind, CodexUsageSnapshot } from '../shared/codex-usage-types' -import type { - OpenCodeUsageBreakdownKind, - OpenCodeUsageSnapshot -} from '../shared/opencode-usage-types' -import type { - AiVaultDeleteSessionArgs, - AiVaultDeleteSessionResult -} from '../shared/ai-vault-session-deletion' -import type { - AiVaultFirstUserPromptArgs, - AiVaultFirstUserPromptResult, - AiVaultListArgs, - AiVaultListResult, - AiVaultSubagentListArgs, - AiVaultSubagentListResult -} from '../shared/ai-vault-types' -import type { - AiVaultSessionTitlesArgs, - AiVaultSessionTitlesResult -} from '../shared/ai-vault-session-title' -import type { - AiVaultPrepareSessionResumeArgs, - AiVaultPrepareSessionResumeResult -} from '../shared/ai-vault-resume-preparation' -import type { - AgentType, - NativeChatMessage, - NativeChatTurnLifecycle -} from '../shared/native-chat-types' -import type { TelemetryConsentState } from '../shared/telemetry-consent-types' -import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' -import type { AppStarSource } from '../shared/gh-star-source' -import type { - RemoteWorkspaceChangedEvent, - RemoteWorkspaceConnectedClient, - RemoteWorkspacePatchResult, - RemoteWorkspaceSnapshot -} from '../shared/remote-workspace-types' -import type { - Automation, - AutomationCreateInput, - AutomationDispatchRequest, - AutomationDispatchResult, - ExternalAutomationCreateInput, - ExternalAutomationActionInput, - ExternalAutomationManager, - ExternalAutomationRunsInput, - ExternalAutomationRunsPage, - ExternalAutomationUpdateInput, - AutomationRun, - AutomationPrecheckResult, - AutomationUpdateInput -} from '../shared/automations-types' -import type { - WorkspaceCleanupDismissArgs, - WorkspaceCleanupLocalProcessArgs, - WorkspaceCleanupLocalProcessResult, - WorkspaceCleanupScanArgs, - WorkspaceCleanupScanProgress, - WorkspaceCleanupScanResult -} from '../shared/workspace-cleanup' -import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings' - -type GitLabRepoSelectorArgs = { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null -} - -type GitHubRepoSelectorArgs = { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null -} - -export type BrowserApi = { - registerGuest: (args: { - browserPageId: string - workspaceId: string - worktreeId: string - sessionProfileId?: string | null - webContentsId: number - }) => Promise - isGuestRegistered: (args: { browserPageId: string; webContentsId: number }) => Promise - repairGuestRegistration: (args: { - browserPageId: string - workspaceId: string - worktreeId: string - sessionProfileId?: string | null - webContentsId: number - }) => Promise - unregisterGuest: (args: { browserPageId: string }) => Promise - openDevTools: (args: { browserPageId: string }) => Promise - setViewportOverride: (args: { - browserPageId: string - override: BrowserViewportOverride | null - }) => Promise - setAnnotationViewportBridge: (args: BrowserSetAnnotationViewportBridgeArgs) => Promise - onGuestLoadFailed: ( - callback: (args: { browserPageId: string; loadError: BrowserLoadError }) => void - ) => () => void - onCertificateFailureChanged: ( - callback: (event: { browserPageId: string; failure: BrowserCertificateFailure | null }) => void - ) => () => void - proceedCertificate: (args: { - browserPageId: string - challengeId: string - }) => Promise - onPermissionDenied: (callback: (event: BrowserPermissionDeniedEvent) => void) => () => void - onPopup: (callback: (event: BrowserPopupEvent) => void) => () => void - onDownloadRequested: (callback: (event: BrowserDownloadRequestedEvent) => void) => () => void - onDownloadProgress: (callback: (event: BrowserDownloadProgressEvent) => void) => () => void - onDownloadFinished: (callback: (event: BrowserDownloadFinishedEvent) => void) => () => void - onContextMenuRequested: ( - callback: (event: BrowserContextMenuRequestedEvent) => void - ) => () => void - onContextMenuDismissed: ( - callback: (event: BrowserContextMenuDismissedEvent) => void - ) => () => void - onNavigationUpdate: ( - callback: (event: { browserPageId: string; url: string; title: string }) => void - ) => () => void - onActivateView: ( - callback: (data: { worktreeId?: string; browserPageId?: string }) => void - ) => () => void - onPaneFocus: ( - callback: (data: { worktreeId: string | null; browserPageId: string }) => void - ) => () => void - onOpenLinkInOrcaTab: ( - callback: (event: { browserPageId: string; url: string }) => void - ) => () => void - cancelDownload: (args: { downloadId: string }) => Promise - setGrabMode: (args: BrowserSetGrabModeArgs) => Promise - awaitGrabSelection: (args: BrowserAwaitGrabSelectionArgs) => Promise - cancelGrab: (args: BrowserCancelGrabArgs) => Promise - captureSelectionScreenshot: ( - args: BrowserCaptureSelectionScreenshotArgs - ) => Promise - extractHoverPayload: (args: BrowserExtractHoverArgs) => Promise - onGrabModeToggle: (callback: (browserPageId: string) => void) => () => void - onGrabActionShortcut: ( - callback: (args: { browserPageId: string; key: 'c' | 's' }) => void - ) => () => void - sessionListProfiles: () => Promise - sessionCreateProfile: ( - args: { - scope: BrowserSessionProfileScope - label: string - } & BrowserSessionProfileCreateOptions - ) => Promise - sessionDeleteProfile: (args: { profileId: string }) => Promise - sessionImportCookies: (args: { profileId: string }) => Promise - sessionResolvePartition: (args: { profileId: string | null }) => Promise - sessionDetectBrowsers: () => Promise - sessionImportFromBrowser: (args: { - profileId: string - browserFamily: string - browserProfile?: string - }) => Promise - sessionClearDefaultCookies: () => Promise - notifyActiveTabChanged: (args: { browserPageId: string }) => Promise -} - -export type EmulatorApi = { - onPaneFocus: (callback: (data: { worktreeId: string }) => void) => () => void - onAutoAttach: ( - callback: (data: { - worktreeId: string - info: { deviceUdid: string; streamUrl: string; wsUrl: string; axUrl?: string } - }) => void - ) => () => void - startFrameStream: (args: { streamUrl: string; streamKey?: string }) => Promise<{ - streamId: string - }> - stopFrameStream: (args: { streamId: string }) => Promise - onFrameStreamFrame: ( - callback: (data: { streamId: string; bytes: ArrayBuffer }) => void - ) => () => void - onFrameStreamError: ( - callback: (data: { streamId: string; message: string }) => void - ) => () => void - startVideoStream: (args: { deviceId: string; streamId: string }) => Promise<{ streamId: string }> - stopVideoStream: (args: { streamId: string }) => Promise - onVideoStreamMeta: ( - callback: (data: { - streamId: string - deviceId: string - meta: { codecId: string; width: number; height: number } - }) => void - ) => () => void - onVideoStreamFrame: ( - callback: (data: { - streamId: string - deviceId: string - config: boolean - keyFrame: boolean - bytes: ArrayBuffer - }) => void - ) => () => void -} - -export type DetectedBrowserProfileInfo = { - name: string - directory: string -} - -export type DetectedBrowserInfo = { - family: BrowserSessionProfileSource['browserFamily'] - label: string - profiles: DetectedBrowserProfileInfo[] - selectedProfile: string -} - -export type PreflightStatus = { - git: { installed: boolean } - gh: { installed: boolean; authenticated: boolean } - /** Optional — older preload payloads predating GitLab support omit it; consumers gate on `glab?.installed`. */ - glab?: { installed: boolean; authenticated: boolean } - bitbucket?: { configured: boolean; authenticated: boolean; account: string | null } - azureDevOps?: { - configured: boolean - authenticated: boolean - account: string | null - baseUrl: string | null - tokenConfigured: boolean - } - gitea?: { - configured: boolean - authenticated: boolean - account: string | null - baseUrl: string | null - tokenConfigured: boolean - } -} - -export type RefreshAgentsResult = { - agents: string[] - addedPathSegments: string[] - shellHydrationOk: boolean - /** Drives agent_picks `on_path:false` triage (dashboard 1562016). `'shell_hydrate'` = detection saw the user's - * full shell PATH; `'sync_seed_only'` = hydration failed and detection ran against the `patchPackagedProcessPath` seed list. */ - pathSource: PathSource - /** Classified hydration outcome: `'none'` on success, else a failure mode when `shellHydrationOk` is false. */ - pathFailureReason: ShellHydrationFailureReason -} - -export type PreflightRuntimeContext = { - wslDistro?: string | null - wslDefault?: boolean - projectRuntime?: ProjectExecutionRuntimeResolution -} - -export type PreflightApi = { - check: (args?: PreflightRuntimeContext & { force?: boolean }) => Promise - detectAgents: (args?: PreflightRuntimeContext) => Promise - refreshAgents: (args?: PreflightRuntimeContext) => Promise - detectRemoteAgents: (args: { connectionId: string }) => Promise - detectRemoteWindowsTerminalCapabilities: (args: { connectionId: string }) => Promise<{ - wslAvailable: boolean - wslDistros: string[] - pwshAvailable: boolean - gitBashAvailable: boolean - hostPlatform: NodeJS.Platform | null - }> -} - -// Mirror of daemon's `DaemonSessionInfo` (src/main/daemon/types.ts); not imported — preload can't depend on main-only protocol types. -export type PtyManagementSession = { - sessionId: string - state: 'created' | 'spawning' | 'running' | 'exiting' | 'exited' - shellState: 'pending' | 'ready' | 'timed_out' | 'unsupported' - isAlive: boolean - pid: number | null - cwd: string | null - cols: number - rows: number - createdAt: number - protocolVersion: number -} - -// 'severed': macOS can no longer attribute daemon terminals to Orca, so Accessibility/ -// Automation grants silently stop applying until the daemon is restarted (STA-3491). -export type PtyManagementMacTccAttributionHealth = 'intact' | 'severed' | 'unknown' - -export type PtyManagementApi = { - // `degraded`: daemon is alive but can't spawn fresh PTYs, so new terminals run locally without daemon persistence. - listSessions: () => Promise<{ sessions: PtyManagementSession[]; degraded: boolean }> - killAll: () => Promise<{ - killedCount: number - remainingCount: number - killedSessionIds?: string[] - }> - killOne: (args: { sessionId: string }) => Promise<{ success: boolean }> - restart: () => Promise<{ success: boolean }> - macTccAttribution: () => Promise<{ health: PtyManagementMacTccAttributionHealth }> -} - -export type ExportApi = { - htmlToPdf: (args: { - html: string - title: string - }) => Promise< - { success: true; filePath: string } | { success: false; cancelled?: boolean; error?: string } - > -} - -export type StatsApi = { - getSummary: () => Promise -} - -// Diagnostics IPC payloads; mirror the runtime types in `src/main/observability/{index,bundle}.ts`. -export type DiagnosticsStatusPayload = { - readonly localFileEnabled: boolean - readonly bundleEnabled: boolean - readonly traceFilePath: string - readonly traceFamilySize: number - readonly disabledReason?: - | 'do_not_track' - | 'orca_telemetry_disabled' - | 'orca_diagnostics_disabled' - | 'ci' -} -export type DiagnosticsBundlePayload = { - readonly bundleSubmissionId: string - readonly bytes: number - readonly spanCount: number -} -export type DiagnosticsUploadPayload = - | { - readonly ticketId: string - } - | { - readonly canceled: true - } - -export type MemoryApi = { - getSnapshot: () => Promise -} - -type UsageProviderSnapshot = { - scanState: unknown - summary: { scope: string; range: string } - daily: unknown[] - modelBreakdown: unknown[] - recentSessions: unknown[] -} - -type UsageQueryArgs = Pick< - Snapshot['summary'], - 'scope' | 'range' -> - -type UsageProviderApi = { - getScanState: () => Promise - setEnabled: (args: { enabled: boolean }) => Promise - refresh: (args?: { force?: boolean }) => Promise - getSnapshot: (args: UsageQueryArgs & { limit?: number }) => Promise - getSummary: (args: UsageQueryArgs) => Promise - getDaily: (args: UsageQueryArgs) => Promise - getBreakdown: ( - args: UsageQueryArgs & { kind: BreakdownKind } - ) => Promise - getRecentSessions: ( - args: UsageQueryArgs & { limit?: number } - ) => Promise -} - -export type ClaudeUsageApi = UsageProviderApi - -export type CodexUsageApi = UsageProviderApi - -export type OpenCodeUsageApi = UsageProviderApi - -export type AiVaultApi = { - listSessions: (args?: AiVaultListArgs) => Promise - resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => Promise - cancelListSessions: (args: { requestToken: string }) => Promise - prepareSessionResume: ( - args: AiVaultPrepareSessionResumeArgs - ) => Promise - /** Lists the Task subagent transcripts of one session, on demand. */ - listSubagentSessions: (args: AiVaultSubagentListArgs) => Promise - /** Full first user prompt for copy/reuse (re-parses one transcript). */ - getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs) => Promise - /** Moves a deletable session's transcript to the OS trash; local sessions only. */ - deleteSession: (args: AiVaultDeleteSessionArgs) => Promise - /** Fires when any app window regains OS focus; returns an unsubscribe. */ - onWindowFocused: (callback: () => void) => () => void -} - -// notFound marks a not-yet-on-disk miss (retry-worthy) vs a real read/parse error (#8401). -export type NativeChatReadSessionResult = - | { - messages: NativeChatMessage[] - lifecycle?: NativeChatTurnLifecycle - } - | { error: string; notFound?: true } - -/** Messages appended to a live-tailed transcript since the previous emit. */ -export type NativeChatAppendedMessages = NativeChatMessage[] - -export type NativeChatSubscriptionFrame = - | { - type: 'snapshot' - messages: NativeChatMessage[] - hasMore: boolean - error?: string - lifecycle?: NativeChatTurnLifecycle - } - | { - type: 'replacement' - messages: NativeChatMessage[] - hasMore: boolean - lifecycle?: NativeChatTurnLifecycle - } - | { - type: 'appended' - messages: NativeChatMessage[] - lifecycle?: NativeChatTurnLifecycle - } - -/** Wire payload for the `nativeChat:appended` push channel. */ -export type NativeChatAppendedPayload = { - subscriptionId: string - frame: NativeChatSubscriptionFrame -} - -export type NativeChatSubscribeArgs = { - /** Unique per-caller id, echoed on every append so multiple live panes in - * one renderer don't cross-talk. */ - subscriptionId: string - agent: AgentType - sessionId: string - /** Authoritative transcript path from the agent hook (providerSession). */ - transcriptPath?: string - /** First snapshot size; later readSession calls grow this for pagination. */ - limit?: number -} - -export type NativeChatApi = { - /** Read the on-disk transcript for an agent + session id, windowed to the most recent `limit` - * turns. `transcriptPath` is the hook-reported authoritative path, preferred over the id glob. */ - readSession: ( - agent: AgentType, - sessionId: string, - limit?: number, - transcriptPath?: string - ) => Promise - /** Live-tail a transcript. The first frame is a bounded race-safe snapshot; - * later frames contain only newly appended messages. */ - subscribe: ( - args: NativeChatSubscribeArgs, - onFrame: (frame: NativeChatSubscriptionFrame) => void - ) => () => void -} - -export type AppApi = { - /** Returns the app identity currently exposed to native chrome and the titlebar. */ - getIdentity: () => Promise - /** Returns a URL base for feature-wall assets. In dev this is Vite /@fs; - * in packaged builds this is file:// resources. Renderer appends filenames. */ - getFeatureWallAssetBaseUrl: () => Promise - /** Relaunches the app (app.relaunch() + app.exit(0)) for settings that need a full restart to apply. */ - relaunch: () => Promise - /** Restarts Orca through the normal quit pipeline so daemon-backed terminal - * sessions survive and can reattach after the new process starts. */ - restart: () => Promise - /** Reloads the current app renderer through main so expected renderer - * teardown can be classified before Electron emits process-gone events. */ - reload: () => Promise - /** Stages the renderer's final state synchronously before unload. */ - stageBeforeUnloadSync: (args: { - sessions: { state: WorkspaceSessionState; hostId?: ExecutionHostId }[] - ui: Partial - }) => void - /** Resolves once the last staged checkpoint is durably written; rejects if that - * write failed, so a reload/restart can abort instead of losing the snapshot. */ - awaitBeforeUnloadCheckpoint: () => Promise - /** Resolves when the daemon PTY provider and hook receiver have either - * started or failed open for the first BrowserWindow. */ - awaitFirstWindowStartupServices: () => Promise - /** Reconciles legacy worker authority around persisted terminal reconnect. */ - recoverLegacyWorkerTerminalsForRendererStartup: () => Promise - /** Emits a startup benchmark marker when ORCA_STARTUP_DIAGNOSTICS is enabled. */ - startupDiagnostic: (event: string, details?: Record) => Promise - /** macOS active input mode, or layout ID when no IME is selected (e.g. `com.apple.keylayout.PolishPro`). - * Distinguishes CJK IMEs and Option-layer-composing layouts that look like US QWERTY (issue #1205). - * Returns null on non-Darwin or when the defaults read fails. */ - getKeyboardInputSourceId: () => Promise - /** Updates the macOS Dock unread badge. No-op on Windows/Linux. */ - setUnreadDockBadgeCount: (count: number) => Promise - /** Resolves the launch directory for global Floating Terminal tabs. */ - getFloatingTerminalCwd: (args?: FloatingTerminalCwdRequest) => Promise - /** Resolves Orca's app-owned directory for auto-created Floating Workspace - * markdown notes. */ - getFloatingMarkdownDirectory: () => Promise - /** Opens a native picker for markdown documents, rooted in the floating - * workspace, and authorizes the selected file for editor reads/writes. */ - pickFloatingMarkdownDocument: () => Promise - /** Opens a native directory picker and authorizes the selected directory - * for Floating Workspace markdown file creation. */ - pickFloatingWorkspaceDirectory: () => Promise - /** Persists flag-gated terminal render evidence under app-owned userData. */ - writeTerminalRenderDesyncEvidence: ( - args: WriteTerminalRenderDesyncEvidenceArgs - ) => Promise -} - -/** Panel contribution as surfaced by the main-process plugin service. */ -export type PluginHostPanel = { - id: string - title: string - /** Lucide icon name declared in the plugin manifest. */ - icon?: string - tabKey: `plugin:${string}` -} - -/** `pending` = awaiting (re-)consent; `idle` = enabled, worker not running - * (lazy); `restarting` = waiting for supervised backoff; `errored` = crashed past the restart budget or failed to start; - * `invalid` = unreadable manifest. */ -export type PluginHostStatus = - | 'running' - | 'restarting' - | 'idle' - | 'pending' - | 'disabled' - | 'errored' - | 'invalid' - -/** Wire shape of plugins:list — must stay assignable from the main-process - * projection in src/main/plugins/plugin-list-projection.ts. */ -export type PluginHostListEntry = { - pluginKey: string - consentFingerprint: string | null - name: string - version: string - publisher: string - description?: string - status: PluginHostStatus - needsReconsent: boolean - error?: string - isDev: boolean - official: boolean - bundled: boolean - capabilities: { kind: string; description: string }[] - panels: PluginHostPanel[] - commands: { - id: string - title: string - context: 'global' | 'worktree' - handler: { type: 'built-in'; action: string } | { type: 'worker' } - keybindings: { key: string; when: 'global' | 'worktree' }[] - }[] - hasWorker: boolean - vmRecipes?: { - id: string - name: string - description?: string - commands: { - phase: 'create' | 'suspend' | 'resume' | 'destroy' - command: string - }[] - }[] - restarts: number - blockedByKillList?: { reason: string; advisoryUrl?: string } - source?: { - kind: 'local-path' | 'git' | 'marketplace' | 'bundled' - reference: string - resolvedCommit: string | null - contentHash: string - marketplace?: { reference: string; resolvedCommit: string } - } -} - -export type PluginHostLogLine = { ts: number; level: 'info' | 'warn' | 'error'; line: string } - -export type PluginHostInstallSource = - | { kind: 'local-path'; path: string } - | { kind: 'git'; url: string; ref: string } - -export type PluginHostInstallResult = - | { - ok: true - pluginKey: string - version: string - contentHash: string - consentFingerprint: string - resolvedCommit: string | null - } - | { ok: false; error: string } - -export type PluginMarketplaceHostSourceState = { - id: string - source: PluginMarketplaceGitSource - addedAt: number - marketplace: { - name: string - owner: string - resolvedCommit: string - fetchedAt: number - } | null - stale: boolean - official: boolean - error?: string -} - -export type PluginMarketplaceHostListing = { - marketplaceSourceId: string - marketplaceName: string - marketplaceOwner: string - marketplaceCommit: string - pluginKey: string - source: PluginMarketplaceGitSource - description?: string - categories: string[] - official: boolean - bundled: boolean - blockedByKillList?: { reason: string; advisoryUrl?: string } -} - -export type PluginMarketplaceHostInstallPreview = { - marketplaceSourceId: string - marketplaceName: string - marketplaceOwner: string - marketplaceCommit: string - pluginKey: string - source: PluginMarketplaceGitSource - resolvedCommit: string - contentHash: string - consentFingerprint: string - manifest: PluginManifest - official: boolean - bundled: boolean - blockedByKillList?: { reason: string; advisoryUrl?: string } -} +// Flattens contracts that share one PreloadApi key: an intersection is not type-identical to the flat shape. +type Merged = { [K in keyof T]: T[K] } export type PreloadApi = { app: AppApi - orcaProfiles: { - list: () => Promise - authStatus: () => Promise - createLocal: (args?: CreateLocalOrcaProfileArgs) => Promise - createCloudLinked: ( - args?: CreateCloudLinkedOrcaProfileArgs - ) => Promise - switchProfile: (args: SwitchOrcaProfileArgs) => Promise - transferProject: ( - args: TransferOrcaProfileProjectArgs - ) => Promise - findProjectProfiles: ( - args: FindOrcaProfileProjectsByPathArgs - ) => Promise - connectCurrent: () => Promise - refreshAuth: () => Promise - signOutCurrent: () => Promise - selectOrg: (args: SelectOrcaProfileOrgArgs) => Promise - orgMembersList: ( - args: OrcaProfileOrgMembersListArgs - ) => Promise - orgMemberInvite: ( - args: OrcaProfileOrgMemberInviteArgs - ) => Promise - orgInviteRevoke: ( - args: OrcaProfileOrgInviteRevokeArgs - ) => Promise - orgMemberChangeRole: ( - args: OrcaProfileOrgMemberChangeRoleArgs - ) => Promise - orgMemberRemove: ( - args: OrcaProfileOrgMemberRemoveArgs - ) => Promise - } - platform: { - get: () => { - platform: NodeJS.Platform - osRelease: string - arch: string - /** Login shell or ComSpec when available. */ - shell: string - displayServer: 'wayland' | 'x11' | null - } - } - e2e: { - getConfig: () => E2EConfig - } - repos: { - list: () => Promise - listForExecutionHost?: (args: ListReposForExecutionHostArgs) => Promise - // Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`. - add: (args: { - path: string - kind?: 'git' | 'folder' - }) => Promise<{ repo: Repo } | { error: string }> - remove: (args: { repoId: string }) => Promise - // Forget a project on one execution host only, leaving the same repo id on other hosts intact. - removeForHost: (args: { repoId: string; hostId: string }) => Promise - reorder: (args: { orderedIds: string[] }) => Promise<{ status: 'applied' | 'rejected' }> - reorderForHost: (args: { - orderedIds: string[] - hostId: string - }) => Promise<{ status: 'applied' | 'rejected' }> - update: (args: { - repoId: string - hostId?: ExecutionHostId - updates: Partial< - Pick< - Repo, - | 'displayName' - | 'badgeColor' - | 'repoIcon' - | 'upstream' - | 'hookSettings' - | 'worktreeBaseRef' - | 'worktreeBasePath' - | 'kind' - | 'issueSourcePreference' - | 'externalWorktreeVisibilityPromptDismissedAt' - | 'externalWorktreeInboxBaselinePaths' - | 'importedExternalWorktreePaths' - | 'customWorktreeVisibilitySources' - | 'worktreeVisibilitySourcePreferences' - | 'projectGroupId' - | 'projectGroupOrder' - | 'forkSyncMode' - > - > & { - externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null - agentWorktreeVisibility?: Repo['agentWorktreeVisibility'] | null - sourceControlAi?: Repo['sourceControlAi'] | null - externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null - } - }) => Promise - pickFolder: () => Promise - pickFolders: () => Promise - pickDirectory: () => Promise - clone: (args: { url: string; destination: string }) => Promise - cloneRemote: (args: { connectionId: string; url: string; destination: string }) => Promise - createRemote: (args: { - connectionId: string - parentPath: string - name: string - kind: 'git' | 'folder' - }) => Promise<{ repo: Repo } | { error: string }> - cloneAbort: () => Promise - // Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`. - addRemote: (args: { - connectionId: string - remotePath: string - displayName?: string - kind?: 'git' | 'folder' - }) => Promise<{ repo: Repo } | { error: string }> - // Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`. - create: (args: { - parentPath: string - name: string - kind: 'git' | 'folder' - }) => Promise<{ repo: Repo } | { error: string }> - isGitAvailable: () => Promise - getDefaultCreateProjectParent: () => Promise - onCloneProgress: (callback: (data: { phase: string; percent: number }) => void) => () => void - getGitUsername: (args: { repoId: string }) => Promise - getBaseRefDefault: (args: { - repoId: string - hostId?: ExecutionHostId - }) => Promise - searchBaseRefs: (args: { - repoId: string - query: string - limit?: number - hostId?: ExecutionHostId - }) => Promise - searchBaseRefDetails: (args: { - repoId: string - query: string - limit?: number - hostId?: ExecutionHostId - }) => Promise - onChanged: (callback: () => void) => () => void - } - projects: { - list: () => Promise - update: (args: ProjectUpdateArgs) => Promise - listHostSetups: () => Promise - createHostSetup: (args: ProjectHostSetupCreateArgs) => Promise - setupExistingFolder: ( - args: ProjectHostSetupExistingFolderArgs - ) => Promise - updateHostSetup: (args: ProjectHostSetupUpdateArgs) => Promise - deleteHostSetup: (args: ProjectHostSetupDeleteArgs) => Promise - } - projectGroups: { - list: () => Promise - create: (args: { - name: string - parentPath?: string | null - connectionId?: string | null - parentGroupId?: string | null - createdFrom?: ProjectGroup['createdFrom'] - }) => Promise - update: (args: { - groupId: string - updates: Partial> - }) => Promise - delete: (args: { groupId: string }) => Promise - moveProject: (args: { - projectId: string - groupId: string | null - order?: number - }) => Promise - scanNested: (args: { - path: string - connectionId?: string - scanId?: string - options?: Record - }) => Promise - cancelNestedScan: (args: { scanId: string }) => Promise - onNestedScanProgress: ( - callback: (data: { scanId: string; scan: NestedRepoScanResult }) => void - ) => () => void - importNested: (args: { - parentPath: string - groupName: string - projectPaths: string[] - connectionId?: string - scanId?: string - mode: ProjectGroupImportMode - }) => Promise - } - folderWorkspaces: { - list: () => Promise - getPathStatus: (args: FolderWorkspacePathStatusRequest) => Promise - create: (args: { - projectGroupId: string - name?: string - folderPath?: string | null - connectionId?: string | null - linkedTask?: FolderWorkspace['linkedTask'] - createdWithAgent?: FolderWorkspace['createdWithAgent'] - pendingFirstAgentMessageRename?: boolean - }) => Promise - update: (args: { - folderWorkspaceId: string - updates: Partial< - Pick< - FolderWorkspace, - | 'name' - | 'folderPath' - | 'linkedTask' - | 'comment' - | 'isArchived' - | 'isUnread' - | 'isPinned' - | 'sortOrder' - | 'manualOrder' - | 'workspaceStatus' - | 'createdWithAgent' - | 'pendingFirstAgentMessageRename' - | 'firstAgentMessageRenameError' - | 'lastActivityAt' - | 'diffComments' - > - > - }) => Promise - delete: (args: { folderWorkspaceId: string }) => Promise - } - sparsePresets: { - list: (args: { repoId: string }) => Promise - save: (args: { - repoId: string - id?: string - name: string - directories: string[] - }) => Promise - remove: (args: { repoId: string; presetId: string }) => Promise - onChanged: (callback: (data: { repoId: string }) => void) => () => void - } - worktrees: { - list: (args: { repoId: string }) => Promise - listDetected: { - ( - args: ListDetectedWorktreesArgs - ): Promise - (args: LegacyDetectedWorktreeRequest): Promise - } - listKnownForExecutionHost?: ( - args: ListKnownWorktreesForExecutionHostArgs - ) => Promise - /** Retires the persisted metadata an authoritative scan proved gone, so it stops feeding the read above. */ - forgetRemovedForExecutionHost?: ( - args: ForgetRemovedWorktreesForExecutionHostArgs - ) => Promise - cancelListDetected?: (args: { providerRequestId: ProviderRequestId }) => Promise - listAll: () => Promise - create: (args: CreateWorktreeArgs) => Promise - adoptProvisionedRoot: (args: AdoptProvisionedRootArgs) => Promise - /** Two-phase progress for a background `create`, correlated by `creationId`. The remote/runtime - * create path emits nothing, so the surface falls back to an indeterminate spinner. */ - onCreateProgress: ( - callback: (data: { creationId?: string; phase: 'fetching' | 'creating' }) => void - ) => () => void - prefetchCreateBase: (args: { repoId: string; baseBranch?: string }) => Promise - resolvePrBase: (args: { - repoId: string - prNumber: number - headRefName?: string - baseRefName?: string - isCrossRepository?: boolean - }) => Promise - /** GitLab parallel of resolvePrBase. For same-project MRs returns - * `/`; for fork MRs fetches - * refs/merge-requests//head and returns the SHA. */ - resolveMrBase: (args: { - repoId: string - mrIid: number - sourceBranch?: string - targetBranch?: string - isCrossRepository?: boolean - }) => Promise< - | { baseBranch: string; compareBaseRef?: string; pushTarget?: GitPushTarget } - | { error: string } - > - remove: (args: { - worktreeId: string - hostId?: ExecutionHostId - force?: boolean - // Why (#11960): distinct from `force`, which the plain Delete confirmation - // already sets to skip the dirty-file prompt. Only an explicit Force Delete - // may waive the proof that every PTY stopped. - allowUnverifiedPtyStop?: boolean - skipArchive?: boolean - }) => Promise - // Forget a workspace from Orca only (no remote Git/FS work) — for workspaces pinned to a removed/disconnected SSH host. - forgetLocal: (args: { - worktreeId: string - hostId?: ExecutionHostId - }) => Promise - forceDeletePreservedBranch: (args: { - worktreeId: string - branchName: string - expectedHead: string - hostId?: ExecutionHostId - }) => Promise - updateMeta: (args: { worktreeId: string; updates: Partial }) => Promise - listLineage: () => Promise<{ - lineage: Record - workspaceLineage?: Record - }> - listLineageForHost?: (args: ListDesktopLineageForHostArgs) => Promise - updateLineage: (args: { - worktreeId: string - parentWorktreeId?: string - noParent?: boolean - }) => Promise - persistSortOrder: (args: { orderedIds: string[] }) => Promise - /** Full CLI output of the last branch auto-rename generation failure, held - * in main memory only — null after a restart or once the failure clears. */ - getBranchRenameFailureOutput: (args: { worktreeId: string }) => Promise - onChanged: (callback: (data: { repoId: string }) => void) => () => void - onGitStatusMetadataChanged: (callback: (data: { repoId: string }) => void) => () => void - onHeadIdentitiesChanged: ( - callback: (data: { repoId: string; identities: WorktreeHeadIdentity[] }) => void - ) => () => void - onBaseStatus: (callback: (data: WorktreeBaseStatusEvent) => void) => () => void - onRemoteBranchConflict: ( - callback: (data: WorktreeRemoteBranchConflictEvent) => void - ) => () => void - } - workspaceCleanup: { - scan: ( - args?: WorkspaceCleanupScanArgs, - onProgress?: (progress: WorkspaceCleanupScanProgress) => void - ) => Promise - dismiss: (args: WorkspaceCleanupDismissArgs) => Promise - clearDismissals: () => Promise - hasKillableLocalProcesses: ( - args: WorkspaceCleanupLocalProcessArgs - ) => Promise - } - workspaceSpace: { - analyze: () => Promise - cancel: () => Promise - onProgress: (callback: (progress: WorkspaceSpaceScanProgress) => void) => () => void - } - workspacePorts: { - scan: (args: WorkspacePortScanRequest) => Promise - kill: (args: WorkspacePortKillRequest) => Promise - onAdvertisedUrlChanged: ( - callback: (event: WorkspacePortAdvertisedUrlChangedEvent) => void - ) => () => void - } - pty: { - spawn: (opts: { - cols: number - rows: number - cwd?: string - cwdFallback?: 'worktree' - env?: Record - envToDelete?: string[] - command?: string - commandDelivery?: 'renderer' | 'provider' - launchConfig?: SleepingAgentLaunchConfig - resumeProviderSession?: AgentProviderSessionMetadata - launchToken?: string - launchAgent?: TuiAgent - startupCommandDelivery?: StartupCommandDelivery - connectionId?: string | null - worktreeId?: string - sessionId?: string - // Why: lets a single tab open in a different shell than the user's default. - shellOverride?: string - projectRuntime?: ProjectExecutionRuntimeResolution - terminalColorQueryReplies?: { foreground?: string; background?: string } - // Why: mark the PTY hidden before its first byte so the delivery gate owns spawn-time queries (terminal-query-authority.md §races). - initiallyHidden?: boolean - // Why: main sync-flushes the (worktreeId,tabId,leafId→ptyId) binding before pty:spawn returns to close a SIGKILL race (INVESTIGATION.md). - tabId?: string - leafId?: string - // Why: main fires `agent_started` only on spawn success, so launch metadata rides this field (telemetry-plan.md §Agent launch semantics). - telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } - }) => Promise<{ - id: string - launchAgent?: TuiAgent - launchConfig?: SleepingAgentLaunchConfig - snapshot?: string - snapshotCols?: number - snapshotRows?: number - snapshotPrefixAnsi?: string - snapshotFrameAnsi?: string - snapshotFrameRestoreAnsi?: string - snapshotKittyKeyboardFlags?: number - snapshotSeq?: number - isReattach?: boolean - isAlternateScreen?: boolean - replay?: string - sessionExpired?: boolean - coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } - startupCwdFallback?: { kind: 'worktree'; cwd: string } - agentResumeUnavailable?: true - }> - write: (id: string, data: string) => void - writeAccepted: (id: string, data: string) => Promise - onWriteUnavailable?: (callback: (payload: { id: string }) => void) => () => void - resize: (id: string, cols: number, rows: number) => void - claimViewport: (id: string, cols: number, rows: number) => void - reportGeometry: (id: string, cols: number, rows: number) => void - signal: (id: string, signal: string) => void - clearBuffer: (id: string) => void - kill: (id: string, opts?: { keepHistory?: boolean }) => Promise - ackColdRestore: (id: string) => void - ackData: (id: string, charCount: number, processedChars?: number) => void - onDeliveryResyncRequest: (callback: (payload: { requestId: number }) => void) => () => void - respondDeliveryResync: (payload: { - requestId: number - processedCharsByPty: Record - }) => void - /** Renderer-initiated delivery health/heal lane over invoke — reaches main - * even when every main→renderer push channel is dead (field wedge). */ - reportRendererDeliveryState: ( - report: PtyRendererDeliveryStateReport - ) => Promise - /** Live pty:data listener count on the preload emitter (sync) — heal-time - * discriminator between a detached listener and a dead channel. */ - getPtyDataListenerCount: () => number - /** One-shot signal that this page's pty:data dispatcher is registered, so - * main can release sends held during the load/reload boot window. */ - rendererDispatcherReady: () => void - setActiveRendererPty: (id: string, active: boolean) => void - setRendererPtyVisible: (id: string, visible: boolean) => void - /** Hidden-delivery gate (Phase 4): hidden=true lets main drop renderer - * byte delivery after model ingestion; reveal restores from snapshots. */ - setHiddenRendererPty: (id: string, hidden: boolean) => void - /** Ref-counted-on-the-renderer delivery-interest signal that suppresses - * the hidden-delivery gate while any raw-byte consumer is registered. */ - setPtyDeliveryInterest: (id: string, interested: boolean) => void - /** View-attribute bridge (Phase 5 slice 2): app-global composed terminal - * appearance push backing main's hidden-PTY OSC/DSR color replies. */ - publishTerminalViewAttributes: (attributes: TerminalViewAttributes) => void - hasChildProcesses: (id: string) => Promise - getForegroundProcess: (id: string) => Promise - inspectProcess: (id: string) => Promise<{ - foregroundProcess: string | null - hasChildProcesses: boolean - unavailable?: true - }> - confirmForegroundProcess: (id: string) => Promise - getCwd: (id: string) => Promise - getSize: (id: string) => Promise<{ cols: number; rows: number } | null> - listSessions: () => Promise - getAuthoritativeBufferSnapshotCapabilities?: ( - ids: string[] - ) => Promise<{ id: string; authoritative: boolean | null }[]> - hasPty: (id: string) => Promise - getMainBufferSnapshot: ( - id: string, - opts?: { scrollbackRows?: number } - ) => Promise<{ - data: string - frameRestoreAnsi?: string - cols: number - rows: number - cwd?: string | null - seq?: number - /** Start of main's pending renderer-delivery queue at snapshot time - * (equals `seq` when empty) — bounds the renderer's post-restore - * duplicate window. */ - pendingDeliveryStartSeq?: number - source?: 'headless' | 'renderer' - alternateScreen?: boolean - /** Authoritative normal buffer paired with an alternate-screen frame. */ - scrollbackAnsi?: string - /** Trailing incomplete escape the emulator ingested; the restorer must - * write it after its post-replay resets, last before live chunks. */ - pendingEscapeTailAnsi?: string - /** Effective kitty flags the snapshot owner proved at `seq`. Absent means - * unknown; consumers must not turn that into a known `0`. */ - kittyKeyboardFlags?: number - } | null> - getRendererDeliveryDebugSnapshot: () => Promise<{ - pendingPtyCount: number - pendingChars: number - maxPendingCharsByPty: number - rendererInFlightPtyCount: number - rendererInFlightChars: number - maxRendererInFlightCharsByPty: number - activeRendererPtyCount: number - flushScheduled: boolean - peakPendingChars: number - peakMaxPendingCharsByPty: number - peakRendererInFlightChars: number - peakMaxRendererInFlightCharsByPty: number - ackGatedFlushSkipCount: number - hiddenDeliveryGatedPtyCount: number - hiddenDeliveryGatedVisiblePtyCount: number - hiddenDeliveryGatedActivePtyCount: number - deliveryInterestPtyCount: number - hiddenDeliveryDroppedChars: number - hiddenDeliveryDroppedChunks: number - pendingDroppedChars: number - diagnostics: PtyMainDeliveryDiagnostics - rendererLifecycleResetCount: number - lastLifecycleResetClearedChars: number - rendererPtyDispatcherReady: boolean - rendererDispatcherReadyForcedCount: number - }> - resetRendererDeliveryDebug: () => Promise - onData: ( - callback: (data: { - id: string - data: string - seq?: number - rawLength?: number - transformed?: boolean - background?: boolean - droppedOutput?: boolean - }) => void - ) => () => void - onReplay: (callback: (data: { id: string; data: string }) => void) => () => void - /** Out-of-band main→renderer signal that renderer-bound bytes were - * dropped (hidden-delivery gate / pending cap); the pane restores from - * the model snapshot. Never delivered in-band on pty:data. */ - onModelRestoreNeeded: (callback: (event: PtyModelRestoreNeededEvent) => void) => () => void - /** Batched derived side-effect facts for PTYs whose bytes transit local - * main. */ - onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void) => () => void - /** Title-only replay snapshot for (re)attach; attention facts never replay. */ - getSideEffectSnapshot: (id: string) => Promise - onExit: ( - callback: (data: { id: string; code: number; preserveRendererBinding?: boolean }) => void - ) => () => void - onSpawned: (callback: (data: { id: string }) => void) => () => void - onSerializeBufferRequest: ( - callback: (data: { - requestId: string - ptyId: string - opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } - }) => void - ) => () => void - onClearBufferRequest: (callback: (data: { ptyId: string }) => void) => () => void - sendSerializedBuffer: ( - requestId: string, - snapshot: { - data: string - cols: number - rows: number - seq?: number - lastTitle?: string - kittyKeyboardFlags?: number - } | null - ) => void - declarePendingPaneSerializer: (paneKey: string) => Promise - settlePaneSerializer: (paneKey: string, gen: number) => Promise - clearPendingPaneSerializer: (paneKey: string, gen: number) => Promise - reportRendererSerializerReady?: (ptyId: string) => Promise - management: PtyManagementApi - } - feedback: { - submit: (args: { - feedback: string - submitAnonymously?: boolean - githubLogin: string | null - githubEmail: string | null - images?: { contentType: string; data: Uint8Array }[] - }) => Promise< - { ok: true; imagesDelivered?: boolean } | { ok: false; status: number | null; error: string } - > - } - crashReports: { - getLatestPending: () => Promise - getLatestReport: () => Promise - dismiss: (args: { reportId: string }) => Promise - recordRendererError: ( - args: ReactErrorBoundaryReportArgs - ) => Promise - recordBreadcrumb: (args: { name: string; data?: CrashReportBreadcrumbData }) => void - submit: (args: CrashReportSubmitArgs) => Promise - copyLatestDiagnostics: ( - args?: CrashReportCopyDiagnosticsArgs - ) => Promise<{ ok: true } | { ok: false; error: string }> - /** Exact V8/Blink heap sizes; null when the runtime withholds them. */ - readHeapStatistics: () => RendererHeapStatistics | null - } + orcaProfiles: OrcaProfileApi + platform: PlatformApi + e2e: E2EApi + repos: RepositoryApi + projects: ProjectsApi + projectGroups: ProjectGroupsApi + folderWorkspaces: FolderWorkspacesApi + sparsePresets: SparsePresetsApi + worktrees: WorktreeApi + workspaceCleanup: WorkspaceCleanupApi + workspaceSpace: WorkspaceSpaceApi + workspacePorts: WorkspacePortsApi + pty: PtyApi + feedback: FeedbackApi + crashReports: CrashReportsApi export: ExportApi - gh: { - viewer: () => Promise - repoSlug: (args: { - repoPath: string - repoId?: string - }) => Promise<{ owner: string; repo: string; host?: string } | null> - repoUpstream: (args: { - repoPath: string - repoId?: string - }) => Promise<{ owner: string; repo: string; host?: string } | null> - prForBranch: (args: { - repoPath: string - repoId?: string - branch: string - linkedPRNumber?: number | null - fallbackPRNumber?: number | null - acceptMergedFallbackPR?: boolean - currentHeadOid?: string | null - }) => Promise - refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }) => Promise - enqueuePRRefresh: (args: { - candidate: GitHubPRRefreshCandidate - reason: GitHubPRRefreshReason - priority?: number - }) => Promise - reportVisiblePRRefreshCandidates: (args: { - candidates: GitHubPRRefreshCandidate[] - generation: number - }) => Promise - onPRRefreshEvent: (callback: (event: GitHubPRRefreshEvent) => void) => () => void - issue: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - number: number - }) => Promise - workItem: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - number: number - type?: 'issue' | 'pr' - }) => Promise | null> - workItemByOwnerRepo: (args: { - repoPath: string - repoId?: string - owner: string - repo: string - host?: string - number: number - type: 'issue' | 'pr' - }) => Promise | null> - workItemDetails: ( - args: GitHubRepoSelectorArgs & { - number: number - type?: 'issue' | 'pr' - } - ) => Promise - notifyWorkItemMutated: (args: { - repoPath: string - repoId?: string - type: 'issue' | 'pr' - number: number - }) => Promise - prFileContents: ( - args: GitHubRepoSelectorArgs & { - prNumber: number - prRepo?: GitHubOwnerRepo | null - path: string - oldPath?: string - status: GitHubPRFile['status'] - headSha: string - baseSha: string - } - ) => Promise - listIssues: (args: { - repoPath: string - repoId?: string - limit?: number - }) => Promise - createIssue: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - title: string - body: string - labels?: string[] - assignees?: string[] - }) => Promise - countWorkItems: (args: { repoPath: string; repoId?: string; query?: string }) => Promise - listWorkItems: (args: { - repoPath: string - repoId?: string - limit?: number - query?: string - page?: number - noCache?: boolean - }) => Promise>> - prChecks: ( - args: GitHubRepoSelectorArgs & { - prNumber: number - headSha?: string - prRepo?: GitHubOwnerRepo | null - noCache?: boolean - } - ) => Promise - prCheckDetails: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - checkRunId?: number - workflowRunId?: number - checkName?: string - url?: string | null - prRepo?: GitHubOwnerRepo | null - }) => Promise - rerunPRChecks: ( - args: GitHubRepoSelectorArgs & { - prNumber: number - headSha?: string - failedOnly?: boolean - prRepo?: GitHubOwnerRepo | null - } - ) => Promise<{ ok: true; count: number } | { ok: false; error: string }> - prComments: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - noCache?: boolean - }) => Promise - setPRCommentReaction: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - reactionSubjectId: string - content: GitHubReactionContent - reacted: boolean - prRepo?: GitHubOwnerRepo | null - }) => Promise - resolveReviewThread: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - threadId: string - resolve: boolean - prRepo?: GitHubOwnerRepo | null - }) => Promise - setPRFileViewed: ( - args: GitHubRepoSelectorArgs & { - prNumber: number - prRepo?: GitHubOwnerRepo | null - pullRequestId: string - path: string - viewed: boolean - } - ) => Promise - updatePRTitle: (args: { - repoPath: string - repoId?: string - prNumber: number - title: string - prRepo?: GitHubOwnerRepo | null - }) => Promise - mergePR: ( - args: GitHubRepoSelectorArgs & { - prNumber: number - method?: 'merge' | 'squash' | 'rebase' - prRepo?: GitHubOwnerRepo | null - } - ) => Promise<{ ok: true } | { ok: false; error: string }> - setPRAutoMerge: ( - args: GitHubRepoSelectorArgs & { - prNumber: number - enabled: boolean - method?: 'merge' | 'squash' | 'rebase' - prRepo?: GitHubOwnerRepo | null - } - ) => Promise<{ ok: true } | { ok: false; error: string }> - updatePRState: ( - args: GitHubRepoSelectorArgs & { - prNumber: number - updates: { state: 'open' | 'closed' } - prRepo?: GitHubOwnerRepo | null - } - ) => Promise<{ ok: true } | { ok: false; error: string }> - requestPRReviewers: ( - args: GitHubRepoSelectorArgs & { - prNumber: number - reviewers: string[] - prRepo?: GitHubOwnerRepo | null - } - ) => Promise<{ ok: true } | { ok: false; error: string }> - removePRReviewers: ( - args: GitHubRepoSelectorArgs & { - prNumber: number - reviewers: string[] - prRepo?: GitHubOwnerRepo | null - } - ) => Promise<{ ok: true } | { ok: false; error: string }> - updateIssue: ( - args: GitHubRepoSelectorArgs & { - number: number - updates: GitHubIssueUpdate - } - ) => Promise<{ ok: true } | { ok: false; error: string }> - addIssueComment: ( - args: GitHubRepoSelectorArgs & { - number: number - body: string - /** Why: scopes the cross-window cache invalidation so a PR and issue sharing the same number don't evict each other. */ - type?: 'issue' | 'pr' - prRepo?: GitHubOwnerRepo | null - } - ) => Promise - addPRReviewCommentReply: ( - args: GitHubRepoSelectorArgs & { - prNumber: number - commentId: number - body: string - threadId?: string - path?: string - line?: number - prRepo?: GitHubOwnerRepo | null - } - ) => Promise - addPRReviewComment: ( - args: GitHubPRReviewCommentInput & { - repoId?: string - sourceContext?: TaskSourceContext | null - } - ) => Promise - listLabels: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - }) => Promise - listAssignableUsers: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - }) => Promise - /** Subscribe to local-mutation broadcasts so the work-item-drawer cache can invalidate across windows. Returns an unsubscribe. */ - onWorkItemMutated: ( - callback: (payload: { - repoPath: string - repoId?: string - type: 'issue' | 'pr' - number: number - }) => void - ) => () => void - checkOrcaStarred: () => Promise - starOrca: (source: AppStarSource) => Promise - /** - * GitHub API rate-limit snapshot. Does NOT consume quota (the - * `rate_limit` endpoint is exempt). Cached 30s server-side — pass - * `force: true` to bust after a known-expensive op. - */ - rateLimit: (args?: { force?: boolean }) => Promise - /** Explains scope_missing ProjectV2 failures — notably a shell `GITHUB_TOKEN` shadowing the keyring credential, where `gh auth refresh` is a no-op. */ - diagnoseAuth: (args?: { host?: string }) => Promise - // ── ProjectV2 (GitHub Projects) ───────────────────────────────── - listAccessibleProjects: ( - args?: ListAccessibleProjectsArgs - ) => Promise - resolveProjectRef: (args: ResolveProjectRefArgs) => Promise - listProjectViews: (args: ListProjectViewsArgs) => Promise - getProjectViewTable: (args: GetProjectViewTableArgs) => Promise - projectWorkItemDetailsBySlug: ( - args: ProjectWorkItemDetailsBySlugArgs - ) => Promise - updateProjectItemField: ( - args: UpdateProjectItemFieldArgs - ) => Promise - clearProjectItemField: (args: ClearProjectItemFieldArgs) => Promise - updateIssueBySlug: (args: UpdateIssueBySlugArgs) => Promise - updatePullRequestBySlug: ( - args: UpdatePullRequestBySlugArgs - ) => Promise - addIssueCommentBySlug: ( - args: AddIssueCommentBySlugArgs - ) => Promise - updateIssueCommentBySlug: ( - args: UpdateIssueCommentBySlugArgs - ) => Promise - deleteIssueCommentBySlug: ( - args: DeleteIssueCommentBySlugArgs - ) => Promise - listLabelsBySlug: (args: ListLabelsBySlugArgs) => Promise - listAssignableUsersBySlug: ( - args: ListAssignableUsersBySlugArgs - ) => Promise - listIssueTypesBySlug: (args: ListIssueTypesBySlugArgs) => Promise - updateIssueTypeBySlug: (args: UpdateIssueTypeBySlugArgs) => Promise - } - hostedReview: { - forBranch: (args: HostedReviewForBranchArgs) => Promise - getCreationEligibility: ( - args: HostedReviewCreationEligibilityArgs - ) => Promise - create: (args: CreateHostedReviewArgs) => Promise - createStacked: (args: CreateStackedHostedReviewArgs) => Promise - } - // ── GitLab — parallel to gh, MR/issue surface only in v1 ──────── - // Shapes mirror gh.* except where GitLab's API differs (MR states, host-qualified project path, `glab api -i` paging). - gl: { - viewer: () => Promise - diagnoseAuth: () => Promise - rateLimit: (args?: { - force?: boolean - host?: string | null - }) => Promise - projectSlug: (args: GitLabRepoSelectorArgs) => Promise - mrForBranch: ( - args: GitLabRepoSelectorArgs & { - branch: string - linkedMRIid?: number | null - } - ) => Promise - mr: (args: GitLabRepoSelectorArgs & { iid: number }) => Promise - listMRs: ( - args: GitLabRepoSelectorArgs & { - state?: MRListState - page?: number - perPage?: number - query?: string - } - ) => Promise - /** Combined MR + issue list filtered by state. Issues are skipped - * when state is 'merged' (issues don't merge). */ - listWorkItems: ( - args: GitLabRepoSelectorArgs & { - state?: MRListState - page?: number - perPage?: number - query?: string - } - ) => Promise - issue: (args: GitLabRepoSelectorArgs & { number: number }) => Promise - listIssues: ( - args: GitLabRepoSelectorArgs & { - state?: 'opened' | 'closed' | 'all' - assignee?: string - limit?: number - } - ) => Promise<{ items: GitLabWorkItem[]; error?: ClassifiedError }> - createIssue: ( - args: GitLabRepoSelectorArgs & { - title: string - body: string - } - ) => Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> - updateIssue: ( - args: GitLabRepoSelectorArgs & { - number: number - updates: GitLabIssueUpdate - } - ) => Promise<{ ok: true } | { ok: false; error: string }> - addIssueComment: ( - args: GitLabRepoSelectorArgs & { - number: number - body: string - } - ) => Promise - listLabels: (args: GitLabRepoSelectorArgs) => Promise - listAssignableUsers: (args: GitLabRepoSelectorArgs) => Promise - /** Cross-project user-scoped todos (gitlab.com/dashboard/todos). */ - todos: (args: GitLabRepoSelectorArgs) => Promise - /** Aggregated dialog payload — body + discussions + pipeline jobs. */ - workItemDetails: ( - args: GitLabRepoSelectorArgs & { - iid: number - type: 'issue' | 'mr' - } - ) => Promise - closeMR: ( - args: GitLabRepoSelectorArgs & { - iid: number - } - ) => Promise<{ ok: true } | { ok: false; error: string }> - reopenMR: ( - args: GitLabRepoSelectorArgs & { - iid: number - } - ) => Promise<{ ok: true } | { ok: false; error: string }> - mergeMR: ( - args: GitLabRepoSelectorArgs & { - iid: number - method?: 'merge' | 'squash' | 'rebase' - } - ) => Promise<{ ok: true } | { ok: false; error: string }> - updateMR: ( - args: GitLabRepoSelectorArgs & { - iid: number - updates: GitLabMRUpdate - } - ) => Promise<{ ok: true } | { ok: false; error: string }> - updateMRReviewers: ( - args: GitLabRepoSelectorArgs & { - iid: number - reviewerIds: number[] - projectRef?: GitLabProjectRef | null - } - ) => Promise - addMRComment: ( - args: GitLabRepoSelectorArgs & { - iid: number - body: string - } - ) => Promise - addMRInlineComment: ( - args: GitLabRepoSelectorArgs & { - iid: number - input: GitLabMRInlineCommentInput - projectRef?: GitLabProjectRef | null - } - ) => Promise - resolveMRDiscussion: ( - args: GitLabRepoSelectorArgs & { - iid: number - discussionId: string - resolved: boolean - } - ) => Promise - jobTrace: ( - args: GitLabRepoSelectorArgs & { - jobId: number - projectRef?: GitLabProjectRef | null - /** Bound the trace in main to a readable excerpt (see gitLabJobTraceToLogExcerpt). */ - logExcerpt?: boolean - } - ) => Promise - retryJob: ( - args: GitLabRepoSelectorArgs & { - jobId: number - projectRef?: GitLabProjectRef | null - } - ) => Promise - workItemByPath: ( - args: GitLabRepoSelectorArgs & { - host: string - path: string - iid: number - type: 'issue' | 'mr' - } - ) => Promise | null> - } - bitbucket: { - connect: ( - args: BitbucketConnectArgs - ) => Promise<{ ok: true; account: string | null } | { ok: false; error: string }> - disconnect: () => Promise - status: () => Promise - } - linear: { - connect: (args: { - apiKey: string - }) => Promise<{ ok: true; viewer: LinearViewer } | { ok: false; error: string }> - disconnect: (args?: { workspaceId?: string }) => Promise - selectWorkspace: (args: { - workspaceId: LinearWorkspaceSelection - }) => Promise - status: () => Promise - testConnection: (args?: { - workspaceId?: string - }) => Promise<{ ok: true; viewer: LinearViewer } | { ok: false; error: string }> - searchIssues: (args: { - query: string - limit?: number - workspaceId?: LinearWorkspaceSelection - }) => Promise - listIssues: (args?: { - filter?: 'assigned' | 'created' | 'all' | 'completed' - limit?: number - workspaceId?: LinearWorkspaceSelection - attributeFilter?: LinearIssueAttributeFilter - }) => Promise> - createIssue: (args: { - teamId: string - title: string - description?: string - workspaceId?: string - parentIssueId?: string - projectId?: string | null - stateId?: string - priority?: number - assigneeId?: string | null - labelIds?: string[] - }) => Promise< - | { ok: true; id: string; identifier: string; title: string; url: string } - | { ok: false; error: string } - > - getIssue: (args: { id: string; workspaceId?: string }) => Promise - updateIssue: (args: { - id: string - updates: LinearIssueUpdate - workspaceId?: string - }) => Promise<{ ok: true } | { ok: false; error: string }> - addIssueComment: (args: { - issueId: string - body: string - workspaceId?: string - }) => Promise<{ ok: true; id: string } | { ok: false; error: string }> - issueComments: (args: { issueId: string; workspaceId?: string }) => Promise - listTeams: (args?: { workspaceId?: LinearWorkspaceSelection }) => Promise - listProjects: (args?: { - query?: string - limit?: number - workspaceId?: LinearWorkspaceSelection - force?: boolean - }) => Promise> - createProject: (args: { - name: string - description?: string - content?: string - teamIds: string[] - workspaceId?: string - leadId?: string | null - memberIds?: string[] - labelIds?: string[] - priority?: number - startDate?: string - targetDate?: string - }) => Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> - getProject: (args: { - id: string - workspaceId: string - force?: boolean - }) => Promise - listProjectIssues: (args: { - projectId: string - limit?: number - workspaceId: string - force?: boolean - }) => Promise> - listCustomViews: (args: { - model: LinearCustomViewModel - limit?: number - workspaceId?: LinearWorkspaceSelection - force?: boolean - }) => Promise> - getCustomView: (args: { - viewId: string - model: LinearCustomViewModel - workspaceId: string - force?: boolean - }) => Promise - listCustomViewIssues: (args: { - viewId: string - limit?: number - workspaceId: string - force?: boolean - }) => Promise> - listCustomViewProjects: (args: { - viewId: string - limit?: number - workspaceId: string - force?: boolean - }) => Promise> - teamStates: (args: { teamId: string; workspaceId?: string }) => Promise - teamLabels: (args: { teamId: string; workspaceId?: string }) => Promise - teamMembers: (args: { teamId: string; workspaceId?: string }) => Promise - } - jira: { - connect: (args: { - siteUrl: string - email: string - apiToken: string - authType?: 'cloud' | 'server' - }) => Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }> - disconnect: (args?: { siteId?: string }) => Promise - selectSite: (args: { siteId: JiraSiteSelection }) => Promise - status: () => Promise - readStatus: () => Promise - testConnection: (args?: { - siteId?: string - }) => Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }> - searchIssues: (args: { - jql: string - limit?: number - siteId?: JiraSiteSelection - requestId?: string - }) => Promise - cancelSearchIssues: (args: { requestId: string }) => Promise - listIssues: (args?: { - filter?: JiraIssueFilter - limit?: number - siteId?: JiraSiteSelection - }) => Promise - getIssue: (args: { key: string; siteId?: string }) => Promise - lookupIssueSummary: (args: { - key: string - siteId: string - requestId?: string - }) => Promise - cancelIssueSummary: (args: { requestId: string }) => Promise - createIssue: ( - args: JiraCreateIssueArgs - ) => Promise<{ ok: true; id: string; key: string; url: string } | { ok: false; error: string }> - updateIssue: (args: { - key: string - updates: JiraIssueUpdate - siteId?: string - }) => Promise<{ ok: true } | { ok: false; error: string }> - addIssueComment: (args: { - key: string - body: string - siteId?: string - }) => Promise<{ ok: true; id: string } | { ok: false; error: string }> - issueComments: (args: { key: string; siteId?: string }) => Promise - listProjects: (args?: { siteId?: JiraSiteSelection }) => Promise - listIssueTypes: (args: { projectIdOrKey: string; siteId?: string }) => Promise - listCreateFields: (args: { - projectIdOrKey: string - issueTypeId: string - siteId?: string - }) => Promise - listPriorities: (args?: { siteId?: string }) => Promise - listAssignableUsers: (args: { - key: string - query?: string - siteId?: string - }) => Promise - listTransitions: (args: { key: string; siteId?: string }) => Promise - getProjectStatusOrder: (args: { - projectKey: string - siteId?: string - }) => Promise - } - starNag: { - onShow: ( - callback: (payload?: { mode?: 'gh' | 'web'; surface?: 'card' | 'toast' }) => void - ) => () => void - onHide: (callback: () => void) => () => void - dismiss: () => Promise - later: () => Promise - complete: () => Promise - disable: () => Promise - openWeb: () => Promise - starOrca: () => Promise - forceShow: () => Promise - agentValueMoment: () => Promise<{ status: 'ready'; mode: 'gh' | 'web' } | { status: 'skipped' }> - showAgentValueMoment: () => Promise - onboardingCompleted: () => Promise - } - /** Fire-and-forget track. Loose IPC typing on purpose — the main-side validator enforces; - * renderer sites should import `track()` from lib/telemetry.ts, not reach here. */ - telemetryTrack: (name: string, props: Record) => Promise - /** Flip the persisted opt-in preference. Subject to a per-session - * consent-mutation rate limit on the main side (≤5/session). */ - telemetrySetOptIn: (optedIn: boolean) => Promise - /** Diagnostic file controls (telemetry-error-tracking.md §User controls). Main does the FS/network - * work and retains upload payloads so the renderer can't read or substitute arbitrary bytes. */ - diagnostics: { - getStatus: () => Promise - collectBundle: (lookbackMinutes?: number) => Promise - openBundlePreview: (bundleSubmissionId: string) => Promise - discardBundlePreview: (bundleSubmissionId: string) => Promise - uploadBundle: (bundleSubmissionId: string) => Promise - deleteBundle: (ticketId: string) => Promise - } - /** Read-only effective consent state (+ reason if disabled) — env vars are main-side state the renderer can't read directly. */ - telemetryGetConsentState: () => Promise - /** Banner ✕ — persist `optedIn = true` silently. Separate channel from `telemetrySetOptIn`, - * whose `via` derivation would wrongly fire `telemetry_opted_in`. Same per-session rate limit. */ - telemetryAcknowledgeBanner: () => Promise - settings: { - get: () => Promise - /** Synchronous persisted-settings read for startup decisions that can't wait for async hydration. Blocking IPC — call sparingly. */ - getSync: () => GlobalSettings | null - set: (args: Partial) => Promise - setActiveRuntimeEnvironmentPreference: (args: { - environmentId: string | null - }) => Promise - updatePRBotAuthorOverride: (args: { author: string; isBot: boolean }) => Promise - listFonts: () => Promise - previewGhosttyImport: () => Promise - previewWarpThemeImport: (source: WarpThemeImportSource) => Promise - /** Subscribe to out-of-band settings updates (e.g. View > Appearance toggles) to stay in sync with main. */ - onChanged: (callback: (updates: Partial) => void) => () => void - } - agentAwake: { - getStatus: () => Promise - onChanged: (callback: (status: ComputerAwakeStatus) => void) => () => void - } - localhostWorktreeLabels: { - register: (args: LocalhostWorktreeLabelRoute) => Promise - } - keybindings: { - get: () => Promise - ensureFile: () => Promise - setAction: (args: { - actionId: KeybindingActionId - bindings: string[] | null - }) => Promise - reload: () => Promise - openFile: () => Promise - revealFile: () => Promise - onChanged: (callback: (snapshot: KeybindingFileSnapshot) => void) => () => void - } - codexAccounts: { - list: () => Promise - add: (args?: { - runtime?: 'host' | 'wsl' - wslDistro?: string | null - }) => Promise - reauthenticate: (args: { accountId: string }) => Promise - remove: (args: { accountId: string }) => Promise - select: (args: { - accountId: string | null - runtime?: 'host' | 'wsl' - wslDistro?: string | null - }) => Promise - /** Live PTYs whose baked CODEX_HOME still points at a deselected account. */ - listStalePanes: (args: { ptyIds: string[] }) => Promise< - { - ptyId: string - launchAccountId: string | null - activeAccountId: string | null - /** Optional for compatibility with a pre-reason main process. */ - reason?: 'account-change' | 'home-route-change' - }[] - > - /** The selection lane each PTY launched from, keyed by pty id; unrecorded panes are absent. */ - listRecordedPaneLanes: (args: { ptyIds: string[] }) => Promise> - /** Drops launch records so a dismissed prompt stays dismissed across restarts. */ - forgetStalePanes: (args: { ptyIds: string[] }) => Promise - } - claudeAccounts: { - list: () => Promise - add: (args?: { - runtime?: 'host' | 'wsl' - wslDistro?: string | null - }) => Promise - cancelPendingLogin: () => Promise - reauthenticate: (args: { accountId: string }) => Promise - remove: (args: { accountId: string }) => Promise - select: (args: { - accountId: string | null - runtime?: 'host' | 'wsl' - wslDistro?: string | null - }) => Promise - } - cli: { - getInstallStatus: () => Promise - install: () => Promise - remove: () => Promise - getWslInstallStatus: (args?: { distro?: string | null }) => Promise - installWsl: (args?: { distro?: string | null }) => Promise - removeWsl: (args?: { distro?: string | null }) => Promise - } - codexConfigSync: { - status: () => Promise - } - agentHooks: { - claudeStatus: () => Promise - openClaudeStatus: () => Promise - codexStatus: () => Promise - geminiStatus: () => Promise - antigravityStatus: () => Promise - ampStatus: () => Promise - cursorStatus: () => Promise - droidStatus: () => Promise - commandCodeStatus: () => Promise - grokStatus: () => Promise - copilotStatus: () => Promise - hermesStatus: () => Promise - devinStatus: () => Promise - } - agentTrust: { - markTrusted: (args: { - preset: 'cursor' | 'copilot' | 'codex' - workspacePath: string - connectionId?: string - }) => Promise - } + gh: Merged + hostedReview: HostedReviewApi + gl: GitLabApi + bitbucket: BitbucketApi + linear: LinearApi + jira: JiraApi + starNag: StarNagApi + telemetryTrack: TelemetryApi['telemetryTrack'] + telemetrySetOptIn: TelemetryApi['telemetrySetOptIn'] + diagnostics: DiagnosticsApi + telemetryGetConsentState: TelemetryApi['telemetryGetConsentState'] + telemetryAcknowledgeBanner: TelemetryApi['telemetryAcknowledgeBanner'] + settings: SettingsApi + agentAwake: AgentAwakeApi + localhostWorktreeLabels: LocalhostWorktreeLabelsApi + keybindings: KeybindingsApi + codexAccounts: CodexAccountsApi + claudeAccounts: ClaudeAccountsApi + cli: CliApi + codexConfigSync: CodexConfigSyncApi + agentHooks: AgentHooksApi + agentTrust: AgentTrustApi preflight: PreflightApi - notifications: { - dispatch: (args: NotificationDispatchRequest) => Promise - dismiss: (ids: string[]) => Promise - openSystemSettings: () => Promise - getPermissionStatus: () => Promise - probeDelivery: (args?: { force?: boolean }) => Promise - playSound: (options?: { force?: boolean; volume?: number }) => Promise - } - onboarding: { - get: () => Promise - // Why: main merges the checklist field-by-field, so a partial checklist is fine. - update: ( - updates: Partial> & { - checklist?: Partial - } - ) => Promise - } - dashboard: { - openPopout: (view?: 'board' | 'map') => Promise - publishSnapshot: (snapshot: DashboardSnapshot) => Promise - getPopoutOpen: () => Promise - onPopoutOpenChanged: (callback: (open: boolean) => void) => () => void - onSnapshotRequested: (callback: () => void) => () => void - onRevealAgent: (callback: (args: DashboardRevealAgentArgs) => void) => () => void - onAckAgent: (callback: (paneKey: string) => void) => () => void - onSpawnAgent: (callback: (args: DashboardSpawnAgentArgs) => void) => () => void - onSleepWorkspace: (callback: (args: DashboardSleepWorkspaceArgs) => void) => () => void - requestSnapshot: () => Promise - onSnapshot: (callback: (snapshot: DashboardSnapshot) => void) => () => void - onViewRequested: (callback: (view: 'board' | 'map') => void) => () => void - revealAgent: (args: DashboardRevealAgentArgs) => Promise - ackAgent: (paneKey: string) => Promise - spawnAgent: (args: DashboardSpawnAgentArgs) => Promise - sleepWorkspace: (args: DashboardSleepWorkspaceArgs) => Promise - } - terminalPreview: { - connect: ( - ptyId: string, - opts?: { scrollbackRows?: number } - ) => Promise - input: (ptyId: string, data: string) => Promise - /** Claim the PTY grid for the preview dialog; resolves to the size actually in effect. */ - fit: ( - ptyId: string, - cols: number, - rows: number - ) => Promise<{ cols: number; rows: number } | null> - ack: (ptyId: string, bytes: number) => Promise - unsubscribe: (ptyId: string) => Promise - onData: (callback: (payload: TerminalPreviewDataPayload) => void) => () => void - } - macosTccPrompts: { - /** Fires once macOS has raised its Nth consent dialog naming Orca (#9756). */ - onThreshold: (callback: (payload: { promptCount: number }) => void) => () => void - consumePending: () => Promise<{ claimId: number; promptCount: number } | null> - acknowledgePending: (claimId: number) => Promise - releasePending: (claimId: number) => Promise - dismiss: () => Promise - } - developerPermissions: { - getStatus: () => Promise - request: (args: { id: DeveloperPermissionId }) => Promise - openSettings: (args: { id: DeveloperPermissionId }) => Promise - testLocalNetworkConnection: (args: { - host: string - port: number - }) => Promise - } - computerUsePermissions: { - getStatus: () => Promise - openSetup: (args?: { - id?: ComputerUsePermissionId - }) => Promise - reset: () => Promise - } - shell: { - openPath: (path: string) => Promise - openInFileManager: (path: string) => Promise - openInExternalEditor: ( - request: ShellOpenExternalEditorRequest - ) => Promise - openUrl: (url: string) => Promise - openFilePath: (path: string) => Promise - openFileUri: (uri: string) => Promise - pathExists: (path: string) => Promise - pickAttachment: () => Promise - pickImage: () => Promise - pickRepoIconImage: () => Promise<{ dataUrl: string; fileName: string } | null> - pickAudio: () => Promise - pickDirectory: (args: { defaultPath?: string }) => Promise - copyFile: (args: { srcPath: string; destPath: string }) => Promise - } - skills: { - discover: (target?: SkillDiscoveryTarget) => Promise - freshnessInventory: () => Promise - startUpdateRun: (names: string[]) => Promise - cancelUpdateRun: () => Promise - acknowledgeUpdateRun: () => Promise - getUpdateRun: () => Promise - onUpdateRun: (callback: (run: SkillUpdateRun) => void) => () => void - } - pet: { - import: () => Promise - importPetBundle: () => Promise - read: (id: string, fileName: string, kind?: 'image' | 'bundle') => Promise - delete: (id: string, fileName: string, kind?: 'image' | 'bundle') => Promise - } + notifications: NotificationsApi + onboarding: OnboardingApi + dashboard: DashboardApi + terminalPreview: TerminalPreviewApi + macosTccPrompts: MacosTccPromptsApi + developerPermissions: DeveloperPermissionsApi + computerUsePermissions: ComputerUsePermissionsApi + shell: ShellApi + skills: SkillsApi + pet: PetApi browser: BrowserApi emulator: EmulatorApi - hooks: { - check: (args: { repoId: string; hostId?: ExecutionHostId }) => Promise<{ - status?: 'ok' | 'error' - hasHooks: boolean - hooks: OrcaHooks | null - mayNeedUpdate: boolean - }> - inspectSetupScriptImports: (args: { - repoId: string - hostId?: ExecutionHostId - }) => Promise - createIssueCommandRunner: (args: { - repoId: string - worktreePath: string - command: string - }) => Promise - readIssueCommand: (args: { repoId: string; hostId?: ExecutionHostId }) => Promise<{ - status?: 'ok' | 'error' - localContent: string | null - sharedContent: string | null - effectiveContent: string | null - localFilePath: string - source: 'local' | 'shared' | 'none' - }> - writeIssueCommand: (args: { - repoId: string - content: string - hostId?: ExecutionHostId - }) => Promise - } - ephemeralVm: { - listRecipes: (args: { repoId: string }) => Promise<{ - status: 'ok' | 'error' - repoPath: string | null - recipes: OrcaHooks['environmentRecipes'] - diagnostics: NonNullable - message?: string - }> - listRecipeCatalog: () => Promise< - { - repoId: string - repoName: string - repoPath: string - recipes: NonNullable - diagnostics: NonNullable - }[] - > - doctor: (args: { repoId: string; recipeId: string }) => Promise - provision: (args: { - repoId: string - recipeId: string - workspaceName?: string - projectId?: string - workspaceId?: string - branch?: string - ref?: string - provisionId?: string - }) => Promise< - | { - ok: true - connectionType: 'orca-server' - runtime: EphemeralVmRuntimeRecord - environment: PublicKnownRuntimeEnvironment - stderr: string - warnings: EphemeralVmRecipeResultWarning[] - } - | { - ok: true - connectionType: 'ssh' - runtime: EphemeralVmRuntimeRecord - sshTargetId: string - stderr: string - warnings: EphemeralVmRecipeResultWarning[] - } - | { ok: false; error: string; stderr: string; stdout: string } - > - cancelProvision: (args: { provisionId: string }) => Promise<{ cancelled: boolean }> - onProvisionEvent: ( - callback: (event: { provisionId: string; stream: 'stdout' | 'stderr'; chunk: string }) => void - ) => () => void - listRuntimes: () => Promise - attachWorkspace: (args: { - runtimeId: string - workspaceId: string - }) => Promise - suspendWorkspace: (args: { workspaceId: string }) => Promise - resumeWorkspace: (args: { workspaceId: string }) => Promise - cleanup: (args: { runtimeId: string }) => Promise - getCleanupCommand: (args: { runtimeId: string }) => Promise<{ - runtimeId: string - command: string | null - payloadJson: string - cleanupDisabled: boolean - message?: string - }> - } - cache: { - getGitHub: () => Promise<{ - pr: Record - issue: Record - }> - setGitHub: (args: { - cache: { - pr: Record - issue: Record - } - }) => Promise - } - session: { - // hostId defaults to the 'local' partition on main, so omitting it stays backward-compatible. - get: (hostId?: ExecutionHostId) => Promise - set: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => Promise - patch: (args: WorkspaceSessionPatch, hostId?: ExecutionHostId) => Promise - flush: () => Promise - readTerminalScrollback: (args: { ref: string }) => string | null - setSync: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => void - } - remoteWorkspace: { - get: (args: { targetId: string }) => Promise - setForConnectedTargets: (args: { - session?: WorkspaceSessionState - hydratedTargetIds?: string[] - }) => Promise<{ targetId: string; result: RemoteWorkspacePatchResult }[]> - listEnabledConnectedTargets: () => Promise - listConnectedClients: (args?: { - targetIds?: string[] - }) => Promise<{ targetId: string; clients: RemoteWorkspaceConnectedClient[] }[]> - clientId: () => Promise - onChanged: (callback: (event: RemoteWorkspaceChangedEvent) => void) => () => void - } - updater: { - getVersion: () => Promise - getStatus: () => Promise - check: (options?: UpdateCheckOptions) => Promise - download: () => Promise - quitAndInstall: () => Promise - dismissNudge: () => Promise - dismissAvailableUpdate: () => Promise - /** Desktop-only. Rejects unless the current status carries `linux-package-install` recovery. */ - getLinuxPackageInstallInstructions: () => Promise - /** Desktop-only. Reveals the revalidated cached package in the native file manager. */ - showLinuxPackage: () => Promise - listBuilds: (channel: ReleaseChannel) => Promise - - onStatus: (callback: (status: UpdateStatus) => void) => () => void - onClearDismissal: (callback: () => void) => () => void - } - notebook: { - runPythonCell: (args: { - filePath: string - code: string - preamble?: string - connectionId?: string | null - }) => Promise<{ stdout: string; stderr: string; exitCode: number | null; error?: string }> - } + hooks: HooksApi + ephemeralVm: EphemeralVmApi + cache: WorkspaceSessionApi['cache'] + session: WorkspaceSessionApi['session'] + remoteWorkspace: WorkspaceSessionApi['remoteWorkspace'] + updater: UpdaterApi + notebook: FilesystemApi['notebook'] stats: StatsApi memory: MemoryApi claudeUsage: ClaudeUsageApi @@ -2748,999 +132,74 @@ export type PreloadApi = { openCodeUsage: OpenCodeUsageApi aiVault: AiVaultApi nativeChat: NativeChatApi - fs: { - readDir: (args: { dirPath: string; connectionId?: string }) => Promise - readFile: (args: { - filePath: string - connectionId?: string - includeLocalLogMetadata?: boolean - }) => Promise<{ - content: string - isBinary: boolean - isImage?: boolean - mimeType?: string - fileIdentity?: string - }> - readLocalLogTail: (args: LocalLogTailReadArgs) => Promise - startLocalLogTail: (args: LocalLogTailWatchArgs) => Promise - stopLocalLogTail: (args: { subscriptionId: string }) => Promise - onLocalLogTailChanged: (callback: (payload: LocalLogTailChangedPayload) => void) => () => void - downloadFile: (args: { - filePath: string - connectionId: string - }) => Promise<{ canceled: true } | { canceled: false; destinationPath: string }> - downloadFolder: (args: { - dirPath: string - connectionId: string - }) => Promise<{ canceled: true } | { canceled: false; destinationPath: string }> - saveDownloadedFile: (args: { - suggestedName: string - content: string - encoding: 'utf8' | 'base64' - }) => Promise<{ canceled: true } | { canceled: false; destinationPath: string }> - startDownloadedFile: (args: { - suggestedName: string - }) => Promise< - { canceled: true } | { canceled: false; transferId: string; destinationPath: string } - > - appendDownloadedFileChunk: (args: { - transferId: string - contentBase64: string - }) => Promise<{ ok: true }> - finishDownloadedFile: (args: { - transferId: string - }) => Promise<{ canceled: false; destinationPath: string }> - cancelDownloadedFile: (args: { transferId: string }) => Promise<{ ok: true }> - listMarkdownDocuments: (args: { - rootPath: string - connectionId?: string - }) => Promise - writeFile: ( - args: { filePath: string; content: string; connectionId?: string } & SshMutationExpectation - ) => Promise - createFile: ( - args: { filePath: string; connectionId?: string } & SshMutationExpectation - ) => Promise - createDir: ( - args: { dirPath: string; connectionId?: string } & SshMutationExpectation - ) => Promise - rename: ( - args: { oldPath: string; newPath: string; connectionId?: string } & SshMutationExpectation - ) => Promise - copy: ( - args: { - sourcePath: string - destinationPath: string - connectionId?: string - } & SshMutationExpectation - ) => Promise - deletePath: ( - args: { - targetPath: string - connectionId?: string - recursive?: boolean - } & SshMutationExpectation - ) => Promise - authorizeExternalPath: (args: { targetPath: string }) => Promise - stat: (args: { - filePath: string - connectionId?: string - }) => Promise<{ size: number; isDirectory: boolean; mtime: number }> - pathExists: (args: { filePath: string; connectionId?: string }) => Promise - listFiles: (args: { - rootPath: string - connectionId?: string - excludePaths?: string[] - requestToken?: string - }) => Promise - cancelListFiles: (args: { requestToken: string }) => Promise - search: (args: SearchOptions & { connectionId?: string }) => Promise - importExternalPaths: ( - args: { - sourcePaths: string[] - destDir: string - connectionId?: string - ensureDir?: boolean - } & SshMutationExpectation - ) => Promise<{ - results: ( - | { - sourcePath: string - status: 'imported' - destPath: string - kind: 'file' | 'directory' - renamed: boolean - } - | { - sourcePath: string - status: 'skipped' - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - } - | { - sourcePath: string - status: 'failed' - reason: string - } - )[] - }> - stageExternalPathsForRuntimeUpload: (args: { sourcePaths: string[] }) => Promise<{ - sources: ( - | { - sourcePath: string - status: 'staged' - name: string - kind: 'file' | 'directory' - entries: ( - | { relativePath: string; kind: 'directory' } - | { relativePath: string; kind: 'file'; contentBase64: string } - )[] - } - | { - sourcePath: string - status: 'skipped' - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - } - | { - sourcePath: string - status: 'failed' - reason: string - } - )[] - }> - resolveDroppedPathsForAgent: ( - args: { - paths: string[] - worktreePath: string - connectionId?: string - } & SshMutationExpectation - ) => Promise<{ - resolvedPaths: string[] - skipped: { - sourcePath: string - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - }[] - failed: { sourcePath: string; reason: string }[] - }> - watchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise - unwatchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise - onFsChanged: (callback: (payload: FsChangedPayload) => void) => () => void - } - git: { - status: (args: { - worktreePath: string - connectionId?: string - includeIgnored?: boolean - bypassEffectiveUpstreamNegativeCache?: boolean - reuseLineStats?: boolean - /** Merge-base OID to measure the branch line total against; omit to skip the work. */ - branchLineTotalMergeBase?: string - requestToken?: string - }) => Promise - cancelStatus: (args: { requestToken: string }) => Promise - setStatusUpstreamRefWatch: (args: { - worktreeId: string - worktreePath: string - executionHostId: string - connectionId?: string - branch?: string - upstreamName?: string - }) => Promise - submoduleStatus: (args: { - worktreePath: string - submodulePath: string - connectionId?: string - area?: GitStagingArea - }) => Promise - checkIgnored: (args: { - worktreePath: string - paths: string[] - connectionId?: string - }) => Promise - findHugeFoldersToIgnore: (args: { worktreePath: string }) => Promise - appendGitignore: (args: { worktreePath: string; folderName: string }) => Promise - history: ( - args: { worktreePath: string; connectionId?: string } & GitHistoryOptions - ) => Promise - conflictOperation: (args: { - worktreePath: string - connectionId?: string - }) => Promise - abortMerge: (args: { worktreePath: string; connectionId?: string }) => Promise - abortRebase: (args: { worktreePath: string; connectionId?: string }) => Promise - diff: (args: { - worktreePath: string - filePath: string - staged: boolean - compareAgainstHead?: boolean - connectionId?: string - }) => Promise - branchCompare: (args: { - worktreePath: string - baseRef: string - connectionId?: string - }) => Promise - commitCompare: (args: { - worktreePath: string - commitId: string - connectionId?: string - }) => Promise - upstreamStatus: (args: { - worktreePath: string - connectionId?: string - pushTarget?: GitPushTarget - }) => Promise - fetch: (args: { - worktreePath: string - connectionId?: string - pushTarget?: GitPushTarget - }) => Promise - syncFork: (args: { - worktreePath: string - connectionId?: string - expectedUpstream: GitForkSyncExpectedUpstream - }) => Promise - push: (args: { - worktreePath: string - publish?: boolean - forceWithLease?: boolean - connectionId?: string - pushTarget?: GitPushTarget - }) => Promise - pull: (args: { - worktreePath: string - connectionId?: string - pushTarget?: GitPushTarget - }) => Promise - fastForward: (args: { - worktreePath: string - connectionId?: string - pushTarget?: GitPushTarget - }) => Promise - rebaseFromBase: (args: { - worktreePath: string - baseRef: string - connectionId?: string - }) => Promise - branchDiff: (args: { - worktreePath: string - compare: { - baseRef: string - baseOid: string - headOid: string - mergeBase: string - } - filePath: string - oldPath?: string - connectionId?: string - }) => Promise - commitDiff: (args: { - worktreePath: string - commitOid: string - parentOid?: string | null - filePath: string - oldPath?: string - connectionId?: string - }) => Promise - commit: (args: { - worktreePath: string - message: string - connectionId?: string - }) => Promise<{ success: boolean; error?: string }> - generateCommitMessage: (args: { - worktreePath: string - /** Raw (unstripped) worktree meta key; validated against worktreePath in main. */ - worktreeId?: string - repoId?: string - connectionId?: string - sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams - sourceControlAi?: SourceControlAiSettings - agentCmdOverrides?: Partial> - }) => Promise< - | { success: true; message: string; agentLabel?: string } - | { success: false; error: string; canceled?: boolean } - > - discoverCommitMessageModels: (args: { - agentId: string - worktreePath?: string - connectionId?: string - }) => Promise< - | { - success: true - capability: CommitMessageAgentCapability - models: CommitMessageModelCapability[] - defaultModelId: string - catalogOrigin: 'probe' | 'spec' - } - | { success: false; error: string } - > - cancelGenerateCommitMessage: (args: { - worktreePath: string - connectionId?: string - }) => Promise - generatePullRequestFields: (args: { - worktreePath: string - /** Raw (unstripped) worktree meta key; validated against worktreePath in main. */ - worktreeId?: string - repoId?: string - base: string - title: string - body: string - draft: boolean - provider?: HostedReviewProvider - useTemplate?: boolean - connectionId?: string - sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams - sourceControlAi?: SourceControlAiSettings - agentCmdOverrides?: Partial> - }) => Promise< - | { - success: true - fields: { base: string; title: string; body: string; draft: boolean } - agentLabel?: string - branchChangedByPreparation?: boolean - } - | { success: false; error: string; canceled?: boolean; branchChangedByPreparation?: boolean } - > - cancelGeneratePullRequestFields: (args: { - worktreePath: string - connectionId?: string - }) => Promise - stage: (args: { - worktreePath: string - filePath: string - connectionId?: string - }) => Promise - bulkStage: (args: { - worktreePath: string - filePaths: string[] - connectionId?: string - }) => Promise - unstage: (args: { - worktreePath: string - filePath: string - connectionId?: string - }) => Promise - bulkUnstage: (args: { - worktreePath: string - filePaths: string[] - connectionId?: string - }) => Promise - discard: (args: { - worktreePath: string - filePath: string - connectionId?: string - }) => Promise - bulkDiscard: (args: { - worktreePath: string - filePaths: string[] - connectionId?: string - }) => Promise - remoteFileUrl: (args: { - worktreePath: string - relativePath: string - line: number - connectionId?: string - }) => Promise - remoteCommitUrl: (args: { - worktreePath: string - sha: string - connectionId?: string - }) => Promise - } - ui: { - get: () => Promise - set: (args: Partial) => Promise - recordFeatureInteraction: (id: FeatureInteractionId) => Promise - onStateChanged: (callback: (ui: PersistedUIState) => void) => () => void - onOpenSettings: (callback: () => void) => () => void - /** Consumes a one-shot tray/menu-bar "open settings" intent queued before mount. */ - consumePendingOpenSettings: () => Promise - onOpenSetupGuide: (callback: () => void) => () => void - onOpenFeatureTour: (callback: () => void) => () => void - onOpenCrashReport: (callback: () => void) => () => void - onToggleLeftSidebar: (callback: () => void) => () => void - onToggleRightSidebar: (callback: () => void) => () => void - onToggleWorktreePalette: (callback: () => void) => () => void - onToggleFloatingTerminal: (callback: () => void) => () => void - onTerminalShortcutCaptured: ( - callback: (data: { actionId: KeybindingActionId }) => void - ) => () => void - onOpenQuickOpen: (callback: () => void) => () => void - onToggleQuickCommandsMenu: (callback: () => void) => () => void - onOpenNewWorkspace: (callback: () => void) => () => void - onDeleteCurrentWorkspace: (callback: () => void) => () => void - onOpenWorkspaceBoard: (callback: () => void) => () => void - onOpenTasks: (callback: () => void) => () => void - onJumpToWorktreeIndex: (callback: (index: number) => void) => () => void - onJumpToTabIndex: (callback: (index: number) => void) => () => void - onWorktreeHistoryNavigate: (callback: (direction: 'back' | 'forward') => void) => () => void - onNewBrowserTab: (callback: () => void) => () => void - onNewMarkdownTab: (callback: () => void) => () => void - onNewSimulatorTab: (callback: () => void) => () => void - onRequestTabCreate: ( - callback: (data: { - requestId: string - url: string - worktreeId?: string - sessionProfileId?: string | null - sessionPartition?: string - activate?: boolean - }) => void - ) => () => void - replyTabCreate: (reply: { requestId: string; browserPageId?: string; error?: string }) => void - onRequestTabSetProfile: ( - callback: (data: { - requestId: string - browserPageId: string - profileId: string - sessionPartition?: string - }) => void - ) => () => void - replyTabSetProfile: (reply: { requestId: string; error?: string }) => void - onRequestTabClose: ( - callback: (data: { requestId: string; tabId: string | null; worktreeId?: string }) => void - ) => () => void - replyTabClose: (reply: { requestId: string; error?: string }) => void - onNewTerminalTab: (callback: () => void) => () => void - onFocusBrowserAddressBar: (callback: () => void) => () => void - onFindInBrowserPage: (source: BrowserFindSource, callback: () => void) => () => void - onReloadBrowserPage: (callback: () => void) => () => void - onBrowserHistoryNavigate: (callback: (direction: 'back' | 'forward') => void) => () => void - onZoomBrowserPage: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void - onHardReloadBrowserPage: (callback: () => void) => () => void - onCloseActiveTab: (callback: () => void) => () => void - onCloseFloatingItem: (callback: (payload: { sourceId: string }) => void) => () => void - onSelectFloatingIndex: (callback: (payload: { index: number }) => void) => () => void - onSwitchTab: (callback: (direction: 1 | -1) => void) => () => void - onSwitchTabAcrossAllTypes: (callback: (direction: 1 | -1) => void) => () => void - onSwitchRecentTab: (callback: () => void) => () => void - onSwitchTerminalTab: (callback: (direction: 1 | -1) => void) => () => void - onCtrlTabKeyDown: (callback: (data: { shiftKey: boolean }) => void) => () => void - onCtrlTabKeyUp: (callback: () => void) => () => void - onToggleStatusBar: (callback: () => void) => () => void - onDictationKeyDown: (callback: () => void) => () => void - onExportPdfRequested: (callback: () => void) => () => void - onAppMenuPaste: (callback: () => void) => () => void - onAppMenuSelectionAction: (callback: (action: 'copy' | 'select-all') => void) => () => void - onEditableContextPaste: (callback: (data: { plainTextOnly: boolean }) => void) => () => void - onActivateWorktree: ( - callback: (data: { - repoId: string - worktreeId: string - setup?: WorktreeSetupLaunch - startup?: WorktreeStartupLaunch - defaultTabs?: WorktreeDefaultTabsLaunch - }) => void - ) => () => void - onCreateTerminal: ( - callback: (data: { - requestId?: string - worktreeId: string - command?: string - cwd?: string - env?: Record - launchConfig?: SleepingAgentLaunchConfig - resumeProviderSession?: AgentProviderSessionMetadata - launchToken?: string - launchAgent?: TuiAgent - viewMode?: 'terminal' | 'chat' - title?: string - ptyId?: string - activate?: boolean - focus?: boolean - presentation?: RuntimeTerminalPresentation - surfaceOwner?: false - tabId?: string - leafId?: string - splitFromLeafId?: string - splitDirection?: 'horizontal' | 'vertical' - splitTelemetrySource?: TerminalPaneSplitSource - }) => void - ) => () => void - onRequestTerminalCreate: ( - callback: (data: RuntimeTerminalCreateRequestPayload) => void - ) => () => void - onRequestTerminalTabMount: ( - callback: (data: { worktreeId: string; tabId?: string; ptyId?: string }) => void - ) => () => void - replyTerminalCreate: (reply: TerminalTabCreateReply) => void - onSplitTerminal: ( - callback: (data: { - tabId: string - paneRuntimeId: number - direction: 'horizontal' | 'vertical' - command?: string - telemetrySource?: TerminalPaneSplitSource - }) => void - ) => () => void - onRenameTerminal: ( - callback: (data: { tabId: string; title: string | null }) => void - ) => () => void - onFocusTerminal: ( - callback: (data: { - tabId: string - worktreeId: string - leafId?: string | null - ackPaneKeyOnSuccess?: string - flashFocusedPane?: boolean - scrollToBottomIfOutputSinceLastView?: boolean - }) => void - ) => () => void - onFocusEditorTab: ( - callback: (data: { tabId: string; worktreeId: string }) => void - ) => () => void - onCloseSessionTab: ( - callback: (data: { tabId: string; worktreeId: string }) => void - ) => () => void - onMoveSessionTab: ( - callback: (data: { worktreeId: string } & RuntimeMobileSessionTabMove) => void - ) => () => void - onOpenFileFromMobile: ( - callback: (data: { - worktreeId: string - filePath: string - relativePath: string - runtimeEnvironmentId?: string - }) => void - ) => () => void - onOpenDiffFromMobile: ( - callback: (data: { - worktreeId: string - filePath: string - relativePath: string - staged: boolean - runtimeEnvironmentId?: string - }) => void - ) => () => void - onMobileMarkdownRequest: ( - callback: (request: RuntimeMobileMarkdownRequest) => void - ) => () => void - respondMobileMarkdownRequest: (response: RuntimeMobileMarkdownResponse) => void - onCloseTerminal: ( - callback: (data: { tabId: string; paneRuntimeId?: number }) => void - ) => () => void - onTerminalTabCloseRequest: (callback: (request: TerminalTabCloseRequest) => void) => () => void - respondTerminalTabClose: (response: TerminalTabCloseResponse) => void - onSleepWorktree: (callback: (data: { worktreeId: string }) => void) => () => void - onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void) => () => void - onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void - onSystemResumed: (callback: () => void) => () => void - readClipboardText: (options?: ReadClipboardTextOptions) => Promise - readSelectionClipboardText: (options?: ReadClipboardTextOptions) => Promise - saveClipboardImageAsTempFile: (args?: { - connectionId?: string | null - runtimeEnvironmentId?: string | null - }) => Promise - writeClipboardText: (text: string) => Promise - writeTerminalClipboardText: (text: string) => Promise - writeSelectionClipboardText: (text: string) => Promise - writeClipboardImage: (dataUrl: string) => Promise - performNativePaste: (options?: { mode?: 'paste' | 'paste-and-match-style' }) => void - performNativeSelectionAction: (action: 'copy' | 'select-all') => void - writeClipboardFile: ( - args: - | { - filePath: string - connectionId?: string | null - } - | string - ) => Promise<{ ok: boolean; reason?: string }> - onFileDrop: (callback: (data: NativeFileDropPayload) => void) => () => void - getZoomLevel: () => number - setZoomLevel: (level: number) => void - syncTrafficLights: (zoomFactor: number) => void - setMarkdownEditorFocused: (focused: boolean) => void - setRichMarkdownContextMenuTarget: (target: RichMarkdownContextMenuTableTarget | null) => void - setTerminalInputFocused: (focused: boolean) => void - setFloatingFocus: (state: { panelFocused: boolean; terminalFocused: boolean }) => void - setShortcutRecorderFocused: (focused: boolean) => void - onRichMarkdownContextCommand: ( - callback: (payload: RichMarkdownContextMenuCommandPayload) => void - ) => () => void - onFullscreenChanged: (callback: (isFullScreen: boolean) => void) => () => void - minimize: () => void - maximize: () => void - isMaximized: () => Promise - onMaximizeChanged: (callback: (isMaximized: boolean) => void) => () => void - requestClose: () => void - popupMenu: () => void - onWindowCloseRequested: (callback: (data: { isQuitting: boolean }) => void) => () => void - confirmWindowClose: () => void - notifyWindowRevealed: () => void - } - runtime: { - syncWindowGraph: (graph: RuntimeSyncWindowGraph) => Promise - getStatus: () => Promise - call: (args: { method: string; params?: unknown }) => Promise> - getTerminalFitOverrides: () => Promise< - { ptyId: string; mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number }[] - > - getTerminalDrivers: () => Promise< - { - ptyId: string - driver: RuntimeTerminalDriverState - }[] - > - getBrowserDrivers: () => Promise< - { - browserPageId: string - driver: RuntimeBrowserDriverState - }[] - > - restoreTerminalFit: (ptyId: string) => Promise<{ restored: boolean }> - reclaimBrowserForDesktop: (browserPageId: string) => Promise<{ reclaimed: boolean }> - onTerminalFitOverrideChanged: ( - callback: (event: { - ptyId: string - mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' - cols: number - rows: number - }) => void - ) => () => void - onTerminalDriverChanged: ( - callback: (event: { ptyId: string; driver: RuntimeTerminalDriverState }) => void - ) => () => void - onNativeChatLaunchDraftResolved?: ( - callback: (event: { tabId: string; text: string; createdAt: number }) => void - ) => () => void - onBrowserDriverChanged: ( - callback: (event: { browserPageId: string; driver: RuntimeBrowserDriverState }) => void - ) => () => void - } - runtimeEnvironments: { - list: () => Promise - addFromPairingCode: (args: { - name: string - pairingCode: string - }) => Promise<{ environment: PublicKnownRuntimeEnvironment }> - verifyAndAddFromPairingCode: (args: { - name: string - pairingCode: string - allowLoopback?: boolean - }) => Promise - resolve: (args: { selector: string }) => Promise - remove: (args: { selector: string }) => Promise<{ removed: PublicKnownRuntimeEnvironment }> - disconnect: (args: { - selector: string - }) => Promise<{ disconnected: PublicKnownRuntimeEnvironment }> - connect: (args: { - selector: string - timeoutMs?: number - }) => Promise> - getStatus: (args: { - selector: string - timeoutMs?: number - }) => Promise> - // Why: system resume / browser online advance pending shared-control reconnect timers only. - retryConnectionsNow?: () => Promise - call: (args: { - selector: string - method: string - params?: unknown - timeoutMs?: number - expectedEnvironmentPairingRevision?: number - }) => Promise> - subscribe: ( - args: { - selector: string - method: string - params?: unknown - timeoutMs?: number - expectedEnvironmentPairingRevision?: number - }, - callbacks: { - onResponse: (response: RuntimeRpcResponse) => void - onBinary?: (bytes: Uint8Array) => void - onError?: (error: { code: string; message: string }) => void - onClose?: () => void - } - ) => Promise - } - rateLimits: { - get: () => Promise - refresh: () => Promise - refreshCodexForTarget: (target: RateLimitRuntimeTarget) => Promise - consumeCodexResetCredit: () => Promise - refreshClaudeForTarget: (target: RateLimitRuntimeTarget) => Promise - setPollingInterval: (ms: number) => Promise - fetchInactiveClaudeAccounts: () => Promise - fetchInactiveCodexAccounts: () => Promise - refreshMiniMax: () => Promise - refreshGrok: () => Promise - onUpdate: (callback: (state: RateLimitState) => void) => () => void - } - minimaxCredentials: { - getStatus: () => Promise<{ configured: boolean }> - saveCookie: (cookie: string) => Promise<{ configured: boolean }> - clearCookie: () => Promise<{ configured: boolean }> - } - grokAccounts: { - getStatus: () => Promise - } - ssh: { - listTargets: () => Promise - // Removed-target id → last known label, for a friendly host name on workspaces still pinned to a removed target. - listRemovedTargetLabels: () => Promise> - addTarget: (args: { target: Omit }) => Promise - updateTarget: (args: { - id: string - updates: Partial> - }) => Promise - removeTarget: (args: { id: string }) => Promise - importConfig: (args?: { reAdopt?: boolean }) => Promise - listConfigHosts: (args?: SshConfigHostListArgs) => Promise - resolveConfigHost: (args: { alias: string }) => Promise - connect: (args: { targetId: string }) => Promise - disconnect: (args: { targetId: string }) => Promise - terminateSessions: (args: { targetId: string }) => Promise - resetRelay: (args: { targetId: string }) => Promise - getState: (args: { targetId: string }) => Promise - needsPassphrasePrompt: (args: { targetId: string }) => Promise - testConnection: (args: { - targetId: string - }) => Promise<{ success: boolean; error?: string; state?: SshConnectionState }> - onStateChanged: ( - callback: (data: { targetId: string; state: SshConnectionState }) => void - ) => () => void - addPortForward: (args: { - targetId: string - localPort: number - remoteHost: string - remotePort: number - label?: string - }) => Promise - updatePortForward: (args: { - id: string - targetId: string - localPort: number - remoteHost: string - remotePort: number - label?: string - }) => Promise - removePortForward: (args: { id: string }) => Promise - listPortForwards: (args?: { targetId?: string }) => Promise - listDetectedPorts: (args: { targetId: string }) => Promise - onPortForwardsChanged: ( - callback: (data: { targetId: string; forwards: PortForwardEntry[] }) => void - ) => () => void - onDetectedPortsChanged: ( - callback: (data: { targetId: string; ports: EnrichedDetectedPort[] }) => void - ) => () => void - browseDir: (args: { targetId: string; dirPath: string }) => Promise<{ - entries: { name: string; isDirectory: boolean }[] - resolvedPath: string - pathFlavor: FilesystemPathFlavor - }> - onCredentialRequest: ( - callback: (data: { - requestId: string - targetId: string - kind: 'passphrase' | 'password' - detail: string - }) => void - ) => () => void - onCredentialResolved: (callback: (data: { requestId: string }) => void) => () => void - submitCredential: (args: { requestId: string; value: string | null }) => Promise - } - automations: { - list: () => Promise - listRuns: (args?: { automationId?: string }) => Promise - listExternalManagers: () => Promise - listExternalRuns: (input: ExternalAutomationRunsInput) => Promise - createExternal: (input: ExternalAutomationCreateInput) => Promise - updateExternal: (input: ExternalAutomationUpdateInput) => Promise - runExternalAction: (input: ExternalAutomationActionInput) => Promise - create: (input: AutomationCreateInput) => Promise - update: (args: { id: string; updates: AutomationUpdateInput }) => Promise - delete: (args: { id: string }) => Promise - runNow: (args: { id: string }) => Promise - runPrecheck: (args: { - automationId: string - runId: string - }) => Promise - markDispatchResult: (result: AutomationDispatchResult) => Promise - snapshotWorkspaceName: (args: { workspaceId: string; displayName: string }) => Promise - rendererReady: () => Promise - onDispatchRequested: (callback: (request: AutomationDispatchRequest) => void) => () => void - } - wsl: { - isAvailable: () => Promise - listDistros: () => Promise - } - pwsh: { - isAvailable: () => Promise - } - gitBash: { - isAvailable: () => Promise - } - plugins: { - list: () => Promise - listLanguagePacks: () => Promise - /** Records the consent-dialog answer; approval is keyed to the plugin's - * current capability and trusted-worker fingerprint. */ - consent: (args: PluginConsentRequest) => Promise - setEnabled: (args: { pluginKey: string; enabled: boolean }) => Promise - /** Returns the panel's CSP-wrapped HTML, or null when the plugin or - * panel is missing/disabled. Rendered only inside a sandboxed iframe. */ - readPanelEntry: (args: { - pluginKey: string - panelId: string - }) => Promise - invokeCommand: (args: { - pluginKey: string - commandId: string - args?: unknown - }) => Promise - /** Relays a sandboxed panel's bridge request to main, which enforces the - * plugin's consented capabilities before executing. */ - panelAction: (args: { - sessionToken: string - action: string - params?: unknown - }) => Promise - install: (source: PluginHostInstallSource) => Promise - listMarketplaces: () => Promise - addMarketplace: ( - source: PluginMarketplaceGitSource - ) => Promise - removeMarketplace: (args: { sourceId: string }) => Promise - refreshMarketplaces: (args?: { - sourceId?: string - }) => Promise - listMarketplacePlugins: () => Promise - previewMarketplacePlugin: (args: { - marketplaceSourceId: string - pluginKey: string - }) => Promise - installMarketplacePlugin: ( - preview: Pick< - PluginMarketplaceHostInstallPreview, - 'marketplaceSourceId' | 'marketplaceCommit' | 'pluginKey' | 'resolvedCommit' - > - ) => Promise - previewMarketplaceUpdate: (args: { - pluginKey: string - }) => Promise - rollbackMarketplacePlugin: (args: { pluginKey: string }) => Promise - remove: (args: { pluginKey: string }) => Promise - getLogs: (args: { pluginKey: string }) => Promise - /** Re-discovers after settings edits (feature flag, dev paths). */ - refresh: () => Promise - /** Fires whenever installed plugins, worker states, panels, or content packs change. */ - onChanged: (callback: (event: PluginChangeEvent) => void) => () => void - } - agentStatus: { - /** Listen for agent status updates forwarded from native hook receivers. */ - onSet: (callback: (data: AgentStatusIpcPayload) => void) => () => void - /** Listen for main-process cleanup that evicted cached hook status. */ - onClear: (callback: (data: AgentStatusClearIpcPayload) => void) => () => void - /** Return the current main-process hook cache after renderer hydration. */ - getSnapshot: () => Promise - inferInterrupt: (request: AgentInterruptInferenceRequest) => Promise - /** Guarded clear for an answered AskUserQuestion wait — the CLI emits no hook at answer time, so the renderer reports the submit keystroke. */ - inferQuestionAnswered: (request: AgentQuestionAnsweredInferenceRequest) => Promise - /** Listen for PTYs on a legacy numeric pane key that have registry-backed UUID pane proof. */ - onMigrationUnsupported: (callback: (entry: MigrationUnsupportedPtyEntry) => void) => () => void - onMigrationUnsupportedClear: (callback: (data: { ptyId: string }) => void) => () => void - onLegacyWorkerTerminalRecovery: ( - callback: (data: { - paneKey: string - resolution: 'adopted' | 'exited' | 'rolled_back' - ptyId?: string - }) => void - ) => () => void - getMigrationUnsupportedSnapshot: () => Promise - /** Drop a paneKey from the main-process hook cache and on-disk last-status file. Fire-and-forget. */ - drop: (paneKey: string) => void - /** Drop every cached hook status under one terminal tab prefix. Fire-and-forget. */ - dropByTabPrefix: (tabId: string) => void - /** Permanently retire one pane's hook authority while siblings stay live. */ - retirePaneAuthority: (paneKey: string) => void - /** Move hook authority when a live pane is detached into another tab. */ - transferPaneAuthority: (args: { - fromPaneKey: string - toPaneKey: string - ptyId?: string - }) => void - } - mobile: { - listNetworkInterfaces: () => Promise<{ - interfaces: { name: string; address: string; hasDefaultRoute?: boolean }[] - }> - getPairingQR: (args?: { - address?: string - connectionMode?: MobilePairingConnectionMode - rotate?: boolean - }) => Promise< - | { - available: false - reason?: string - guidance?: string - relayFailure?: MobileRelayMintFailure - } - | { - available: true - qrDataUrl: string | null - qrError?: 'encoding_failed' - pairingUrl: string - /** Null when no direct address was advertised — the QR pairs over Relay alone. */ - endpoint: string | null - deviceId: string - /** Mode the QR actually encodes. */ - connectionMode: MobilePairingConnectionMode - } - > - getWindowsFirewallStatus: (args?: { address?: string }) => Promise< - | { supported: false } - | { - supported: true - port: number - ruleAllowed: boolean - blockingRuleDetected: boolean - privateFirewallEnabled: boolean - networkCategory: 'private' | 'public' | 'domain' | 'unknown' - inspectionAvailable: boolean - } - > - repairWindowsFirewall: () => Promise< - { ok: true } | { ok: false; reason: 'cancelled' | 'failed' | 'unsupported' } - > - openWindowsNetworkSettings: () => Promise - getRuntimePairingUrl: (args?: { - address?: string - rotate?: boolean - reach?: RuntimePairingReach - }) => Promise< - | { available: false; reason?: 'network_exposure_failed'; guidance?: string } - | { - available: true - pairingUrl: string - webClientUrl: string | null - endpoint: string - deviceId: string - } - > - listDevices: () => Promise<{ - devices: { deviceId: string; name: string; pairedAt: number; lastSeenAt: number }[] - }> - revokeDevice: (args: { deviceId: string }) => Promise<{ revoked: boolean }> - listRuntimeAccessGrants: () => Promise<{ grants: RuntimeAccessGrant[] }> - revokeRuntimeAccess: (args: { deviceId: string }) => Promise<{ revoked: boolean }> - isWebSocketReady: () => Promise<{ ready: boolean; endpoint: string | null }> - getRelayStatus: () => Promise<{ status: MobileRelayStatus }> - onRelayStatusChanged: (callback: (status: MobileRelayStatus) => void) => () => void - /** Consumes an auth-failure notification that arrived before the renderer listener mounted. */ - consumePendingUnpairedDeviceAuthFailure?: () => Promise - /** Fires (throttled, once per session) when an unpaired phone repeatedly fails direct-transport auth. */ - onUnpairedDeviceAuthFailure?: (callback: () => void) => () => void - } - speech: { - getCatalog: () => Promise - getModelStates: () => Promise - getOpenAiApiKeyStatus: () => Promise<{ configured: boolean }> - saveOpenAiApiKey: (apiKey: string) => Promise<{ configured: boolean }> - clearOpenAiApiKey: () => Promise<{ configured: boolean }> - downloadModel: (modelId: string) => Promise - cancelDownload: (modelId: string) => Promise - deleteModel: (modelId: string) => Promise - startDictation: ( - modelId: string, - hotwords: string[] | undefined, - sessionId: string - ) => Promise - feedAudio: (samples: Float32Array, sampleRate: number, sessionId?: string) => Promise - stopDictation: (sessionId?: string) => Promise - onPartialTranscript: (callback: (data: SpeechTranscriptEvent) => void) => () => void - onFinalTranscript: (callback: (data: SpeechTranscriptEvent) => void) => () => void - onDownloadProgress: ( - callback: (data: { modelId: string; progress: number }) => void - ) => () => void - onReady: (callback: (data: SpeechLifecycleEvent) => void) => () => void - onStopped: (callback: (data: SpeechLifecycleEvent) => void) => () => void - onError: (callback: (data: SpeechErrorEvent) => void) => () => void - } + fs: FilesystemApi['fs'] + git: Merged + ui: Merged + runtime: RuntimeApi['runtime'] + runtimeEnvironments: RuntimeApi['runtimeEnvironments'] + rateLimits: RateLimitsApi + minimaxCredentials: MinimaxCredentialsApi + grokAccounts: GrokAccountsApi + ssh: SshApi + automations: AutomationsApi + wsl: RuntimeApi['wsl'] + pwsh: RuntimeApi['pwsh'] + gitBash: RuntimeApi['gitBash'] + plugins: PluginsApi + agentStatus: AgentStatusApi + mobile: MobileApi + speech: SpeechApi } +export type { ClaudeUsageApi, CodexUsageApi, OpenCodeUsageApi } from './api/agent-usage-api' +export type { AiVaultApi } from './api/ai-vault-api' +export type { AppApi } from './api/app-api' +export type { BrowserApi, DetectedBrowserInfo, DetectedBrowserProfileInfo } from './api/browser-api' +export type { EmulatorApi } from './api/emulator-api' +export type { ExportApi } from './api/filesystem-api' +export type { + NativeChatApi, + NativeChatAppendedMessages, + NativeChatAppendedPayload, + NativeChatReadSessionResult, + NativeChatSubscribeArgs, + NativeChatSubscriptionFrame +} from './api/native-chat-api' +export type { + PluginHostInstallResult, + PluginHostInstallSource, + PluginHostListEntry, + PluginHostLogLine, + PluginHostPanel, + PluginHostStatus, + PluginMarketplaceHostInstallPreview, + PluginMarketplaceHostListing, + PluginMarketplaceHostSourceState +} from './api/plugin-host-api' +export type { + PreflightApi, + PreflightRuntimeContext, + PreflightStatus, + RefreshAgentsResult +} from './api/preflight-api' +export type { + PtyManagementApi, + PtyManagementMacTccAttributionHealth, + PtyManagementSession +} from './api/pty-management-api' +export type { + ShellOpenExternalEditorRequest, + ShellOpenExternalEditorResult, + ShellOpenLocalPathResult +} from './api/shell-api' +export type { + DiagnosticsBundlePayload, + DiagnosticsStatusPayload, + DiagnosticsUploadPayload, + MemoryApi, + StatsApi +} from './api/telemetry-api' + declare global { // oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging requires interface interface Window { diff --git a/src/preload/api/agent-account-api.ts b/src/preload/api/agent-account-api.ts new file mode 100644 index 00000000000..43e8542cddd --- /dev/null +++ b/src/preload/api/agent-account-api.ts @@ -0,0 +1,62 @@ +import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/types' +import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types' +import type { GrokAccountStatus } from '../../shared/rate-limit-types' + +export type CodexAccountsApi = { + list: () => Promise + add: (args?: { + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }) => Promise + reauthenticate: (args: { accountId: string }) => Promise + remove: (args: { accountId: string }) => Promise + select: (args: { + accountId: string | null + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }) => Promise + /** Live PTYs whose baked CODEX_HOME still points at a deselected account. */ + listStalePanes: (args: { ptyIds: string[] }) => Promise< + { + ptyId: string + launchAccountId: string | null + activeAccountId: string | null + /** Optional for compatibility with a pre-reason main process. */ + reason?: 'account-change' | 'home-route-change' + }[] + > + /** The selection lane each PTY launched from, keyed by pty id; unrecorded panes are absent. */ + listRecordedPaneLanes: (args: { ptyIds: string[] }) => Promise> + /** Drops launch records so a dismissed prompt stays dismissed across restarts. */ + forgetStalePanes: (args: { ptyIds: string[] }) => Promise +} + +export type ClaudeAccountsApi = { + list: () => Promise + add: (args?: { + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }) => Promise + cancelPendingLogin: () => Promise + reauthenticate: (args: { accountId: string }) => Promise + remove: (args: { accountId: string }) => Promise + select: (args: { + accountId: string | null + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }) => Promise +} + +export type GrokAccountsApi = { + getStatus: () => Promise +} + +export type MinimaxCredentialsApi = { + getStatus: () => Promise<{ configured: boolean }> + saveCookie: (cookie: string) => Promise<{ configured: boolean }> + clearCookie: () => Promise<{ configured: boolean }> +} + +export type CodexConfigSyncApi = { + status: () => Promise +} diff --git a/src/preload/api/agent-hook-api.ts b/src/preload/api/agent-hook-api.ts new file mode 100644 index 00000000000..df782419c5c --- /dev/null +++ b/src/preload/api/agent-hook-api.ts @@ -0,0 +1,51 @@ +import type { OrcaHooks, WorktreeSetupLaunch } from '../../shared/types' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { SetupScriptImportCandidate } from '../../shared/setup-script-imports' +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' + +export type HooksApi = { + check: (args: { repoId: string; hostId?: ExecutionHostId }) => Promise<{ + status?: 'ok' | 'error' + hasHooks: boolean + hooks: OrcaHooks | null + mayNeedUpdate: boolean + }> + inspectSetupScriptImports: (args: { + repoId: string + hostId?: ExecutionHostId + }) => Promise + createIssueCommandRunner: (args: { + repoId: string + worktreePath: string + command: string + }) => Promise + readIssueCommand: (args: { repoId: string; hostId?: ExecutionHostId }) => Promise<{ + status?: 'ok' | 'error' + localContent: string | null + sharedContent: string | null + effectiveContent: string | null + localFilePath: string + source: 'local' | 'shared' | 'none' + }> + writeIssueCommand: (args: { + repoId: string + content: string + hostId?: ExecutionHostId + }) => Promise +} + +export type AgentHooksApi = { + claudeStatus: () => Promise + openClaudeStatus: () => Promise + codexStatus: () => Promise + geminiStatus: () => Promise + antigravityStatus: () => Promise + ampStatus: () => Promise + cursorStatus: () => Promise + droidStatus: () => Promise + commandCodeStatus: () => Promise + grokStatus: () => Promise + copilotStatus: () => Promise + hermesStatus: () => Promise + devinStatus: () => Promise +} diff --git a/src/preload/api/agent-skill-api.ts b/src/preload/api/agent-skill-api.ts new file mode 100644 index 00000000000..489d48c3d5d --- /dev/null +++ b/src/preload/api/agent-skill-api.ts @@ -0,0 +1,16 @@ +import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/skills' +import type { + SkillFreshnessInventory, + SkillUpdateRun, + SkillUpdateStartResult +} from '../../shared/skill-freshness' + +export type SkillsApi = { + discover: (target?: SkillDiscoveryTarget) => Promise + freshnessInventory: () => Promise + startUpdateRun: (names: string[]) => Promise + cancelUpdateRun: () => Promise + acknowledgeUpdateRun: () => Promise + getUpdateRun: () => Promise + onUpdateRun: (callback: (run: SkillUpdateRun) => void) => () => void +} diff --git a/src/preload/api/agent-status-api.ts b/src/preload/api/agent-status-api.ts new file mode 100644 index 00000000000..6e61114df47 --- /dev/null +++ b/src/preload/api/agent-status-api.ts @@ -0,0 +1,52 @@ +import type { + AgentStatusClearIpcPayload, + AgentStatusIpcPayload, + MigrationUnsupportedPtyEntry +} from '../../shared/agent-status-types' +import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent' +import type { AgentQuestionAnsweredInferenceRequest } from '../../shared/agent-question-answered-intent' +import type { ComputerAwakeStatus } from '../../shared/computer-awake-mode' + +export type AgentStatusApi = { + /** Listen for agent status updates forwarded from native hook receivers. */ + onSet: (callback: (data: AgentStatusIpcPayload) => void) => () => void + /** Listen for main-process cleanup that evicted cached hook status. */ + onClear: (callback: (data: AgentStatusClearIpcPayload) => void) => () => void + /** Return the current main-process hook cache after renderer hydration. */ + getSnapshot: () => Promise + inferInterrupt: (request: AgentInterruptInferenceRequest) => Promise + /** Guarded clear for an answered AskUserQuestion wait — the CLI emits no hook at answer time, so the renderer reports the submit keystroke. */ + inferQuestionAnswered: (request: AgentQuestionAnsweredInferenceRequest) => Promise + /** Listen for PTYs on a legacy numeric pane key that have registry-backed UUID pane proof. */ + onMigrationUnsupported: (callback: (entry: MigrationUnsupportedPtyEntry) => void) => () => void + onMigrationUnsupportedClear: (callback: (data: { ptyId: string }) => void) => () => void + onLegacyWorkerTerminalRecovery: ( + callback: (data: { + paneKey: string + resolution: 'adopted' | 'exited' | 'rolled_back' + ptyId?: string + }) => void + ) => () => void + getMigrationUnsupportedSnapshot: () => Promise + /** Drop a paneKey from the main-process hook cache and on-disk last-status file. Fire-and-forget. */ + drop: (paneKey: string) => void + /** Drop every cached hook status under one terminal tab prefix. Fire-and-forget. */ + dropByTabPrefix: (tabId: string) => void + /** Permanently retire one pane's hook authority while siblings stay live. */ + retirePaneAuthority: (paneKey: string) => void + /** Move hook authority when a live pane is detached into another tab. */ + transferPaneAuthority: (args: { fromPaneKey: string; toPaneKey: string; ptyId?: string }) => void +} + +export type AgentTrustApi = { + markTrusted: (args: { + preset: 'cursor' | 'copilot' | 'codex' + workspacePath: string + connectionId?: string + }) => Promise +} + +export type AgentAwakeApi = { + getStatus: () => Promise + onChanged: (callback: (status: ComputerAwakeStatus) => void) => () => void +} diff --git a/src/preload/api/agent-usage-api.ts b/src/preload/api/agent-usage-api.ts new file mode 100644 index 00000000000..59322ad9bad --- /dev/null +++ b/src/preload/api/agent-usage-api.ts @@ -0,0 +1,59 @@ +import type { ClaudeUsageBreakdownKind, ClaudeUsageSnapshot } from '../../shared/claude-usage-types' +import type { CodexUsageBreakdownKind, CodexUsageSnapshot } from '../../shared/codex-usage-types' +import type { + OpenCodeUsageBreakdownKind, + OpenCodeUsageSnapshot +} from '../../shared/opencode-usage-types' +import type { + CodexRateLimitResetResult, + RateLimitRuntimeTarget, + RateLimitState +} from '../../shared/rate-limit-types' + +export type UsageProviderSnapshot = { + scanState: unknown + summary: { scope: string; range: string } + daily: unknown[] + modelBreakdown: unknown[] + recentSessions: unknown[] +} + +export type UsageQueryArgs = Pick< + Snapshot['summary'], + 'scope' | 'range' +> + +export type UsageProviderApi = { + getScanState: () => Promise + setEnabled: (args: { enabled: boolean }) => Promise + refresh: (args?: { force?: boolean }) => Promise + getSnapshot: (args: UsageQueryArgs & { limit?: number }) => Promise + getSummary: (args: UsageQueryArgs) => Promise + getDaily: (args: UsageQueryArgs) => Promise + getBreakdown: ( + args: UsageQueryArgs & { kind: BreakdownKind } + ) => Promise + getRecentSessions: ( + args: UsageQueryArgs & { limit?: number } + ) => Promise +} + +export type ClaudeUsageApi = UsageProviderApi + +export type CodexUsageApi = UsageProviderApi + +export type OpenCodeUsageApi = UsageProviderApi + +export type RateLimitsApi = { + get: () => Promise + refresh: () => Promise + refreshCodexForTarget: (target: RateLimitRuntimeTarget) => Promise + consumeCodexResetCredit: () => Promise + refreshClaudeForTarget: (target: RateLimitRuntimeTarget) => Promise + setPollingInterval: (ms: number) => Promise + fetchInactiveClaudeAccounts: () => Promise + fetchInactiveCodexAccounts: () => Promise + refreshMiniMax: () => Promise + refreshGrok: () => Promise + onUpdate: (callback: (state: RateLimitState) => void) => () => void +} diff --git a/src/preload/api/ai-vault-api.ts b/src/preload/api/ai-vault-api.ts new file mode 100644 index 00000000000..10cf8a4d515 --- /dev/null +++ b/src/preload/api/ai-vault-api.ts @@ -0,0 +1,37 @@ +import type { + AiVaultDeleteSessionArgs, + AiVaultDeleteSessionResult +} from '../../shared/ai-vault-session-deletion' +import type { + AiVaultFirstUserPromptArgs, + AiVaultFirstUserPromptResult, + AiVaultListArgs, + AiVaultListResult, + AiVaultSubagentListArgs, + AiVaultSubagentListResult +} from '../../shared/ai-vault-types' +import type { + AiVaultSessionTitlesArgs, + AiVaultSessionTitlesResult +} from '../../shared/ai-vault-session-title' +import type { + AiVaultPrepareSessionResumeArgs, + AiVaultPrepareSessionResumeResult +} from '../../shared/ai-vault-resume-preparation' + +export type AiVaultApi = { + listSessions: (args?: AiVaultListArgs) => Promise + resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => Promise + cancelListSessions: (args: { requestToken: string }) => Promise + prepareSessionResume: ( + args: AiVaultPrepareSessionResumeArgs + ) => Promise + /** Lists the Task subagent transcripts of one session, on demand. */ + listSubagentSessions: (args: AiVaultSubagentListArgs) => Promise + /** Full first user prompt for copy/reuse (re-parses one transcript). */ + getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs) => Promise + /** Moves a deletable session's transcript to the OS trash; local sessions only. */ + deleteSession: (args: AiVaultDeleteSessionArgs) => Promise + /** Fires when any app window regains OS focus; returns an unsubscribe. */ + onWindowFocused: (callback: () => void) => () => void +} diff --git a/src/preload/api/app-api.ts b/src/preload/api/app-api.ts new file mode 100644 index 00000000000..a03960fd531 --- /dev/null +++ b/src/preload/api/app-api.ts @@ -0,0 +1,82 @@ +import type { AppIdentity } from '../../shared/app-identity' +import type { E2EConfig } from '../../shared/e2e-config' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { + WriteTerminalRenderDesyncEvidenceArgs, + WriteTerminalRenderDesyncEvidenceResult +} from '../../shared/terminal-render-desync-evidence' +import type { + FloatingTerminalCwdRequest, + MarkdownDocument, + PersistedUIState, + WorkspaceSessionState +} from '../../shared/types' + +export type AppApi = { + /** Returns the app identity currently exposed to native chrome and the titlebar. */ + getIdentity: () => Promise + /** Returns a URL base for feature-wall assets. In dev this is Vite /@fs; + * in packaged builds this is file:// resources. Renderer appends filenames. */ + getFeatureWallAssetBaseUrl: () => Promise + /** Relaunches the app (app.relaunch() + app.exit(0)) for settings that need a full restart to apply. */ + relaunch: () => Promise + /** Restarts Orca through the normal quit pipeline so daemon-backed terminal + * sessions survive and can reattach after the new process starts. */ + restart: () => Promise + /** Reloads the current app renderer through main so expected renderer + * teardown can be classified before Electron emits process-gone events. */ + reload: () => Promise + /** Stages the renderer's final state synchronously before unload. */ + stageBeforeUnloadSync: (args: { + sessions: { state: WorkspaceSessionState; hostId?: ExecutionHostId }[] + ui: Partial + }) => void + /** Resolves once the last staged checkpoint is durably written; rejects if that + * write failed, so a reload/restart can abort instead of losing the snapshot. */ + awaitBeforeUnloadCheckpoint: () => Promise + /** Resolves when the daemon PTY provider and hook receiver have either + * started or failed open for the first BrowserWindow. */ + awaitFirstWindowStartupServices: () => Promise + /** Reconciles legacy worker authority around persisted terminal reconnect. */ + recoverLegacyWorkerTerminalsForRendererStartup: () => Promise + /** Emits a startup benchmark marker when ORCA_STARTUP_DIAGNOSTICS is enabled. */ + startupDiagnostic: (event: string, details?: Record) => Promise + /** macOS active input mode, or layout ID when no IME is selected (e.g. `com.apple.keylayout.PolishPro`). + * Distinguishes CJK IMEs and Option-layer-composing layouts that look like US QWERTY (issue #1205). + * Returns null on non-Darwin or when the defaults read fails. */ + getKeyboardInputSourceId: () => Promise + /** Updates the macOS Dock unread badge. No-op on Windows/Linux. */ + setUnreadDockBadgeCount: (count: number) => Promise + /** Resolves the launch directory for global Floating Terminal tabs. */ + getFloatingTerminalCwd: (args?: FloatingTerminalCwdRequest) => Promise + /** Resolves Orca's app-owned directory for auto-created Floating Workspace + * markdown notes. */ + getFloatingMarkdownDirectory: () => Promise + /** Opens a native picker for markdown documents, rooted in the floating + * workspace, and authorizes the selected file for editor reads/writes. */ + pickFloatingMarkdownDocument: () => Promise + /** Opens a native directory picker and authorizes the selected directory + * for Floating Workspace markdown file creation. */ + pickFloatingWorkspaceDirectory: () => Promise + /** Persists flag-gated terminal render evidence under app-owned userData. */ + writeTerminalRenderDesyncEvidence: ( + args: WriteTerminalRenderDesyncEvidenceArgs + ) => Promise +} + +/** Panel contribution as surfaced by the main-process plugin service. */ + +export type PlatformApi = { + get: () => { + platform: NodeJS.Platform + osRelease: string + arch: string + /** Login shell or ComSpec when available. */ + shell: string + displayServer: 'wayland' | 'x11' | null + } +} + +export type E2EApi = { + getConfig: () => E2EConfig +} diff --git a/src/preload/api/automation-api.ts b/src/preload/api/automation-api.ts new file mode 100644 index 00000000000..90f13e4ce1e --- /dev/null +++ b/src/preload/api/automation-api.ts @@ -0,0 +1,37 @@ +import type { + Automation, + AutomationCreateInput, + AutomationDispatchRequest, + AutomationDispatchResult, + ExternalAutomationCreateInput, + ExternalAutomationActionInput, + ExternalAutomationManager, + ExternalAutomationRunsInput, + ExternalAutomationRunsPage, + ExternalAutomationUpdateInput, + AutomationRun, + AutomationPrecheckResult, + AutomationUpdateInput +} from '../../shared/automations-types' + +export type AutomationsApi = { + list: () => Promise + listRuns: (args?: { automationId?: string }) => Promise + listExternalManagers: () => Promise + listExternalRuns: (input: ExternalAutomationRunsInput) => Promise + createExternal: (input: ExternalAutomationCreateInput) => Promise + updateExternal: (input: ExternalAutomationUpdateInput) => Promise + runExternalAction: (input: ExternalAutomationActionInput) => Promise + create: (input: AutomationCreateInput) => Promise + update: (args: { id: string; updates: AutomationUpdateInput }) => Promise + delete: (args: { id: string }) => Promise + runNow: (args: { id: string }) => Promise + runPrecheck: (args: { + automationId: string + runId: string + }) => Promise + markDispatchResult: (result: AutomationDispatchResult) => Promise + snapshotWorkspaceName: (args: { workspaceId: string; displayName: string }) => Promise + rendererReady: () => Promise + onDispatchRequested: (callback: (request: AutomationDispatchRequest) => void) => () => void +} diff --git a/src/preload/api/browser-api.ts b/src/preload/api/browser-api.ts new file mode 100644 index 00000000000..b6591162d19 --- /dev/null +++ b/src/preload/api/browser-api.ts @@ -0,0 +1,132 @@ +import type { BrowserSetAnnotationViewportBridgeArgs } from '../../shared/browser-annotation-viewport-bridge' +import type { + BrowserSetGrabModeArgs, + BrowserSetGrabModeResult, + BrowserAwaitGrabSelectionArgs, + BrowserGrabResult, + BrowserCancelGrabArgs, + BrowserCaptureSelectionScreenshotArgs, + BrowserCaptureSelectionScreenshotResult, + BrowserExtractHoverArgs, + BrowserExtractHoverResult +} from '../../shared/browser-grab-types' +import type { + BrowserContextMenuDismissedEvent, + BrowserContextMenuRequestedEvent, + BrowserDownloadFinishedEvent, + BrowserDownloadProgressEvent, + BrowserDownloadRequestedEvent, + BrowserPermissionDeniedEvent, + BrowserPopupEvent +} from '../../shared/browser-guest-events' +import type { + BrowserCookieImportResult, + BrowserCertificateFailure, + BrowserCertificateProceedResult, + BrowserLoadError, + BrowserSessionProfile, + BrowserSessionProfileCreateOptions, + BrowserSessionProfileScope, + BrowserSessionProfileSource, + BrowserViewportOverride +} from '../../shared/types' + +export type BrowserApi = { + registerGuest: (args: { + browserPageId: string + workspaceId: string + worktreeId: string + sessionProfileId?: string | null + webContentsId: number + }) => Promise + isGuestRegistered: (args: { browserPageId: string; webContentsId: number }) => Promise + repairGuestRegistration: (args: { + browserPageId: string + workspaceId: string + worktreeId: string + sessionProfileId?: string | null + webContentsId: number + }) => Promise + unregisterGuest: (args: { browserPageId: string }) => Promise + openDevTools: (args: { browserPageId: string }) => Promise + setViewportOverride: (args: { + browserPageId: string + override: BrowserViewportOverride | null + }) => Promise + setAnnotationViewportBridge: (args: BrowserSetAnnotationViewportBridgeArgs) => Promise + onGuestLoadFailed: ( + callback: (args: { browserPageId: string; loadError: BrowserLoadError }) => void + ) => () => void + onCertificateFailureChanged: ( + callback: (event: { browserPageId: string; failure: BrowserCertificateFailure | null }) => void + ) => () => void + proceedCertificate: (args: { + browserPageId: string + challengeId: string + }) => Promise + onPermissionDenied: (callback: (event: BrowserPermissionDeniedEvent) => void) => () => void + onPopup: (callback: (event: BrowserPopupEvent) => void) => () => void + onDownloadRequested: (callback: (event: BrowserDownloadRequestedEvent) => void) => () => void + onDownloadProgress: (callback: (event: BrowserDownloadProgressEvent) => void) => () => void + onDownloadFinished: (callback: (event: BrowserDownloadFinishedEvent) => void) => () => void + onContextMenuRequested: ( + callback: (event: BrowserContextMenuRequestedEvent) => void + ) => () => void + onContextMenuDismissed: ( + callback: (event: BrowserContextMenuDismissedEvent) => void + ) => () => void + onNavigationUpdate: ( + callback: (event: { browserPageId: string; url: string; title: string }) => void + ) => () => void + onActivateView: ( + callback: (data: { worktreeId?: string; browserPageId?: string }) => void + ) => () => void + onPaneFocus: ( + callback: (data: { worktreeId: string | null; browserPageId: string }) => void + ) => () => void + onOpenLinkInOrcaTab: ( + callback: (event: { browserPageId: string; url: string }) => void + ) => () => void + cancelDownload: (args: { downloadId: string }) => Promise + setGrabMode: (args: BrowserSetGrabModeArgs) => Promise + awaitGrabSelection: (args: BrowserAwaitGrabSelectionArgs) => Promise + cancelGrab: (args: BrowserCancelGrabArgs) => Promise + captureSelectionScreenshot: ( + args: BrowserCaptureSelectionScreenshotArgs + ) => Promise + extractHoverPayload: (args: BrowserExtractHoverArgs) => Promise + onGrabModeToggle: (callback: (browserPageId: string) => void) => () => void + onGrabActionShortcut: ( + callback: (args: { browserPageId: string; key: 'c' | 's' }) => void + ) => () => void + sessionListProfiles: () => Promise + sessionCreateProfile: ( + args: { + scope: BrowserSessionProfileScope + label: string + } & BrowserSessionProfileCreateOptions + ) => Promise + sessionDeleteProfile: (args: { profileId: string }) => Promise + sessionImportCookies: (args: { profileId: string }) => Promise + sessionResolvePartition: (args: { profileId: string | null }) => Promise + sessionDetectBrowsers: () => Promise + sessionImportFromBrowser: (args: { + profileId: string + browserFamily: string + browserProfile?: string + }) => Promise + sessionClearDefaultCookies: () => Promise + notifyActiveTabChanged: (args: { browserPageId: string }) => Promise +} + +export type DetectedBrowserProfileInfo = { + name: string + directory: string +} + +export type DetectedBrowserInfo = { + family: BrowserSessionProfileSource['browserFamily'] + label: string + profiles: DetectedBrowserProfileInfo[] + selectedProfile: string +} diff --git a/src/preload/api/cli-install-api.ts b/src/preload/api/cli-install-api.ts new file mode 100644 index 00000000000..cadf7f5cfb7 --- /dev/null +++ b/src/preload/api/cli-install-api.ts @@ -0,0 +1,10 @@ +import type { CliInstallStatus } from '../../shared/cli-install-types' + +export type CliApi = { + getInstallStatus: () => Promise + install: () => Promise + remove: () => Promise + getWslInstallStatus: (args?: { distro?: string | null }) => Promise + installWsl: (args?: { distro?: string | null }) => Promise + removeWsl: (args?: { distro?: string | null }) => Promise +} diff --git a/src/preload/api/crash-report-api.ts b/src/preload/api/crash-report-api.ts new file mode 100644 index 00000000000..c99e1ccf767 --- /dev/null +++ b/src/preload/api/crash-report-api.ts @@ -0,0 +1,38 @@ +import type { + CrashReportBreadcrumbData, + CrashReportCopyDiagnosticsArgs, + CrashReportRecord, + CrashReportSubmitArgs, + CrashReportSubmitResult, + ReactErrorBoundaryReportArgs, + ReactErrorBoundaryReportResult +} from '../../shared/crash-reporting' +import type { RendererHeapStatistics } from '../../shared/renderer-heap-statistics' + +export type CrashReportsApi = { + getLatestPending: () => Promise + getLatestReport: () => Promise + dismiss: (args: { reportId: string }) => Promise + recordRendererError: ( + args: ReactErrorBoundaryReportArgs + ) => Promise + recordBreadcrumb: (args: { name: string; data?: CrashReportBreadcrumbData }) => void + submit: (args: CrashReportSubmitArgs) => Promise + copyLatestDiagnostics: ( + args?: CrashReportCopyDiagnosticsArgs + ) => Promise<{ ok: true } | { ok: false; error: string }> + /** Exact V8/Blink heap sizes; null when the runtime withholds them. */ + readHeapStatistics: () => RendererHeapStatistics | null +} + +export type FeedbackApi = { + submit: (args: { + feedback: string + submitAnonymously?: boolean + githubLogin: string | null + githubEmail: string | null + images?: { contentType: string; data: Uint8Array }[] + }) => Promise< + { ok: true; imagesDelivered?: boolean } | { ok: false; status: number | null; error: string } + > +} diff --git a/src/preload/api/dashboard-api.ts b/src/preload/api/dashboard-api.ts new file mode 100644 index 00000000000..aa8d6dabd8a --- /dev/null +++ b/src/preload/api/dashboard-api.ts @@ -0,0 +1,42 @@ +import type { + DashboardRevealAgentArgs, + DashboardSleepWorkspaceArgs, + DashboardSnapshot, + DashboardSpawnAgentArgs +} from '../../shared/dashboard-snapshot' +import type { + TerminalPreviewConnectResult, + TerminalPreviewDataPayload +} from '../../shared/terminal-preview' + +export type DashboardApi = { + openPopout: (view?: 'board' | 'map') => Promise + publishSnapshot: (snapshot: DashboardSnapshot) => Promise + getPopoutOpen: () => Promise + onPopoutOpenChanged: (callback: (open: boolean) => void) => () => void + onSnapshotRequested: (callback: () => void) => () => void + onRevealAgent: (callback: (args: DashboardRevealAgentArgs) => void) => () => void + onAckAgent: (callback: (paneKey: string) => void) => () => void + onSpawnAgent: (callback: (args: DashboardSpawnAgentArgs) => void) => () => void + onSleepWorkspace: (callback: (args: DashboardSleepWorkspaceArgs) => void) => () => void + requestSnapshot: () => Promise + onSnapshot: (callback: (snapshot: DashboardSnapshot) => void) => () => void + onViewRequested: (callback: (view: 'board' | 'map') => void) => () => void + revealAgent: (args: DashboardRevealAgentArgs) => Promise + ackAgent: (paneKey: string) => Promise + spawnAgent: (args: DashboardSpawnAgentArgs) => Promise + sleepWorkspace: (args: DashboardSleepWorkspaceArgs) => Promise +} + +export type TerminalPreviewApi = { + connect: ( + ptyId: string, + opts?: { scrollbackRows?: number } + ) => Promise + input: (ptyId: string, data: string) => Promise + /** Claim the PTY grid for the preview dialog; resolves to the size actually in effect. */ + fit: (ptyId: string, cols: number, rows: number) => Promise<{ cols: number; rows: number } | null> + ack: (ptyId: string, bytes: number) => Promise + unsubscribe: (ptyId: string) => Promise + onData: (callback: (payload: TerminalPreviewDataPayload) => void) => () => void +} diff --git a/src/preload/api/emulator-api.ts b/src/preload/api/emulator-api.ts new file mode 100644 index 00000000000..5781045bc67 --- /dev/null +++ b/src/preload/api/emulator-api.ts @@ -0,0 +1,37 @@ +export type EmulatorApi = { + onPaneFocus: (callback: (data: { worktreeId: string }) => void) => () => void + onAutoAttach: ( + callback: (data: { + worktreeId: string + info: { deviceUdid: string; streamUrl: string; wsUrl: string; axUrl?: string } + }) => void + ) => () => void + startFrameStream: (args: { streamUrl: string; streamKey?: string }) => Promise<{ + streamId: string + }> + stopFrameStream: (args: { streamId: string }) => Promise + onFrameStreamFrame: ( + callback: (data: { streamId: string; bytes: ArrayBuffer }) => void + ) => () => void + onFrameStreamError: ( + callback: (data: { streamId: string; message: string }) => void + ) => () => void + startVideoStream: (args: { deviceId: string; streamId: string }) => Promise<{ streamId: string }> + stopVideoStream: (args: { streamId: string }) => Promise + onVideoStreamMeta: ( + callback: (data: { + streamId: string + deviceId: string + meta: { codecId: string; width: number; height: number } + }) => void + ) => () => void + onVideoStreamFrame: ( + callback: (data: { + streamId: string + deviceId: string + config: boolean + keyFrame: boolean + bytes: ArrayBuffer + }) => void + ) => () => void +} diff --git a/src/preload/api/ephemeral-vm-api.ts b/src/preload/api/ephemeral-vm-api.ts new file mode 100644 index 00000000000..e3b6ea202fd --- /dev/null +++ b/src/preload/api/ephemeral-vm-api.ts @@ -0,0 +1,72 @@ +import type { OrcaHooks } from '../../shared/types' +import type { PublicKnownRuntimeEnvironment } from '../../shared/runtime-environments' +import type { EphemeralVmRecipeDoctorResult } from '../../shared/ephemeral-vm-recipes' +import type { EphemeralVmRecipeResultWarning } from '../../shared/ephemeral-vm-recipe-diagnostics' +import type { EphemeralVmRuntimeRecord } from '../../shared/ephemeral-vm-runtimes' + +export type EphemeralVmApi = { + listRecipes: (args: { repoId: string }) => Promise<{ + status: 'ok' | 'error' + repoPath: string | null + recipes: OrcaHooks['environmentRecipes'] + diagnostics: NonNullable + message?: string + }> + listRecipeCatalog: () => Promise< + { + repoId: string + repoName: string + repoPath: string + recipes: NonNullable + diagnostics: NonNullable + }[] + > + doctor: (args: { repoId: string; recipeId: string }) => Promise + provision: (args: { + repoId: string + recipeId: string + workspaceName?: string + projectId?: string + workspaceId?: string + branch?: string + ref?: string + provisionId?: string + }) => Promise< + | { + ok: true + connectionType: 'orca-server' + runtime: EphemeralVmRuntimeRecord + environment: PublicKnownRuntimeEnvironment + stderr: string + warnings: EphemeralVmRecipeResultWarning[] + } + | { + ok: true + connectionType: 'ssh' + runtime: EphemeralVmRuntimeRecord + sshTargetId: string + stderr: string + warnings: EphemeralVmRecipeResultWarning[] + } + | { ok: false; error: string; stderr: string; stdout: string } + > + cancelProvision: (args: { provisionId: string }) => Promise<{ cancelled: boolean }> + onProvisionEvent: ( + callback: (event: { provisionId: string; stream: 'stdout' | 'stderr'; chunk: string }) => void + ) => () => void + listRuntimes: () => Promise + attachWorkspace: (args: { + runtimeId: string + workspaceId: string + }) => Promise + suspendWorkspace: (args: { workspaceId: string }) => Promise + resumeWorkspace: (args: { workspaceId: string }) => Promise + cleanup: (args: { runtimeId: string }) => Promise + getCleanupCommand: (args: { runtimeId: string }) => Promise<{ + runtimeId: string + command: string | null + payloadJson: string + cleanupDisabled: boolean + message?: string + }> +} diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts new file mode 100644 index 00000000000..fdc1ac73250 --- /dev/null +++ b/src/preload/api/filesystem-api.ts @@ -0,0 +1,208 @@ +import type { + DirEntry, + FsChangedPayload, + MarkdownDocument, + SearchOptions, + SearchResult +} from '../../shared/types' +import type { + LocalLogTailChangedPayload, + LocalLogTailReadArgs, + LocalLogTailReadResult, + LocalLogTailWatchArgs +} from '../../shared/local-log-tail-types' +import type { SshMutationExpectation } from '../../shared/ssh-types' + +export type ExportApi = { + htmlToPdf: (args: { + html: string + title: string + }) => Promise< + { success: true; filePath: string } | { success: false; cancelled?: boolean; error?: string } + > +} + +export type FilesystemApi = { + fs: { + readDir: (args: { dirPath: string; connectionId?: string }) => Promise + readFile: (args: { + filePath: string + connectionId?: string + includeLocalLogMetadata?: boolean + }) => Promise<{ + content: string + isBinary: boolean + isImage?: boolean + mimeType?: string + fileIdentity?: string + }> + readLocalLogTail: (args: LocalLogTailReadArgs) => Promise + startLocalLogTail: (args: LocalLogTailWatchArgs) => Promise + stopLocalLogTail: (args: { subscriptionId: string }) => Promise + onLocalLogTailChanged: (callback: (payload: LocalLogTailChangedPayload) => void) => () => void + downloadFile: (args: { + filePath: string + connectionId: string + }) => Promise<{ canceled: true } | { canceled: false; destinationPath: string }> + downloadFolder: (args: { + dirPath: string + connectionId: string + }) => Promise<{ canceled: true } | { canceled: false; destinationPath: string }> + saveDownloadedFile: (args: { + suggestedName: string + content: string + encoding: 'utf8' | 'base64' + }) => Promise<{ canceled: true } | { canceled: false; destinationPath: string }> + startDownloadedFile: (args: { + suggestedName: string + }) => Promise< + { canceled: true } | { canceled: false; transferId: string; destinationPath: string } + > + appendDownloadedFileChunk: (args: { + transferId: string + contentBase64: string + }) => Promise<{ ok: true }> + finishDownloadedFile: (args: { + transferId: string + }) => Promise<{ canceled: false; destinationPath: string }> + cancelDownloadedFile: (args: { transferId: string }) => Promise<{ ok: true }> + listMarkdownDocuments: (args: { + rootPath: string + connectionId?: string + }) => Promise + writeFile: ( + args: { + filePath: string + content: string + connectionId?: string + } & SshMutationExpectation + ) => Promise + createFile: ( + args: { + filePath: string + connectionId?: string + } & SshMutationExpectation + ) => Promise + createDir: ( + args: { dirPath: string; connectionId?: string } & SshMutationExpectation + ) => Promise + rename: ( + args: { + oldPath: string + newPath: string + connectionId?: string + } & SshMutationExpectation + ) => Promise + copy: ( + args: { + sourcePath: string + destinationPath: string + connectionId?: string + } & SshMutationExpectation + ) => Promise + deletePath: ( + args: { + targetPath: string + connectionId?: string + recursive?: boolean + } & SshMutationExpectation + ) => Promise + authorizeExternalPath: (args: { targetPath: string }) => Promise + stat: (args: { + filePath: string + connectionId?: string + }) => Promise<{ size: number; isDirectory: boolean; mtime: number }> + pathExists: (args: { filePath: string; connectionId?: string }) => Promise + listFiles: (args: { + rootPath: string + connectionId?: string + excludePaths?: string[] + requestToken?: string + }) => Promise + cancelListFiles: (args: { requestToken: string }) => Promise + search: (args: SearchOptions & { connectionId?: string }) => Promise + importExternalPaths: ( + args: { + sourcePaths: string[] + destDir: string + connectionId?: string + ensureDir?: boolean + } & SshMutationExpectation + ) => Promise<{ + results: ( + | { + sourcePath: string + status: 'imported' + destPath: string + kind: 'file' | 'directory' + renamed: boolean + } + | { + sourcePath: string + status: 'skipped' + reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + } + | { + sourcePath: string + status: 'failed' + reason: string + } + )[] + }> + stageExternalPathsForRuntimeUpload: (args: { sourcePaths: string[] }) => Promise<{ + sources: ( + | { + sourcePath: string + status: 'staged' + name: string + kind: 'file' | 'directory' + entries: ( + | { relativePath: string; kind: 'directory' } + | { relativePath: string; kind: 'file'; contentBase64: string } + )[] + } + | { + sourcePath: string + status: 'skipped' + reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + } + | { + sourcePath: string + status: 'failed' + reason: string + } + )[] + }> + resolveDroppedPathsForAgent: ( + args: { + paths: string[] + worktreePath: string + connectionId?: string + } & SshMutationExpectation + ) => Promise<{ + resolvedPaths: string[] + skipped: { + sourcePath: string + reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + }[] + failed: { sourcePath: string; reason: string }[] + }> + watchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise + unwatchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise + onFsChanged: (callback: (payload: FsChangedPayload) => void) => () => void + } + notebook: { + runPythonCell: (args: { + filePath: string + code: string + preamble?: string + connectionId?: string | null + }) => Promise<{ + stdout: string + stderr: string + exitCode: number | null + error?: string + }> + } + export: ExportApi +} diff --git a/src/preload/api/git-inspection-api.ts b/src/preload/api/git-inspection-api.ts new file mode 100644 index 00000000000..c23bcdeecaa --- /dev/null +++ b/src/preload/api/git-inspection-api.ts @@ -0,0 +1,123 @@ +import type { + GitBranchCompareResult, + GitCommitCompareResult, + GitConflictOperation, + GitDiffResult, + GitPushTarget, + GitStagingArea, + GitStatusResult, + GitUpstreamStatus +} from '../../shared/types' +import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' +import type { + CommitMessageAgentCapability, + CommitMessageModelCapability +} from '../../shared/commit-message-agent-spec' + +export type GitInspectionApi = { + status: (args: { + worktreePath: string + connectionId?: string + includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean + reuseLineStats?: boolean + /** Merge-base OID to measure the branch line total against; omit to skip the work. */ + branchLineTotalMergeBase?: string + requestToken?: string + }) => Promise + cancelStatus: (args: { requestToken: string }) => Promise + setStatusUpstreamRefWatch: (args: { + worktreeId: string + worktreePath: string + executionHostId: string + connectionId?: string + branch?: string + upstreamName?: string + }) => Promise + submoduleStatus: (args: { + worktreePath: string + submodulePath: string + connectionId?: string + area?: GitStagingArea + }) => Promise + checkIgnored: (args: { + worktreePath: string + paths: string[] + connectionId?: string + }) => Promise + findHugeFoldersToIgnore: (args: { worktreePath: string }) => Promise + history: ( + args: { worktreePath: string; connectionId?: string } & GitHistoryOptions + ) => Promise + conflictOperation: (args: { + worktreePath: string + connectionId?: string + }) => Promise + diff: (args: { + worktreePath: string + filePath: string + staged: boolean + compareAgainstHead?: boolean + connectionId?: string + }) => Promise + branchCompare: (args: { + worktreePath: string + baseRef: string + connectionId?: string + }) => Promise + commitCompare: (args: { + worktreePath: string + commitId: string + connectionId?: string + }) => Promise + upstreamStatus: (args: { + worktreePath: string + connectionId?: string + pushTarget?: GitPushTarget + }) => Promise + branchDiff: (args: { + worktreePath: string + compare: { + baseRef: string + baseOid: string + headOid: string + mergeBase: string + } + filePath: string + oldPath?: string + connectionId?: string + }) => Promise + commitDiff: (args: { + worktreePath: string + commitOid: string + parentOid?: string | null + filePath: string + oldPath?: string + connectionId?: string + }) => Promise + discoverCommitMessageModels: (args: { + agentId: string + worktreePath?: string + connectionId?: string + }) => Promise< + | { + success: true + capability: CommitMessageAgentCapability + models: CommitMessageModelCapability[] + defaultModelId: string + catalogOrigin: 'probe' | 'spec' + } + | { success: false; error: string } + > + remoteFileUrl: (args: { + worktreePath: string + relativePath: string + line: number + connectionId?: string + }) => Promise + remoteCommitUrl: (args: { + worktreePath: string + sha: string + connectionId?: string + }) => Promise +} diff --git a/src/preload/api/git-operation-api.ts b/src/preload/api/git-operation-api.ts new file mode 100644 index 00000000000..fee33db4efd --- /dev/null +++ b/src/preload/api/git-operation-api.ts @@ -0,0 +1,128 @@ +import type { + GitForkSyncExpectedUpstream, + GitForkSyncResult, + GitPushTarget, + TuiAgent +} from '../../shared/types' +import type { HostedReviewProvider } from '../../shared/hosted-review' +import type { ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai' +import type { SourceControlAiSettings } from '../../shared/source-control-ai-types' + +export type GitOperationApi = { + appendGitignore: (args: { worktreePath: string; folderName: string }) => Promise + abortMerge: (args: { worktreePath: string; connectionId?: string }) => Promise + abortRebase: (args: { worktreePath: string; connectionId?: string }) => Promise + fetch: (args: { + worktreePath: string + connectionId?: string + pushTarget?: GitPushTarget + }) => Promise + syncFork: (args: { + worktreePath: string + connectionId?: string + expectedUpstream: GitForkSyncExpectedUpstream + }) => Promise + push: (args: { + worktreePath: string + publish?: boolean + forceWithLease?: boolean + connectionId?: string + pushTarget?: GitPushTarget + }) => Promise + pull: (args: { + worktreePath: string + connectionId?: string + pushTarget?: GitPushTarget + }) => Promise + fastForward: (args: { + worktreePath: string + connectionId?: string + pushTarget?: GitPushTarget + }) => Promise + rebaseFromBase: (args: { + worktreePath: string + baseRef: string + connectionId?: string + }) => Promise + commit: (args: { + worktreePath: string + message: string + connectionId?: string + }) => Promise<{ success: boolean; error?: string }> + generateCommitMessage: (args: { + worktreePath: string + /** Raw (unstripped) worktree meta key; validated against worktreePath in main. */ + worktreeId?: string + repoId?: string + connectionId?: string + sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams + sourceControlAi?: SourceControlAiSettings + agentCmdOverrides?: Partial> + }) => Promise< + | { success: true; message: string; agentLabel?: string } + | { success: false; error: string; canceled?: boolean } + > + cancelGenerateCommitMessage: (args: { + worktreePath: string + connectionId?: string + }) => Promise + generatePullRequestFields: (args: { + worktreePath: string + /** Raw (unstripped) worktree meta key; validated against worktreePath in main. */ + worktreeId?: string + repoId?: string + base: string + title: string + body: string + draft: boolean + provider?: HostedReviewProvider + useTemplate?: boolean + connectionId?: string + sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams + sourceControlAi?: SourceControlAiSettings + agentCmdOverrides?: Partial> + }) => Promise< + | { + success: true + fields: { base: string; title: string; body: string; draft: boolean } + agentLabel?: string + branchChangedByPreparation?: boolean + } + | { + success: false + error: string + canceled?: boolean + branchChangedByPreparation?: boolean + } + > + cancelGeneratePullRequestFields: (args: { + worktreePath: string + connectionId?: string + }) => Promise + stage: (args: { worktreePath: string; filePath: string; connectionId?: string }) => Promise + bulkStage: (args: { + worktreePath: string + filePaths: string[] + connectionId?: string + }) => Promise + unstage: (args: { + worktreePath: string + filePath: string + connectionId?: string + }) => Promise + bulkUnstage: (args: { + worktreePath: string + filePaths: string[] + connectionId?: string + }) => Promise + discard: (args: { + worktreePath: string + filePath: string + connectionId?: string + }) => Promise + bulkDiscard: (args: { + worktreePath: string + filePaths: string[] + connectionId?: string + }) => Promise +} diff --git a/src/preload/api/github-pull-request-api.ts b/src/preload/api/github-pull-request-api.ts new file mode 100644 index 00000000000..245453cb18f --- /dev/null +++ b/src/preload/api/github-pull-request-api.ts @@ -0,0 +1,196 @@ +import type { AppStarSource } from '../../shared/gh-star-source' +import type { GhAuthDiagnostic } from '../../shared/github/auth-types' +import type { TaskSourceContext } from '../../shared/task-source-context' +import type { + GetRateLimitResult, + GitHubCommentResult, + GitHubOwnerRepo, + GitHubPRFile, + GitHubPRFileContents, + GitHubPRRefreshCandidate, + GitHubPRRefreshEnqueueResult, + GitHubPRRefreshEvent, + GitHubPRRefreshReason, + GitHubPRReviewCommentInput, + GitHubReactionContent, + GitHubViewer, + PRCheckDetail, + PRCheckRunDetails, + PRComment, + PRInfo, + PRRefreshOutcome +} from '../../shared/types' +import type { GitHubRepoSelectorArgs } from './github-work-item-api' + +export type GithubPullRequestApi = { + viewer: () => Promise + repoSlug: (args: { + repoPath: string + repoId?: string + }) => Promise<{ owner: string; repo: string; host?: string } | null> + repoUpstream: (args: { + repoPath: string + repoId?: string + }) => Promise<{ owner: string; repo: string; host?: string } | null> + prForBranch: (args: { + repoPath: string + repoId?: string + branch: string + linkedPRNumber?: number | null + fallbackPRNumber?: number | null + acceptMergedFallbackPR?: boolean + currentHeadOid?: string | null + }) => Promise + refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }) => Promise + enqueuePRRefresh: (args: { + candidate: GitHubPRRefreshCandidate + reason: GitHubPRRefreshReason + priority?: number + }) => Promise + reportVisiblePRRefreshCandidates: (args: { + candidates: GitHubPRRefreshCandidate[] + generation: number + }) => Promise + onPRRefreshEvent: (callback: (event: GitHubPRRefreshEvent) => void) => () => void + prFileContents: ( + args: GitHubRepoSelectorArgs & { + prNumber: number + prRepo?: GitHubOwnerRepo | null + path: string + oldPath?: string + status: GitHubPRFile['status'] + headSha: string + baseSha: string + } + ) => Promise + prChecks: ( + args: GitHubRepoSelectorArgs & { + prNumber: number + headSha?: string + prRepo?: GitHubOwnerRepo | null + noCache?: boolean + } + ) => Promise + prCheckDetails: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + checkRunId?: number + workflowRunId?: number + checkName?: string + url?: string | null + prRepo?: GitHubOwnerRepo | null + }) => Promise + rerunPRChecks: ( + args: GitHubRepoSelectorArgs & { + prNumber: number + headSha?: string + failedOnly?: boolean + prRepo?: GitHubOwnerRepo | null + } + ) => Promise<{ ok: true; count: number } | { ok: false; error: string }> + prComments: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + noCache?: boolean + }) => Promise + setPRCommentReaction: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + reactionSubjectId: string + content: GitHubReactionContent + reacted: boolean + prRepo?: GitHubOwnerRepo | null + }) => Promise + resolveReviewThread: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + threadId: string + resolve: boolean + prRepo?: GitHubOwnerRepo | null + }) => Promise + setPRFileViewed: ( + args: GitHubRepoSelectorArgs & { + prNumber: number + prRepo?: GitHubOwnerRepo | null + pullRequestId: string + path: string + viewed: boolean + } + ) => Promise + updatePRTitle: (args: { + repoPath: string + repoId?: string + prNumber: number + title: string + prRepo?: GitHubOwnerRepo | null + }) => Promise + mergePR: ( + args: GitHubRepoSelectorArgs & { + prNumber: number + method?: 'merge' | 'squash' | 'rebase' + prRepo?: GitHubOwnerRepo | null + } + ) => Promise<{ ok: true } | { ok: false; error: string }> + setPRAutoMerge: ( + args: GitHubRepoSelectorArgs & { + prNumber: number + enabled: boolean + method?: 'merge' | 'squash' | 'rebase' + prRepo?: GitHubOwnerRepo | null + } + ) => Promise<{ ok: true } | { ok: false; error: string }> + updatePRState: ( + args: GitHubRepoSelectorArgs & { + prNumber: number + updates: { state: 'open' | 'closed' } + prRepo?: GitHubOwnerRepo | null + } + ) => Promise<{ ok: true } | { ok: false; error: string }> + requestPRReviewers: ( + args: GitHubRepoSelectorArgs & { + prNumber: number + reviewers: string[] + prRepo?: GitHubOwnerRepo | null + } + ) => Promise<{ ok: true } | { ok: false; error: string }> + removePRReviewers: ( + args: GitHubRepoSelectorArgs & { + prNumber: number + reviewers: string[] + prRepo?: GitHubOwnerRepo | null + } + ) => Promise<{ ok: true } | { ok: false; error: string }> + addPRReviewCommentReply: ( + args: GitHubRepoSelectorArgs & { + prNumber: number + commentId: number + body: string + threadId?: string + path?: string + line?: number + prRepo?: GitHubOwnerRepo | null + } + ) => Promise + addPRReviewComment: ( + args: GitHubPRReviewCommentInput & { + repoId?: string + sourceContext?: TaskSourceContext | null + } + ) => Promise + checkOrcaStarred: () => Promise + starOrca: (source: AppStarSource) => Promise + /** + * GitHub API rate-limit snapshot. Does NOT consume quota (the + * `rate_limit` endpoint is exempt). Cached 30s server-side — pass + * `force: true` to bust after a known-expensive op. + */ + rateLimit: (args?: { force?: boolean }) => Promise + /** Explains scope_missing ProjectV2 failures — notably a shell `GITHUB_TOKEN` shadowing the keyring credential, where `gh auth refresh` is a no-op. */ + diagnoseAuth: (args?: { host?: string }) => Promise +} diff --git a/src/preload/api/github-work-item-api.ts b/src/preload/api/github-work-item-api.ts new file mode 100644 index 00000000000..e302e4517a8 --- /dev/null +++ b/src/preload/api/github-work-item-api.ts @@ -0,0 +1,167 @@ +import type { + AddIssueCommentBySlugArgs, + ClearProjectItemFieldArgs, + DeleteIssueCommentBySlugArgs, + GetProjectViewTableArgs, + GetProjectViewTableResult, + GitHubProjectCommentMutationResult, + GitHubProjectMutationResult, + ListAccessibleProjectsArgs, + ListAccessibleProjectsResult, + ListAssignableUsersBySlugArgs, + ListAssignableUsersBySlugResult, + ListIssueTypesBySlugArgs, + ListIssueTypesBySlugResult, + ListLabelsBySlugArgs, + ListLabelsBySlugResult, + ListProjectViewsArgs, + ListProjectViewsResult, + ProjectWorkItemDetailsBySlugArgs, + ProjectWorkItemDetailsBySlugResult, + ResolveProjectRefArgs, + ResolveProjectRefResult, + UpdateIssueBySlugArgs, + UpdateIssueCommentBySlugArgs, + UpdateIssueTypeBySlugArgs, + UpdateProjectItemFieldArgs, + UpdatePullRequestBySlugArgs +} from '../../shared/github/project-types' +import type { TaskSourceContext } from '../../shared/task-source-context' +import type { + GitHubAssignableUser, + GitHubCommentResult, + GitHubCreateIssueResult, + GitHubIssueUpdate, + GitHubOwnerRepo, + GitHubWorkItem, + GitHubWorkItemDetails, + IssueInfo, + ListWorkItemsResult +} from '../../shared/types' + +export type GitHubRepoSelectorArgs = { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null +} + +export type GithubWorkItemApi = { + issue: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + number: number + }) => Promise + workItem: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + number: number + type?: 'issue' | 'pr' + }) => Promise | null> + workItemByOwnerRepo: (args: { + repoPath: string + repoId?: string + owner: string + repo: string + host?: string + number: number + type: 'issue' | 'pr' + }) => Promise | null> + workItemDetails: ( + args: GitHubRepoSelectorArgs & { + number: number + type?: 'issue' | 'pr' + } + ) => Promise + notifyWorkItemMutated: (args: { + repoPath: string + repoId?: string + type: 'issue' | 'pr' + number: number + }) => Promise + listIssues: (args: { repoPath: string; repoId?: string; limit?: number }) => Promise + createIssue: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + title: string + body: string + labels?: string[] + assignees?: string[] + }) => Promise + countWorkItems: (args: { repoPath: string; repoId?: string; query?: string }) => Promise + listWorkItems: (args: { + repoPath: string + repoId?: string + limit?: number + query?: string + page?: number + noCache?: boolean + }) => Promise>> + updateIssue: ( + args: GitHubRepoSelectorArgs & { + number: number + updates: GitHubIssueUpdate + } + ) => Promise<{ ok: true } | { ok: false; error: string }> + addIssueComment: ( + args: GitHubRepoSelectorArgs & { + number: number + body: string + /** Why: scopes the cross-window cache invalidation so a PR and issue sharing the same number don't evict each other. */ + type?: 'issue' | 'pr' + prRepo?: GitHubOwnerRepo | null + } + ) => Promise + listLabels: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + }) => Promise + listAssignableUsers: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + }) => Promise + /** Subscribe to local-mutation broadcasts so the work-item-drawer cache can invalidate across windows. Returns an unsubscribe. */ + onWorkItemMutated: ( + callback: (payload: { + repoPath: string + repoId?: string + type: 'issue' | 'pr' + number: number + }) => void + ) => () => void + // ── ProjectV2 (GitHub Projects) ───────────────────────────────── + listAccessibleProjects: ( + args?: ListAccessibleProjectsArgs + ) => Promise + resolveProjectRef: (args: ResolveProjectRefArgs) => Promise + listProjectViews: (args: ListProjectViewsArgs) => Promise + getProjectViewTable: (args: GetProjectViewTableArgs) => Promise + projectWorkItemDetailsBySlug: ( + args: ProjectWorkItemDetailsBySlugArgs + ) => Promise + updateProjectItemField: (args: UpdateProjectItemFieldArgs) => Promise + clearProjectItemField: (args: ClearProjectItemFieldArgs) => Promise + updateIssueBySlug: (args: UpdateIssueBySlugArgs) => Promise + updatePullRequestBySlug: ( + args: UpdatePullRequestBySlugArgs + ) => Promise + addIssueCommentBySlug: ( + args: AddIssueCommentBySlugArgs + ) => Promise + updateIssueCommentBySlug: ( + args: UpdateIssueCommentBySlugArgs + ) => Promise + deleteIssueCommentBySlug: ( + args: DeleteIssueCommentBySlugArgs + ) => Promise + listLabelsBySlug: (args: ListLabelsBySlugArgs) => Promise + listAssignableUsersBySlug: ( + args: ListAssignableUsersBySlugArgs + ) => Promise + listIssueTypesBySlug: (args: ListIssueTypesBySlugArgs) => Promise + updateIssueTypeBySlug: (args: UpdateIssueTypeBySlugArgs) => Promise +} diff --git a/src/preload/api/gitlab-api.ts b/src/preload/api/gitlab-api.ts new file mode 100644 index 00000000000..b1467a891f4 --- /dev/null +++ b/src/preload/api/gitlab-api.ts @@ -0,0 +1,172 @@ +import type { TaskSourceContext } from '../../shared/task-source-context' +import type { + ClassifiedError, + GetGitLabRateLimitResult, + GitLabAssignableUser, + GitLabAuthDiagnostic, + GitLabCommentResult, + GitLabDiscussionResolveResult, + GitLabIssueInfo, + GitLabIssueUpdate, + GitLabJobTraceResult, + GitLabMRInlineCommentInput, + GitLabMRReviewersUpdateResult, + GitLabMRUpdate, + GitLabProjectRef, + GitLabRetryJobResult, + GitLabTodo, + GitLabViewer, + GitLabWorkItem, + GitLabWorkItemDetails, + ListMergeRequestsResult, + MRInfo, + MRListState +} from '../../shared/types' + +export type GitLabRepoSelectorArgs = { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null +} + +// ── GitLab — parallel to gh, MR/issue surface only in v1 ──────── +// Shapes mirror gh.* except where GitLab's API differs (MR states, host-qualified project path, `glab api -i` paging). +export type GitLabApi = { + viewer: () => Promise + diagnoseAuth: () => Promise + rateLimit: (args?: { force?: boolean; host?: string | null }) => Promise + projectSlug: (args: GitLabRepoSelectorArgs) => Promise + mrForBranch: ( + args: GitLabRepoSelectorArgs & { + branch: string + linkedMRIid?: number | null + } + ) => Promise + mr: (args: GitLabRepoSelectorArgs & { iid: number }) => Promise + listMRs: ( + args: GitLabRepoSelectorArgs & { + state?: MRListState + page?: number + perPage?: number + query?: string + } + ) => Promise + /** Combined MR + issue list filtered by state. Issues are skipped + * when state is 'merged' (issues don't merge). */ + listWorkItems: ( + args: GitLabRepoSelectorArgs & { + state?: MRListState + page?: number + perPage?: number + query?: string + } + ) => Promise + issue: (args: GitLabRepoSelectorArgs & { number: number }) => Promise + listIssues: ( + args: GitLabRepoSelectorArgs & { + state?: 'opened' | 'closed' | 'all' + assignee?: string + limit?: number + } + ) => Promise<{ items: GitLabWorkItem[]; error?: ClassifiedError }> + createIssue: ( + args: GitLabRepoSelectorArgs & { + title: string + body: string + } + ) => Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> + updateIssue: ( + args: GitLabRepoSelectorArgs & { + number: number + updates: GitLabIssueUpdate + } + ) => Promise<{ ok: true } | { ok: false; error: string }> + addIssueComment: ( + args: GitLabRepoSelectorArgs & { + number: number + body: string + } + ) => Promise + listLabels: (args: GitLabRepoSelectorArgs) => Promise + listAssignableUsers: (args: GitLabRepoSelectorArgs) => Promise + /** Cross-project user-scoped todos (gitlab.com/dashboard/todos). */ + todos: (args: GitLabRepoSelectorArgs) => Promise + /** Aggregated dialog payload — body + discussions + pipeline jobs. */ + workItemDetails: ( + args: GitLabRepoSelectorArgs & { + iid: number + type: 'issue' | 'mr' + } + ) => Promise + closeMR: ( + args: GitLabRepoSelectorArgs & { + iid: number + } + ) => Promise<{ ok: true } | { ok: false; error: string }> + reopenMR: ( + args: GitLabRepoSelectorArgs & { + iid: number + } + ) => Promise<{ ok: true } | { ok: false; error: string }> + mergeMR: ( + args: GitLabRepoSelectorArgs & { + iid: number + method?: 'merge' | 'squash' | 'rebase' + } + ) => Promise<{ ok: true } | { ok: false; error: string }> + updateMR: ( + args: GitLabRepoSelectorArgs & { + iid: number + updates: GitLabMRUpdate + } + ) => Promise<{ ok: true } | { ok: false; error: string }> + updateMRReviewers: ( + args: GitLabRepoSelectorArgs & { + iid: number + reviewerIds: number[] + projectRef?: GitLabProjectRef | null + } + ) => Promise + addMRComment: ( + args: GitLabRepoSelectorArgs & { + iid: number + body: string + } + ) => Promise + addMRInlineComment: ( + args: GitLabRepoSelectorArgs & { + iid: number + input: GitLabMRInlineCommentInput + projectRef?: GitLabProjectRef | null + } + ) => Promise + resolveMRDiscussion: ( + args: GitLabRepoSelectorArgs & { + iid: number + discussionId: string + resolved: boolean + } + ) => Promise + jobTrace: ( + args: GitLabRepoSelectorArgs & { + jobId: number + projectRef?: GitLabProjectRef | null + /** Bound the trace in main to a readable excerpt (see gitLabJobTraceToLogExcerpt). */ + logExcerpt?: boolean + } + ) => Promise + retryJob: ( + args: GitLabRepoSelectorArgs & { + jobId: number + projectRef?: GitLabProjectRef | null + } + ) => Promise + workItemByPath: ( + args: GitLabRepoSelectorArgs & { + host: string + path: string + iid: number + type: 'issue' | 'mr' + } + ) => Promise | null> +} diff --git a/src/preload/api/hosted-review-api.ts b/src/preload/api/hosted-review-api.ts new file mode 100644 index 00000000000..7abe2d81f61 --- /dev/null +++ b/src/preload/api/hosted-review-api.ts @@ -0,0 +1,31 @@ +import type { + BitbucketConnectArgs, + BitbucketConnectionStatus +} from '../../shared/bitbucket-credentials' +import type { + CreateHostedReviewArgs, + CreateHostedReviewResult, + CreateStackedHostedReviewArgs, + CreateStackedHostedReviewResult, + HostedReviewCreationEligibility, + HostedReviewCreationEligibilityArgs, + HostedReviewForBranchArgs, + HostedReviewInfo +} from '../../shared/hosted-review' + +export type HostedReviewApi = { + forBranch: (args: HostedReviewForBranchArgs) => Promise + getCreationEligibility: ( + args: HostedReviewCreationEligibilityArgs + ) => Promise + create: (args: CreateHostedReviewArgs) => Promise + createStacked: (args: CreateStackedHostedReviewArgs) => Promise +} + +export type BitbucketApi = { + connect: ( + args: BitbucketConnectArgs + ) => Promise<{ ok: true; account: string | null } | { ok: false; error: string }> + disconnect: () => Promise + status: () => Promise +} diff --git a/src/preload/api/jira-api.ts b/src/preload/api/jira-api.ts new file mode 100644 index 00000000000..d452bd9728d --- /dev/null +++ b/src/preload/api/jira-api.ts @@ -0,0 +1,84 @@ +import type { + JiraComment, + JiraConnectionStatus, + JiraCreateField, + JiraCreateIssueArgs, + JiraIssue, + JiraIssueFilter, + JiraIssueType, + JiraIssueUpdate, + JiraPriority, + JiraProject, + JiraProjectStatusOrder, + JiraSiteSelection, + JiraTransition, + JiraUser, + JiraViewer +} from '../../shared/types' + +export type JiraApi = { + connect: (args: { + siteUrl: string + email: string + apiToken: string + authType?: 'cloud' | 'server' + }) => Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }> + disconnect: (args?: { siteId?: string }) => Promise + selectSite: (args: { siteId: JiraSiteSelection }) => Promise + status: () => Promise + readStatus: () => Promise + testConnection: (args?: { + siteId?: string + }) => Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }> + searchIssues: (args: { + jql: string + limit?: number + siteId?: JiraSiteSelection + requestId?: string + }) => Promise + cancelSearchIssues: (args: { requestId: string }) => Promise + listIssues: (args?: { + filter?: JiraIssueFilter + limit?: number + siteId?: JiraSiteSelection + }) => Promise + getIssue: (args: { key: string; siteId?: string }) => Promise + lookupIssueSummary: (args: { + key: string + siteId: string + requestId?: string + }) => Promise + cancelIssueSummary: (args: { requestId: string }) => Promise + createIssue: ( + args: JiraCreateIssueArgs + ) => Promise<{ ok: true; id: string; key: string; url: string } | { ok: false; error: string }> + updateIssue: (args: { + key: string + updates: JiraIssueUpdate + siteId?: string + }) => Promise<{ ok: true } | { ok: false; error: string }> + addIssueComment: (args: { + key: string + body: string + siteId?: string + }) => Promise<{ ok: true; id: string } | { ok: false; error: string }> + issueComments: (args: { key: string; siteId?: string }) => Promise + listProjects: (args?: { siteId?: JiraSiteSelection }) => Promise + listIssueTypes: (args: { projectIdOrKey: string; siteId?: string }) => Promise + listCreateFields: (args: { + projectIdOrKey: string + issueTypeId: string + siteId?: string + }) => Promise + listPriorities: (args?: { siteId?: string }) => Promise + listAssignableUsers: (args: { + key: string + query?: string + siteId?: string + }) => Promise + listTransitions: (args: { key: string; siteId?: string }) => Promise + getProjectStatusOrder: (args: { + projectKey: string + siteId?: string + }) => Promise +} diff --git a/src/preload/api/linear-api.ts b/src/preload/api/linear-api.ts new file mode 100644 index 00000000000..613837c4fa4 --- /dev/null +++ b/src/preload/api/linear-api.ts @@ -0,0 +1,128 @@ +import type { LinearIssueAttributeFilter } from '../../shared/linear/issue-attribute-filter' +import type { + LinearCollectionResult, + LinearComment, + LinearConnectionStatus, + LinearCustomViewModel, + LinearCustomViewSummary, + LinearIssue, + LinearIssueUpdate, + LinearLabel, + LinearMember, + LinearProjectDetail, + LinearProjectSummary, + LinearTeam, + LinearViewer, + LinearWorkflowState, + LinearWorkspaceSelection +} from '../../shared/types' + +export type LinearApi = { + connect: (args: { + apiKey: string + }) => Promise<{ ok: true; viewer: LinearViewer } | { ok: false; error: string }> + disconnect: (args?: { workspaceId?: string }) => Promise + selectWorkspace: (args: { + workspaceId: LinearWorkspaceSelection + }) => Promise + status: () => Promise + testConnection: (args?: { + workspaceId?: string + }) => Promise<{ ok: true; viewer: LinearViewer } | { ok: false; error: string }> + searchIssues: (args: { + query: string + limit?: number + workspaceId?: LinearWorkspaceSelection + }) => Promise + listIssues: (args?: { + filter?: 'assigned' | 'created' | 'all' | 'completed' + limit?: number + workspaceId?: LinearWorkspaceSelection + attributeFilter?: LinearIssueAttributeFilter + }) => Promise> + createIssue: (args: { + teamId: string + title: string + description?: string + workspaceId?: string + parentIssueId?: string + projectId?: string | null + stateId?: string + priority?: number + assigneeId?: string | null + labelIds?: string[] + }) => Promise< + | { ok: true; id: string; identifier: string; title: string; url: string } + | { ok: false; error: string } + > + getIssue: (args: { id: string; workspaceId?: string }) => Promise + updateIssue: (args: { + id: string + updates: LinearIssueUpdate + workspaceId?: string + }) => Promise<{ ok: true } | { ok: false; error: string }> + addIssueComment: (args: { + issueId: string + body: string + workspaceId?: string + }) => Promise<{ ok: true; id: string } | { ok: false; error: string }> + issueComments: (args: { issueId: string; workspaceId?: string }) => Promise + listTeams: (args?: { workspaceId?: LinearWorkspaceSelection }) => Promise + listProjects: (args?: { + query?: string + limit?: number + workspaceId?: LinearWorkspaceSelection + force?: boolean + }) => Promise> + createProject: (args: { + name: string + description?: string + content?: string + teamIds: string[] + workspaceId?: string + leadId?: string | null + memberIds?: string[] + labelIds?: string[] + priority?: number + startDate?: string + targetDate?: string + }) => Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> + getProject: (args: { + id: string + workspaceId: string + force?: boolean + }) => Promise + listProjectIssues: (args: { + projectId: string + limit?: number + workspaceId: string + force?: boolean + }) => Promise> + listCustomViews: (args: { + model: LinearCustomViewModel + limit?: number + workspaceId?: LinearWorkspaceSelection + force?: boolean + }) => Promise> + getCustomView: (args: { + viewId: string + model: LinearCustomViewModel + workspaceId: string + force?: boolean + }) => Promise + listCustomViewIssues: (args: { + viewId: string + limit?: number + workspaceId: string + force?: boolean + }) => Promise> + listCustomViewProjects: (args: { + viewId: string + limit?: number + workspaceId: string + force?: boolean + }) => Promise> + teamStates: (args: { teamId: string; workspaceId?: string }) => Promise + teamLabels: (args: { teamId: string; workspaceId?: string }) => Promise + teamMembers: (args: { teamId: string; workspaceId?: string }) => Promise +} diff --git a/src/preload/api/mobile-api.ts b/src/preload/api/mobile-api.ts new file mode 100644 index 00000000000..02e82f34044 --- /dev/null +++ b/src/preload/api/mobile-api.ts @@ -0,0 +1,86 @@ +import type { MobileRelayStatus } from '../../shared/mobile-relay-status' +import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' +import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' +import type { MobileRelayMintFailure } from '../../shared/mobile-relay-mint-failure' +import type { RuntimeAccessGrant } from '../../shared/runtime-access-grants' + +export type MobileApi = { + listNetworkInterfaces: () => Promise<{ + interfaces: { name: string; address: string; hasDefaultRoute?: boolean }[] + }> + getPairingQR: (args?: { + address?: string + connectionMode?: MobilePairingConnectionMode + rotate?: boolean + }) => Promise< + | { + available: false + reason?: string + guidance?: string + relayFailure?: MobileRelayMintFailure + } + | { + available: true + qrDataUrl: string | null + qrError?: 'encoding_failed' + pairingUrl: string + /** Null when no direct address was advertised — the QR pairs over Relay alone. */ + endpoint: string | null + deviceId: string + /** Mode the QR actually encodes. */ + connectionMode: MobilePairingConnectionMode + } + > + getWindowsFirewallStatus: (args?: { address?: string }) => Promise< + | { supported: false } + | { + supported: true + port: number + ruleAllowed: boolean + blockingRuleDetected: boolean + privateFirewallEnabled: boolean + networkCategory: 'private' | 'public' | 'domain' | 'unknown' + inspectionAvailable: boolean + } + > + repairWindowsFirewall: () => Promise< + { ok: true } | { ok: false; reason: 'cancelled' | 'failed' | 'unsupported' } + > + openWindowsNetworkSettings: () => Promise + getRuntimePairingUrl: (args?: { + address?: string + rotate?: boolean + reach?: RuntimePairingReach + }) => Promise< + | { + available: false + reason?: 'network_exposure_failed' + guidance?: string + } + | { + available: true + pairingUrl: string + webClientUrl: string | null + endpoint: string + deviceId: string + } + > + listDevices: () => Promise<{ + devices: { + deviceId: string + name: string + pairedAt: number + lastSeenAt: number + }[] + }> + revokeDevice: (args: { deviceId: string }) => Promise<{ revoked: boolean }> + listRuntimeAccessGrants: () => Promise<{ grants: RuntimeAccessGrant[] }> + revokeRuntimeAccess: (args: { deviceId: string }) => Promise<{ revoked: boolean }> + isWebSocketReady: () => Promise<{ ready: boolean; endpoint: string | null }> + getRelayStatus: () => Promise<{ status: MobileRelayStatus }> + onRelayStatusChanged: (callback: (status: MobileRelayStatus) => void) => () => void + /** Consumes an auth-failure notification that arrived before the renderer listener mounted. */ + consumePendingUnpairedDeviceAuthFailure?: () => Promise + /** Fires (throttled, once per session) when an unpaired phone repeatedly fails direct-transport auth. */ + onUnpairedDeviceAuthFailure?: (callback: () => void) => () => void +} diff --git a/src/preload/api/native-chat-api.ts b/src/preload/api/native-chat-api.ts new file mode 100644 index 00000000000..01eac44bde6 --- /dev/null +++ b/src/preload/api/native-chat-api.ts @@ -0,0 +1,71 @@ +import type { + AgentType, + NativeChatMessage, + NativeChatTurnLifecycle +} from '../../shared/native-chat-types' + +// notFound marks a not-yet-on-disk miss (retry-worthy) vs a real read/parse error (#8401). +export type NativeChatReadSessionResult = + | { + messages: NativeChatMessage[] + lifecycle?: NativeChatTurnLifecycle + } + | { error: string; notFound?: true } + +/** Messages appended to a live-tailed transcript since the previous emit. */ +export type NativeChatAppendedMessages = NativeChatMessage[] + +export type NativeChatSubscriptionFrame = + | { + type: 'snapshot' + messages: NativeChatMessage[] + hasMore: boolean + error?: string + lifecycle?: NativeChatTurnLifecycle + } + | { + type: 'replacement' + messages: NativeChatMessage[] + hasMore: boolean + lifecycle?: NativeChatTurnLifecycle + } + | { + type: 'appended' + messages: NativeChatMessage[] + lifecycle?: NativeChatTurnLifecycle + } + +/** Wire payload for the `nativeChat:appended` push channel. */ +export type NativeChatAppendedPayload = { + subscriptionId: string + frame: NativeChatSubscriptionFrame +} + +export type NativeChatSubscribeArgs = { + /** Unique per-caller id, echoed on every append so multiple live panes in + * one renderer don't cross-talk. */ + subscriptionId: string + agent: AgentType + sessionId: string + /** Authoritative transcript path from the agent hook (providerSession). */ + transcriptPath?: string + /** First snapshot size; later readSession calls grow this for pagination. */ + limit?: number +} + +export type NativeChatApi = { + /** Read the on-disk transcript for an agent + session id, windowed to the most recent `limit` + * turns. `transcriptPath` is the hook-reported authoritative path, preferred over the id glob. */ + readSession: ( + agent: AgentType, + sessionId: string, + limit?: number, + transcriptPath?: string + ) => Promise + /** Live-tail a transcript. The first frame is a bounded race-safe snapshot; + * later frames contain only newly appended messages. */ + subscribe: ( + args: NativeChatSubscribeArgs, + onFrame: (frame: NativeChatSubscriptionFrame) => void + ) => () => void +} diff --git a/src/preload/api/onboarding-api.ts b/src/preload/api/onboarding-api.ts new file mode 100644 index 00000000000..678a6b38294 --- /dev/null +++ b/src/preload/api/onboarding-api.ts @@ -0,0 +1,28 @@ +import type { OnboardingState } from '../../shared/types' + +export type OnboardingApi = { + get: () => Promise + // Why: main merges the checklist field-by-field, so a partial checklist is fine. + update: ( + updates: Partial> & { + checklist?: Partial + } + ) => Promise +} + +export type StarNagApi = { + onShow: ( + callback: (payload?: { mode?: 'gh' | 'web'; surface?: 'card' | 'toast' }) => void + ) => () => void + onHide: (callback: () => void) => () => void + dismiss: () => Promise + later: () => Promise + complete: () => Promise + disable: () => Promise + openWeb: () => Promise + starOrca: () => Promise + forceShow: () => Promise + agentValueMoment: () => Promise<{ status: 'ready'; mode: 'gh' | 'web' } | { status: 'skipped' }> + showAgentValueMoment: () => Promise + onboardingCompleted: () => Promise +} diff --git a/src/preload/api/orca-profile-api.ts b/src/preload/api/orca-profile-api.ts new file mode 100644 index 00000000000..16c2a575078 --- /dev/null +++ b/src/preload/api/orca-profile-api.ts @@ -0,0 +1,59 @@ +import type { + ConnectCurrentOrcaProfileResult, + CreateCloudLinkedOrcaProfileArgs, + CreateCloudLinkedOrcaProfileResult, + CreateLocalOrcaProfileArgs, + CreateLocalOrcaProfileResult, + FindOrcaProfileProjectsByPathArgs, + FindOrcaProfileProjectsByPathResult, + OrcaProfileAuthStatus, + OrcaProfileListResult, + OrcaProfileOrgInviteRevokeArgs, + OrcaProfileOrgMemberChangeRoleArgs, + OrcaProfileOrgMemberInviteArgs, + OrcaProfileOrgMemberMutationResult, + OrcaProfileOrgMemberRemoveArgs, + OrcaProfileOrgMembersListArgs, + OrcaProfileOrgMembersListResult, + RefreshCurrentOrcaProfileAuthResult, + SelectOrcaProfileOrgArgs, + SelectOrcaProfileOrgResult, + SignOutCurrentOrcaProfileResult, + SwitchOrcaProfileArgs, + SwitchOrcaProfileResult, + TransferOrcaProfileProjectArgs, + TransferOrcaProfileProjectResult +} from '../../shared/orca-profiles' + +export type OrcaProfileApi = { + list: () => Promise + authStatus: () => Promise + createLocal: (args?: CreateLocalOrcaProfileArgs) => Promise + createCloudLinked: ( + args?: CreateCloudLinkedOrcaProfileArgs + ) => Promise + switchProfile: (args: SwitchOrcaProfileArgs) => Promise + transferProject: ( + args: TransferOrcaProfileProjectArgs + ) => Promise + findProjectProfiles: ( + args: FindOrcaProfileProjectsByPathArgs + ) => Promise + connectCurrent: () => Promise + refreshAuth: () => Promise + signOutCurrent: () => Promise + selectOrg: (args: SelectOrcaProfileOrgArgs) => Promise + orgMembersList: (args: OrcaProfileOrgMembersListArgs) => Promise + orgMemberInvite: ( + args: OrcaProfileOrgMemberInviteArgs + ) => Promise + orgInviteRevoke: ( + args: OrcaProfileOrgInviteRevokeArgs + ) => Promise + orgMemberChangeRole: ( + args: OrcaProfileOrgMemberChangeRoleArgs + ) => Promise + orgMemberRemove: ( + args: OrcaProfileOrgMemberRemoveArgs + ) => Promise +} diff --git a/src/preload/api/os-permission-api.ts b/src/preload/api/os-permission-api.ts new file mode 100644 index 00000000000..fc452df1196 --- /dev/null +++ b/src/preload/api/os-permission-api.ts @@ -0,0 +1,54 @@ +import type { + ComputerUsePermissionId, + ComputerUsePermissionResetResult, + ComputerUsePermissionSetupResult, + ComputerUsePermissionStatusResult +} from '../../shared/computer-use-permissions-types' +import type { + DeveloperPermissionId, + DeveloperPermissionRequestResult, + DeveloperPermissionState, + LocalNetworkConnectionTestResult +} from '../../shared/developer-permissions-types' +import type { + NotificationDispatchRequest, + NotificationDispatchResult, + NotificationDeliveryProbeResult, + NotificationDismissResult, + NotificationPermissionStatusResult, + NotificationSoundResult +} from '../../shared/types' + +export type NotificationsApi = { + dispatch: (args: NotificationDispatchRequest) => Promise + dismiss: (ids: string[]) => Promise + openSystemSettings: () => Promise + getPermissionStatus: () => Promise + probeDelivery: (args?: { force?: boolean }) => Promise + playSound: (options?: { force?: boolean; volume?: number }) => Promise +} + +export type MacosTccPromptsApi = { + /** Fires once macOS has raised its Nth consent dialog naming Orca (#9756). */ + onThreshold: (callback: (payload: { promptCount: number }) => void) => () => void + consumePending: () => Promise<{ claimId: number; promptCount: number } | null> + acknowledgePending: (claimId: number) => Promise + releasePending: (claimId: number) => Promise + dismiss: () => Promise +} + +export type DeveloperPermissionsApi = { + getStatus: () => Promise + request: (args: { id: DeveloperPermissionId }) => Promise + openSettings: (args: { id: DeveloperPermissionId }) => Promise + testLocalNetworkConnection: (args: { + host: string + port: number + }) => Promise +} + +export type ComputerUsePermissionsApi = { + getStatus: () => Promise + openSetup: (args?: { id?: ComputerUsePermissionId }) => Promise + reset: () => Promise +} diff --git a/src/preload/api/pet-api.ts b/src/preload/api/pet-api.ts new file mode 100644 index 00000000000..e0031cb8e36 --- /dev/null +++ b/src/preload/api/pet-api.ts @@ -0,0 +1,8 @@ +import type { CustomPet } from '../../shared/types' + +export type PetApi = { + import: () => Promise + importPetBundle: () => Promise + read: (id: string, fileName: string, kind?: 'image' | 'bundle') => Promise + delete: (id: string, fileName: string, kind?: 'image' | 'bundle') => Promise +} diff --git a/src/preload/api/plugin-host-api.ts b/src/preload/api/plugin-host-api.ts new file mode 100644 index 00000000000..49222ae7a0b --- /dev/null +++ b/src/preload/api/plugin-host-api.ts @@ -0,0 +1,191 @@ +import type { + PluginPanelActionOutcome, + PluginPanelEntry +} from '../../shared/plugins/plugin-panel-bridge' +import type { PluginConsentRequest } from '../../shared/plugins/plugin-consent-request' +import type { PluginLanguagePackRegistration } from '../../shared/plugins/plugin-language-pack-artifact' +import type { PluginChangeEvent } from '../../shared/plugins/plugin-change-event' +import type { PluginManifest } from '../../shared/plugins/plugin-manifest' +import type { PluginMarketplaceGitSource } from '../../shared/plugins/plugin-marketplace' + +/** Panel contribution as surfaced by the main-process plugin service. */ +export type PluginHostPanel = { + id: string + title: string + /** Lucide icon name declared in the plugin manifest. */ + icon?: string + tabKey: `plugin:${string}` +} + +/** `pending` = awaiting (re-)consent; `idle` = enabled, worker not running + * (lazy); `restarting` = waiting for supervised backoff; `errored` = crashed past the restart budget or failed to start; + * `invalid` = unreadable manifest. */ +export type PluginHostStatus = + | 'running' + | 'restarting' + | 'idle' + | 'pending' + | 'disabled' + | 'errored' + | 'invalid' + +/** Wire shape of plugins:list — must stay assignable from the main-process + * projection in src/main/plugins/plugin-list-projection.ts. */ +export type PluginHostListEntry = { + pluginKey: string + consentFingerprint: string | null + name: string + version: string + publisher: string + description?: string + status: PluginHostStatus + needsReconsent: boolean + error?: string + isDev: boolean + official: boolean + bundled: boolean + capabilities: { kind: string; description: string }[] + panels: PluginHostPanel[] + commands: { + id: string + title: string + context: 'global' | 'worktree' + handler: { type: 'built-in'; action: string } | { type: 'worker' } + keybindings: { key: string; when: 'global' | 'worktree' }[] + }[] + hasWorker: boolean + vmRecipes?: { + id: string + name: string + description?: string + commands: { + phase: 'create' | 'suspend' | 'resume' | 'destroy' + command: string + }[] + }[] + restarts: number + blockedByKillList?: { reason: string; advisoryUrl?: string } + source?: { + kind: 'local-path' | 'git' | 'marketplace' | 'bundled' + reference: string + resolvedCommit: string | null + contentHash: string + marketplace?: { reference: string; resolvedCommit: string } + } +} + +export type PluginHostLogLine = { + ts: number + level: 'info' | 'warn' | 'error' + line: string +} + +export type PluginHostInstallSource = + | { kind: 'local-path'; path: string } + | { kind: 'git'; url: string; ref: string } + +export type PluginHostInstallResult = + | { + ok: true + pluginKey: string + version: string + contentHash: string + consentFingerprint: string + resolvedCommit: string | null + } + | { ok: false; error: string } + +export type PluginMarketplaceHostSourceState = { + id: string + source: PluginMarketplaceGitSource + addedAt: number + marketplace: { + name: string + owner: string + resolvedCommit: string + fetchedAt: number + } | null + stale: boolean + official: boolean + error?: string +} + +export type PluginMarketplaceHostListing = { + marketplaceSourceId: string + marketplaceName: string + marketplaceOwner: string + marketplaceCommit: string + pluginKey: string + source: PluginMarketplaceGitSource + description?: string + categories: string[] + official: boolean + bundled: boolean + blockedByKillList?: { reason: string; advisoryUrl?: string } +} + +export type PluginMarketplaceHostInstallPreview = { + marketplaceSourceId: string + marketplaceName: string + marketplaceOwner: string + marketplaceCommit: string + pluginKey: string + source: PluginMarketplaceGitSource + resolvedCommit: string + contentHash: string + consentFingerprint: string + manifest: PluginManifest + official: boolean + bundled: boolean + blockedByKillList?: { reason: string; advisoryUrl?: string } +} + +export type PluginsApi = { + list: () => Promise + listLanguagePacks: () => Promise + /** Records the consent-dialog answer; approval is keyed to the plugin's + * current capability and trusted-worker fingerprint. */ + consent: (args: PluginConsentRequest) => Promise + setEnabled: (args: { pluginKey: string; enabled: boolean }) => Promise + /** Returns the panel's CSP-wrapped HTML, or null when the plugin or + * panel is missing/disabled. Rendered only inside a sandboxed iframe. */ + readPanelEntry: (args: { pluginKey: string; panelId: string }) => Promise + invokeCommand: (args: { + pluginKey: string + commandId: string + args?: unknown + }) => Promise + /** Relays a sandboxed panel's bridge request to main, which enforces the + * plugin's consented capabilities before executing. */ + panelAction: (args: { + sessionToken: string + action: string + params?: unknown + }) => Promise + install: (source: PluginHostInstallSource) => Promise + listMarketplaces: () => Promise + addMarketplace: (source: PluginMarketplaceGitSource) => Promise + removeMarketplace: (args: { sourceId: string }) => Promise + refreshMarketplaces: (args?: { sourceId?: string }) => Promise + listMarketplacePlugins: () => Promise + previewMarketplacePlugin: (args: { + marketplaceSourceId: string + pluginKey: string + }) => Promise + installMarketplacePlugin: ( + preview: Pick< + PluginMarketplaceHostInstallPreview, + 'marketplaceSourceId' | 'marketplaceCommit' | 'pluginKey' | 'resolvedCommit' + > + ) => Promise + previewMarketplaceUpdate: (args: { + pluginKey: string + }) => Promise + rollbackMarketplacePlugin: (args: { pluginKey: string }) => Promise + remove: (args: { pluginKey: string }) => Promise + getLogs: (args: { pluginKey: string }) => Promise + /** Re-discovers after settings edits (feature flag, dev paths). */ + refresh: () => Promise + /** Fires whenever installed plugins, worker states, panels, or content packs change. */ + onChanged: (callback: (event: PluginChangeEvent) => void) => () => void +} diff --git a/src/preload/api/preflight-api.ts b/src/preload/api/preflight-api.ts new file mode 100644 index 00000000000..f63099a07ec --- /dev/null +++ b/src/preload/api/preflight-api.ts @@ -0,0 +1,55 @@ +import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' +import type { PathSource, ShellHydrationFailureReason } from '../../shared/types' + +export type PreflightStatus = { + git: { installed: boolean } + gh: { installed: boolean; authenticated: boolean } + /** Optional — older preload payloads predating GitLab support omit it; consumers gate on `glab?.installed`. */ + glab?: { installed: boolean; authenticated: boolean } + bitbucket?: { configured: boolean; authenticated: boolean; account: string | null } + azureDevOps?: { + configured: boolean + authenticated: boolean + account: string | null + baseUrl: string | null + tokenConfigured: boolean + } + gitea?: { + configured: boolean + authenticated: boolean + account: string | null + baseUrl: string | null + tokenConfigured: boolean + } +} + +export type RefreshAgentsResult = { + agents: string[] + addedPathSegments: string[] + shellHydrationOk: boolean + /** Drives agent_picks `on_path:false` triage (dashboard 1562016). `'shell_hydrate'` = detection saw the user's + * full shell PATH; `'sync_seed_only'` = hydration failed and detection ran against the `patchPackagedProcessPath` seed list. */ + pathSource: PathSource + /** Classified hydration outcome: `'none'` on success, else a failure mode when `shellHydrationOk` is false. */ + pathFailureReason: ShellHydrationFailureReason +} + +export type PreflightRuntimeContext = { + wslDistro?: string | null + wslDefault?: boolean + projectRuntime?: ProjectExecutionRuntimeResolution +} + +export type PreflightApi = { + check: (args?: PreflightRuntimeContext & { force?: boolean }) => Promise + detectAgents: (args?: PreflightRuntimeContext) => Promise + refreshAgents: (args?: PreflightRuntimeContext) => Promise + detectRemoteAgents: (args: { connectionId: string }) => Promise + detectRemoteWindowsTerminalCapabilities: (args: { connectionId: string }) => Promise<{ + wslAvailable: boolean + wslDistros: string[] + pwshAvailable: boolean + gitBashAvailable: boolean + hostPlatform: NodeJS.Platform | null + }> +} diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts new file mode 100644 index 00000000000..774196b6a01 --- /dev/null +++ b/src/preload/api/pty-api.ts @@ -0,0 +1,224 @@ +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../shared/agent-session-resume' +import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' +import type { PtyListedSession } from '../../shared/pty-listed-session' +import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics' +import type { PtyModelRestoreNeededEvent } from '../../shared/pty-model-restore-marker' +import type { + PtyRendererDeliveryHealthReply, + PtyRendererDeliveryStateReport +} from '../../shared/pty-renderer-delivery-health' +import type { AgentKind, LaunchSource, RequestKind } from '../../shared/telemetry-events' +import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' +import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes' +import type { TuiAgent } from '../../shared/types' +import type { PtyManagementApi } from './pty-management-api' + +export type PtyApi = { + spawn: (opts: { + cols: number + rows: number + cwd?: string + cwdFallback?: 'worktree' + env?: Record + envToDelete?: string[] + command?: string + commandDelivery?: 'renderer' | 'provider' + launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata + launchToken?: string + launchAgent?: TuiAgent + startupCommandDelivery?: StartupCommandDelivery + connectionId?: string | null + worktreeId?: string + sessionId?: string + // Why: lets a single tab open in a different shell than the user's default. + shellOverride?: string + projectRuntime?: ProjectExecutionRuntimeResolution + terminalColorQueryReplies?: { foreground?: string; background?: string } + // Why: mark the PTY hidden before its first byte so the delivery gate owns spawn-time queries (terminal-query-authority.md §races). + initiallyHidden?: boolean + // Why: main sync-flushes the (worktreeId,tabId,leafId→ptyId) binding before pty:spawn returns to close a SIGKILL race (INVESTIGATION.md). + tabId?: string + leafId?: string + // Why: main fires `agent_started` only on spawn success, so launch metadata rides this field (telemetry-plan.md §Agent launch semantics). + telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } + }) => Promise<{ + id: string + launchAgent?: TuiAgent + launchConfig?: SleepingAgentLaunchConfig + snapshot?: string + snapshotCols?: number + snapshotRows?: number + snapshotPrefixAnsi?: string + snapshotFrameAnsi?: string + snapshotFrameRestoreAnsi?: string + snapshotKittyKeyboardFlags?: number + snapshotSeq?: number + isReattach?: boolean + isAlternateScreen?: boolean + replay?: string + sessionExpired?: boolean + coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } + startupCwdFallback?: { kind: 'worktree'; cwd: string } + agentResumeUnavailable?: true + }> + write: (id: string, data: string) => void + writeAccepted: (id: string, data: string) => Promise + onWriteUnavailable?: (callback: (payload: { id: string }) => void) => () => void + resize: (id: string, cols: number, rows: number) => void + claimViewport: (id: string, cols: number, rows: number) => void + reportGeometry: (id: string, cols: number, rows: number) => void + signal: (id: string, signal: string) => void + clearBuffer: (id: string) => void + kill: (id: string, opts?: { keepHistory?: boolean }) => Promise + ackColdRestore: (id: string) => void + ackData: (id: string, charCount: number, processedChars?: number) => void + onDeliveryResyncRequest: (callback: (payload: { requestId: number }) => void) => () => void + respondDeliveryResync: (payload: { + requestId: number + processedCharsByPty: Record + }) => void + /** Renderer-initiated delivery health/heal lane over invoke — reaches main + * even when every main→renderer push channel is dead (field wedge). */ + reportRendererDeliveryState: ( + report: PtyRendererDeliveryStateReport + ) => Promise + /** Live pty:data listener count on the preload emitter (sync) — heal-time + * discriminator between a detached listener and a dead channel. */ + getPtyDataListenerCount: () => number + /** One-shot signal that this page's pty:data dispatcher is registered, so + * main can release sends held during the load/reload boot window. */ + rendererDispatcherReady: () => void + setActiveRendererPty: (id: string, active: boolean) => void + setRendererPtyVisible: (id: string, visible: boolean) => void + /** Hidden-delivery gate (Phase 4): hidden=true lets main drop renderer + * byte delivery after model ingestion; reveal restores from snapshots. */ + setHiddenRendererPty: (id: string, hidden: boolean) => void + /** Ref-counted-on-the-renderer delivery-interest signal that suppresses + * the hidden-delivery gate while any raw-byte consumer is registered. */ + setPtyDeliveryInterest: (id: string, interested: boolean) => void + /** View-attribute bridge (Phase 5 slice 2): app-global composed terminal + * appearance push backing main's hidden-PTY OSC/DSR color replies. */ + publishTerminalViewAttributes: (attributes: TerminalViewAttributes) => void + hasChildProcesses: (id: string) => Promise + getForegroundProcess: (id: string) => Promise + inspectProcess: (id: string) => Promise<{ + foregroundProcess: string | null + hasChildProcesses: boolean + unavailable?: true + }> + confirmForegroundProcess: (id: string) => Promise + getCwd: (id: string) => Promise + getSize: (id: string) => Promise<{ cols: number; rows: number } | null> + listSessions: () => Promise + getAuthoritativeBufferSnapshotCapabilities?: ( + ids: string[] + ) => Promise<{ id: string; authoritative: boolean | null }[]> + hasPty: (id: string) => Promise + getMainBufferSnapshot: ( + id: string, + opts?: { scrollbackRows?: number } + ) => Promise<{ + data: string + frameRestoreAnsi?: string + cols: number + rows: number + cwd?: string | null + seq?: number + /** Start of main's pending renderer-delivery queue at snapshot time + * (equals `seq` when empty) — bounds the renderer's post-restore + * duplicate window. */ + pendingDeliveryStartSeq?: number + source?: 'headless' | 'renderer' + alternateScreen?: boolean + /** Authoritative normal buffer paired with an alternate-screen frame. */ + scrollbackAnsi?: string + /** Trailing incomplete escape the emulator ingested; the restorer must + * write it after its post-replay resets, last before live chunks. */ + pendingEscapeTailAnsi?: string + /** Effective kitty flags the snapshot owner proved at `seq`. Absent means + * unknown; consumers must not turn that into a known `0`. */ + kittyKeyboardFlags?: number + } | null> + getRendererDeliveryDebugSnapshot: () => Promise<{ + pendingPtyCount: number + pendingChars: number + maxPendingCharsByPty: number + rendererInFlightPtyCount: number + rendererInFlightChars: number + maxRendererInFlightCharsByPty: number + activeRendererPtyCount: number + flushScheduled: boolean + peakPendingChars: number + peakMaxPendingCharsByPty: number + peakRendererInFlightChars: number + peakMaxRendererInFlightCharsByPty: number + ackGatedFlushSkipCount: number + hiddenDeliveryGatedPtyCount: number + hiddenDeliveryGatedVisiblePtyCount: number + hiddenDeliveryGatedActivePtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number + pendingDroppedChars: number + diagnostics: PtyMainDeliveryDiagnostics + rendererLifecycleResetCount: number + lastLifecycleResetClearedChars: number + rendererPtyDispatcherReady: boolean + rendererDispatcherReadyForcedCount: number + }> + resetRendererDeliveryDebug: () => Promise + onData: ( + callback: (data: { + id: string + data: string + seq?: number + rawLength?: number + transformed?: boolean + background?: boolean + droppedOutput?: boolean + }) => void + ) => () => void + onReplay: (callback: (data: { id: string; data: string }) => void) => () => void + /** Out-of-band main→renderer signal that renderer-bound bytes were + * dropped (hidden-delivery gate / pending cap); the pane restores from + * the model snapshot. Never delivered in-band on pty:data. */ + onModelRestoreNeeded: (callback: (event: PtyModelRestoreNeededEvent) => void) => () => void + /** Batched derived side-effect facts for PTYs whose bytes transit local + * main. */ + onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void) => () => void + /** Title-only replay snapshot for (re)attach; attention facts never replay. */ + getSideEffectSnapshot: (id: string) => Promise + onExit: ( + callback: (data: { id: string; code: number; preserveRendererBinding?: boolean }) => void + ) => () => void + onSpawned: (callback: (data: { id: string }) => void) => () => void + onSerializeBufferRequest: ( + callback: (data: { + requestId: string + ptyId: string + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + }) => void + ) => () => void + onClearBufferRequest: (callback: (data: { ptyId: string }) => void) => () => void + sendSerializedBuffer: ( + requestId: string, + snapshot: { + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + kittyKeyboardFlags?: number + } | null + ) => void + declarePendingPaneSerializer: (paneKey: string) => Promise + settlePaneSerializer: (paneKey: string, gen: number) => Promise + clearPendingPaneSerializer: (paneKey: string, gen: number) => Promise + reportRendererSerializerReady?: (ptyId: string) => Promise + management: PtyManagementApi +} diff --git a/src/preload/api/pty-management-api.ts b/src/preload/api/pty-management-api.ts new file mode 100644 index 00000000000..10fef2d2303 --- /dev/null +++ b/src/preload/api/pty-management-api.ts @@ -0,0 +1,30 @@ +// Mirror of daemon's `DaemonSessionInfo` (src/main/daemon/types.ts); not imported — preload can't depend on main-only protocol types. +export type PtyManagementSession = { + sessionId: string + state: 'created' | 'spawning' | 'running' | 'exiting' | 'exited' + shellState: 'pending' | 'ready' | 'timed_out' | 'unsupported' + isAlive: boolean + pid: number | null + cwd: string | null + cols: number + rows: number + createdAt: number + protocolVersion: number +} + +// 'severed': macOS can no longer attribute daemon terminals to Orca, so Accessibility/ +// Automation grants silently stop applying until the daemon is restarted (STA-3491). +export type PtyManagementMacTccAttributionHealth = 'intact' | 'severed' | 'unknown' + +export type PtyManagementApi = { + // `degraded`: daemon is alive but can't spawn fresh PTYs, so new terminals run locally without daemon persistence. + listSessions: () => Promise<{ sessions: PtyManagementSession[]; degraded: boolean }> + killAll: () => Promise<{ + killedCount: number + remainingCount: number + killedSessionIds?: string[] + }> + killOne: (args: { sessionId: string }) => Promise<{ success: boolean }> + restart: () => Promise<{ success: boolean }> + macTccAttribution: () => Promise<{ health: PtyManagementMacTccAttributionHealth }> +} diff --git a/src/preload/api/repository-api.ts b/src/preload/api/repository-api.ts new file mode 100644 index 00000000000..50b79e8640f --- /dev/null +++ b/src/preload/api/repository-api.ts @@ -0,0 +1,169 @@ +import type { ExecutionHostId } from '../../shared/execution-host' +import type { + HostRepoCatalogSnapshot, + ListReposForExecutionHostArgs +} from '../../shared/host-repo-catalog-contract' +import type { + BaseRefDefaultResult, + BaseRefSearchResult, + NestedRepoScanResult, + Project, + ProjectGroup, + ProjectGroupImportMode, + ProjectGroupImportResult, + ProjectHostSetup, + ProjectHostSetupCreateArgs, + ProjectHostSetupCreateResult, + ProjectHostSetupDeleteArgs, + ProjectHostSetupDeleteResult, + ProjectHostSetupExistingFolderArgs, + ProjectHostSetupResult, + ProjectHostSetupUpdateArgs, + ProjectHostSetupUpdateResult, + ProjectUpdateArgs, + Repo +} from '../../shared/types' + +export type RepositoryApi = { + list: () => Promise + listForExecutionHost?: (args: ListReposForExecutionHostArgs) => Promise + // Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`. + add: (args: { + path: string + kind?: 'git' | 'folder' + }) => Promise<{ repo: Repo } | { error: string }> + remove: (args: { repoId: string }) => Promise + // Forget a project on one execution host only, leaving the same repo id on other hosts intact. + removeForHost: (args: { repoId: string; hostId: string }) => Promise + reorder: (args: { orderedIds: string[] }) => Promise<{ status: 'applied' | 'rejected' }> + reorderForHost: (args: { + orderedIds: string[] + hostId: string + }) => Promise<{ status: 'applied' | 'rejected' }> + update: (args: { + repoId: string + hostId?: ExecutionHostId + updates: Partial< + Pick< + Repo, + | 'displayName' + | 'badgeColor' + | 'repoIcon' + | 'upstream' + | 'hookSettings' + | 'worktreeBaseRef' + | 'worktreeBasePath' + | 'kind' + | 'issueSourcePreference' + | 'externalWorktreeVisibilityPromptDismissedAt' + | 'externalWorktreeInboxBaselinePaths' + | 'importedExternalWorktreePaths' + | 'customWorktreeVisibilitySources' + | 'worktreeVisibilitySourcePreferences' + | 'projectGroupId' + | 'projectGroupOrder' + | 'forkSyncMode' + > + > & { + externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null + agentWorktreeVisibility?: Repo['agentWorktreeVisibility'] | null + sourceControlAi?: Repo['sourceControlAi'] | null + externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null + } + }) => Promise + pickFolder: () => Promise + pickFolders: () => Promise + pickDirectory: () => Promise + clone: (args: { url: string; destination: string }) => Promise + cloneRemote: (args: { connectionId: string; url: string; destination: string }) => Promise + createRemote: (args: { + connectionId: string + parentPath: string + name: string + kind: 'git' | 'folder' + }) => Promise<{ repo: Repo } | { error: string }> + cloneAbort: () => Promise + // Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`. + addRemote: (args: { + connectionId: string + remotePath: string + displayName?: string + kind?: 'git' | 'folder' + }) => Promise<{ repo: Repo } | { error: string }> + // Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`. + create: (args: { + parentPath: string + name: string + kind: 'git' | 'folder' + }) => Promise<{ repo: Repo } | { error: string }> + isGitAvailable: () => Promise + getDefaultCreateProjectParent: () => Promise + onCloneProgress: (callback: (data: { phase: string; percent: number }) => void) => () => void + getGitUsername: (args: { repoId: string }) => Promise + getBaseRefDefault: (args: { + repoId: string + hostId?: ExecutionHostId + }) => Promise + searchBaseRefs: (args: { + repoId: string + query: string + limit?: number + hostId?: ExecutionHostId + }) => Promise + searchBaseRefDetails: (args: { + repoId: string + query: string + limit?: number + hostId?: ExecutionHostId + }) => Promise + onChanged: (callback: () => void) => () => void +} + +export type ProjectsApi = { + list: () => Promise + update: (args: ProjectUpdateArgs) => Promise + listHostSetups: () => Promise + createHostSetup: (args: ProjectHostSetupCreateArgs) => Promise + setupExistingFolder: (args: ProjectHostSetupExistingFolderArgs) => Promise + updateHostSetup: (args: ProjectHostSetupUpdateArgs) => Promise + deleteHostSetup: (args: ProjectHostSetupDeleteArgs) => Promise +} + +export type ProjectGroupsApi = { + list: () => Promise + create: (args: { + name: string + parentPath?: string | null + connectionId?: string | null + parentGroupId?: string | null + createdFrom?: ProjectGroup['createdFrom'] + }) => Promise + update: (args: { + groupId: string + updates: Partial> + }) => Promise + delete: (args: { groupId: string }) => Promise + moveProject: (args: { + projectId: string + groupId: string | null + order?: number + }) => Promise + scanNested: (args: { + path: string + connectionId?: string + scanId?: string + options?: Record + }) => Promise + cancelNestedScan: (args: { scanId: string }) => Promise + onNestedScanProgress: ( + callback: (data: { scanId: string; scan: NestedRepoScanResult }) => void + ) => () => void + importNested: (args: { + parentPath: string + groupName: string + projectPaths: string[] + connectionId?: string + scanId?: string + mode: ProjectGroupImportMode + }) => Promise +} diff --git a/src/preload/api/runtime-api.ts b/src/preload/api/runtime-api.ts new file mode 100644 index 00000000000..1acc43f5f2c --- /dev/null +++ b/src/preload/api/runtime-api.ts @@ -0,0 +1,116 @@ +import type { + RuntimeBrowserDriverState, + RuntimeStatus, + RuntimeSyncWindowGraph, + RuntimeSyncWindowGraphResult, + RuntimeTerminalDriverState +} from '../../shared/runtime-types' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import type { PublicKnownRuntimeEnvironment } from '../../shared/runtime-environments' +import type { VerifyAndAddRuntimeEnvironmentResult } from '../../shared/remote-pairing-verification' + +export type RuntimeEnvironmentSubscriptionHandle = { + unsubscribe: () => void + sendBinary: (bytes: Uint8Array) => void +} + +export type RuntimeApi = { + runtime: { + syncWindowGraph: (graph: RuntimeSyncWindowGraph) => Promise + getStatus: () => Promise + call: (args: { method: string; params?: unknown }) => Promise> + getTerminalFitOverrides: () => Promise< + { ptyId: string; mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number }[] + > + getTerminalDrivers: () => Promise< + { + ptyId: string + driver: RuntimeTerminalDriverState + }[] + > + getBrowserDrivers: () => Promise< + { + browserPageId: string + driver: RuntimeBrowserDriverState + }[] + > + restoreTerminalFit: (ptyId: string) => Promise<{ restored: boolean }> + reclaimBrowserForDesktop: (browserPageId: string) => Promise<{ reclaimed: boolean }> + onTerminalFitOverrideChanged: ( + callback: (event: { + ptyId: string + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' + cols: number + rows: number + }) => void + ) => () => void + onTerminalDriverChanged: ( + callback: (event: { ptyId: string; driver: RuntimeTerminalDriverState }) => void + ) => () => void + onNativeChatLaunchDraftResolved?: ( + callback: (event: { tabId: string; text: string; createdAt: number }) => void + ) => () => void + onBrowserDriverChanged: ( + callback: (event: { browserPageId: string; driver: RuntimeBrowserDriverState }) => void + ) => () => void + } + runtimeEnvironments: { + list: () => Promise + addFromPairingCode: (args: { + name: string + pairingCode: string + }) => Promise<{ environment: PublicKnownRuntimeEnvironment }> + verifyAndAddFromPairingCode: (args: { + name: string + pairingCode: string + allowLoopback?: boolean + }) => Promise + resolve: (args: { selector: string }) => Promise + remove: (args: { selector: string }) => Promise<{ removed: PublicKnownRuntimeEnvironment }> + disconnect: (args: { + selector: string + }) => Promise<{ disconnected: PublicKnownRuntimeEnvironment }> + connect: (args: { + selector: string + timeoutMs?: number + }) => Promise> + getStatus: (args: { + selector: string + timeoutMs?: number + }) => Promise> + // Why: system resume / browser online advance pending shared-control reconnect timers only. + retryConnectionsNow?: () => Promise + call: (args: { + selector: string + method: string + params?: unknown + timeoutMs?: number + expectedEnvironmentPairingRevision?: number + }) => Promise> + subscribe: ( + args: { + selector: string + method: string + params?: unknown + timeoutMs?: number + expectedEnvironmentPairingRevision?: number + }, + callbacks: { + onResponse: (response: RuntimeRpcResponse) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { code: string; message: string }) => void + onClose?: () => void + } + ) => Promise + } + wsl: { + isAvailable: () => Promise + listDistros: () => Promise + } + pwsh: { + isAvailable: () => Promise + } + gitBash: { + isAvailable: () => Promise + } +} diff --git a/src/preload/api/settings-api.ts b/src/preload/api/settings-api.ts new file mode 100644 index 00000000000..b50843160dd --- /dev/null +++ b/src/preload/api/settings-api.ts @@ -0,0 +1,35 @@ +import type { KeybindingActionId, KeybindingFileSnapshot } from '../../shared/keybindings' +import type { + WarpThemeImportPreview, + WarpThemeImportSource +} from '../../shared/terminal-custom-themes' +import type { GhosttyImportPreview, GlobalSettings } from '../../shared/types' + +export type SettingsApi = { + get: () => Promise + /** Synchronous persisted-settings read for startup decisions that can't wait for async hydration. Blocking IPC — call sparingly. */ + getSync: () => GlobalSettings | null + set: (args: Partial) => Promise + setActiveRuntimeEnvironmentPreference: (args: { + environmentId: string | null + }) => Promise + updatePRBotAuthorOverride: (args: { author: string; isBot: boolean }) => Promise + listFonts: () => Promise + previewGhosttyImport: () => Promise + previewWarpThemeImport: (source: WarpThemeImportSource) => Promise + /** Subscribe to out-of-band settings updates (e.g. View > Appearance toggles) to stay in sync with main. */ + onChanged: (callback: (updates: Partial) => void) => () => void +} + +export type KeybindingsApi = { + get: () => Promise + ensureFile: () => Promise + setAction: (args: { + actionId: KeybindingActionId + bindings: string[] | null + }) => Promise + reload: () => Promise + openFile: () => Promise + revealFile: () => Promise + onChanged: (callback: (snapshot: KeybindingFileSnapshot) => void) => () => void +} diff --git a/src/preload/api/shell-api.ts b/src/preload/api/shell-api.ts new file mode 100644 index 00000000000..d466ac26163 --- /dev/null +++ b/src/preload/api/shell-api.ts @@ -0,0 +1,32 @@ +import type { + ShellOpenExternalEditorRequest, + ShellOpenExternalEditorResult, + ShellOpenLocalPathResult +} from '../../shared/shell-open-types' + +export type { + ShellOpenExternalEditorRequest, + ShellOpenExternalEditorResult, + ShellOpenLocalPathResult +} from '../../shared/shell-open-types' + +export type ShellApi = { + openPath: (path: string) => Promise + openInFileManager: (path: string) => Promise + openInExternalEditor: ( + request: ShellOpenExternalEditorRequest + ) => Promise + openUrl: (url: string) => Promise + openFilePath: (path: string) => Promise + openFileUri: (uri: string) => Promise + pathExists: (path: string) => Promise + pickAttachment: () => Promise + pickImage: () => Promise + pickRepoIconImage: () => Promise<{ + dataUrl: string + fileName: string + } | null> + pickAudio: () => Promise + pickDirectory: (args: { defaultPath?: string }) => Promise + copyFile: (args: { srcPath: string; destPath: string }) => Promise +} diff --git a/src/preload/api/speech-api.ts b/src/preload/api/speech-api.ts new file mode 100644 index 00000000000..fb866dace15 --- /dev/null +++ b/src/preload/api/speech-api.ts @@ -0,0 +1,33 @@ +import type { + SpeechErrorEvent, + SpeechLifecycleEvent, + SpeechModelManifest, + SpeechModelState, + SpeechTranscriptEvent +} from '../../shared/speech-types' + +export type SpeechApi = { + getCatalog: () => Promise + getModelStates: () => Promise + getOpenAiApiKeyStatus: () => Promise<{ configured: boolean }> + saveOpenAiApiKey: (apiKey: string) => Promise<{ configured: boolean }> + clearOpenAiApiKey: () => Promise<{ configured: boolean }> + downloadModel: (modelId: string) => Promise + cancelDownload: (modelId: string) => Promise + deleteModel: (modelId: string) => Promise + startDictation: ( + modelId: string, + hotwords: string[] | undefined, + sessionId: string + ) => Promise + feedAudio: (samples: Float32Array, sampleRate: number, sessionId?: string) => Promise + stopDictation: (sessionId?: string) => Promise + onPartialTranscript: (callback: (data: SpeechTranscriptEvent) => void) => () => void + onFinalTranscript: (callback: (data: SpeechTranscriptEvent) => void) => () => void + onDownloadProgress: ( + callback: (data: { modelId: string; progress: number }) => void + ) => () => void + onReady: (callback: (data: SpeechLifecycleEvent) => void) => () => void + onStopped: (callback: (data: SpeechLifecycleEvent) => void) => () => void + onError: (callback: (data: SpeechErrorEvent) => void) => () => void +} diff --git a/src/preload/api/ssh-api.ts b/src/preload/api/ssh-api.ts new file mode 100644 index 00000000000..b25f1713e16 --- /dev/null +++ b/src/preload/api/ssh-api.ts @@ -0,0 +1,78 @@ +import type { + EnrichedDetectedPort, + PortForwardEntry, + SshConfigHostListArgs, + SshConfigHostListResult, + SshConfigHostResolution, + SshConfigImportResult, + SshConnectionState, + SshTarget, + SshTargetAddResult +} from '../../shared/ssh-types' +import type { FilesystemPathFlavor } from '../../shared/types' + +export type SshApi = { + listTargets: () => Promise + // Removed-target id → last known label, for a friendly host name on workspaces still pinned to a removed target. + listRemovedTargetLabels: () => Promise> + addTarget: (args: { target: Omit }) => Promise + updateTarget: (args: { + id: string + updates: Partial> + }) => Promise + removeTarget: (args: { id: string }) => Promise + importConfig: (args?: { reAdopt?: boolean }) => Promise + listConfigHosts: (args?: SshConfigHostListArgs) => Promise + resolveConfigHost: (args: { alias: string }) => Promise + connect: (args: { targetId: string }) => Promise + disconnect: (args: { targetId: string }) => Promise + terminateSessions: (args: { targetId: string }) => Promise + resetRelay: (args: { targetId: string }) => Promise + getState: (args: { targetId: string }) => Promise + needsPassphrasePrompt: (args: { targetId: string }) => Promise + testConnection: (args: { + targetId: string + }) => Promise<{ success: boolean; error?: string; state?: SshConnectionState }> + onStateChanged: ( + callback: (data: { targetId: string; state: SshConnectionState }) => void + ) => () => void + addPortForward: (args: { + targetId: string + localPort: number + remoteHost: string + remotePort: number + label?: string + }) => Promise + updatePortForward: (args: { + id: string + targetId: string + localPort: number + remoteHost: string + remotePort: number + label?: string + }) => Promise + removePortForward: (args: { id: string }) => Promise + listPortForwards: (args?: { targetId?: string }) => Promise + listDetectedPorts: (args: { targetId: string }) => Promise + onPortForwardsChanged: ( + callback: (data: { targetId: string; forwards: PortForwardEntry[] }) => void + ) => () => void + onDetectedPortsChanged: ( + callback: (data: { targetId: string; ports: EnrichedDetectedPort[] }) => void + ) => () => void + browseDir: (args: { targetId: string; dirPath: string }) => Promise<{ + entries: { name: string; isDirectory: boolean }[] + resolvedPath: string + pathFlavor: FilesystemPathFlavor + }> + onCredentialRequest: ( + callback: (data: { + requestId: string + targetId: string + kind: 'passphrase' | 'password' + detail: string + }) => void + ) => () => void + onCredentialResolved: (callback: (data: { requestId: string }) => void) => () => void + submitCredential: (args: { requestId: string; value: string | null }) => Promise +} diff --git a/src/preload/api/telemetry-api.ts b/src/preload/api/telemetry-api.ts new file mode 100644 index 00000000000..9fc70f908b7 --- /dev/null +++ b/src/preload/api/telemetry-api.ts @@ -0,0 +1,63 @@ +import type { TelemetryConsentState } from '../../shared/telemetry-consent-types' +import type { MemorySnapshot, StatsSummary } from '../../shared/types' + +export type StatsApi = { + getSummary: () => Promise +} + +// Diagnostics IPC payloads; mirror the runtime types in `src/main/observability/{index,bundle}.ts`. +export type DiagnosticsStatusPayload = { + readonly localFileEnabled: boolean + readonly bundleEnabled: boolean + readonly traceFilePath: string + readonly traceFamilySize: number + readonly disabledReason?: + | 'do_not_track' + | 'orca_telemetry_disabled' + | 'orca_diagnostics_disabled' + | 'ci' +} +export type DiagnosticsBundlePayload = { + readonly bundleSubmissionId: string + readonly bytes: number + readonly spanCount: number +} +export type DiagnosticsUploadPayload = + | { + readonly ticketId: string + } + | { + readonly canceled: true + } + +export type MemoryApi = { + getSnapshot: () => Promise +} + +export type DiagnosticsApi = { + getStatus: () => Promise + collectBundle: (lookbackMinutes?: number) => Promise + openBundlePreview: (bundleSubmissionId: string) => Promise + discardBundlePreview: (bundleSubmissionId: string) => Promise + uploadBundle: (bundleSubmissionId: string) => Promise + deleteBundle: (ticketId: string) => Promise +} + +export type TelemetryApi = { + /** Fire-and-forget track. Loose IPC typing on purpose — the main-side validator enforces; + * renderer sites should import `track()` from lib/telemetry.ts, not reach here. */ + telemetryTrack: (name: string, props: Record) => Promise + /** Flip the persisted opt-in preference. Subject to a per-session + * consent-mutation rate limit on the main side (≤5/session). */ + telemetrySetOptIn: (optedIn: boolean) => Promise + /** Diagnostic file controls (telemetry-error-tracking.md §User controls). Main does the FS/network + * work and retains upload payloads so the renderer can't read or substitute arbitrary bytes. */ + diagnostics: DiagnosticsApi + /** Read-only effective consent state (+ reason if disabled) — env vars are main-side state the renderer can't read directly. */ + telemetryGetConsentState: () => Promise + /** Banner ✕ — persist `optedIn = true` silently. Separate channel from `telemetrySetOptIn`, + * whose `via` derivation would wrongly fire `telemetry_opted_in`. Same per-session rate limit. */ + telemetryAcknowledgeBanner: () => Promise + stats: StatsApi + memory: MemoryApi +} diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts new file mode 100644 index 00000000000..e46c09139b7 --- /dev/null +++ b/src/preload/api/ui-command-event-api.ts @@ -0,0 +1,203 @@ +import type { + PersistedUIState, + TuiAgent, + WorktreeDefaultTabsLaunch, + WorktreeSetupLaunch, + WorktreeStartupLaunch +} from '../../shared/types' +import type { FeatureInteractionId } from '../../shared/feature-interactions' +import type { KeybindingActionId } from '../../shared/keybindings' +import type { BrowserFindSource } from '../../shared/browser-find-source' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../shared/agent-session-resume' +import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry' +import type { + RuntimeMobileSessionTabMove, + RuntimeTerminalCreateRequestPayload, + RuntimeTerminalPresentation +} from '../../shared/runtime-types' +import type { + RuntimeMobileMarkdownRequest, + RuntimeMobileMarkdownResponse +} from '../../shared/mobile-markdown-document' +import type { TerminalTabCreateReply } from '../../shared/terminal-reveal-identity' +import type { + TerminalTabCloseRequest, + TerminalTabCloseResponse +} from '../../shared/terminal-tab-close' + +export type UiCommandEventApi = { + get: () => Promise + set: (args: Partial) => Promise + recordFeatureInteraction: (id: FeatureInteractionId) => Promise + onStateChanged: (callback: (ui: PersistedUIState) => void) => () => void + onOpenSettings: (callback: () => void) => () => void + /** Consumes a one-shot tray/menu-bar "open settings" intent queued before mount. */ + consumePendingOpenSettings: () => Promise + onOpenSetupGuide: (callback: () => void) => () => void + onOpenFeatureTour: (callback: () => void) => () => void + onOpenCrashReport: (callback: () => void) => () => void + onToggleLeftSidebar: (callback: () => void) => () => void + onToggleRightSidebar: (callback: () => void) => () => void + onToggleWorktreePalette: (callback: () => void) => () => void + onToggleFloatingTerminal: (callback: () => void) => () => void + onTerminalShortcutCaptured: ( + callback: (data: { actionId: KeybindingActionId }) => void + ) => () => void + onOpenQuickOpen: (callback: () => void) => () => void + onToggleQuickCommandsMenu: (callback: () => void) => () => void + onOpenNewWorkspace: (callback: () => void) => () => void + onDeleteCurrentWorkspace: (callback: () => void) => () => void + onOpenWorkspaceBoard: (callback: () => void) => () => void + onOpenTasks: (callback: () => void) => () => void + onJumpToWorktreeIndex: (callback: (index: number) => void) => () => void + onJumpToTabIndex: (callback: (index: number) => void) => () => void + onWorktreeHistoryNavigate: (callback: (direction: 'back' | 'forward') => void) => () => void + onNewBrowserTab: (callback: () => void) => () => void + onNewMarkdownTab: (callback: () => void) => () => void + onNewSimulatorTab: (callback: () => void) => () => void + onRequestTabCreate: ( + callback: (data: { + requestId: string + url: string + worktreeId?: string + sessionProfileId?: string | null + sessionPartition?: string + activate?: boolean + }) => void + ) => () => void + replyTabCreate: (reply: { requestId: string; browserPageId?: string; error?: string }) => void + onRequestTabSetProfile: ( + callback: (data: { + requestId: string + browserPageId: string + profileId: string + sessionPartition?: string + }) => void + ) => () => void + replyTabSetProfile: (reply: { requestId: string; error?: string }) => void + onRequestTabClose: ( + callback: (data: { requestId: string; tabId: string | null; worktreeId?: string }) => void + ) => () => void + replyTabClose: (reply: { requestId: string; error?: string }) => void + onNewTerminalTab: (callback: () => void) => () => void + onFocusBrowserAddressBar: (callback: () => void) => () => void + onFindInBrowserPage: (source: BrowserFindSource, callback: () => void) => () => void + onReloadBrowserPage: (callback: () => void) => () => void + onBrowserHistoryNavigate: (callback: (direction: 'back' | 'forward') => void) => () => void + onZoomBrowserPage: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void + onHardReloadBrowserPage: (callback: () => void) => () => void + onCloseActiveTab: (callback: () => void) => () => void + onCloseFloatingItem: (callback: (payload: { sourceId: string }) => void) => () => void + onSelectFloatingIndex: (callback: (payload: { index: number }) => void) => () => void + onSwitchTab: (callback: (direction: 1 | -1) => void) => () => void + onSwitchTabAcrossAllTypes: (callback: (direction: 1 | -1) => void) => () => void + onSwitchRecentTab: (callback: () => void) => () => void + onSwitchTerminalTab: (callback: (direction: 1 | -1) => void) => () => void + onCtrlTabKeyDown: (callback: (data: { shiftKey: boolean }) => void) => () => void + onCtrlTabKeyUp: (callback: () => void) => () => void + onToggleStatusBar: (callback: () => void) => () => void + onDictationKeyDown: (callback: () => void) => () => void + onExportPdfRequested: (callback: () => void) => () => void + onAppMenuPaste: (callback: () => void) => () => void + onAppMenuSelectionAction: (callback: (action: 'copy' | 'select-all') => void) => () => void + onEditableContextPaste: (callback: (data: { plainTextOnly: boolean }) => void) => () => void + onActivateWorktree: ( + callback: (data: { + repoId: string + worktreeId: string + setup?: WorktreeSetupLaunch + startup?: WorktreeStartupLaunch + defaultTabs?: WorktreeDefaultTabsLaunch + }) => void + ) => () => void + onCreateTerminal: ( + callback: (data: { + requestId?: string + worktreeId: string + command?: string + cwd?: string + env?: Record + launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata + launchToken?: string + launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' + title?: string + ptyId?: string + activate?: boolean + focus?: boolean + presentation?: RuntimeTerminalPresentation + surfaceOwner?: false + tabId?: string + leafId?: string + splitFromLeafId?: string + splitDirection?: 'horizontal' | 'vertical' + splitTelemetrySource?: TerminalPaneSplitSource + }) => void + ) => () => void + onRequestTerminalCreate: ( + callback: (data: RuntimeTerminalCreateRequestPayload) => void + ) => () => void + onRequestTerminalTabMount: ( + callback: (data: { worktreeId: string; tabId?: string; ptyId?: string }) => void + ) => () => void + replyTerminalCreate: (reply: TerminalTabCreateReply) => void + onSplitTerminal: ( + callback: (data: { + tabId: string + paneRuntimeId: number + direction: 'horizontal' | 'vertical' + command?: string + telemetrySource?: TerminalPaneSplitSource + }) => void + ) => () => void + onRenameTerminal: ( + callback: (data: { tabId: string; title: string | null }) => void + ) => () => void + onFocusTerminal: ( + callback: (data: { + tabId: string + worktreeId: string + leafId?: string | null + ackPaneKeyOnSuccess?: string + flashFocusedPane?: boolean + scrollToBottomIfOutputSinceLastView?: boolean + }) => void + ) => () => void + onFocusEditorTab: (callback: (data: { tabId: string; worktreeId: string }) => void) => () => void + onCloseSessionTab: (callback: (data: { tabId: string; worktreeId: string }) => void) => () => void + onMoveSessionTab: ( + callback: (data: { worktreeId: string } & RuntimeMobileSessionTabMove) => void + ) => () => void + onOpenFileFromMobile: ( + callback: (data: { + worktreeId: string + filePath: string + relativePath: string + runtimeEnvironmentId?: string + }) => void + ) => () => void + onOpenDiffFromMobile: ( + callback: (data: { + worktreeId: string + filePath: string + relativePath: string + staged: boolean + runtimeEnvironmentId?: string + }) => void + ) => () => void + onMobileMarkdownRequest: (callback: (request: RuntimeMobileMarkdownRequest) => void) => () => void + respondMobileMarkdownRequest: (response: RuntimeMobileMarkdownResponse) => void + onCloseTerminal: ( + callback: (data: { tabId: string; paneRuntimeId?: number }) => void + ) => () => void + onTerminalTabCloseRequest: (callback: (request: TerminalTabCloseRequest) => void) => () => void + respondTerminalTabClose: (response: TerminalTabCloseResponse) => void + onSleepWorktree: (callback: (data: { worktreeId: string }) => void) => () => void + onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void) => () => void + onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void + onSystemResumed: (callback: () => void) => () => void +} diff --git a/src/preload/api/ui-window-api.ts b/src/preload/api/ui-window-api.ts new file mode 100644 index 00000000000..fc6aef6991b --- /dev/null +++ b/src/preload/api/ui-window-api.ts @@ -0,0 +1,51 @@ +import type { ReadClipboardTextOptions } from '../../shared/clipboard-text' +import type { NativeFileDropPayload } from '../../shared/native-file-drop' +import type { + RichMarkdownContextMenuCommandPayload, + RichMarkdownContextMenuTableTarget +} from '../../shared/rich-markdown-context-menu' + +export type UiWindowApi = { + readClipboardText: (options?: ReadClipboardTextOptions) => Promise + readSelectionClipboardText: (options?: ReadClipboardTextOptions) => Promise + saveClipboardImageAsTempFile: (args?: { + connectionId?: string | null + runtimeEnvironmentId?: string | null + }) => Promise + writeClipboardText: (text: string) => Promise + writeTerminalClipboardText: (text: string) => Promise + writeSelectionClipboardText: (text: string) => Promise + writeClipboardImage: (dataUrl: string) => Promise + performNativePaste: (options?: { mode?: 'paste' | 'paste-and-match-style' }) => void + performNativeSelectionAction: (action: 'copy' | 'select-all') => void + writeClipboardFile: ( + args: + | { + filePath: string + connectionId?: string | null + } + | string + ) => Promise<{ ok: boolean; reason?: string }> + onFileDrop: (callback: (data: NativeFileDropPayload) => void) => () => void + getZoomLevel: () => number + setZoomLevel: (level: number) => void + syncTrafficLights: (zoomFactor: number) => void + setMarkdownEditorFocused: (focused: boolean) => void + setRichMarkdownContextMenuTarget: (target: RichMarkdownContextMenuTableTarget | null) => void + setTerminalInputFocused: (focused: boolean) => void + setFloatingFocus: (state: { panelFocused: boolean; terminalFocused: boolean }) => void + setShortcutRecorderFocused: (focused: boolean) => void + onRichMarkdownContextCommand: ( + callback: (payload: RichMarkdownContextMenuCommandPayload) => void + ) => () => void + onFullscreenChanged: (callback: (isFullScreen: boolean) => void) => () => void + minimize: () => void + maximize: () => void + isMaximized: () => Promise + onMaximizeChanged: (callback: (isMaximized: boolean) => void) => () => void + requestClose: () => void + popupMenu: () => void + onWindowCloseRequested: (callback: (data: { isQuitting: boolean }) => void) => () => void + confirmWindowClose: () => void + notifyWindowRevealed: () => void +} diff --git a/src/preload/api/updater-api.ts b/src/preload/api/updater-api.ts new file mode 100644 index 00000000000..f6a43da9834 --- /dev/null +++ b/src/preload/api/updater-api.ts @@ -0,0 +1,25 @@ +import type { + LinuxPackageInstallInstructions, + ReleaseBuildListResult, + UpdateCheckOptions, + UpdateStatus +} from '../../shared/types' +import type { ReleaseChannel } from '../../shared/release-channel' + +export type UpdaterApi = { + getVersion: () => Promise + getStatus: () => Promise + check: (options?: UpdateCheckOptions) => Promise + download: () => Promise + quitAndInstall: () => Promise + dismissNudge: () => Promise + dismissAvailableUpdate: () => Promise + /** Desktop-only. Rejects unless the current status carries `linux-package-install` recovery. */ + getLinuxPackageInstallInstructions: () => Promise + /** Desktop-only. Reveals the revalidated cached package in the native file manager. */ + showLinuxPackage: () => Promise + listBuilds: (channel: ReleaseChannel) => Promise + + onStatus: (callback: (status: UpdateStatus) => void) => () => void + onClearDismissal: (callback: () => void) => () => void +} diff --git a/src/preload/api/workspace-cleanup-api.ts b/src/preload/api/workspace-cleanup-api.ts new file mode 100644 index 00000000000..748d8216540 --- /dev/null +++ b/src/preload/api/workspace-cleanup-api.ts @@ -0,0 +1,30 @@ +import type { + WorkspaceCleanupDismissArgs, + WorkspaceCleanupLocalProcessArgs, + WorkspaceCleanupLocalProcessResult, + WorkspaceCleanupScanArgs, + WorkspaceCleanupScanProgress, + WorkspaceCleanupScanResult +} from '../../shared/workspace-cleanup' +import type { + WorkspaceSpaceAnalyzeResult, + WorkspaceSpaceScanProgress +} from '../../shared/workspace-space-types' + +export type WorkspaceCleanupApi = { + scan: ( + args?: WorkspaceCleanupScanArgs, + onProgress?: (progress: WorkspaceCleanupScanProgress) => void + ) => Promise + dismiss: (args: WorkspaceCleanupDismissArgs) => Promise + clearDismissals: () => Promise + hasKillableLocalProcesses: ( + args: WorkspaceCleanupLocalProcessArgs + ) => Promise +} + +export type WorkspaceSpaceApi = { + analyze: () => Promise + cancel: () => Promise + onProgress: (callback: (progress: WorkspaceSpaceScanProgress) => void) => () => void +} diff --git a/src/preload/api/workspace-port-api.ts b/src/preload/api/workspace-port-api.ts new file mode 100644 index 00000000000..8c0166695ac --- /dev/null +++ b/src/preload/api/workspace-port-api.ts @@ -0,0 +1,23 @@ +import type { + LocalhostWorktreeLabelResult, + LocalhostWorktreeLabelRoute +} from '../../shared/localhost-worktree-labels' +import type { + WorkspacePortAdvertisedUrlChangedEvent, + WorkspacePortKillRequest, + WorkspacePortKillResult, + WorkspacePortScanRequest, + WorkspacePortScanResult +} from '../../shared/workspace-ports' + +export type WorkspacePortsApi = { + scan: (args: WorkspacePortScanRequest) => Promise + kill: (args: WorkspacePortKillRequest) => Promise + onAdvertisedUrlChanged: ( + callback: (event: WorkspacePortAdvertisedUrlChangedEvent) => void + ) => () => void +} + +export type LocalhostWorktreeLabelsApi = { + register: (args: LocalhostWorktreeLabelRoute) => Promise +} diff --git a/src/preload/api/workspace-session-api.ts b/src/preload/api/workspace-session-api.ts new file mode 100644 index 00000000000..35eb17486d7 --- /dev/null +++ b/src/preload/api/workspace-session-api.ts @@ -0,0 +1,50 @@ +import type { + IssueInfo, + PRInfo, + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../shared/types' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { + RemoteWorkspaceChangedEvent, + RemoteWorkspaceConnectedClient, + RemoteWorkspacePatchResult, + RemoteWorkspaceSnapshot +} from '../../shared/remote-workspace-types' + +export type WorkspaceSessionApi = { + session: { + // hostId defaults to the 'local' partition on main, so omitting it stays backward-compatible. + get: (hostId?: ExecutionHostId) => Promise + set: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => Promise + patch: (args: WorkspaceSessionPatch, hostId?: ExecutionHostId) => Promise + flush: () => Promise + readTerminalScrollback: (args: { ref: string }) => string | null + setSync: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => void + } + cache: { + getGitHub: () => Promise<{ + pr: Record + issue: Record + }> + setGitHub: (args: { + cache: { + pr: Record + issue: Record + } + }) => Promise + } + remoteWorkspace: { + get: (args: { targetId: string }) => Promise + setForConnectedTargets: (args: { + session?: WorkspaceSessionState + hydratedTargetIds?: string[] + }) => Promise<{ targetId: string; result: RemoteWorkspacePatchResult }[]> + listEnabledConnectedTargets: () => Promise + listConnectedClients: (args?: { + targetIds?: string[] + }) => Promise<{ targetId: string; clients: RemoteWorkspaceConnectedClient[] }[]> + clientId: () => Promise + onChanged: (callback: (event: RemoteWorkspaceChangedEvent) => void) => () => void + } +} diff --git a/src/preload/api/worktree-api.ts b/src/preload/api/worktree-api.ts new file mode 100644 index 00000000000..96089d10e88 --- /dev/null +++ b/src/preload/api/worktree-api.ts @@ -0,0 +1,179 @@ +import type { + ForgetRemovedWorktreesForExecutionHostArgs, + ForgetRemovedWorktreesForExecutionHostResult, + HostQualifiedDetectedWorktreeResult, + HostQualifiedKnownWorktreeResult, + LegacyDetectedWorktreeRequest, + ListDetectedWorktreesArgs, + ListKnownWorktreesForExecutionHostArgs, + ProviderRequestId +} from '../../shared/detected-worktree-provider-contract' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { + FolderWorkspacePathStatus, + FolderWorkspacePathStatusRequest +} from '../../shared/folder-workspace-path-status' +import type { + HostLineageSnapshot, + ListDesktopLineageForHostArgs +} from '../../shared/host-lineage-contract' +import type { + AdoptProvisionedRootArgs, + CreateWorktreeArgs, + CreateWorktreeResult, + DetectedWorktreeListResult, + FolderWorkspace, + ForceDeleteWorktreeBranchResult, + GitHubPrStartPoint, + GitPushTarget, + RemoveWorktreeResult, + SparsePreset, + WorkspaceLineage, + Worktree, + WorktreeBaseStatusEvent, + WorktreeHeadIdentity, + WorktreeLineage, + WorktreeMeta, + WorktreeRemoteBranchConflictEvent +} from '../../shared/types' + +export type WorktreeApi = { + list: (args: { repoId: string }) => Promise + listDetected: { + ( + args: ListDetectedWorktreesArgs + ): Promise + (args: LegacyDetectedWorktreeRequest): Promise + } + listKnownForExecutionHost?: ( + args: ListKnownWorktreesForExecutionHostArgs + ) => Promise + /** Retires the persisted metadata an authoritative scan proved gone, so it stops feeding the read above. */ + forgetRemovedForExecutionHost?: ( + args: ForgetRemovedWorktreesForExecutionHostArgs + ) => Promise + cancelListDetected?: (args: { providerRequestId: ProviderRequestId }) => Promise + listAll: () => Promise + create: (args: CreateWorktreeArgs) => Promise + adoptProvisionedRoot: (args: AdoptProvisionedRootArgs) => Promise + /** Two-phase progress for a background `create`, correlated by `creationId`. The remote/runtime + * create path emits nothing, so the surface falls back to an indeterminate spinner. */ + onCreateProgress: ( + callback: (data: { creationId?: string; phase: 'fetching' | 'creating' }) => void + ) => () => void + prefetchCreateBase: (args: { repoId: string; baseBranch?: string }) => Promise + resolvePrBase: (args: { + repoId: string + prNumber: number + headRefName?: string + baseRefName?: string + isCrossRepository?: boolean + }) => Promise + /** GitLab parallel of resolvePrBase. For same-project MRs returns + * `/`; for fork MRs fetches + * refs/merge-requests//head and returns the SHA. */ + resolveMrBase: (args: { + repoId: string + mrIid: number + sourceBranch?: string + targetBranch?: string + isCrossRepository?: boolean + }) => Promise< + { baseBranch: string; compareBaseRef?: string; pushTarget?: GitPushTarget } | { error: string } + > + remove: (args: { + worktreeId: string + hostId?: ExecutionHostId + force?: boolean + // Why (#11960): distinct from `force`, which the plain Delete confirmation + // already sets to skip the dirty-file prompt. Only an explicit Force Delete + // may waive the proof that every PTY stopped. + allowUnverifiedPtyStop?: boolean + skipArchive?: boolean + }) => Promise + // Forget a workspace from Orca only (no remote Git/FS work) — for workspaces pinned to a removed/disconnected SSH host. + forgetLocal: (args: { + worktreeId: string + hostId?: ExecutionHostId + }) => Promise + forceDeletePreservedBranch: (args: { + worktreeId: string + branchName: string + expectedHead: string + hostId?: ExecutionHostId + }) => Promise + updateMeta: (args: { worktreeId: string; updates: Partial }) => Promise + listLineage: () => Promise<{ + lineage: Record + workspaceLineage?: Record + }> + listLineageForHost?: (args: ListDesktopLineageForHostArgs) => Promise + updateLineage: (args: { + worktreeId: string + parentWorktreeId?: string + noParent?: boolean + }) => Promise + persistSortOrder: (args: { orderedIds: string[] }) => Promise + /** Full CLI output of the last branch auto-rename generation failure, held + * in main memory only — null after a restart or once the failure clears. */ + getBranchRenameFailureOutput: (args: { worktreeId: string }) => Promise + onChanged: (callback: (data: { repoId: string }) => void) => () => void + onGitStatusMetadataChanged: (callback: (data: { repoId: string }) => void) => () => void + onHeadIdentitiesChanged: ( + callback: (data: { repoId: string; identities: WorktreeHeadIdentity[] }) => void + ) => () => void + onBaseStatus: (callback: (data: WorktreeBaseStatusEvent) => void) => () => void + onRemoteBranchConflict: ( + callback: (data: WorktreeRemoteBranchConflictEvent) => void + ) => () => void +} + +export type FolderWorkspacesApi = { + list: () => Promise + getPathStatus: (args: FolderWorkspacePathStatusRequest) => Promise + create: (args: { + projectGroupId: string + name?: string + folderPath?: string | null + connectionId?: string | null + linkedTask?: FolderWorkspace['linkedTask'] + createdWithAgent?: FolderWorkspace['createdWithAgent'] + pendingFirstAgentMessageRename?: boolean + }) => Promise + update: (args: { + folderWorkspaceId: string + updates: Partial< + Pick< + FolderWorkspace, + | 'name' + | 'folderPath' + | 'linkedTask' + | 'comment' + | 'isArchived' + | 'isUnread' + | 'isPinned' + | 'sortOrder' + | 'manualOrder' + | 'workspaceStatus' + | 'createdWithAgent' + | 'pendingFirstAgentMessageRename' + | 'firstAgentMessageRenameError' + | 'lastActivityAt' + | 'diffComments' + > + > + }) => Promise + delete: (args: { folderWorkspaceId: string }) => Promise +} + +export type SparsePresetsApi = { + list: (args: { repoId: string }) => Promise + save: (args: { + repoId: string + id?: string + name: string + directories: string[] + }) => Promise + remove: (args: { repoId: string; presetId: string }) => Promise + onChanged: (callback: (data: { repoId: string }) => void) => () => void +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 518feac7d82..24e692088e0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -160,7 +160,7 @@ import type { import type { WorkspaceSpaceScanProgress } from '../shared/workspace-space-types' import type { WorkspaceCleanupScanProgress } from '../shared/workspace-cleanup' import type { WorkspacePortAdvertisedUrlChangedEvent } from '../shared/workspace-ports' -import type { GhAuthDiagnostic } from '../shared/github-auth-types' +import type { GhAuthDiagnostic } from '../shared/github/auth-types' import type { TaskSourceContext } from '../shared/task-source-context' import type { AddIssueCommentBySlugArgs, @@ -189,7 +189,7 @@ import type { UpdateIssueTypeBySlugArgs, UpdatePullRequestBySlugArgs, UpdateProjectItemFieldArgs -} from '../shared/github-project-types' +} from '../shared/github/project-types' import { richMarkdownContextMenuCommandChannel, richMarkdownContextMenuTargetChannel, diff --git a/src/relay/agent-exec-handler.test.ts b/src/relay/agent-exec-handler.test.ts index c884c499dac..42a79469d42 100644 --- a/src/relay/agent-exec-handler.test.ts +++ b/src/relay/agent-exec-handler.test.ts @@ -1,5 +1,5 @@ import { execFile, spawn } from 'node:child_process' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as ChildProcess from 'node:child_process' import { createFakeChild, createHandlers, requestContext } from './agent-exec-handler-test-harness' import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../shared/terminal-git-credential-guard' @@ -18,10 +18,29 @@ const execFileMock = vi.mocked(execFile) type AgentExecResult = { exitCode: number | null; timedOut: boolean } +const GUARD_OWNED_ENV_RE = /^(?:GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)|WSLENV)$/ + describe('AgentExecHandler', () => { + let ambientGuardEnv: Record = {} + beforeEach(() => { spawnMock.mockReset() execFileMock.mockReset() + // Why: the guard rewrites these, so an already-guarded runner (Orca guards + // its own agent terminals) would not see its ambient values passed through. + ambientGuardEnv = {} + for (const key of Object.keys(process.env).filter((name) => GUARD_OWNED_ENV_RE.test(name))) { + ambientGuardEnv[key] = process.env[key] + delete process.env[key] + } + }) + + afterEach(() => { + for (const [key, value] of Object.entries(ambientGuardEnv)) { + if (value !== undefined) { + process.env[key] = value + } + } }) it('executes a non-interactive command with captured output and stdin', async () => { diff --git a/src/relay/git-handler-worktree-ops.ts b/src/relay/git-handler-worktree-ops.ts index 487fabd7688..0cdb0761b47 100644 --- a/src/relay/git-handler-worktree-ops.ts +++ b/src/relay/git-handler-worktree-ops.ts @@ -1,5 +1,5 @@ import * as path from 'node:path' -import { resolveWorktreeAddBaseRef } from '../shared/worktree-base-ref' +import { resolveWorktreeAddBaseRef } from '../shared/worktree/base-ref' import type { GitExec } from './git-handler-ops' export { removeWorktreeOp } from './git-handler-worktree-remove' export { readRelayWorktreeList } from './git-handler-worktree-list' diff --git a/src/relay/git-handler-worktree-remove.ts b/src/relay/git-handler-worktree-remove.ts index 46ebaf8a355..80bf08c25fe 100644 --- a/src/relay/git-handler-worktree-remove.ts +++ b/src/relay/git-handler-worktree-remove.ts @@ -1,7 +1,7 @@ import * as path from 'node:path' import type { RemoveWorktreeResult } from '../shared/types' -import { assertWorktreeUnlockedForRemoval } from '../shared/worktree-removal' -import { isSubmoduleWorktreeRemovalRefusal } from '../shared/worktree-submodule-removal' +import { assertWorktreeUnlockedForRemoval } from '../shared/worktree/removal' +import { isSubmoduleWorktreeRemovalRefusal } from '../shared/worktree/submodule-removal' import { deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure } from './git-handler-branch-cleanup' import type { GitExec } from './git-handler-ops' import type { GitCapabilityCache } from '../shared/git-capability-cache' diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 3f8bd593e08..faa12d5093b 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -25,7 +25,7 @@ import { isPathInsideOrEqual, normalizeRuntimePathForComparison } from '../shared/cross-platform-path' -import { splitWorktreeId } from '../shared/worktree-id' +import { splitWorktreeId } from '../shared/worktree/id' import { PhysicalExitTracker } from '../shared/physical-exit-tracker' import { SHELL_READY_MARKER_PREFIX } from '../main/shell-ready-marker-scanner' import { diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 5f73fb31bea..bba079e9f6d 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -177,8 +177,8 @@ import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' import { GITHUB_PR_MERGE_METHOD_LABELS, resolveGitHubPRMergeMethods -} from '../../../shared/github-pr-merge-methods' -import { githubRepoIdentityKey } from '../../../shared/github-repository-identity-key' +} from '../../../shared/github/pull-request-merge-methods' +import { githubRepoIdentityKey } from '../../../shared/github/repository-identity-key' import { findGithubIssueWorkspaceAttachment, getGithubWorkItemWorkspaceAttachmentLabel diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index aedc7bdd5e0..f47b452b573 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -170,8 +170,8 @@ import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' import { GITHUB_PR_MERGE_METHOD_LABELS, resolveGitHubPRMergeMethods -} from '../../../shared/github-pr-merge-methods' -import { githubRepoIdentityKey } from '../../../shared/github-repository-identity-key' +} from '../../../shared/github/pull-request-merge-methods' +import { githubRepoIdentityKey } from '../../../shared/github/repository-identity-key' import { findGithubPrWorkspaceAttachment, getGithubPrWorkspaceAttachmentLabel diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 29bc0bc4587..d7cfc3e9e5e 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -127,11 +127,11 @@ import { getLinearStatePillStyle } from '@/components/linear-state-pill-style' import { parseTaskQuery, stripRepoQualifiers, withQualifier } from '../../../shared/task-query' -import { githubProjectHost } from '../../../shared/github-project-identity' +import { githubProjectHost } from '../../../shared/github/project-identity' import { buildLinearTeamUrl, getLinearOrganizationUrlKeyFromIssueUrl -} from '../../../shared/linear-links' +} from '../../../shared/linear/links' import PRFilterDropdowns, { type PRFilterChange } from '@/components/github/PRFilterDropdowns' import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer' import { GitHubUserAvatar } from '@/components/github/github-user-avatar' @@ -287,7 +287,7 @@ import { import { linearIssueAttributeFilterSignature, type LinearIssueAttributeFilter -} from '../../../shared/linear-issue-attribute-filter' +} from '../../../shared/linear/issue-attribute-filter' import { DEFAULT_LINEAR_GROUP_BY, DEFAULT_LINEAR_ORDER_BY, @@ -296,7 +296,7 @@ import { selectLinearWorkspaceIssueFilter, serializeLinearIssueViewResumeState, setLinearWorkspaceIssueFilter -} from '../../../shared/linear-issue-view-resume-state' +} from '../../../shared/linear/issue-view-resume-state' import { loadLinearIssueView, saveLinearIssueView } from './linear-issue-view-storage' import { isNewIssueDraftContentful, @@ -349,7 +349,7 @@ import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' import { GITHUB_PR_MERGE_METHOD_LABELS, resolveGitHubPRMergeMethods -} from '../../../shared/github-pr-merge-methods' +} from '../../../shared/github/pull-request-merge-methods' import type { GitHubOwnerRepo, GitHubAssignableUser, @@ -382,7 +382,7 @@ import type { GitLabProjectRef } from '../../../shared/gitlab-types' import { LINEAR_ISSUE_LIST_MAX, clampLinearIssueListLimit -} from '../../../shared/linear-issue-read-limits' +} from '../../../shared/linear/issue-read-limits' import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard' import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index b1fe9c4988b..a17d79e4197 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -29,7 +29,7 @@ import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-ov import type { PreflightStatus } from '../../../../preload/api-types' import type { TaskSourceContext } from '../../../../shared/task-source-context' import type { OrcaHooks, Repo } from '../../../../shared/types' -import { getWorktreePathBasenameFromId } from '../../../../shared/worktree-id' +import { getWorktreePathBasenameFromId } from '../../../../shared/worktree/id' import { buildAutomationRrule, isValidAutomationCronSchedule, diff --git a/src/renderer/src/components/github-pr-merge-state.ts b/src/renderer/src/components/github-pr-merge-state.ts index ca6c47e71b4..d67a3b8a3ef 100644 --- a/src/renderer/src/components/github-pr-merge-state.ts +++ b/src/renderer/src/components/github-pr-merge-state.ts @@ -5,7 +5,7 @@ import type { PRReviewDecision, PRState } from '../../../shared/types' -import { canEnableGitHubPRAutoMerge } from '../../../shared/github-pr-auto-merge-availability' +import { canEnableGitHubPRAutoMerge } from '../../../shared/github/pull-request-auto-merge-availability' import { translate } from '@/i18n/i18n' export type GitHubPRMergeStateInput = { diff --git a/src/renderer/src/components/github-project/GhAuthErrorHelp.test.ts b/src/renderer/src/components/github-project/GhAuthErrorHelp.test.ts index 248783de9e0..5e352e13884 100644 --- a/src/renderer/src/components/github-project/GhAuthErrorHelp.test.ts +++ b/src/renderer/src/components/github-project/GhAuthErrorHelp.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { GhAuthDiagnostic } from '../../../../shared/github-auth-types' +import type { GhAuthDiagnostic } from '../../../../shared/github/auth-types' vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback diff --git a/src/renderer/src/components/github-project/GhAuthErrorHelp.tsx b/src/renderer/src/components/github-project/GhAuthErrorHelp.tsx index 7bbeff291d7..6cec15c57b5 100644 --- a/src/renderer/src/components/github-project/GhAuthErrorHelp.tsx +++ b/src/renderer/src/components/github-project/GhAuthErrorHelp.tsx @@ -12,8 +12,8 @@ import { useEffect, useState } from 'react' import { Copy, ExternalLink, RotateCw } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' -import type { GitHubProjectViewError } from '@/../../shared/github-project-types' -import type { GhAuthDiagnostic } from '@/../../shared/github-auth-types' +import type { GitHubProjectViewError } from '../../../../shared/github/project-types' +import type { GhAuthDiagnostic } from '../../../../shared/github/auth-types' import { translate } from '@/i18n/i18n' type AuthErrorKind = 'auth_required' | 'scope_missing' diff --git a/src/renderer/src/components/github-project/ProjectCell.tsx b/src/renderer/src/components/github-project/ProjectCell.tsx index 305fccc3873..877f426311a 100644 --- a/src/renderer/src/components/github-project/ProjectCell.tsx +++ b/src/renderer/src/components/github-project/ProjectCell.tsx @@ -24,7 +24,7 @@ import type { GitHubProjectRow, GitHubProjectUser, ListIssueTypesBySlugResult -} from '../../../../shared/github-project-types' +} from '../../../../shared/github/project-types' import type { GlobalSettings } from '../../../../shared/types' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/github-project/ProjectGroupHeader.tsx b/src/renderer/src/components/github-project/ProjectGroupHeader.tsx index c0856cb59a3..add12fb4e99 100644 --- a/src/renderer/src/components/github-project/ProjectGroupHeader.tsx +++ b/src/renderer/src/components/github-project/ProjectGroupHeader.tsx @@ -1,7 +1,7 @@ import React from 'react' import { ChevronDown, ChevronRight } from 'lucide-react' import { cn } from '@/lib/utils' -import { isIterationCurrent, type ProjectGroup } from '../../../../shared/github-project-group-sort' +import { isIterationCurrent, type ProjectGroup } from '../../../../shared/github/project-group-sort' import { translate } from '@/i18n/i18n' type Props = { diff --git a/src/renderer/src/components/github-project/ProjectPicker.tsx b/src/renderer/src/components/github-project/ProjectPicker.tsx index 2abf6273e59..cd142d5b04c 100644 --- a/src/renderer/src/components/github-project/ProjectPicker.tsx +++ b/src/renderer/src/components/github-project/ProjectPicker.tsx @@ -23,12 +23,12 @@ import type { ListAccessibleProjectsResult, ListProjectViewsResult, ResolveProjectRefResult -} from '../../../../shared/github-project-types' +} from '../../../../shared/github/project-types' import { GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR, hasBoundedGitHubProjectRefInputText, isGitHubProjectRefInputTooLarge -} from '../../../../shared/github-project-ref-input' +} from '../../../../shared/github/project-ref-input' import { filterGitHubProjectPickerProjects } from './github-project-picker-filter' import { getProjectPickerBrowseCacheEntry, @@ -39,7 +39,7 @@ import { translate } from '@/i18n/i18n' import { githubProjectHost, githubProjectIdentityKey -} from '../../../../shared/github-project-identity' +} from '../../../../shared/github/project-identity' export type ResolvedProjectSelection = { owner: string diff --git a/src/renderer/src/components/github-project/ProjectRow.tsx b/src/renderer/src/components/github-project/ProjectRow.tsx index e59c58903eb..d57a807702e 100644 --- a/src/renderer/src/components/github-project/ProjectRow.tsx +++ b/src/renderer/src/components/github-project/ProjectRow.tsx @@ -11,7 +11,7 @@ import type { GitHubProjectField, GitHubProjectFieldMutationValue, GitHubProjectRow as GitHubProjectRowType -} from '../../../../shared/github-project-types' +} from '../../../../shared/github/project-types' import type { GlobalSettings } from '../../../../shared/types' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/github-project/ProjectViewList.tsx b/src/renderer/src/components/github-project/ProjectViewList.tsx index fbc869b1107..8bd6963b4c7 100644 --- a/src/renderer/src/components/github-project/ProjectViewList.tsx +++ b/src/renderer/src/components/github-project/ProjectViewList.tsx @@ -5,7 +5,7 @@ import { cn } from '@/lib/utils' import ColumnResizeHandle from './ColumnResizeHandle' import ProjectGroupHeader from './ProjectGroupHeader' import ProjectRow from './ProjectRow' -import { groupRows, sortRows } from '../../../../shared/github-project-group-sort' +import { groupRows, sortRows } from '../../../../shared/github/project-group-sort' import { getAvailableColumns, loadHiddenColumns, saveHiddenColumns } from './columns' import { ACTION_COLUMN_WIDTH, @@ -21,7 +21,7 @@ import type { GitHubProjectRow, GitHubProjectSortDirection, GitHubProjectTable -} from '../../../../shared/github-project-types' +} from '../../../../shared/github/project-types' import type { GlobalSettings } from '../../../../shared/types' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx index 59d02e24f31..24941730c94 100644 --- a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx +++ b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx @@ -40,7 +40,7 @@ import type { GitHubProjectViewError, GitHubProjectViewSummary, ListProjectViewsResult -} from '../../../../shared/github-project-types' +} from '../../../../shared/github/project-types' import type { GitHubWorkItem } from '../../../../shared/types' import ProjectPicker, { type ResolvedProjectSelection } from './ProjectPicker' import ProjectViewList from './ProjectViewList' @@ -64,7 +64,8 @@ import { buildTaskSourceContextFromRepo } from '../../../../shared/task-source-c import { githubProjectHost, githubProjectIdentityKey -} from '../../../../shared/github-project-identity' +} from '../../../../shared/github/project-identity' +import { buildProjectWorkItem } from './project-work-item' type Props = { selectedRepoIds: ReadonlySet @@ -94,43 +95,6 @@ function getProjectViewSourceScope(settings: Parameters label.name), - updatedAt: row.updatedAt, - author: null, - repoId, - prRepo - } -} - export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JSX.Element { const settings = useAppStore((s) => s.settings) const projectViewCache = useAppStore((s) => s.projectViewCache) diff --git a/src/renderer/src/components/github-project/column-widths.ts b/src/renderer/src/components/github-project/column-widths.ts index 68106fb451b..5967e4d2b5e 100644 --- a/src/renderer/src/components/github-project/column-widths.ts +++ b/src/renderer/src/components/github-project/column-widths.ts @@ -7,7 +7,7 @@ // the grid always fit its container exactly. Resize redistributes // weights between a column pair so the total stays constant and the // table never grows beyond its container. -import type { GitHubProjectField } from '../../../../shared/github-project-types' +import type { GitHubProjectField } from '../../../../shared/github/project-types' const STORAGE_KEY = 'orca.githubProject.columnWidths' diff --git a/src/renderer/src/components/github-project/columns.ts b/src/renderer/src/components/github-project/columns.ts index 9e3684b5bfe..b2cd21b5276 100644 --- a/src/renderer/src/components/github-project/columns.ts +++ b/src/renderer/src/components/github-project/columns.ts @@ -3,7 +3,7 @@ // local visibility filter on top. Persisted in localStorage (not settings) // because it's purely cosmetic per device and would otherwise bloat the // debounced settings write on every checkbox toggle. -import type { GitHubProjectField, GitHubProjectView } from '../../../../shared/github-project-types' +import type { GitHubProjectField, GitHubProjectView } from '../../../../shared/github/project-types' export const TYPE_FIELD_ID = '__type__' export const TYPE_FIELD_DATA_TYPE = '__TYPE__' diff --git a/src/renderer/src/components/github-project/github-project-picker-filter.test.ts b/src/renderer/src/components/github-project/github-project-picker-filter.test.ts index 2f04b73f71f..b3b3aee335c 100644 --- a/src/renderer/src/components/github-project/github-project-picker-filter.test.ts +++ b/src/renderer/src/components/github-project/github-project-picker-filter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { GitHubProjectSummary } from '../../../../shared/github-project-types' +import type { GitHubProjectSummary } from '../../../../shared/github/project-types' import { GITHUB_PROJECT_PICKER_QUERY_MAX_BYTES, filterGitHubProjectPickerProjects, diff --git a/src/renderer/src/components/github-project/github-project-picker-filter.ts b/src/renderer/src/components/github-project/github-project-picker-filter.ts index 0f8065db291..fcee171db82 100644 --- a/src/renderer/src/components/github-project/github-project-picker-filter.ts +++ b/src/renderer/src/components/github-project/github-project-picker-filter.ts @@ -2,8 +2,8 @@ import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard import type { GitHubProjectSettings, GitHubProjectSummary -} from '../../../../shared/github-project-types' -import { githubProjectIdentityKey } from '../../../../shared/github-project-identity' +} from '../../../../shared/github/project-types' +import { githubProjectIdentityKey } from '../../../../shared/github/project-identity' export const GITHUB_PROJECT_PICKER_QUERY_MAX_BYTES = 2 * 1024 diff --git a/src/renderer/src/components/github-project/group-sort.test.ts b/src/renderer/src/components/github-project/group-sort.test.ts index 4d36f0125da..cd5e77738bc 100644 --- a/src/renderer/src/components/github-project/group-sort.test.ts +++ b/src/renderer/src/components/github-project/group-sort.test.ts @@ -8,8 +8,8 @@ import type { GitHubProjectSort, GitHubProjectTable, GitHubProjectView -} from '../../../../shared/github-project-types' -import { sortRows, groupRows } from '../../../../shared/github-project-group-sort' +} from '../../../../shared/github/project-types' +import { sortRows, groupRows } from '../../../../shared/github/project-group-sort' const singleSelectField: GitHubProjectField = { kind: 'single-select', diff --git a/src/renderer/src/components/github-project/project-picker-browse-cache.test.ts b/src/renderer/src/components/github-project/project-picker-browse-cache.test.ts index 8f0052c4fbe..cb0e5b2f7a6 100644 --- a/src/renderer/src/components/github-project/project-picker-browse-cache.test.ts +++ b/src/renderer/src/components/github-project/project-picker-browse-cache.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest' -import type { GitHubProjectSummary } from '../../../../shared/github-project-types' +import type { GitHubProjectSummary } from '../../../../shared/github/project-types' import { PROJECT_PICKER_BROWSE_CACHE_MAX_ENTRIES, PROJECT_PICKER_BROWSE_CACHE_TTL_MS, diff --git a/src/renderer/src/components/github-project/project-picker-browse-cache.ts b/src/renderer/src/components/github-project/project-picker-browse-cache.ts index bd1726aec3a..1854d1f4ca3 100644 --- a/src/renderer/src/components/github-project/project-picker-browse-cache.ts +++ b/src/renderer/src/components/github-project/project-picker-browse-cache.ts @@ -1,4 +1,4 @@ -import type { GitHubProjectSummary } from '../../../../shared/github-project-types' +import type { GitHubProjectSummary } from '../../../../shared/github/project-types' export const PROJECT_PICKER_BROWSE_CACHE_TTL_MS = 5 * 60_000 export const PROJECT_PICKER_BROWSE_CACHE_MAX_ENTRIES = 32 diff --git a/src/renderer/src/components/github-project/project-row-filtering.test.ts b/src/renderer/src/components/github-project/project-row-filtering.test.ts index 8871c047f7a..636382aa53c 100644 --- a/src/renderer/src/components/github-project/project-row-filtering.test.ts +++ b/src/renderer/src/components/github-project/project-row-filtering.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { GitHubProjectRow, GitHubProjectTable } from '../../../../shared/github-project-types' +import type { GitHubProjectRow, GitHubProjectTable } from '../../../../shared/github/project-types' import type { Repo } from '../../../../shared/types' import { filterProjectTableRowsByOpenRepos, diff --git a/src/renderer/src/components/github-project/project-row-filtering.ts b/src/renderer/src/components/github-project/project-row-filtering.ts index 52dd0138274..b4c1b3738a3 100644 --- a/src/renderer/src/components/github-project/project-row-filtering.ts +++ b/src/renderer/src/components/github-project/project-row-filtering.ts @@ -1,4 +1,4 @@ -import type { GitHubProjectRow, GitHubProjectTable } from '../../../../shared/github-project-types' +import type { GitHubProjectRow, GitHubProjectTable } from '../../../../shared/github/project-types' import type { Repo } from '../../../../shared/types' export type ProjectRowSlugLookup = ( diff --git a/src/renderer/src/components/github-project/project-view-wrapper-source-context-boundary.test.ts b/src/renderer/src/components/github-project/project-view-wrapper-source-context-boundary.test.ts index 6fe48619960..0ee72795a97 100644 --- a/src/renderer/src/components/github-project/project-view-wrapper-source-context-boundary.test.ts +++ b/src/renderer/src/components/github-project/project-view-wrapper-source-context-boundary.test.ts @@ -1,9 +1,7 @@ -// @vitest-environment happy-dom - import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import type { GitHubProjectRow } from '../../../../shared/github-project-types' +import type { GitHubProjectRow } from '../../../../shared/github/project-types' const COMPONENT_ROOT = __dirname @@ -21,7 +19,7 @@ function sourceBetween(source: string, startPattern: string, endPattern: string) describe('ProjectViewWrapper GitHub source context boundary', () => { it('builds project work items with a host-pinned repository identity', async () => { - const { buildProjectWorkItem } = await import('./ProjectViewWrapper') + const { buildProjectWorkItem } = await import('./project-work-item') const row: GitHubProjectRow = { id: 'PVTI_1', itemType: 'PULL_REQUEST', diff --git a/src/renderer/src/components/github-project/project-visible-table-cache.test.ts b/src/renderer/src/components/github-project/project-visible-table-cache.test.ts index e5eeabc40fc..ddf089a49d0 100644 --- a/src/renderer/src/components/github-project/project-visible-table-cache.test.ts +++ b/src/renderer/src/components/github-project/project-visible-table-cache.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { GitHubProjectTable } from '../../../../shared/github-project-types' +import type { GitHubProjectTable } from '../../../../shared/github/project-types' import { getSelectedRepoFingerprint, getNextVisibleProjectTableCache, diff --git a/src/renderer/src/components/github-project/project-visible-table-cache.ts b/src/renderer/src/components/github-project/project-visible-table-cache.ts index 6390e897281..e3dc0772aa0 100644 --- a/src/renderer/src/components/github-project/project-visible-table-cache.ts +++ b/src/renderer/src/components/github-project/project-visible-table-cache.ts @@ -1,4 +1,4 @@ -import type { GitHubProjectTable } from '../../../../shared/github-project-types' +import type { GitHubProjectTable } from '../../../../shared/github/project-types' export type CachedVisibleProjectTable = { cacheKey: string diff --git a/src/renderer/src/components/github-project/project-work-item.ts b/src/renderer/src/components/github-project/project-work-item.ts new file mode 100644 index 00000000000..cc68266fe88 --- /dev/null +++ b/src/renderer/src/components/github-project/project-work-item.ts @@ -0,0 +1,40 @@ +import { githubProjectHost } from '../../../../shared/github/project-identity' +import type { GitHubProjectRow } from '../../../../shared/github/project-types' +import type { GitHubWorkItem } from '../../../../shared/types' + +export function buildProjectWorkItem( + row: GitHubProjectRow, + repoId: string, + host?: string +): GitHubWorkItem | null { + if (row.itemType !== 'ISSUE' && row.itemType !== 'PULL_REQUEST') { + return null + } + if (row.content.number == null || !row.content.url) { + return null + } + const [owner, repo] = row.content.repository?.split('/') ?? [] + // Why: Project rows can reach mutation controls before detail hydration, so + // preserve their host-bearing repository identity on the initial item. + const prRepo = owner && repo ? { owner, repo, host: githubProjectHost(host) } : undefined + return { + id: `${row.itemType === 'PULL_REQUEST' ? 'pr' : 'issue'}:${row.content.number}`, + type: row.itemType === 'PULL_REQUEST' ? 'pr' : 'issue', + number: row.content.number, + title: row.content.title, + state: + row.content.state === 'MERGED' + ? 'merged' + : row.content.state === 'CLOSED' + ? 'closed' + : row.content.isDraft + ? 'draft' + : 'open', + url: row.content.url, + labels: row.content.labels.map((label) => label.name), + updatedAt: row.updatedAt, + author: null, + repoId, + prRepo + } +} diff --git a/src/renderer/src/components/github-project/slug-dialog/Comments.tsx b/src/renderer/src/components/github-project/slug-dialog/Comments.tsx index 6de67e2ca54..748edc1fe74 100644 --- a/src/renderer/src/components/github-project/slug-dialog/Comments.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/Comments.tsx @@ -16,7 +16,7 @@ import type { GlobalSettings, PRComment } from '../../../../../shared/types' import type { GitHubProjectCommentMutationResult, GitHubProjectMutationResult -} from '../../../../../shared/github-project-types' +} from '../../../../../shared/github/project-types' import { translate } from '@/i18n/i18n' function getRuntimeTarget(settings: Parameters[0]) { diff --git a/src/renderer/src/components/github/IssueSourceIndicator.tsx b/src/renderer/src/components/github/IssueSourceIndicator.tsx index 57e3d0a2af7..c9615562e81 100644 --- a/src/renderer/src/components/github/IssueSourceIndicator.tsx +++ b/src/renderer/src/components/github/IssueSourceIndicator.tsx @@ -5,7 +5,7 @@ import { cn } from '@/lib/utils' import { githubRepoIdentityKey, isDefaultGitHubHost -} from '../../../../shared/github-repository-identity-key' +} from '../../../../shared/github/repository-identity-key' export type IssueSourceIndicatorProps = { /** Resolved issue-source owner/repo. `null` means the source hasn't been diff --git a/src/renderer/src/components/github/github-work-item-edit-mutations.ts b/src/renderer/src/components/github/github-work-item-edit-mutations.ts index 9385ef14ff2..e81adba1f0e 100644 --- a/src/renderer/src/components/github/github-work-item-edit-mutations.ts +++ b/src/renderer/src/components/github/github-work-item-edit-mutations.ts @@ -8,7 +8,7 @@ import { import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import type { GitHubWorkItemProjectOrigin } from '@/components/github/github-work-item-identity' import { notifyWorkItemDetailsMutation } from '@/components/github/github-work-item-comment-mutations' -import { githubProjectHost } from '../../../../shared/github-project-identity' +import { githubProjectHost } from '../../../../shared/github/project-identity' import { getTaskSourceRuntimeSettings, type TaskSourceContext diff --git a/src/renderer/src/components/github/github-work-item-identity.ts b/src/renderer/src/components/github/github-work-item-identity.ts index b4feceed9fb..f4808e17f3f 100644 --- a/src/renderer/src/components/github/github-work-item-identity.ts +++ b/src/renderer/src/components/github/github-work-item-identity.ts @@ -1,4 +1,4 @@ -import { githubProjectHost } from '../../../../shared/github-project-identity' +import { githubProjectHost } from '../../../../shared/github/project-identity' import type { GitHubOwnerRepo, GitHubWorkItem } from '../../../../shared/types' // Why: the dialog lacks repository context, so recover its host-aware identity from the canonical item URL. diff --git a/src/renderer/src/components/linear-api-key-dialog.tsx b/src/renderer/src/components/linear-api-key-dialog.tsx index b9e94ba3bd0..0f00205d26e 100644 --- a/src/renderer/src/components/linear-api-key-dialog.tsx +++ b/src/renderer/src/components/linear-api-key-dialog.tsx @@ -4,7 +4,7 @@ import type { LinearWorkspace } from '../../../shared/types' import { buildLinearPersonalApiKeySettingsUrl, buildLinearWorkspaceApiSettingsUrl -} from '../../../shared/linear-links' +} from '../../../shared/linear/links' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { useAppStore } from '@/store' import { useMountedRef } from '@/hooks/useMountedRef' diff --git a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx index 8fa3956959d..2352f25b876 100644 --- a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx +++ b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx @@ -9,7 +9,7 @@ import { countLinearIssueAttributeFilters, linearIssueAttributeFilterPillLabels } from './linear-issue-attribute-filter-sections' -import type { LinearIssueAttributeFilter } from '../../../shared/linear-issue-attribute-filter' +import type { LinearIssueAttributeFilter } from '../../../shared/linear/issue-attribute-filter' import LinearIssueAttributeFilterDropdowns from './linear-issue-attribute-filter-dropdowns' const metadataMocks = vi.hoisted(() => ({ diff --git a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx index 85e6553ba36..e914a40c064 100644 --- a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx +++ b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx @@ -11,7 +11,7 @@ import { canonicalizeLinearIssueAttributeFilter, emptyLinearIssueAttributeFilter, type LinearIssueAttributeFilter -} from '../../../shared/linear-issue-attribute-filter' +} from '../../../shared/linear/issue-attribute-filter' import type { LinearTeam } from '../../../shared/types' import { LinearIssueFilterSectionDetail, diff --git a/src/renderer/src/components/linear-issue-attribute-filter-sections.tsx b/src/renderer/src/components/linear-issue-attribute-filter-sections.tsx index 451a25a8ea9..dcf12b026e9 100644 --- a/src/renderer/src/components/linear-issue-attribute-filter-sections.tsx +++ b/src/renderer/src/components/linear-issue-attribute-filter-sections.tsx @@ -10,7 +10,7 @@ import { translate } from '@/i18n/i18n' import { canonicalizeLinearIssueAttributeFilter, type LinearIssueAttributeFilter -} from '../../../shared/linear-issue-attribute-filter' +} from '../../../shared/linear/issue-attribute-filter' import { getLinearPriorityLabel } from './task-page-localized-options' export type LinearIssueFilterSectionKey = 'status' | 'priority' | 'assignee' | 'labels' diff --git a/src/renderer/src/components/linear-issue-view-storage.test.ts b/src/renderer/src/components/linear-issue-view-storage.test.ts index b9654721ba8..c1c632ed116 100644 --- a/src/renderer/src/components/linear-issue-view-storage.test.ts +++ b/src/renderer/src/components/linear-issue-view-storage.test.ts @@ -1,6 +1,6 @@ // @vitest-environment happy-dom import { beforeEach, describe, expect, it } from 'vitest' -import { defaultLinearIssueViewResumeState } from '../../../shared/linear-issue-view-resume-state' +import { defaultLinearIssueViewResumeState } from '../../../shared/linear/issue-view-resume-state' import { loadLinearIssueView, saveLinearIssueView } from './linear-issue-view-storage' const STORAGE_KEY = 'orca.linear.issue-view.v1' diff --git a/src/renderer/src/components/linear-issue-view-storage.ts b/src/renderer/src/components/linear-issue-view-storage.ts index b34f0cc55fa..2a227644028 100644 --- a/src/renderer/src/components/linear-issue-view-storage.ts +++ b/src/renderer/src/components/linear-issue-view-storage.ts @@ -2,7 +2,7 @@ import { normalizeLinearIssueViewResumeState, resolveLinearIssueViewResumeState, type LinearIssueViewResumeState -} from '../../../shared/linear-issue-view-resume-state' +} from '../../../shared/linear/issue-view-resume-state' // Why: issue-list layout is a per-device view preference, not host state. Routing it // through `ui.set` put it inside a strict nested schema, where a host that predates diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index b87523a7d2d..66dd756e18f 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -96,7 +96,7 @@ import { type TaskSourceContext } from '../../../../shared/task-source-context' import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' -import { githubRepoIdentityKey } from '../../../../shared/github-repository-identity-key' +import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { getGitHubRuntimeRepoId, diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 25489863d7f..2d63be3bacf 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -40,7 +40,7 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { isFolderRepo } from '../../../../shared/repo-kind' -import { githubProjectHost } from '../../../../shared/github-project-identity' +import { githubProjectHost } from '../../../../shared/github/project-identity' import HostedReviewActions from './HostedReviewActions' import { GitHubPRStackMap, type GitHubPRStackMapNavigationModifiers } from './GitHubPRStackMap' import { @@ -97,7 +97,7 @@ import type { PendingPRCommentAiAck, PendingPRCommentAiAckGithubTarget } from './pr-comments-ai-launch-ack' -import { parseGitHubIssueOrPRLink } from '../../../../shared/github-links' +import { parseGitHubIssueOrPRLink } from '../../../../shared/github/links' import { startFixChecksAgent } from '@/lib/fix-checks-agent-launch' import { hostedReviewProviderSupportsDraft, diff --git a/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx b/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx index b8adeb40c4f..cace5ee7d98 100644 --- a/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx +++ b/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx @@ -13,7 +13,7 @@ import { } from '@/components/ui/dropdown-menu' import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' import type { PRInfo, Repo, Worktree } from '../../../../shared/types' -import { resolveGitHubPRMergeMethods } from '../../../../shared/github-pr-merge-methods' +import { resolveGitHubPRMergeMethods } from '../../../../shared/github/pull-request-merge-methods' import { runWorktreeDelete } from '../sidebar/delete-worktree-flow' import { presentGitLabMRMergeState } from './gitlab-mr-merge-state' import { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.ts b/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.ts index c9ad8137fc9..bcd3391a4fc 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.ts @@ -4,7 +4,7 @@ import { } from '../../../../shared/cross-platform-path' import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection' import type { ProjectHostSetup, Worktree } from '../../../../shared/types' -import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' +import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree/id' export function deriveAiVaultWorkspaceScopePaths( activeWorktree: Pick | null, diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-identity.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-identity.ts index f2101f75011..a4effa09096 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-identity.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-identity.ts @@ -1,5 +1,5 @@ import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types' -import { areValuesEqual } from '@/store/slices/repo-identity-reconcile' +import { structuralValuesEqual } from '../../../../shared/structural-value-equality' import { reuseEqualCatalogRows } from '@/store/slices/worktree-catalog-reconciliation' // One instance is shared by every mounted hook, so it is frozen: an in-place @@ -24,7 +24,9 @@ export function reuseAiVaultListResult( return incoming } const sessions = reuseEqualCatalogRows(current.sessions, incoming.sessions) - const issues = areValuesEqual(current.issues, incoming.issues) ? current.issues : incoming.issues + const issues = structuralValuesEqual(current.issues, incoming.issues) + ? current.issues + : incoming.issues if ( sessions === current.sessions && issues === current.issues && diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx b/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx index f0c9d2035d4..2ad0b1cca39 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx @@ -6,7 +6,7 @@ import { AgentIcon } from '@/lib/agent-catalog' import type { AgentStatusState } from '../../../../shared/agent-status-types' import { useRepoById } from '@/store/selectors' import { resolveRepoBadgeColor } from '../../../../shared/repo-badge-color' -import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' +import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree/id' import { isAiVaultSessionRecoverableEmpty, type AiVaultScope, diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts index d03aedb809e..a82a68c8f1e 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react' import { parseWslUncPath } from '../../../../shared/wsl-paths' -import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' +import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree/id' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID, diff --git a/src/renderer/src/components/right-sidebar/checks-panel-async-result-key.ts b/src/renderer/src/components/right-sidebar/checks-panel-async-result-key.ts index e3cee92b293..fc95acb9a7c 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-async-result-key.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-async-result-key.ts @@ -1,5 +1,5 @@ import type { GitHubOwnerRepo } from '../../../../shared/types' -import { githubRepoIdentityKey } from '../../../../shared/github-repository-identity-key' +import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' function normalizedPRRepoIdentity(prRepo?: GitHubOwnerRepo | null): string { return prRepo ? githubRepoIdentityKey(prRepo) : 'none' diff --git a/src/renderer/src/components/right-sidebar/checks-panel-terminal-worktree.ts b/src/renderer/src/components/right-sidebar/checks-panel-terminal-worktree.ts index c8f2f3cb720..ab92bbd1469 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-terminal-worktree.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-terminal-worktree.ts @@ -1,5 +1,5 @@ import { parseWslUncPath } from '../../../../shared/wsl-paths' -import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' +import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree/id' import { isPathInsideOrEqual, isRuntimePathAbsolute, diff --git a/src/renderer/src/components/settings/GlobalWorktreeVisibilitySourcesSetting.tsx b/src/renderer/src/components/settings/GlobalWorktreeVisibilitySourcesSetting.tsx index 7831d89e861..c2f2dfbc235 100644 --- a/src/renderer/src/components/settings/GlobalWorktreeVisibilitySourcesSetting.tsx +++ b/src/renderer/src/components/settings/GlobalWorktreeVisibilitySourcesSetting.tsx @@ -7,11 +7,11 @@ import type { import { MAX_CUSTOM_WORKTREE_VISIBILITY_SOURCES, normalizeCustomWorktreeVisibilitySources -} from '../../../../shared/worktree-visibility-sources' +} from '../../../../shared/worktree/visibility-sources' import { buildDefaultWorktreeSourcePreferenceUpdate, removeDefaultCustomWorktreeSourcePreference -} from '../../../../shared/worktree-visibility-source-preferences' +} from '../../../../shared/worktree/visibility-source-preferences' import WorktreeVisibilitySourceList, { type WorktreeVisibilitySourceAddResult, type WorktreeVisibilitySourceRow diff --git a/src/renderer/src/components/settings/McpConfigSection.tsx b/src/renderer/src/components/settings/McpConfigSection.tsx index 881cd3eb8bb..ce9b3d8db4a 100644 --- a/src/renderer/src/components/settings/McpConfigSection.tsx +++ b/src/renderer/src/components/settings/McpConfigSection.tsx @@ -3,7 +3,7 @@ import { AlertCircle, FileCode2, LoaderCircle, Plus, RefreshCw } from 'lucide-re import { toast } from 'sonner' import { useMountedRef } from '@/hooks/useMountedRef' import type { Repo, Worktree } from '../../../../shared/types' -import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import { canInspectLocalMcpConfigRoot, inspectMcpConfigContent, diff --git a/src/renderer/src/components/settings/manage-sessions-format.ts b/src/renderer/src/components/settings/manage-sessions-format.ts index ee1c4c7a193..f7e50dbbddc 100644 --- a/src/renderer/src/components/settings/manage-sessions-format.ts +++ b/src/renderer/src/components/settings/manage-sessions-format.ts @@ -1,5 +1,5 @@ import type { PtyManagementSession } from '../../../../preload/api-types' -import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' +import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree/id' export function shortCwd(cwd: string): string { if (!cwd) { diff --git a/src/renderer/src/components/settings/repository-icon-github.ts b/src/renderer/src/components/settings/repository-icon-github.ts index b5519e36f36..0a44a88f030 100644 --- a/src/renderer/src/components/settings/repository-icon-github.ts +++ b/src/renderer/src/components/settings/repository-icon-github.ts @@ -1,6 +1,6 @@ import type { GitHubRepositoryIdentity, Repo } from '../../../../shared/types' import { githubAvatarIcon, githubAvatarSlug, type RepoIcon } from '../../../../shared/repo-icon' -import { githubRepoIdentityKey } from '../../../../shared/github-repository-identity-key' +import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' import { callRuntimeRpc, type getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' type RuntimeTarget = ReturnType diff --git a/src/renderer/src/components/settings/repository-runtime-session-summary.ts b/src/renderer/src/components/settings/repository-runtime-session-summary.ts index 0279683b22b..5dffcd3b1e2 100644 --- a/src/renderer/src/components/settings/repository-runtime-session-summary.ts +++ b/src/renderer/src/components/settings/repository-runtime-session-summary.ts @@ -1,5 +1,5 @@ import type { AppState } from '../../store/types' -import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' export type ProjectRuntimeSessionSummary = { liveTerminalCount: number diff --git a/src/renderer/src/components/sidebar/PreservedBranchBatchReviewDialog.tsx b/src/renderer/src/components/sidebar/PreservedBranchBatchReviewDialog.tsx index c1340b79327..21b2321c814 100644 --- a/src/renderer/src/components/sidebar/PreservedBranchBatchReviewDialog.tsx +++ b/src/renderer/src/components/sidebar/PreservedBranchBatchReviewDialog.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react' import { Trash2 } from 'lucide-react' -import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import { translate } from '@/i18n/i18n' import type { PreservedBranchCleanup } from '@/lib/preserved-branch-cleanup' import { useAppStore } from '@/store' diff --git a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx index fef27ea71cc..4c31d82e153 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx @@ -9,7 +9,7 @@ import type { Worktree, WorktreeCardProperty } from '../../../../shared/types' -import { COMPACT_WORKTREE_CARD_PROPERTIES } from '../../../../shared/worktree-card-properties' +import { COMPACT_WORKTREE_CARD_PROPERTIES } from '../../../../shared/worktree/card-properties' import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports' const fetchHostedReviewForBranch = vi.fn() diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 0f5f872188e..796c1a9ac0f 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -259,7 +259,7 @@ import { isGitRepoKind } from '../../../../shared/repo-kind' import { effectiveExternalWorktreeVisibility, isLegacyRepoForExternalWorktreeVisibility -} from '../../../../shared/worktree-ownership' +} from '../../../../shared/worktree/ownership' import { RepoIconGlyph } from '@/components/repo/repo-icon' import { RepoForkIndicator } from '@/components/repo/repo-fork-indicator' import ImportedWorktreesVisibilityLine from './ImportedWorktreesVisibilityLine' diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx index 160f06e1d96..86c0b76e5c4 100644 --- a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx @@ -36,11 +36,11 @@ import { effectiveCustomWorktreeSourceVisibility, normalizeCustomWorktreeVisibilitySources, normalizeWorktreeVisibilitySourcePreferences -} from '../../../../shared/worktree-visibility-sources' +} from '../../../../shared/worktree/visibility-sources' import { buildWorktreeSourcePreferenceUpdate, removeCustomWorktreeSourcePreference -} from '../../../../shared/worktree-visibility-source-preferences' +} from '../../../../shared/worktree/visibility-source-preferences' import HiddenWorktreeRecoveryList from './HiddenWorktreeRecoveryList' import { worktreeVisibilityUpdateError } from './worktree-visibility-update-error' import { useRepoOwnerVisibilityDefaults } from './use-repo-owner-visibility-defaults' diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.tsx index 338db8aab53..06bf477d64d 100644 --- a/src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.tsx @@ -21,7 +21,7 @@ import { normalizeWorktreeVisibilitySourcePreferences, resolveCustomWorktreeVisibilitySources, type WorktreeVisibilitySourceMatch -} from '../../../../shared/worktree-visibility-sources' +} from '../../../../shared/worktree/visibility-sources' import { effectiveExternalWorktreeVisibility, isLegacyRepoForExternalWorktreeVisibility diff --git a/src/renderer/src/components/sidebar/delete-worktree-failure-toast.tsx b/src/renderer/src/components/sidebar/delete-worktree-failure-toast.tsx index bb9649f28f3..549c36c97a0 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-failure-toast.tsx +++ b/src/renderer/src/components/sidebar/delete-worktree-failure-toast.tsx @@ -5,7 +5,7 @@ import { translate } from '@/i18n/i18n' import { isLockedWorktreeRemovalError, type WorktreeForceDeleteReason -} from '../../../../shared/worktree-removal' +} from '../../../../shared/worktree/removal' type DeleteWorktreeFailureToastOptions = { error: string diff --git a/src/renderer/src/components/sidebar/delete-worktree-toast.test.ts b/src/renderer/src/components/sidebar/delete-worktree-toast.test.ts index acaa7d8f16f..dffedf06337 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-toast.test.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-toast.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { getDeleteWorktreeToastCopy } from './delete-worktree-toast' -import { classifyWorktreeForceDeleteReason } from '../../../../shared/worktree-removal' +import { classifyWorktreeForceDeleteReason } from '../../../../shared/worktree/removal' // Why: production never hands this function a literal reason — the store derives it from // classifyWorktreeForceDeleteReason (store/slices/worktrees.ts). Passing one in would let a diff --git a/src/renderer/src/components/sidebar/delete-worktree-toast.ts b/src/renderer/src/components/sidebar/delete-worktree-toast.ts index fac1c6ee06a..1c013da0da6 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-toast.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-toast.ts @@ -3,7 +3,7 @@ import { isLockedWorktreeRemovalError, isProvenLivePtyRemovalError, type WorktreeForceDeleteReason -} from '../../../../shared/worktree-removal' +} from '../../../../shared/worktree/removal' export type DeleteWorktreeToastCopy = { title: string description?: string diff --git a/src/renderer/src/components/sidebar/imported-worktrees-card-candidates.ts b/src/renderer/src/components/sidebar/imported-worktrees-card-candidates.ts index a7d906f9da5..0289d07177a 100644 --- a/src/renderer/src/components/sidebar/imported-worktrees-card-candidates.ts +++ b/src/renderer/src/components/sidebar/imported-worktrees-card-candidates.ts @@ -12,7 +12,7 @@ import { isGitRepoKind } from '../../../../shared/repo-kind' import { effectiveExternalWorktreeVisibility, isLegacyRepoForExternalWorktreeVisibility -} from '../../../../shared/worktree-ownership' +} from '../../../../shared/worktree/ownership' import type { ImportedWorktreesCardCandidate } from './worktree-list-groups' export function getHiddenImportedWorktrees( diff --git a/src/renderer/src/components/sidebar/preserved-branch-batch-toast.tsx b/src/renderer/src/components/sidebar/preserved-branch-batch-toast.tsx index d98cd37a438..a5d553da042 100644 --- a/src/renderer/src/components/sidebar/preserved-branch-batch-toast.tsx +++ b/src/renderer/src/components/sidebar/preserved-branch-batch-toast.tsx @@ -3,7 +3,7 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { mapWithConcurrency } from '../../../../shared/map-with-concurrency' -import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import { Button } from '../ui/button' import type { PreservedBranchCleanup } from '@/lib/preserved-branch-cleanup' diff --git a/src/renderer/src/components/sidebar/use-worktree-issue-link.ts b/src/renderer/src/components/sidebar/use-worktree-issue-link.ts index 939d71bb10c..bf1e0a1bb31 100644 --- a/src/renderer/src/components/sidebar/use-worktree-issue-link.ts +++ b/src/renderer/src/components/sidebar/use-worktree-issue-link.ts @@ -4,7 +4,7 @@ import { parseGitHubIssueOrPRNumber } from '@/lib/github-links' import { issueCacheKey as getIssueCacheKey } from '@/store/slices/github' import { useMountedRef } from '@/hooks/useMountedRef' import { findIndexedWorktreeOwner } from '@/lib/worktree-runtime-owner-index' -import { buildLinearIssueUrl, parseLinearIssueInput } from '../../../../shared/linear-links' +import { buildLinearIssueUrl, parseLinearIssueInput } from '../../../../shared/linear/links' import type { IssueLinkProvider } from '../../../../shared/issue-link-input' import type { TaskSourceContext } from '../../../../shared/task-source-context' import { parseExplicitGitHubIssueUrl } from './worktree-meta-updates' diff --git a/src/renderer/src/components/sidebar/worktree-meta-updates.ts b/src/renderer/src/components/sidebar/worktree-meta-updates.ts index 944da175d77..20afdf8af26 100644 --- a/src/renderer/src/components/sidebar/worktree-meta-updates.ts +++ b/src/renderer/src/components/sidebar/worktree-meta-updates.ts @@ -2,7 +2,7 @@ import { parseGitHubIssueOrPRLink, parseGitHubIssueOrPRNumber } from '@/lib/gith import { buildLinearIssueLinkUpdates, LINEAR_ISSUE_LINK_CLEARED -} from '../../../../shared/linear-links' +} from '../../../../shared/linear/links' import { parseIssueLinkInput, type IssueLinkProvider } from '../../../../shared/issue-link-input' import type { WorkspaceSourceProvider } from '../../../../shared/new-workspace/workspace-source' import type { WorkspaceLinkedItem, WorktreeMeta } from '../../../../shared/types' diff --git a/src/renderer/src/components/sidebar/worktree-visibility-repo-sources.ts b/src/renderer/src/components/sidebar/worktree-visibility-repo-sources.ts index ba069654860..ec95ef6f6df 100644 --- a/src/renderer/src/components/sidebar/worktree-visibility-repo-sources.ts +++ b/src/renderer/src/components/sidebar/worktree-visibility-repo-sources.ts @@ -7,7 +7,7 @@ import type { import { normalizeCustomWorktreeVisibilitySources, resolveCustomWorktreeVisibilitySources -} from '../../../../shared/worktree-visibility-sources' +} from '../../../../shared/worktree/visibility-sources' import { getRepoHostIdentity } from '@/store/slices/repo-host-identity' export function getLatestRepoForVisibilityScope(scope: string): Repo | null { diff --git a/src/renderer/src/components/sidebar/worktree-visibility-source-mutation.ts b/src/renderer/src/components/sidebar/worktree-visibility-source-mutation.ts index 47910ffea82..84b085c9456 100644 --- a/src/renderer/src/components/sidebar/worktree-visibility-source-mutation.ts +++ b/src/renderer/src/components/sidebar/worktree-visibility-source-mutation.ts @@ -10,8 +10,8 @@ import { import { effectiveBuiltInWorktreeSourceVisibility, effectiveCustomWorktreeSourceVisibility -} from '../../../../shared/worktree-visibility-sources' -import { buildWorktreeSourcePreferenceUpdate } from '../../../../shared/worktree-visibility-source-preferences' +} from '../../../../shared/worktree/visibility-sources' +import { buildWorktreeSourcePreferenceUpdate } from '../../../../shared/worktree/visibility-source-preferences' import type { RepoUpdate } from '@/store/slices/repos' import type { WorktreeVisibilitySourceRow } from './WorktreeVisibilitySourceList' diff --git a/src/renderer/src/components/sidebar/worktree-visibility-source-provenance.ts b/src/renderer/src/components/sidebar/worktree-visibility-source-provenance.ts index f3da57d0dd9..cdfbf8338f3 100644 --- a/src/renderer/src/components/sidebar/worktree-visibility-source-provenance.ts +++ b/src/renderer/src/components/sidebar/worktree-visibility-source-provenance.ts @@ -10,7 +10,7 @@ import { effectiveDefaultCustomWorktreeSourceVisibility, normalizeCustomWorktreeVisibilitySources, normalizeWorktreeVisibilitySourcePreferences -} from '../../../../shared/worktree-visibility-sources' +} from '../../../../shared/worktree/visibility-sources' import type { WorktreeVisibilitySourceRow } from './WorktreeVisibilitySourceList' export type WorktreeVisibilitySourceProvenance = { diff --git a/src/renderer/src/components/sidebar/worktree-visibility-use-global.ts b/src/renderer/src/components/sidebar/worktree-visibility-use-global.ts index 6f90ca05408..8951ea9acf1 100644 --- a/src/renderer/src/components/sidebar/worktree-visibility-use-global.ts +++ b/src/renderer/src/components/sidebar/worktree-visibility-use-global.ts @@ -11,11 +11,11 @@ import { effectiveBuiltInWorktreeSourceVisibility, effectiveCustomWorktreeSourceVisibility, normalizeWorktreeVisibilitySourcePreferences -} from '../../../../shared/worktree-visibility-sources' +} from '../../../../shared/worktree/visibility-sources' import { removeBuiltInWorktreeSourcePreference, removeCustomWorktreeSourcePreference -} from '../../../../shared/worktree-visibility-source-preferences' +} from '../../../../shared/worktree/visibility-source-preferences' import type { WorktreeVisibilitySourceRow } from './WorktreeVisibilitySourceList' import type { WorktreeVisibilitySourceMutation } from './worktree-visibility-source-mutation' import { diff --git a/src/renderer/src/components/stats/UsageBreakdownSection.test.tsx b/src/renderer/src/components/stats/UsageBreakdownSection.test.tsx new file mode 100644 index 00000000000..c6e5ba63fa0 --- /dev/null +++ b/src/renderer/src/components/stats/UsageBreakdownSection.test.tsx @@ -0,0 +1,35 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { UsageBreakdownSection } from './UsageBreakdownSection' + +afterEach(cleanup) + +describe('UsageBreakdownSection', () => { + it('wraps long model identifiers instead of truncating them', () => { + const firstModel = 'fireworks-ai/accounts/fireworks/models/deepseek-v3p2' + const secondModel = 'fireworks-ai/accounts/fireworks/models/qwen3-coder-480b' + + render( + + ) + + for (const model of [firstModel, secondModel]) { + const label = screen.getByText(model) + expect(label).toHaveClass('min-w-0', 'break-words') + expect(label).not.toHaveClass('truncate') + } + }) +}) diff --git a/src/renderer/src/components/stats/UsageBreakdownSection.tsx b/src/renderer/src/components/stats/UsageBreakdownSection.tsx index 84186694ca3..19b3ad7c224 100644 --- a/src/renderer/src/components/stats/UsageBreakdownSection.tsx +++ b/src/renderer/src/components/stats/UsageBreakdownSection.tsx @@ -44,7 +44,7 @@ export function UsageBreakdownSection({ {rows.slice(0, 5).map((row) => (
- {row.label} + {row.label} {formatTokens(row.tokens)}
diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx index 4353a6fb81a..453e3553792 100644 --- a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx +++ b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx @@ -84,7 +84,7 @@ import { type WorkspaceSpaceSortKey } from './workspace-space-presentation' import { translate } from '@/i18n/i18n' -import type { WorktreeForceDeleteReason } from '../../../../shared/worktree-removal' +import type { WorktreeForceDeleteReason } from '../../../../shared/worktree/removal' const TREEMAP_FILLS = [ 'color-mix(in srgb, var(--chart-2) 34%, var(--card))', diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts index baf9c189fde..672e7c622c2 100644 --- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts @@ -23,7 +23,7 @@ import { parsePaneKey as parseStablePaneKey } from '../../../../shared/stable-pa import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId -} from '../../../../shared/worktree-id' +} from '../../../../shared/worktree/id' import type { DaemonSession, MergeContext, diff --git a/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx b/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx index 5e989f9e4a9..776e7939b3e 100644 --- a/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx +++ b/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx @@ -10,7 +10,7 @@ import { getTerminalQuickCommandScope, isTerminalQuickCommandComplete } from '../../../../shared/terminal-quick-commands' -import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { runQuickCommandInNewTab } from '@/lib/run-quick-command-in-new-tab' import type { TerminalQuickCommand } from '../../../../shared/types' diff --git a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx index c37597e2932..6e1c49ddc94 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx @@ -163,8 +163,6 @@ export function renderTabBarSurface({ .join(' ')} > {renderedItems} - {/* Why: a short end inset keeps a last tab's close control off the chevron and fade. */} -
diff --git a/src/renderer/src/components/tab-bar/tab-strip-active-tab-scroll.test.ts b/src/renderer/src/components/tab-bar/tab-strip-active-tab-scroll.test.ts deleted file mode 100644 index f520afaf568..00000000000 --- a/src/renderer/src/components/tab-bar/tab-strip-active-tab-scroll.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -// @vitest-environment happy-dom -import { describe, expect, it } from 'vitest' -import { - computeTabStripEndInset, - computeTabStripScrollLeft, - findLastTabStripTab, - isLastTabStripTab, - syncTabStripEndPad, - TAB_STRIP_REVEAL_INSET_PX -} from './tab-strip-active-tab-scroll' - -function mockBox( - el: HTMLElement, - box: { - offsetLeft?: number - offsetWidth?: number - clientWidth?: number - scrollWidth?: number - } -): void { - Object.defineProperties(el, { - offsetLeft: { configurable: true, value: box.offsetLeft ?? 0 }, - offsetWidth: { configurable: true, value: box.offsetWidth ?? 0 }, - clientWidth: { configurable: true, value: box.clientWidth ?? 0 }, - scrollWidth: { configurable: true, value: box.scrollWidth ?? 0 } - }) -} - -describe('computeTabStripEndInset', () => { - it('is zero when tabs already fit', () => { - expect( - computeTabStripEndInset({ - stripClientWidth: 400, - lastTabWidth: 180, - contentWidth: 360 - }) - ).toBe(0) - }) - - it('uses the fade-sized inset when the last tab overflows', () => { - expect( - computeTabStripEndInset({ - stripClientWidth: 400, - lastTabWidth: 180, - contentWidth: 900 - }) - ).toBe(TAB_STRIP_REVEAL_INSET_PX) - }) - - it('shrinks the inset when the last tab almost fills the strip', () => { - expect( - computeTabStripEndInset({ - stripClientWidth: 200, - lastTabWidth: 190, - contentWidth: 400 - }) - ).toBe(10) - }) - - it('is zero when the last tab is at least as wide as the strip', () => { - expect( - computeTabStripEndInset({ - stripClientWidth: 180, - lastTabWidth: 180, - contentWidth: 400 - }) - ).toBe(0) - }) -}) - -// Derives scrollWidth from the live pad policy so tests can't assert unreachable geometry. -function lastTabGeometry({ - stripClientWidth, - lastTabWidth, - lastTabOffsetLeft -}: { - stripClientWidth: number - lastTabWidth: number - lastTabOffsetLeft: number -}): { - stripScrollWidth: number - stripClientWidth: number - tabOffsetLeft: number - tabWidth: number -} { - const contentWidth = lastTabOffsetLeft + lastTabWidth - const pad = computeTabStripEndInset({ stripClientWidth, lastTabWidth, contentWidth }) - return { - stripScrollWidth: contentWidth + pad, - stripClientWidth, - tabOffsetLeft: lastTabOffsetLeft, - tabWidth: lastTabWidth - } -} - -describe('computeTabStripScrollLeft', () => { - it('stops a normal-width last tab at the fade inset because the pad caps the range', () => { - const geometry = lastTabGeometry({ - stripClientWidth: 400, - lastTabWidth: 180, - lastTabOffsetLeft: 720 - }) - const scrollLeft = computeTabStripScrollLeft({ - ...geometry, - stripScrollLeft: 0, - inline: 'center' - }) - - expect(scrollLeft).toBe(geometry.stripScrollWidth - geometry.stripClientWidth) - const tabEnd = geometry.tabOffsetLeft + geometry.tabWidth - scrollLeft - expect(geometry.stripClientWidth - tabEnd).toBe(TAB_STRIP_REVEAL_INSET_PX) - }) - - it('centers a last tab once the pad covers the required trailing range', () => { - const geometry = lastTabGeometry({ - stripClientWidth: 200, - lastTabWidth: 190, - lastTabOffsetLeft: 210 - }) - - expect(computeTabStripScrollLeft({ ...geometry, stripScrollLeft: 0, inline: 'center' })).toBe( - geometry.tabOffsetLeft + geometry.tabWidth / 2 - geometry.stripClientWidth / 2 - ) - }) - - it('keeps a fully visible middle tab in place', () => { - expect( - computeTabStripScrollLeft({ - stripScrollWidth: 900, - stripClientWidth: 400, - stripScrollLeft: 200, - tabOffsetLeft: 280, - tabWidth: 120, - inline: 'nearest' - }) - ).toBe(200) - }) - - it('scrolls far enough that a clipped trailing tab clears the fade inset', () => { - expect( - computeTabStripScrollLeft({ - stripScrollWidth: 900, - stripClientWidth: 400, - stripScrollLeft: 0, - tabOffsetLeft: 320, - tabWidth: 180, - inline: 'nearest' - }) - ).toBe(320 + 180 - 400 + TAB_STRIP_REVEAL_INSET_PX) - }) -}) - -describe('tab strip last-tab helpers', () => { - it('treats the last data-tab-id node as the last tab and ignores the end pad', () => { - const strip = document.createElement('div') - const first = document.createElement('div') - first.setAttribute('data-tab-id', 'one') - const last = document.createElement('div') - last.setAttribute('data-tab-id', 'two') - const pad = document.createElement('div') - pad.setAttribute('data-tab-strip-end-pad', '') - strip.append(first, last, pad) - - expect(findLastTabStripTab(strip)).toBe(last) - expect(isLastTabStripTab(strip, last)).toBe(true) - expect(isLastTabStripTab(strip, first)).toBe(false) - }) - - it('sizes the end pad to the fade inset when the last tab overflows', () => { - const strip = document.createElement('div') - const last = document.createElement('div') - last.setAttribute('data-tab-id', 'end') - const pad = document.createElement('div') - pad.setAttribute('data-tab-strip-end-pad', '') - strip.append(last, pad) - mockBox(strip, { clientWidth: 400 }) - mockBox(last, { offsetLeft: 720, offsetWidth: 180 }) - - expect(syncTabStripEndPad(strip)).toBe(TAB_STRIP_REVEAL_INSET_PX) - expect(pad.style.width).toBe(`${TAB_STRIP_REVEAL_INSET_PX}px`) - }) - - it('clears the end pad when every tab already fits', () => { - const strip = document.createElement('div') - const last = document.createElement('div') - last.setAttribute('data-tab-id', 'end') - const pad = document.createElement('div') - pad.setAttribute('data-tab-strip-end-pad', '') - pad.style.width = '110px' - strip.append(last, pad) - mockBox(strip, { clientWidth: 400 }) - mockBox(last, { offsetLeft: 0, offsetWidth: 180 }) - - expect(syncTabStripEndPad(strip)).toBe(0) - expect(pad.style.width).toBe('0px') - }) -}) diff --git a/src/renderer/src/components/tab-bar/tab-strip-active-tab-scroll.ts b/src/renderer/src/components/tab-bar/tab-strip-active-tab-scroll.ts deleted file mode 100644 index fd9551885d4..00000000000 --- a/src/renderer/src/components/tab-bar/tab-strip-active-tab-scroll.ts +++ /dev/null @@ -1,102 +0,0 @@ -export const TAB_STRIP_TAB_ID_SELECTOR = '[data-tab-id]' -export const TAB_STRIP_END_PAD_ATTR = 'data-tab-strip-end-pad' - -// Why: matches the 1.5rem fade mask so nearest-reveal keeps the close control out of the fade. -export const TAB_STRIP_REVEAL_INSET_PX = 24 - -export function findLastTabStripTab(strip: ParentNode): HTMLElement | null { - const tabs = strip.querySelectorAll(TAB_STRIP_TAB_ID_SELECTOR) - return [...tabs].at(-1) ?? null -} - -export function isLastTabStripTab(strip: ParentNode, tab: Element): boolean { - return findLastTabStripTab(strip) === tab -} - -export function computeTabStripEndInset({ - stripClientWidth, - lastTabWidth, - contentWidth -}: { - stripClientWidth: number - lastTabWidth: number - contentWidth: number -}): number { - if (stripClientWidth <= 0 || lastTabWidth <= 0 || contentWidth <= stripClientWidth) { - return 0 - } - // Why: a half-viewport pad centered the last tab but hid earlier tabs and - // created a drop dead-zone; a fade-sized inset keeps the close control clear. - // Trade-off: a center request only lands centered while the strip is within - // 2x this inset of the last tab's width; wider gaps settle at end + inset. - return Math.min(TAB_STRIP_REVEAL_INSET_PX, Math.max(0, stripClientWidth - lastTabWidth)) -} - -export function computeTabStripScrollLeft({ - stripScrollWidth, - stripClientWidth, - stripScrollLeft, - tabOffsetLeft, - tabWidth, - inline -}: { - stripScrollWidth: number - stripClientWidth: number - stripScrollLeft: number - tabOffsetLeft: number - tabWidth: number - inline: 'nearest' | 'center' -}): number { - const maxScrollLeft = Math.max(0, stripScrollWidth - stripClientWidth) - if (inline === 'center') { - return clamp(tabOffsetLeft + tabWidth / 2 - stripClientWidth / 2, 0, maxScrollLeft) - } - - const tabStart = tabOffsetLeft - const tabEnd = tabOffsetLeft + tabWidth - const viewStart = stripScrollLeft + TAB_STRIP_REVEAL_INSET_PX - const viewEnd = stripScrollLeft + stripClientWidth - TAB_STRIP_REVEAL_INSET_PX - if (tabStart >= viewStart && tabEnd <= viewEnd) { - return stripScrollLeft - } - if (tabEnd > viewEnd) { - return clamp(tabEnd - stripClientWidth + TAB_STRIP_REVEAL_INSET_PX, 0, maxScrollLeft) - } - return clamp(tabStart - TAB_STRIP_REVEAL_INSET_PX, 0, maxScrollLeft) -} - -export function syncTabStripEndPad(strip: HTMLElement): number { - const lastTab = findLastTabStripTab(strip) - const pad = computeTabStripEndInset({ - stripClientWidth: strip.clientWidth, - lastTabWidth: lastTab?.offsetWidth ?? 0, - contentWidth: lastTab ? lastTab.offsetLeft + lastTab.offsetWidth : 0 - }) - const spacer = strip.querySelector(`[${TAB_STRIP_END_PAD_ATTR}]`) - if (spacer) { - const nextWidth = `${pad}px` - if (spacer.style.width !== nextWidth) { - spacer.style.width = nextWidth - } - } - return pad -} - -export function scrollTabStripTabIntoView( - strip: HTMLElement, - tab: HTMLElement, - inline: 'nearest' | 'center' -): void { - strip.scrollLeft = computeTabStripScrollLeft({ - stripScrollWidth: strip.scrollWidth, - stripClientWidth: strip.clientWidth, - stripScrollLeft: strip.scrollLeft, - tabOffsetLeft: tab.offsetLeft, - tabWidth: tab.offsetWidth, - inline - }) -} - -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)) -} diff --git a/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts b/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts index 1d5b78df7c7..77af550b748 100644 --- a/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts +++ b/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts @@ -6,11 +6,6 @@ import { type TabStripScrollMetrics } from './tab-strip-scroll-metrics' import { isTabStripPointerGestureActive } from './tab-strip-pointer-gesture' -import { - findLastTabStripTab, - scrollTabStripTabIntoView, - syncTabStripEndPad -} from './tab-strip-active-tab-scroll' const TAB_STRIP_SCROLL_FRACTION = 0.75 const TAB_STRIP_MIN_SCROLL_STEP_PX = 120 @@ -56,10 +51,6 @@ export function useTabStripOverflowNavigation({ const tabStripRef = useRef(null) const prevStripLenRef = useRef<{ worktreeId: string; len: number } | null>(null) const stickToEndRef = useRef(false) - const activeVisibleTabIdRef = useRef(activeVisibleTabId) - useLayoutEffect(() => { - activeVisibleTabIdRef.current = activeVisibleTabId - }) const [tabStripOverflowState, setTabStripOverflowState] = useState( EMPTY_TAB_STRIP_OVERFLOW_STATE ) @@ -118,7 +109,6 @@ export function useTabStripOverflowNavigation({ onScroll() const handleStripResize = (): void => { - syncTabStripEndPad(el) updateTabStripOverflowState() // If the user is pinned to the right edge, keep it pinned even as tab // labels (e.g. "Terminal 5" -> branch name) expand and change scrollWidth. @@ -148,7 +138,6 @@ export function useTabStripOverflowNavigation({ } if (!prev || prev.worktreeId !== worktreeId) { prevStripLenRef.current = { worktreeId, len: tabCount } - syncTabStripEndPad(strip) updateTabStripOverflowState() return } @@ -159,7 +148,6 @@ export function useTabStripOverflowNavigation({ if (!el) { return } - syncTabStripEndPad(el) el.scrollLeft = Math.max(0, el.scrollWidth - el.clientWidth) updateTabStripOverflowState() } @@ -167,28 +155,17 @@ export function useTabStripOverflowNavigation({ requestAnimationFrame(scrollToEnd) } if (tabCount > prev.len && !pointerGestureActive) { - const revealNewEndTab = (): void => { + const scrollToEnd = (): void => { const el = tabStripRef.current if (!el) { return } - syncTabStripEndPad(el) - const lastTab = findLastTabStripTab(el) - const activeId = activeVisibleTabIdRef.current - const activeTab = - activeId == null - ? null - : el.querySelector(`[data-tab-id="${CSS.escape(activeId)}"]`) - // Why: only a newly opened last tab should pin to the end; a restored - // mid-strip tab must not yank the viewport to the last tab. - if (lastTab && (activeTab == null || lastTab === activeTab)) { - scrollTabStripTabIntoView(el, lastTab, 'center') - stickToEndRef.current = true - } + el.scrollLeft = Math.max(0, el.scrollWidth - el.clientWidth) + stickToEndRef.current = true updateTabStripOverflowState() } - revealNewEndTab() - requestAnimationFrame(revealNewEndTab) + scrollToEnd() + requestAnimationFrame(scrollToEnd) } prevStripLenRef.current = { worktreeId, len: tabCount } updateTabStripOverflowState() @@ -212,8 +189,7 @@ export function useTabStripOverflowNavigation({ requestAnimationFrame(updateTabStripOverflowState) return } - syncTabStripEndPad(strip) - scrollTabStripTabIntoView(strip, activeTab, 'nearest') + activeTab.scrollIntoView({ block: 'nearest', inline: 'nearest' }) requestAnimationFrame(updateTabStripOverflowState) }, [activeVisibleTabId, updateTabStripOverflowState]) diff --git a/src/renderer/src/components/task-page-linear-in-orca-issues.ts b/src/renderer/src/components/task-page-linear-in-orca-issues.ts index 38f1dbe4802..55ae9293b85 100644 --- a/src/renderer/src/components/task-page-linear-in-orca-issues.ts +++ b/src/renderer/src/components/task-page-linear-in-orca-issues.ts @@ -1,5 +1,5 @@ import type { LinearIssue, LinearWorkspace, Worktree } from '../../../shared/types' -import { parseLinearIssueInput } from '../../../shared/linear-links' +import { parseLinearIssueInput } from '../../../shared/linear/links' import { getTaskSourceCacheScope, type TaskSourceContext diff --git a/src/renderer/src/components/task-page-linear-issue-empty-state.test.ts b/src/renderer/src/components/task-page-linear-issue-empty-state.test.ts index 914d24db855..9c04161e722 100644 --- a/src/renderer/src/components/task-page-linear-issue-empty-state.test.ts +++ b/src/renderer/src/components/task-page-linear-issue-empty-state.test.ts @@ -3,7 +3,7 @@ import { resolveLinearIssueEmptyKind, shouldOfferLinearIssueFetchMore } from './task-page-linear-issue-empty-state' -import { emptyLinearIssueAttributeFilter } from '../../../shared/linear-issue-attribute-filter' +import { emptyLinearIssueAttributeFilter } from '../../../shared/linear/issue-attribute-filter' describe('task-page-linear-issue-empty-state', () => { it('prefers context, then search, then server filter, then client team, then unfiltered', () => { diff --git a/src/renderer/src/components/task-page-linear-issue-empty-state.ts b/src/renderer/src/components/task-page-linear-issue-empty-state.ts index a62370a1130..c4c4e4a5fb8 100644 --- a/src/renderer/src/components/task-page-linear-issue-empty-state.ts +++ b/src/renderer/src/components/task-page-linear-issue-empty-state.ts @@ -1,7 +1,7 @@ import { isEmptyLinearIssueAttributeFilter, type LinearIssueAttributeFilter -} from '../../../shared/linear-issue-attribute-filter' +} from '../../../shared/linear/issue-attribute-filter' export type LinearIssueEmptyKind = | 'context' diff --git a/src/renderer/src/components/task-page-linear-issue-request.test.ts b/src/renderer/src/components/task-page-linear-issue-request.test.ts index f2ce103f645..1df1d8bd0ff 100644 --- a/src/renderer/src/components/task-page-linear-issue-request.test.ts +++ b/src/renderer/src/components/task-page-linear-issue-request.test.ts @@ -7,7 +7,7 @@ import { shouldForceLinearIssueListRead, teamDerivedFacetsForPrimaryTeamChange } from './task-page-linear-issue-request' -import type { LinearIssueAttributeFilter } from '../../../shared/linear-issue-attribute-filter' +import type { LinearIssueAttributeFilter } from '../../../shared/linear/issue-attribute-filter' const filter: LinearIssueAttributeFilter = { stateIds: ['s1'], diff --git a/src/renderer/src/components/task-page-linear-issue-request.ts b/src/renderer/src/components/task-page-linear-issue-request.ts index 3e85be2def0..158770d26ef 100644 --- a/src/renderer/src/components/task-page-linear-issue-request.ts +++ b/src/renderer/src/components/task-page-linear-issue-request.ts @@ -2,7 +2,7 @@ import { isEmptyLinearIssueAttributeFilter, linearIssueAttributeFilterSignature, type LinearIssueAttributeFilter -} from '../../../shared/linear-issue-attribute-filter' +} from '../../../shared/linear/issue-attribute-filter' import { getTaskSourceCacheScope, type TaskSourceContext diff --git a/src/renderer/src/components/task-page-localized-options.tsx b/src/renderer/src/components/task-page-localized-options.tsx index 74ef7152e8f..506f27add17 100644 --- a/src/renderer/src/components/task-page-localized-options.tsx +++ b/src/renderer/src/components/task-page-localized-options.tsx @@ -13,7 +13,7 @@ import { type LinearGroupBy, type LinearOrderBy, type LinearViewMode -} from '../../../shared/linear-issue-view-resume-state' +} from '../../../shared/linear/issue-view-resume-state' import { getTaskPresetQuery } from '../../../shared/task-preset-query' import type { TaskProvider, TaskViewPresetId } from '../../../shared/types' @@ -46,7 +46,7 @@ export type { LinearGroupBy, LinearOrderBy, LinearViewMode -} from '../../../shared/linear-issue-view-resume-state' +} from '../../../shared/linear/issue-view-resume-state' export function LinearIcon({ className }: { className?: string }): React.JSX.Element { return ( diff --git a/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts b/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts index 67d2cadd3b9..069ed371ec0 100644 --- a/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts @@ -109,6 +109,51 @@ describe('humanizeTerminalError', () => { expect(humanized).toContain('\nterminal_host_gone_extra') }) + it('replaces an expired SSH session token and its internal relay id', () => { + const wrapped = + "Error invoking remote method 'pty:spawn': Error: SSH_SESSION_EXPIRED: orca:2f1c@@pty-7" + const humanized = humanizeTerminalError(wrapped) + expect(humanized).not.toContain('SSH_SESSION_EXPIRED') + expect(humanized).not.toContain('orca:2f1c@@pty-7') + expect(humanized).toContain('Open a new terminal to continue') + }) + + it('replaces an expired SSH session token carrying the identity-mismatch marker', () => { + const humanized = humanizeTerminalError( + 'SSH_SESSION_EXPIRED: orca:2f1c@@pty-7 SSH_PTY_IDENTITY_MISMATCH' + ) + expect(humanized).not.toContain('SSH_SESSION_EXPIRED') + expect(humanized).not.toContain('SSH_PTY_IDENTITY_MISMATCH') + }) + + it('replaces a raw relay PTY-not-found string and its quoted id', () => { + const humanized = humanizeTerminalError( + 'Error invoking remote method \'pty:spawn\': Error: PTY "orca:2f1c@@pty-7" not found' + ) + expect(humanized).not.toContain('not found') + expect(humanized).not.toContain('orca:2f1c@@pty-7') + expect(humanized).toContain('Open a new terminal to continue') + }) + + it('replaces the identity-mismatch form of PTY-not-found', () => { + const humanized = humanizeTerminalError('PTY "orca:2f1c@@pty-7" not found (identity mismatch)') + expect(humanized).not.toContain('identity mismatch') + expect(humanized).not.toContain('orca:2f1c@@pty-7') + }) + + // Why: "no such session" from the relay is not proof the remote shell died, so the copy must not claim either. + it('does not claim the remote shell is still running or dead', () => { + const humanized = humanizeTerminalError('SSH_SESSION_EXPIRED: orca:2f1c@@pty-7') + expect(humanized).not.toContain('may still be running') + expect(humanized).not.toContain('exited') + }) + + it('replaces only the unreattachable line in an aggregated error', () => { + const humanized = humanizeTerminalError('Paste failed.\nSSH_SESSION_EXPIRED: orca:2f1c@@pty-7') + expect(humanized.startsWith('Paste failed.\n')).toBe(true) + expect(humanized).not.toContain('SSH_SESSION_EXPIRED') + }) + it.each(['ENOENT', 'ECONNREFUSED'])( 'does not combine a %s connection failure with a host endpoint on another line', (code) => { @@ -134,6 +179,15 @@ describe('isExplainedTerminalError', () => { ).toBe(true) }) + it('suppresses the issue link for a session the host cannot reattach', () => { + expect(isExplainedTerminalError('SSH_SESSION_EXPIRED: orca:2f1c@@pty-7')).toBe(true) + expect( + isExplainedTerminalError( + 'Error invoking remote method \'pty:spawn\': Error: PTY "orca:2f1c@@pty-7" not found' + ) + ).toBe(true) + }) + it('keeps the issue link for errors Orca cannot explain', () => { expect(isExplainedTerminalError('Paste failed.')).toBe(false) expect(isExplainedTerminalError('node-pty: open_slave failed: EMFILE')).toBe(false) diff --git a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx index 3c65a1b56f5..6aa2bba6252 100644 --- a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx @@ -25,6 +25,19 @@ const TERMINAL_HOST_GONE_PATTERN = new RegExp(TERMINAL_HOST_GONE_SOURCE) const TERMINAL_HOST_GONE_REPLACE_PATTERN = new RegExp(TERMINAL_HOST_GONE_SOURCE, 'g') const LEGACY_TERMINAL_HOST_GONE_PATTERN = /(^|[^a-z])connect (?:ENOENT|ECONNREFUSED) [^\r\n]*orca-terminal-host-v[^\r\n]*/i +// A reattach the host answered "no such session" for: the SSH provider's expiry token, or the relay's +// raw not-found string when nothing mapped it. Both carry an internal PTY id, and neither is proof the +// remote shell died — the copy says only that this pane lost its session. Same lastIndex hazard as above. +const UNREATTACHABLE_SESSION_SOURCES = [ + 'SSH_SESSION_EXPIRED:[ \\t]*\\S*(?:[ \\t]+SSH_PTY_IDENTITY_MISMATCH)?', + 'PTY "[^"\\r\\n]*" not found(?: \\(identity mismatch\\))?' +] +const UNREATTACHABLE_SESSION_PATTERNS = UNREATTACHABLE_SESSION_SOURCES.map( + (source) => new RegExp(source) +) +const UNREATTACHABLE_SESSION_REPLACE_PATTERNS = UNREATTACHABLE_SESSION_SOURCES.map( + (source) => new RegExp(source, 'g') +) function isSshError(error: string): boolean { return isSshReconnectOwnedTerminalError(error) @@ -60,10 +73,24 @@ export function isExplainedTerminalError(error: string): boolean { .split('\n') .some( (line) => - TERMINAL_HOST_GONE_PATTERN.test(line) || LEGACY_TERMINAL_HOST_GONE_PATTERN.test(line) + TERMINAL_HOST_GONE_PATTERN.test(line) || + LEGACY_TERMINAL_HOST_GONE_PATTERN.test(line) || + UNREATTACHABLE_SESSION_PATTERNS.some((pattern) => pattern.test(line)) ) } +function humanizeUnreattachableSession(error: string): string { + const explanation = translate( + 'auto.components.terminal.pane.TerminalErrorToast.sessionUnavailable', + "Orca couldn't reattach to this pane's terminal session on the host. Open a new terminal to continue." + ) + // Why a replacer: a translation containing `$&` or `$1` would otherwise be read as a substitution. + return UNREATTACHABLE_SESSION_REPLACE_PATTERNS.reduce( + (message, pattern) => message.replace(pattern, () => explanation), + error + ) +} + /** Swaps raw daemon-boundary codes for copy a user can act on. */ export function humanizeTerminalError(error: string): string { let humanized = error @@ -76,6 +103,7 @@ export function humanizeTerminalError(error: string): string { ) ) } + humanized = humanizeUnreattachableSession(humanized) if (!isExplainedTerminalError(humanized)) { return humanized } diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 2c2dd44cf92..5546390b6db 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -163,7 +163,7 @@ import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' -import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import { useProjectHostSetupProjection, useRepoById } from '@/store/selectors' import { refitAndRefreshAllTerminalPanes } from '@/lib/pane-manager/pane-manager-registry' import { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 90a3341ad6c..6fbe70501f8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -2236,7 +2236,7 @@ describe('connectPanePty', () => { it('swallows a worktree-removal fence error instead of surfacing it', async () => { const { connectPanePty } = await import('./pty-connection') const { TERMINAL_REMOVAL_IN_PROGRESS_MESSAGE } = - await import('../../../../shared/worktree-removal-fence-error') + await import('../../../../shared/worktree/removal-fence-error') const transport = createMockTransport() const capturedOnError: { current: ((message: string) => void) | null } = { current: null } transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index b4a683874ee..e2efbb0a9c4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -20,7 +20,7 @@ import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-ki import { parseTerminalKittyKeyboardFlags } from '../../../../shared/terminal-kitty-keyboard-flags' import { isRuntimeOwnedSshTargetId, parseExecutionHostId } from '../../../../shared/execution-host' import { createTerminalZeroDimensionsMessage } from '../../../../shared/terminal-zero-dimensions-diagnostic' -import { isWorktreeRemovalFenceError } from '../../../../shared/worktree-removal-fence-error' +import { isWorktreeRemovalFenceError } from '../../../../shared/worktree/removal-fence-error' import { parseTerminalOscColorQuery } from '../../../../shared/terminal-osc-color-reply' import { HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS, diff --git a/src/renderer/src/hooks/remote-workspace-session-merge.ts b/src/renderer/src/hooks/remote-workspace-session-merge.ts index cc9d47ab6e7..0f772ff8794 100644 --- a/src/renderer/src/hooks/remote-workspace-session-merge.ts +++ b/src/renderer/src/hooks/remote-workspace-session-merge.ts @@ -1,6 +1,6 @@ import type { TerminalTab, WorkspaceSessionState } from '../../../shared/types' import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' -import { splitWorktreeId } from '../../../shared/worktree-id' +import { splitWorktreeId } from '../../../shared/worktree/id' import type { AppState } from '../store/types' function preserveNewerLocalTerminalFields(remote: TerminalTab, local: TerminalTab): TerminalTab { diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 4ed46768687..aa83700baf2 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -59,7 +59,7 @@ import type { WorkspaceCreateTelemetrySource, ProjectGroup } from '../../../shared/types' -import { githubRepoIdentityKey } from '../../../shared/github-repository-identity-key' +import { githubRepoIdentityKey } from '../../../shared/github/repository-identity-key' import { isWorkspaceStatusId } from '../../../shared/workspace-statuses' import { CLIENT_PLATFORM, diff --git a/src/renderer/src/hooks/useGitHubSlugMetadata.ts b/src/renderer/src/hooks/useGitHubSlugMetadata.ts index 3da1c66787d..a268a7bbc93 100644 --- a/src/renderer/src/hooks/useGitHubSlugMetadata.ts +++ b/src/renderer/src/hooks/useGitHubSlugMetadata.ts @@ -3,10 +3,10 @@ import type { GitHubAssignableUser, GlobalSettings } from '../../../shared/types import type { ListAssignableUsersBySlugResult, ListLabelsBySlugResult -} from '../../../shared/github-project-types' +} from '../../../shared/github/project-types' import { clearMetadataRequestStore, createMetadataRequestStore } from './metadata-request-cache' -import { githubRepoIdentityKey } from '../../../shared/github-repository-identity-key' -import { githubProjectHost } from '../../../shared/github-project-identity' +import { githubRepoIdentityKey } from '../../../shared/github/repository-identity-key' +import { githubProjectHost } from '../../../shared/github/project-identity' import { useMetadataListRequest, type MetadataListState } from './useMetadataListRequest' const slugLabelStore = createMetadataRequestStore() diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a04afaf91a0..d8a0ee3936a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2875,7 +2875,8 @@ "5c8ce20be6": "If this persists, please", "cc6d997c65": "Restart the terminal daemon from here to clear stale daemon state.", "7ee11bc0db": "Orca couldn't confirm whether this terminal's previous session is still running, so it left the session untouched. Reopen this pane to retry.", - "e16012e31e": "The terminal daemon that owned this session exited, so the session and its scrollback could not be recovered. Open a new terminal to continue." + "e16012e31e": "The terminal daemon that owned this session exited, so the session and its scrollback could not be recovered. Open a new terminal to continue.", + "sessionUnavailable": "Orca couldn't reattach to this pane's terminal session on the host. Open a new terminal to continue." }, "TerminalPane": { "ac112e9036": "Remove title", diff --git a/src/renderer/src/lib/ai-vault-resume-target.ts b/src/renderer/src/lib/ai-vault-resume-target.ts index fca20f25cc8..2cab097ca3d 100644 --- a/src/renderer/src/lib/ai-vault-resume-target.ts +++ b/src/renderer/src/lib/ai-vault-resume-target.ts @@ -7,7 +7,7 @@ import { type ExecutionHostId } from '../../../shared/execution-host' import type { Repo } from '../../../shared/types' -import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { isWslUncPath } from '../../../shared/wsl-paths' import type { AppState } from '@/store/types' diff --git a/src/renderer/src/lib/cmd-j-github-url-lookup.ts b/src/renderer/src/lib/cmd-j-github-url-lookup.ts index bbc7b0e3f10..ba7fcc03d5a 100644 --- a/src/renderer/src/lib/cmd-j-github-url-lookup.ts +++ b/src/renderer/src/lib/cmd-j-github-url-lookup.ts @@ -1,4 +1,4 @@ -import type { GitHubIssueOrPRLink } from '../../../shared/github-links' +import type { GitHubIssueOrPRLink } from '../../../shared/github/links' import type { GitHubWorkItem, Repo } from '../../../shared/types' import type { TaskSourceContext } from '../../../shared/task-source-context' import { lookupGitHubWorkItemByOwnerRepoForSource } from './github-work-item-source-lookup' diff --git a/src/renderer/src/lib/cmd-j-linear-issue-intent.test.ts b/src/renderer/src/lib/cmd-j-linear-issue-intent.test.ts index 124294f5e4f..672f058e25a 100644 --- a/src/renderer/src/lib/cmd-j-linear-issue-intent.test.ts +++ b/src/renderer/src/lib/cmd-j-linear-issue-intent.test.ts @@ -4,7 +4,7 @@ import { findLinearIssueWorkspaceLookupIds, isLinearIssueUrlResolutionMatch, parseLinearIssueUrlIntent -} from '../../../shared/linear-links' +} from '../../../shared/linear/links' describe('Cmd+J Linear issue intent', () => { it('accepts canonical Linear issue URLs with optional slugs', () => { diff --git a/src/renderer/src/lib/connection-owner-resolution.ts b/src/renderer/src/lib/connection-owner-resolution.ts index 3d365621546..91169e939c6 100644 --- a/src/renderer/src/lib/connection-owner-resolution.ts +++ b/src/renderer/src/lib/connection-owner-resolution.ts @@ -1,7 +1,7 @@ import type { AppState } from '@/store/types' import { getIndexedRepoMap, getIndexedWorktreeMap } from '@/store/worktree-repo-index' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' -import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { isPathInsideOrEqual, diff --git a/src/renderer/src/lib/linear-issue-url-lookup.ts b/src/renderer/src/lib/linear-issue-url-lookup.ts index 03e2a1d56f9..55493d628f7 100644 --- a/src/renderer/src/lib/linear-issue-url-lookup.ts +++ b/src/renderer/src/lib/linear-issue-url-lookup.ts @@ -3,7 +3,7 @@ import { findLinearIssueWorkspaceLookupIds, isLinearIssueUrlResolutionMatch, type LinearIssueUrlIntent -} from '../../../shared/linear-links' +} from '../../../shared/linear/links' import type { LinearConnectionStatus, LinearIssue } from '../../../shared/types' import type { TaskSourceContext } from '../../../shared/task-source-context' diff --git a/src/renderer/src/lib/linear-issue-workspace-attachment.ts b/src/renderer/src/lib/linear-issue-workspace-attachment.ts index afea291b21f..e1904dd02d4 100644 --- a/src/renderer/src/lib/linear-issue-workspace-attachment.ts +++ b/src/renderer/src/lib/linear-issue-workspace-attachment.ts @@ -2,7 +2,7 @@ import type { LinearIssue, Worktree } from '../../../shared/types' import { getLinearOrganizationUrlKeyFromIssueUrl, parseLinearIssueInput -} from '../../../shared/linear-links' +} from '../../../shared/linear/links' import { getWorktreeAttachmentLabel } from './worktree-attachment-label' export type LinearIssueAttachmentRef = Pick & diff --git a/src/renderer/src/lib/repo-slug-cache.test.ts b/src/renderer/src/lib/repo-slug-cache.test.ts index d9de7ae3e7b..491afbc22b9 100644 --- a/src/renderer/src/lib/repo-slug-cache.test.ts +++ b/src/renderer/src/lib/repo-slug-cache.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest' import type { Repo } from '../../../shared/types' -import { githubRepoIdentityKey } from '../../../shared/github-repository-identity-key' +import { githubRepoIdentityKey } from '../../../shared/github/repository-identity-key' import { REPO_SLUG_FAILURE_TTL_MS, clearRepoSlugCacheValues, diff --git a/src/renderer/src/lib/repo-slug-cache.ts b/src/renderer/src/lib/repo-slug-cache.ts index cb6552aa572..9c2a455f574 100644 --- a/src/renderer/src/lib/repo-slug-cache.ts +++ b/src/renderer/src/lib/repo-slug-cache.ts @@ -7,7 +7,7 @@ import { getSettingsForRepoRuntimeOwner } from './repo-runtime-owner' import { githubHostFromIdentityKey, githubRepoIdentityKey -} from '../../../shared/github-repository-identity-key' +} from '../../../shared/github/repository-identity-key' /** Lowercased `owner/repo` → Repo[]. */ export type SlugIndex = Map diff --git a/src/renderer/src/lib/repo-slug-index.ts b/src/renderer/src/lib/repo-slug-index.ts index 442a29d6d62..07d6896cb08 100644 --- a/src/renderer/src/lib/repo-slug-index.ts +++ b/src/renderer/src/lib/repo-slug-index.ts @@ -29,7 +29,7 @@ import { type RepoSlugMatches, type SlugIndex } from './repo-slug-cache' -import { githubRepoIdentityKey } from '../../../shared/github-repository-identity-key' +import { githubRepoIdentityKey } from '../../../shared/github/repository-identity-key' export { lookupReposBySlugFromCache } from './repo-slug-cache' diff --git a/src/renderer/src/lib/smart-github-submit.ts b/src/renderer/src/lib/smart-github-submit.ts index 131e532f543..9d500d98eb9 100644 --- a/src/renderer/src/lib/smart-github-submit.ts +++ b/src/renderer/src/lib/smart-github-submit.ts @@ -5,7 +5,7 @@ import { getLinkedWorkItemWorkspaceName } from '../../../shared/workspace-name' import type { LinkedWorkItemSummary } from './new-workspace' import { parseGitHubIssueOrPRLink } from './github-links' import { resolveGitHubWorkItemIdentity } from '@/lib/github-work-item-identity' -import { githubRepoIdentityKey } from '../../../shared/github-repository-identity-key' +import { githubRepoIdentityKey } from '../../../shared/github/repository-identity-key' export type SmartGitHubSubmitIntent = | { diff --git a/src/renderer/src/lib/workspace-session-host-persistence.ts b/src/renderer/src/lib/workspace-session-host-persistence.ts index 445ef2917e4..47a93b4d577 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.ts @@ -6,7 +6,7 @@ import { type ExecutionHostId } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' -import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' import { mergeWorkspaceSessionsFromHosts, splitWorkspaceSessionByHost, diff --git a/src/renderer/src/lib/workspace-session-hydration-keys.ts b/src/renderer/src/lib/workspace-session-hydration-keys.ts index 1166d8702a1..d4339e30cb6 100644 --- a/src/renderer/src/lib/workspace-session-hydration-keys.ts +++ b/src/renderer/src/lib/workspace-session-hydration-keys.ts @@ -2,7 +2,7 @@ import type { ExecutionHostId } from '../../../shared/execution-host' import { parseExecutionHostId } from '../../../shared/execution-host' import type { WorkspaceKey, WorkspaceSessionState } from '../../../shared/types' import { parseWorkspaceKey } from '../../../shared/workspace-scope' -import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' export type WorkspaceSessionHydrationOptions = { additionalValidWorkspaceKeys?: readonly WorkspaceKey[] diff --git a/src/renderer/src/lib/worktree-palette-gitlab-url-match.ts b/src/renderer/src/lib/worktree-palette-gitlab-url-match.ts index 4ec322c5ae4..a40c330208f 100644 --- a/src/renderer/src/lib/worktree-palette-gitlab-url-match.ts +++ b/src/renderer/src/lib/worktree-palette-gitlab-url-match.ts @@ -1,3 +1,9 @@ +import { foldComparableGitLabHost } from '../../../shared/git-remote-host-alias' +import { + matchGitRemoteKeyParts, + splitGitRemoteKey, + type GitRemoteKeyParts +} from '../../../shared/git-remote-identity' import type { HostedReviewInfo } from '../../../shared/hosted-review' import { parseGitLabIssueOrMRLink, @@ -7,15 +13,21 @@ import type { Repo, Worktree } from '../../../shared/types' export type GitLabIssueOrMRLink = NonNullable> -/** Same shape as `GitRemoteIdentity.canonicalKey`: lowercased host (no port) + normalized project path. */ +/** Host + project path, matching how `GitRemoteIdentity.canonicalKey` is built. */ +function gitLabProjectKeyParts(slug: ProjectSlug): GitRemoteKeyParts { + return { + host: foldComparableGitLabHost(slug.host.replace(/:\d+$/, '')), + tail: slug.path + .replace(/^\/+/, '') + .replace(/\/+$/, '') + .replace(/\.git$/i, '') + .toLowerCase() + } +} + function gitLabProjectKey(slug: ProjectSlug): string { - const host = slug.host.replace(/:\d+$/, '').toLowerCase() - const path = slug.path - .replace(/^\/+/, '') - .replace(/\/+$/, '') - .replace(/\.git$/i, '') - .toLowerCase() - return `${host}/${path}` + const { host, tail } = gitLabProjectKeyParts(slug) + return `${host}/${tail}` } function gitLabLinksEqual(left: GitLabIssueOrMRLink, right: GitLabIssueOrMRLink): boolean { @@ -29,16 +41,18 @@ function gitLabLinksEqual(left: GitLabIssueOrMRLink, right: GitLabIssueOrMRLink) /** Tri-state like `repoMatchesGitHubSlug`: `'unknown'` stays permissive for forks and host aliases. */ function repoMatchesGitLabSlug(repo: Repo | undefined, slug: ProjectSlug): boolean | 'unknown' { const identity = repo?.gitRemoteIdentity - if (!identity?.canonicalKey) { + const identityParts = splitGitRemoteKey(identity?.canonicalKey, foldComparableGitLabHost) + if (!identityParts) { return 'unknown' } - if (identity.canonicalKey.replace(/\/+$/, '').toLowerCase() === gitLabProjectKey(slug)) { - return true + const verdict = matchGitRemoteKeyParts(identityParts, gitLabProjectKeyParts(slug)) + if (verdict !== false) { + return verdict } // Why not false: identity keeps one remote, chosen when the repo was added and never re-probed. // An `upstream` pick means a fork's `origin` existed and is invisible here, so rejecting would // drop MR URLs from the fork itself. A stale snapshot can still misjudge a renamed project. - return identity.remoteName === 'upstream' ? 'unknown' : false + return identity?.remoteName === 'upstream' ? 'unknown' : false } export function worktreeMatchesGitLabUrl( diff --git a/src/renderer/src/lib/worktree-palette-task-url-match.test.ts b/src/renderer/src/lib/worktree-palette-task-url-match.test.ts index b75902fd726..88691ac8bb6 100644 --- a/src/renderer/src/lib/worktree-palette-task-url-match.test.ts +++ b/src/renderer/src/lib/worktree-palette-task-url-match.test.ts @@ -50,6 +50,19 @@ function gitLabRepo(canonicalKey: string): Repo { } } +/** Basename displayName: the common non-fork case, where only the remote identifies the repo. */ +function gitHubRepo(canonicalKey: string): Repo { + return { + ...orcaRepo, + displayName: 'orca', + gitRemoteIdentity: { + canonicalKey, + remoteName: 'origin', + remoteUrl: `git@${canonicalKey.replace('/', ':')}.git` + } + } +} + describe('parseCmdJTaskSourceUrl', () => { it('parses GitHub issue and pull URLs', () => { expect(parseCmdJTaskSourceUrl('https://github.com/stablyai/orca/issues/14198')).toEqual({ @@ -165,6 +178,208 @@ describe('matchWorktreePaletteTaskUrl', () => { ).toMatchObject({ matchedField: 'pr', supportingText: { text: 'PR #12789' } }) }) + it('gates a stored GitHub number on the repo remote identity', () => { + const intent = parseCmdJTaskSourceUrl('https://github.com/stablyai/orca/pull/12789') + expect( + matchWorktreePaletteTaskUrl({ + worktree: makeWorktree({ linkedPR: 12789 }), + intent: intent!, + repo: gitHubRepo('github.com/other/project') + }) + ).toBeNull() + expect( + matchWorktreePaletteTaskUrl({ + worktree: makeWorktree({ linkedPR: 12789 }), + intent: intent!, + repo: gitHubRepo('github.com/stablyai/orca') + }) + ).toMatchObject({ matchedField: 'pr', supportingText: { text: 'PR #12789' } }) + }) + + it('gates a stored GitHub work item with no URL on the repo remote identity', () => { + const intent = parseCmdJTaskSourceUrl('https://github.com/stablyai/orca/issues/14198') + const worktree = makeWorktree({ + linkedWorkItem: { provider: 'github', type: 'issue', number: 14198, title: 'Bug', url: '' } + }) + expect( + matchWorktreePaletteTaskUrl({ + worktree, + intent: intent!, + repo: gitHubRepo('github.com/other/project') + }) + ).toBeNull() + expect( + matchWorktreePaletteTaskUrl({ + worktree, + intent: intent!, + repo: gitHubRepo('github.com/stablyai/orca') + }) + ).toMatchObject({ matchedField: 'issue' }) + }) + + it('does not match a GitHub URL on a different host for the same owner/repo', () => { + const intent = parseCmdJTaskSourceUrl('https://ghe.example.com/stablyai/orca/pull/12789') + expect( + matchWorktreePaletteTaskUrl({ + worktree: makeWorktree({ linkedPR: 12789 }), + intent: intent!, + repo: gitHubRepo('github.com/stablyai/orca') + }) + ).toBeNull() + expect( + matchWorktreePaletteTaskUrl({ + worktree: makeWorktree({ linkedPR: 12789 }), + intent: intent!, + repo: gitHubRepo('ghe.example.com/stablyai/orca') + }) + ).toMatchObject({ matchedField: 'pr' }) + }) + + it('matches GitHub remotes whose host is an SSH alias or www form of github.com', () => { + const intent = parseCmdJTaskSourceUrl('https://github.com/stablyai/orca/pull/12789') + for (const canonicalKey of [ + // ssh://git@ssh.github.com:443/... — GitHub's port-443 workaround. + 'ssh.github.com/stablyai/orca', + // git@github-work:... — an OpenSSH `Host` alias `git remote -v` cannot expand. + 'github-work/stablyai/orca', + 'www.github.com/stablyai/orca' + ]) { + expect( + matchWorktreePaletteTaskUrl({ + worktree: makeWorktree({ linkedPR: 12789 }), + intent: intent!, + repo: gitHubRepo(canonicalKey) + }) + ).toMatchObject({ matchedField: 'pr' }) + } + // A real, resolvable host is evidence of a different forge, not an alias. + expect( + matchWorktreePaletteTaskUrl({ + worktree: makeWorktree({ linkedPR: 12789 }), + intent: intent!, + repo: gitHubRepo('ghe.example.com/stablyai/orca') + }) + ).toBeNull() + }) + + it('normalizes host case, port, and owner case before comparing GitHub identities', () => { + const intent = parseCmdJTaskSourceUrl('https://GHE.Example.com:8443/StablyAI/Orca/pull/12789') + expect( + matchWorktreePaletteTaskUrl({ + worktree: makeWorktree({ linkedPR: 12789 }), + intent: intent!, + repo: gitHubRepo('ghe.example.com/stablyai/orca') + }) + ).toMatchObject({ matchedField: 'pr' }) + }) + + it('stays permissive for GitHub numbers when the repo remote identity is unknown', () => { + const intent = parseCmdJTaskSourceUrl('https://github.com/stablyai/orca/pull/12789') + expect( + matchWorktreePaletteTaskUrl({ + worktree: makeWorktree({ linkedPR: 12789 }), + intent: intent!, + repo: { ...orcaRepo, displayName: 'orca' } + }) + ).toMatchObject({ matchedField: 'pr' }) + expect( + matchWorktreePaletteTaskUrl({ worktree: makeWorktree({ linkedPR: 12789 }), intent: intent! }) + ).toMatchObject({ matchedField: 'pr' }) + }) + + it('stays permissive for a GitHub fork whose identity resolved to the upstream remote', () => { + // `deriveGitRemoteIdentity` prefers `upstream`, so the fork's own `origin` is not visible here. + const forkRepo: Repo = { + ...gitHubRepo('github.com/stablyai/orca'), + gitRemoteIdentity: { + canonicalKey: 'github.com/stablyai/orca', + remoteName: 'upstream', + remoteUrl: 'git@github.com:stablyai/orca.git' + } + } + const intent = parseCmdJTaskSourceUrl('https://github.com/me/orca/pull/12789') + expect( + matchWorktreePaletteTaskUrl({ + worktree: makeWorktree({ linkedPR: 12789 }), + intent: intent!, + repo: forkRepo + }) + ).toMatchObject({ matchedField: 'pr' }) + // An `origin`-derived identity is authoritative, so a different repo still loses. + expect( + matchWorktreePaletteTaskUrl({ + worktree: makeWorktree({ linkedPR: 12789 }), + intent: intent!, + repo: gitHubRepo('github.com/stablyai/orca') + }) + ).toBeNull() + }) + + it('keeps the GitHub number gate type-aware across repos', () => { + const prIntent = parseCmdJTaskSourceUrl('https://github.com/stablyai/orca/pull/12789') + const issueIntent = parseCmdJTaskSourceUrl('https://github.com/stablyai/orca/issues/12789') + expect( + matchWorktreePaletteTaskUrl({ + worktree: makeWorktree({ linkedIssue: 12789 }), + intent: prIntent!, + repo: gitHubRepo('github.com/stablyai/orca') + }) + ).toBeNull() + expect( + matchWorktreePaletteTaskUrl({ + worktree: makeWorktree({ linkedIssue: 12789 }), + intent: issueIntent!, + repo: gitHubRepo('github.com/other/project') + }) + ).toBeNull() + expect( + matchWorktreePaletteTaskUrl({ + worktree: makeWorktree({ linkedIssue: 12789 }), + intent: issueIntent!, + repo: gitHubRepo('github.com/stablyai/orca') + }) + ).toMatchObject({ matchedField: 'issue', supportingText: { text: 'Issue #12789' } }) + }) + + it('keeps an owner/repo displayName authoritative over a host-alias remote', () => { + const intent = parseCmdJTaskSourceUrl('https://github.com/stablyai/orca/pull/12789') + expect( + matchWorktreePaletteTaskUrl({ + worktree: makeWorktree({ linkedPR: 12789 }), + intent: intent!, + repo: { + ...orcaRepo, + gitRemoteIdentity: { + canonicalKey: 'git-mirror.example.com/stablyai/orca', + remoteName: 'origin', + remoteUrl: 'git@git-mirror.example.com:stablyai/orca.git' + } + } + }) + ).toMatchObject({ matchedField: 'pr' }) + }) + + it('matches a GitHub PR URL via the linked review URL regardless of remote identity', () => { + const intent = parseCmdJTaskSourceUrl('https://github.com/stablyai/orca/pull/12789') + expect( + matchWorktreePaletteTaskUrl({ + worktree: makeWorktree(), + intent: intent!, + repo: gitHubRepo('github.com/other/project'), + review: { + provider: 'github', + number: 12789, + title: 'Fork PR', + state: 'open', + url: 'https://github.com/stablyai/orca/pull/12789', + status: 'pending', + updatedAt: '2026-01-01T00:00:00Z', + mergeable: 'UNKNOWN' + } + }) + ).toMatchObject({ matchedField: 'pr' }) + }) + it('rejects a GitLab MR URL from a different project than the stored URL', () => { const intent = parseCmdJTaskSourceUrl('https://gitlab.com/acme/orca/-/merge_requests/17') expect( @@ -265,6 +480,31 @@ describe('matchWorktreePaletteTaskUrl', () => { ).toMatchObject({ matchedField: 'pr' }) }) + it('matches GitLab remotes whose host is an SSH alias or www form of gitlab.com', () => { + const intent = parseCmdJTaskSourceUrl('https://gitlab.com/acme/orca/-/merge_requests/17') + for (const canonicalKey of [ + // altssh.gitlab.com is GitLab's port-443 SSH endpoint; `gitlab-work` is an ssh-config alias. + 'altssh.gitlab.com/acme/orca', + 'gitlab-work/acme/orca', + 'www.gitlab.com/acme/orca' + ]) { + expect( + matchWorktreePaletteTaskUrl({ + worktree: makeWorktree({ linkedGitLabMR: 17 }), + intent: intent!, + repo: gitLabRepo(canonicalKey) + }) + ).toMatchObject({ matchedField: 'pr' }) + } + expect( + matchWorktreePaletteTaskUrl({ + worktree: makeWorktree({ linkedGitLabMR: 17 }), + intent: intent!, + repo: gitLabRepo('gitlab.example.com/acme/orca') + }) + ).toBeNull() + }) + it('stays permissive for GitLab numbers when the repo remote identity is unknown', () => { const intent = parseCmdJTaskSourceUrl('https://gitlab.com/acme/orca/-/merge_requests/17') expect( diff --git a/src/renderer/src/lib/worktree-palette-task-url-match.ts b/src/renderer/src/lib/worktree-palette-task-url-match.ts index 2cc310fa35c..d16d135dcb1 100644 --- a/src/renderer/src/lib/worktree-palette-task-url-match.ts +++ b/src/renderer/src/lib/worktree-palette-task-url-match.ts @@ -1,13 +1,19 @@ +import { foldComparableGitHubHost } from '../../../shared/git-remote-host-alias' +import { + matchGitRemoteKeyParts, + splitGitRemoteKey, + type GitRemoteKeyParts +} from '../../../shared/git-remote-identity' import type { HostedReviewInfo } from '../../../shared/hosted-review' import { parseGitHubIssueOrPRLink, type GitHubIssueOrPRLink, type RepoSlug -} from '../../../shared/github-links' -import { githubRepoIdentityKey } from '../../../shared/github-repository-identity-key' +} from '../../../shared/github/links' +import { githubRepoIdentityKey } from '../../../shared/github/repository-identity-key' import { parseGitLabIssueOrMRLink } from '../../../shared/new-workspace/gitlab-links' import { parseJiraIssueUrl, type ParsedJiraIssueUrl } from '../../../shared/jira-issue-url' -import { parseLinearIssueUrlIntent, type LinearIssueUrlIntent } from '../../../shared/linear-links' +import { parseLinearIssueUrlIntent, type LinearIssueUrlIntent } from '../../../shared/linear/links' import type { Repo, Worktree } from '../../../shared/types' import { normalizeLinearIdentifier } from './linear-issue-workspace-attachment' import { @@ -75,10 +81,37 @@ function parseOwnerRepoDisplayName(value: string | null | undefined): RepoSlug | return { owner: match[1], repo: match[2] } } +/** Host + `owner/repo` tail, matching how `GitRemoteIdentity.canonicalKey` is built. */ +function githubRemoteKeyParts(slug: RepoSlug): GitRemoteKeyParts { + return { + host: foldComparableGitHubHost((slug.host || 'github.com').replace(/:\d+$/, '')), + tail: `${slug.owner.toLowerCase()}/${slug.repo.replace(/\.git$/i, '').toLowerCase()}` + } +} + +function remoteIdentityMatchesGitHubSlug(repo: Repo, slug: RepoSlug): boolean | 'unknown' { + const identity = repo.gitRemoteIdentity + const identityParts = splitGitRemoteKey(identity?.canonicalKey, foldComparableGitHubHost) + if (!identityParts) { + return 'unknown' + } + const verdict = matchGitRemoteKeyParts(identityParts, githubRemoteKeyParts(slug)) + if (verdict !== false) { + return verdict + } + // Why not false: identity keeps one remote, chosen when the repo was added and never re-probed. + // An `upstream` pick means a fork's `origin` existed and is invisible here, so rejecting would + // drop URLs from the fork itself. A stale snapshot can still misjudge a renamed repo. + return identity?.remoteName === 'upstream' ? 'unknown' : false +} + +/** Tri-state: `'unknown'` stays permissive for forks and host aliases. */ function repoMatchesGitHubSlug(repo: Repo | undefined, slug: RepoSlug): boolean | 'unknown' { if (!repo) { return 'unknown' } + // Why displayName first: it is compared host-agnostically, so mirrors and host aliases of the + // same owner/repo keep matching; the probed remote only fills in where no name evidence exists. const fromName = parseOwnerRepoDisplayName(repo.displayName) if (fromName) { return githubIdentityKey({ ...fromName, host: slug.host }) === githubIdentityKey(slug) @@ -86,7 +119,9 @@ function repoMatchesGitHubSlug(repo: Repo | undefined, slug: RepoSlug): boolean if (repo.upstream?.owner && repo.upstream.repo) { return githubIdentityKey(repo.upstream) === githubIdentityKey(slug) } - return 'unknown' + // Why: a basename-only displayName is the common non-fork case, and issue/PR numbers are + // per-repo, so a bare number must still clear the remote the repo actually points at. + return remoteIdentityMatchesGitHubSlug(repo, slug) } export function parseCmdJTaskSourceUrl(query: string): CmdJTaskSourceUrl | null { diff --git a/src/renderer/src/runtime/github-check-details-timeout.ts b/src/renderer/src/runtime/github-check-details-timeout.ts index 1ccaca60d6f..014c0ff43fa 100644 --- a/src/renderer/src/runtime/github-check-details-timeout.ts +++ b/src/renderer/src/runtime/github-check-details-timeout.ts @@ -2,7 +2,7 @@ import { translate } from '@/i18n/i18n' import { GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE, isGitHubCheckDetailsTimeout -} from '../../../shared/github-check-details-deadline' +} from '../../../shared/github/check-details-deadline' export const GITHUB_CHECK_DETAILS_TIMEOUT_MS = 30_000 diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts index 48e608daf4a..ae5d2d2156d 100644 --- a/src/renderer/src/runtime/runtime-git-client.ts +++ b/src/renderer/src/runtime/runtime-git-client.ts @@ -22,7 +22,7 @@ import type { HostedReviewProvider } from '../../../shared/hosted-review' import type { ResolvedSourceControlAiGenerationParams } from '../../../shared/source-control-ai' import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../shared/commit-message-host-key' import type { GitHistoryOptions, GitHistoryResult } from '../../../shared/git-history' -import { getRepoIdFromWorktreeId, splitWorktreeIdForFilesystem } from '../../../shared/worktree-id' +import { getRepoIdFromWorktreeId, splitWorktreeIdForFilesystem } from '../../../shared/worktree/id' import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' diff --git a/src/renderer/src/runtime/runtime-linear-client.ts b/src/renderer/src/runtime/runtime-linear-client.ts index 4bf5d5797e2..94d6be6197c 100644 --- a/src/renderer/src/runtime/runtime-linear-client.ts +++ b/src/renderer/src/runtime/runtime-linear-client.ts @@ -29,11 +29,11 @@ import { type TaskSourceContext } from '../../../shared/task-source-context' import { isRuntimeProviderSearchQueryWithinLimit } from './runtime-provider-search-bounds' -import type { LinearIssueAttributeFilter } from '../../../shared/linear-issue-attribute-filter' +import type { LinearIssueAttributeFilter } from '../../../shared/linear/issue-attribute-filter' import { canonicalizeLinearIssueAttributeFilter, isEmptyLinearIssueAttributeFilter -} from '../../../shared/linear-issue-attribute-filter' +} from '../../../shared/linear/issue-attribute-filter' import { LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' export type RuntimeLinearSettings = diff --git a/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts b/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts index 1a9693bdf5f..139312608b2 100644 --- a/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts +++ b/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts @@ -5,7 +5,7 @@ import type { Worktree } from '../../../../shared/types' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' -import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' type WorktreeValidityCatalog = { repos: readonly Pick[] diff --git a/src/renderer/src/store/slices/github-checks.ts b/src/renderer/src/store/slices/github-checks.ts index 46bf368d757..da36a378ddf 100644 --- a/src/renderer/src/store/slices/github-checks.ts +++ b/src/renderer/src/store/slices/github-checks.ts @@ -1,7 +1,7 @@ import type { AppState } from '../types' import type { PRCheckDetail, GitHubOwnerRepo } from '../../../../shared/types' import { getGitHubPRCacheKey } from './github-cache-key' -import { githubRepoIdentityKey } from '../../../../shared/github-repository-identity-key' +import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' import { derivePRCheckStatus } from '../../../../shared/pr-check-status' export function normalizeBranchName(branch: string): string { diff --git a/src/renderer/src/store/slices/github-work-items-query-bounds.ts b/src/renderer/src/store/slices/github-work-items-query-bounds.ts index 0ac2107a8a2..4cd04801973 100644 --- a/src/renderer/src/store/slices/github-work-items-query-bounds.ts +++ b/src/renderer/src/store/slices/github-work-items-query-bounds.ts @@ -2,4 +2,4 @@ export { GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN, GITHUB_WORK_ITEMS_QUERY_MAX_BYTES, isGitHubWorkItemsQueryTooLarge -} from '../../../../shared/github-work-items-query-bounds' +} from '../../../../shared/github/work-items-query-bounds' diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 299e3e21bbb..71cf753e662 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -2,8 +2,8 @@ import type { StateCreator } from 'zustand' import { toast } from 'sonner' import type { AppState } from '../types' -import { githubRepoIdentityKey } from '../../../../shared/github-repository-identity-key' -import { githubProjectIdentityKey } from '../../../../shared/github-project-identity' +import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' +import { githubProjectIdentityKey } from '../../../../shared/github/project-identity' import type { ClassifiedError, GitHubOwnerRepo, @@ -36,7 +36,7 @@ import type { GitHubProjectRow, GitHubProjectTable, GitHubProjectViewError -} from '../../../../shared/github-project-types' +} from '../../../../shared/github/project-types' import { isGitHubWorkItemsSshRemoteRequiredError, sortWorkItemsByNumber, @@ -58,7 +58,7 @@ import { GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN, isGitHubWorkItemsQueryTooLarge } from './github-work-items-query-bounds' -import { classifyGitHubUnavailable } from '../../../../shared/github-api-availability' +import { classifyGitHubUnavailable } from '../../../../shared/github/api-availability' import { isMacAppDataPath } from '@/lib/passive-macos-app-data-access' import { translate } from '@/i18n/i18n' import { @@ -74,11 +74,12 @@ import { getTaskSourceRuntimeSettings, type TaskSourceContext } from '../../../../shared/task-source-context' -import { normalizeGitHubPRForBranchOutcome } from '../../../../shared/github-pr-for-branch-outcome' +import { normalizeGitHubPRForBranchOutcome } from '../../../../shared/github/pull-request-for-branch-outcome' import { restoreReactionOnSubject, setReactionOnSubject } from '@/lib/pr-comment-reactions' import { withGitHubCheckDetailsTimeout } from '@/runtime/github-check-details-timeout' import { getGitHubRepoLookupIndex } from './github-repo-lookup-index' -import { areValuesEqual, reconcileCatalogRows } from './repo-identity-reconcile' +import { reconcileCatalogRows } from './repo-identity-reconcile' +import { structuralValuesEqual } from '../../../../shared/structural-value-equality' // ─── ProjectV2 cache types ──────────────────────────────────────────── // Why: separate from CacheEntry — project-view has a single GraphQL source (no issue/PR fallback) and a distinct error union. @@ -2759,8 +2760,8 @@ export const createGitHubSlice: StateCreator = (s (row) => `${row.repoId}\0${row.id}` ) const nextFellBack = envelope.issueSourceFellBack ? true : undefined - const sourcesUnchanged = areValuesEqual(previousEntry?.sources, envelope.sources) - const errorUnchanged = areValuesEqual(previousEntry?.error, errorForCache) + const sourcesUnchanged = structuralValuesEqual(previousEntry?.sources, envelope.sources) + const errorUnchanged = structuralValuesEqual(previousEntry?.error, errorForCache) const fellBackUnchanged = previousEntry?.issueSourceFellBack === nextFellBack if ( previousEntry && diff --git a/src/renderer/src/store/slices/linear.ts b/src/renderer/src/store/slices/linear.ts index 63991d49148..614020debd5 100644 --- a/src/renderer/src/store/slices/linear.ts +++ b/src/renderer/src/store/slices/linear.ts @@ -18,7 +18,7 @@ import type { LinearWorkspaceSelection } from '../../../../shared/types' import type { CacheEntry } from './github' -import { clampLinearIssueListLimit } from '../../../../shared/linear-issue-read-limits' +import { clampLinearIssueListLimit } from '../../../../shared/linear/issue-read-limits' import { isIntegrationCredentialDecryptionError } from '../../../../shared/integration-credential-errors' import { clearLinearMetadataCache } from '../../hooks/useIssueMetadata' import { @@ -53,7 +53,7 @@ import { isEmptyLinearIssueAttributeFilter, linearIssueAttributeFilterSignature, type LinearIssueAttributeFilter -} from '../../../../shared/linear-issue-attribute-filter' +} from '../../../../shared/linear/issue-attribute-filter' const CACHE_TTL = 60_000 // 60s — same as GitHub work-items revalidation TTL const TEAM_CACHE_TTL = 10 * 60_000 // Teams change rarely and block visible Linear rows. diff --git a/src/renderer/src/store/slices/repo-host-identity.ts b/src/renderer/src/store/slices/repo-host-identity.ts index f21da2b153a..772d5dc2925 100644 --- a/src/renderer/src/store/slices/repo-host-identity.ts +++ b/src/renderer/src/store/slices/repo-host-identity.ts @@ -4,6 +4,9 @@ import { getSettingsFocusedExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { getRepoHostIdentityForParts } from '../../../../shared/repo-host-identity' + +export { getRepoHostIdentityForParts } type RepoIdentityParts = Pick @@ -11,12 +14,6 @@ export function getRepoHostIdentity(repo: RepoIdentityParts): string { return getRepoHostIdentityForParts(repo.id, getRepoExecutionHostId(repo)) } -export function getRepoHostIdentityForParts(repoId: string, hostId: string): string { - // Why: host ids and repo ids can contain punctuation; NUL keeps the composite - // key collision-free without escaping user/provider-owned strings. - return `${hostId}\0${repoId}` -} - export function repoMatchesHostIdentity( repo: RepoIdentityParts, repoId: string, diff --git a/src/renderer/src/store/slices/repo-identity-reconcile.ts b/src/renderer/src/store/slices/repo-identity-reconcile.ts index db59e619d97..ea118295754 100644 --- a/src/renderer/src/store/slices/repo-identity-reconcile.ts +++ b/src/renderer/src/store/slices/repo-identity-reconcile.ts @@ -1,4 +1,5 @@ import type { Repo } from '../../../../shared/types' +import { structuralValuesEqual } from '../../../../shared/structural-value-equality' import { getRepoHostIdentity } from './repo-host-identity' // Why: after a drag-reorder we optimistically set `repos`, persist, and main @@ -8,44 +9,9 @@ import { getRepoHostIdentity } from './repo-host-identity' // virtualizer to rebuild + re-measure a tick after the drop — the visible jump. // Reusing equal objects (and the whole array when nothing moved) makes the echo // a no-op render. -// Why: `Repo` carries nested records (hookSettings, upstream, gitRemoteIdentity, repoIcon, path -// arrays). IPC structured-clone rebuilds those every fetch, and main's hydrateRepo always -// reconstructs hookSettings — so a reference compare reports every repo as changed and no repo -// ever reconciles. Compare nested plain values structurally; they are small sanitized records. -export function areValuesEqual(a: unknown, b: unknown): boolean { - if (a === b) { - return true - } - if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) { - return false - } - if (Array.isArray(a) || Array.isArray(b)) { - return ( - Array.isArray(a) && - Array.isArray(b) && - a.length === b.length && - a.every((item, index) => areValuesEqual(item, b[index])) - ) - } - // Why: only plain records are safe to walk — anything exotic falls back to reference equality. - const aPrototype = Object.getPrototypeOf(a) - const bPrototype = Object.getPrototypeOf(b) - if ( - (aPrototype !== Object.prototype && aPrototype !== null) || - (bPrototype !== Object.prototype && bPrototype !== null) - ) { - return false - } - const aRecord = a as Record - const bRecord = b as Record - const keys = Object.keys(aRecord) - if (keys.length !== Object.keys(bRecord).length) { - return false - } - return keys.every( - (key) => Object.hasOwn(bRecord, key) && areValuesEqual(aRecord[key], bRecord[key]) - ) -} +// Why the structural compare: `Repo` carries nested records (hookSettings, upstream, +// gitRemoteIdentity, repoIcon, path arrays) that structured-clone and main's hydrateRepo rebuild +// every fetch, so a reference compare would report every repo as changed. /** * Reuses equal rows from `previous` — and the whole array when nothing moved — so a refetch that @@ -61,7 +27,7 @@ export function reconcileCatalogRows( let identical = next.length === previous.length const reconciled = next.map((row, index) => { const existing = previousByIdentity.get(getIdentity(row)) - if (existing !== undefined && areValuesEqual(existing, row)) { + if (existing !== undefined && structuralValuesEqual(existing, row)) { if (existing !== previous[index]) { identical = false } @@ -95,7 +61,7 @@ export function reuseEqualRecordMap( const reconciled: Record = {} for (const key of nextKeys) { const existing = Object.hasOwn(previous, key) ? previous[key] : undefined - if (existing !== undefined && areValuesEqual(existing, next[key])) { + if (existing !== undefined && structuralValuesEqual(existing, next[key])) { reconciled[key] = existing continue } diff --git a/src/renderer/src/store/slices/repos-cross-host-refresh-identity.test.ts b/src/renderer/src/store/slices/repos-cross-host-refresh-identity.test.ts new file mode 100644 index 00000000000..6218d251607 --- /dev/null +++ b/src/renderer/src/store/slices/repos-cross-host-refresh-identity.test.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' +import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +// One project cloned on the local Mac and on a remote Orca server under distinct repo ids — +// the shape the compat merge exists to serve, and the only shape whose sourceRepoIds are +// assembled from two hosts. +const SHARED_PROJECT_ID = 'github:stablyai/orca' + +const localRepo: Repo = { + id: 'local-repo', + path: '/local/orca', + displayName: 'orca', + badgeColor: '#22c55e', + upstream: { owner: 'stablyai', repo: 'orca' }, + addedAt: 1_700_000_000_000 +} + +const remoteRepo: Repo = { + id: 'remote-repo', + path: '/srv/orca', + displayName: 'orca', + badgeColor: '#737373', + upstream: { owner: 'stablyai', repo: 'orca' }, + addedAt: 1_700_000_001_000 +} + +const localProject: Project = { + id: SHARED_PROJECT_ID, + displayName: 'orca', + badgeColor: '#22c55e', + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 1 +} + +const remoteProject: Project = { + ...localProject, + badgeColor: '#737373', + sourceRepoIds: ['remote-repo'], + createdAt: 2, + updatedAt: 2 +} + +function setup(repoId: string, path: string): ProjectHostSetup { + return { + id: `${repoId}-setup`, + projectId: SHARED_PROJECT_ID, + hostId: 'local', + repoId, + path, + displayName: 'orca', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 1 + } +} + +const reposList = vi.fn() +const projectsList = vi.fn() +const listHostSetups = vi.fn() +const runtimeEnvironmentsList = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() + +// Why: catalogs arrive over IPC, so every fetch must hand back freshly allocated rows — +// otherwise identity would match by accident and prove nothing. +function clone(value: T): T { + return structuredClone(value) +} + +function runtimeResult(method: string): unknown { + if (method === 'repo.list') { + return { repos: [clone(remoteRepo)] } + } + if (method === 'project.list') { + return { projects: [clone(remoteProject)] } + } + if (method === 'projectHostSetup.list') { + return { setups: [clone(setup('remote-repo', '/srv/orca'))] } + } + return {} +} + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + reposList.mockReset() + projectsList.mockReset() + listHostSetups.mockReset() + runtimeEnvironmentsList.mockReset() + runtimeEnvironmentTransportCall.mockReset() + + reposList.mockImplementation(async () => [clone(localRepo)]) + projectsList.mockImplementation(async () => [clone(localProject)]) + listHostSetups.mockImplementation(async () => [clone(setup('local-repo', '/local/orca'))]) + runtimeEnvironmentsList.mockResolvedValue([{ id: 'env-1', name: 'awin' }]) + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + const compatible = createCompatibleRuntimeStatusResponseIfNeeded(args) + if (compatible) { + return compatible + } + return { + id: `rpc-${args.method}`, + ok: true, + result: runtimeResult(args.method), + _meta: { runtimeId: 'runtime-remote' } + } + }) + + vi.stubGlobal('window', { + api: { + repos: { list: reposList }, + projects: { list: projectsList, listHostSetups }, + projectGroups: { list: vi.fn().mockResolvedValue([]) }, + folderWorkspaces: { list: vi.fn().mockResolvedValue([]) }, + runtimeEnvironments: { + call: runtimeEnvironmentTransportCall, + list: runtimeEnvironmentsList + } + }, + dispatchEvent: vi.fn() + }) +}) + +function sharedProject(store: ReturnType): Project | undefined { + return store.getState().projects.find((project) => project.id === SHARED_PROJECT_ID) +} + +describe('cross-host project refresh identity', () => { + it('keeps sourceRepoIds ordered the same whichever host refreshed', async () => { + const store = createTestStore() + // Why: with no active env, `fetchRepos` targets local, so the two calls below really do + // alternate hosts. + store.setState({ settings: { activeRuntimeEnvironmentId: null } as never }) + await store.getState().fetchReposForAllHosts() + const afterAllHosts = sharedProject(store)?.sourceRepoIds + expect(afterAllHosts).toHaveLength(2) + + await store.getState().fetchRepos() + const afterLocal = sharedProject(store)?.sourceRepoIds + + await store.getState().fetchRuntimeEnvironmentRepos('env-1') + const afterRuntime = sharedProject(store)?.sourceRepoIds + + expect(afterLocal).toEqual(afterAllHosts) + expect(afterRuntime).toEqual(afterAllHosts) + }) + + it('reuses the cross-host project row across alternating single-host refreshes', async () => { + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: null } as never }) + await store.getState().fetchReposForAllHosts() + // Why: the first local pass lands before the runtime catalog exists, so settle onto the + // two-host fixed point before measuring identity. + await store.getState().fetchRepos() + const settled = sharedProject(store) + + await store.getState().fetchRuntimeEnvironmentRepos('env-1') + expect(sharedProject(store)).toBe(settled) + + await store.getState().fetchRepos() + expect(sharedProject(store)).toBe(settled) + }) + + it('reuses the cross-host project row across a no-op all-hosts refresh', async () => { + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + await store.getState().fetchReposForAllHosts() + await store.getState().fetchReposForAllHosts() + const settled = sharedProject(store) + + await store.getState().fetchReposForAllHosts() + + expect(sharedProject(store)).toBe(settled) + }) +}) diff --git a/src/renderer/src/store/slices/repos-refresh-identity.test.ts b/src/renderer/src/store/slices/repos-refresh-identity.test.ts index 779f9e8d04e..d0db07d32b0 100644 --- a/src/renderer/src/store/slices/repos-refresh-identity.test.ts +++ b/src/renderer/src/store/slices/repos-refresh-identity.test.ts @@ -229,6 +229,57 @@ describe('repo catalog refresh identity', () => { expect(store.getState().projectHostSetups).toHaveLength(1) }) + it('keeps a project owned only through a repo on another host', async () => { + // Why: no setup row names this project, so the refreshing host can only be ruled out from the + // project's own source repos. Feeding the host-id resolvers anything but this project's repo + // slice prunes it on a local refresh. + const sshRepo: Repo = { ...secondRepo, executionHostId: 'ssh:host-a', connectionId: 'host-a' } + mockRepos(repo, sshRepo) + const store = createTestStore() + await store.getState().fetchRepos() + const sshOwned: Project = { + id: 'ssh-owned', + displayName: 'SSH Owned', + badgeColor: '#000000', + sourceRepoIds: [sshRepo.id], + createdAt: 1, + updatedAt: 1 + } + store.setState({ + projects: [...store.getState().projects, sshOwned], + projectHostSetups: [] + }) + + await store.getState().fetchRepos() + + expect(store.getState().projects.map((project) => project.id)).toContain('ssh-owned') + }) + + it('keeps a project whose repo id is cloned onto a second host', async () => { + // Why: both rows share `repo.id`, so the project's repo slice must carry every duplicate — + // keeping only the first drops the remote host and the local refresh prunes the project. + const sshRepo: Repo = { ...repo, executionHostId: 'ssh:host-a', connectionId: 'host-a' } + mockRepos(repo, sshRepo) + const store = createTestStore() + await store.getState().fetchRepos() + const dualHostOwned: Project = { + id: 'dual-host-owned', + displayName: 'Dual Host Owned', + badgeColor: '#000000', + sourceRepoIds: [repo.id], + createdAt: 1, + updatedAt: 1 + } + store.setState({ + projects: [...store.getState().projects, dualHostOwned], + projectHostSetups: [] + }) + + await store.getState().fetchRepos() + + expect(store.getState().projects.map((project) => project.id)).toContain('dual-host-owned') + }) + it('adds a project and its setup when a repo appears', async () => { const store = createTestStore() await store.getState().fetchRepos() diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index b55d48655f0..e8f19c6d9b9 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -47,13 +47,10 @@ import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' import { applyManualRepoOrder, getManualRepoOrder } from '../../../../shared/manual-repo-order' import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' -import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +import { structuralValuesEqual } from '../../../../shared/structural-value-equality' import { selectProjectGroupRemovalTargets } from './project-group-removal-targets' -import { - areValuesEqual, - reconcileCatalogRows, - reconcileFetchedRepos -} from './repo-identity-reconcile' +import { reconcileCatalogRows, reconcileFetchedRepos } from './repo-identity-reconcile' import { retainValidFilterRepoIds } from './repo-filter-selection' import { readRuntimeWorktreeVisibilitySnapshot } from './worktree-visibility-owner-settings' import { @@ -98,7 +95,7 @@ import { isRemovedRuntimeHostId } from './stale-runtime-host-rows' import { normalizeCustomWorktreeVisibilitySources, normalizeWorktreeVisibilitySourcePreferences -} from '../../../../shared/worktree-visibility-sources' +} from '../../../../shared/worktree/visibility-sources' import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime-cleanup' import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' import { formatFolderWorkspaceCreateError } from '../../lib/folder-workspace-path-status' @@ -676,10 +673,13 @@ function getMergedSourceRepoIdsForHostRefresh( reposById: ReadonlyMap, hostId: string ): string[] { + // Why: current-first keeps the order independent of which host refreshed. Prefixing the + // cross-host remainder made it a function of hostId, so a cross-host project's ids oscillated + // between refreshes and the projects reconcile could never reuse the row. return [ ...new Set([ - ...getSourceRepoIdsOutsideHost(previous, reposById, hostId), - ...getCurrentSourceRepoIds(current, new Set(reposById.keys())) + ...getCurrentSourceRepoIds(current, new Set(reposById.keys())), + ...getSourceRepoIdsOutsideHost(previous, reposById, hostId) ]) ] } @@ -1023,7 +1023,7 @@ function mergeByIdentity( changed = true continue } - if (areValuesEqual(merged[index], entry)) { + if (structuralValuesEqual(merged[index], entry)) { continue } merged[index] = entry @@ -1044,7 +1044,8 @@ function unchangedMergeSource( // Why: the sidebar effect watching these catalog arrays is the only thing that refills the // folder path-status cache, so a no-op refetch must not wipe it — nothing would repopulate it. -function catalogRowsUnchanged(next: readonly T[], previous: readonly T[]): boolean { +// Element identity only; `structuralValuesEqual` is the structural counterpart. +function arrayElementsUnchanged(next: readonly T[], previous: readonly T[]): boolean { return ( next === previous || (next.length === previous.length && next.every((row, index) => row === previous[index])) @@ -1062,19 +1063,10 @@ function mergeFetchedReposForHost( const existingHostId = getRepoExecutionHostId(repo) return existingHostId !== hostId || fetchedIdentities.has(getRepoHostIdentity(repo)) }) - const merged = [...preserved] - const indexByIdentity = new Map(merged.map((repo, index) => [getRepoHostIdentity(repo), index])) - for (const repo of fetchedWithProjectGroups) { - const identity = getRepoHostIdentity(repo) - const existingIndex = indexByIdentity.get(identity) - if (existingIndex === undefined) { - indexByIdentity.set(identity, merged.length) - merged.push(repo) - continue - } - merged[existingIndex] = repo - } - return reconcileFetchedRepos(previous, merged) + return reconcileFetchedRepos( + previous, + mergeByIdentity(preserved, fetchedWithProjectGroups, getRepoHostIdentity) + ) } function applyInheritedProjectGroups(previous: readonly Repo[], fetched: readonly Repo[]): Repo[] { @@ -2136,7 +2128,7 @@ export const createRepoSlice: StateCreator = (set, pendingSshRepoReadoptions: reconciliation.pendingReadoptions, ...reconcileReadoptedSshWorktreeState(s, s.pendingSshRepoReadoptions), ...mergedProjectCompatibility, - ...(catalogRowsUnchanged(prunedRepos, s.repos) + ...(arrayElementsUnchanged(prunedRepos, s.repos) ? {} : { folderWorkspacePathStatuses: {} }), activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, @@ -2325,7 +2317,7 @@ export const createRepoSlice: StateCreator = (set, pendingSshRepoReadoptions: reconciliation.pendingReadoptions, ...reconcileReadoptedSshWorktreeState(s, s.pendingSshRepoReadoptions), ...mergedProjectCompatibility, - ...(catalogRowsUnchanged(finalizedRepos, s.repos) + ...(arrayElementsUnchanged(finalizedRepos, s.repos) ? {} : { folderWorkspacePathStatuses: {} }), activeRepoId: s.activeRepoId, @@ -2449,7 +2441,7 @@ export const createRepoSlice: StateCreator = (set, const { projectGroups } = mergeFetchedProjectGroupCatalog(catalog, current.projectGroups) return { projectGroups, - ...(catalogRowsUnchanged(projectGroups, current.projectGroups) + ...(arrayElementsUnchanged(projectGroups, current.projectGroups) ? {} : { folderWorkspacePathStatuses: {} }) } @@ -2472,7 +2464,7 @@ export const createRepoSlice: StateCreator = (set, const { projectGroups } = mergeFetchedProjectGroupCatalog(catalog, s.projectGroups) return { projectGroups, - ...(catalogRowsUnchanged(projectGroups, s.projectGroups) + ...(arrayElementsUnchanged(projectGroups, s.projectGroups) ? {} : { folderWorkspacePathStatuses: {} }) } @@ -2536,7 +2528,7 @@ export const createRepoSlice: StateCreator = (set, ) return { folderWorkspaces, - ...(catalogRowsUnchanged(folderWorkspaces, current.folderWorkspaces) + ...(arrayElementsUnchanged(folderWorkspaces, current.folderWorkspaces) ? {} : { folderWorkspacePathStatuses: {} }) } @@ -2574,7 +2566,7 @@ export const createRepoSlice: StateCreator = (set, ) return { folderWorkspaces, - ...(catalogRowsUnchanged(folderWorkspaces, current.folderWorkspaces) + ...(arrayElementsUnchanged(folderWorkspaces, current.folderWorkspaces) ? {} : { folderWorkspacePathStatuses: {} }) } diff --git a/src/renderer/src/store/slices/superseded-ssh-repo-rows.ts b/src/renderer/src/store/slices/superseded-ssh-repo-rows.ts index c18e0f6e5af..a9880577f60 100644 --- a/src/renderer/src/store/slices/superseded-ssh-repo-rows.ts +++ b/src/renderer/src/store/slices/superseded-ssh-repo-rows.ts @@ -1,6 +1,7 @@ import type { SshRepoReadoption } from '../../../../shared/ssh-types' import type { Repo } from '../../../../shared/types' import { getRepoExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host' +import { getRepoHostIdentityForParts } from '../../../../shared/repo-host-identity' export type SshRepoReconciliation = { repos: readonly Repo[] @@ -14,10 +15,6 @@ function repoBelongsToTarget(repo: Repo, targetId: string): boolean { ) } -function repoOwnerKey(hostId: string, repoId: string): string { - return `${hostId}\0${repoId}` -} - /** * Drops old-host rows only when main reports the exact repo re-adoption and the * renderer has received the corresponding new-host row. Evidence stays pending @@ -32,7 +29,7 @@ export function reconcileReadoptedSshRepoRows( const directSshOwners = new Set( repos.flatMap((repo) => repo.connectionId && repoBelongsToTarget(repo, repo.connectionId) - ? [repoOwnerKey(getRepoExecutionHostId(repo), repo.id)] + ? [getRepoHostIdentityForParts(repo.id, getRepoExecutionHostId(repo))] : [] ) ) @@ -40,13 +37,18 @@ export function reconcileReadoptedSshRepoRows( for (const readoption of readoptions) { const pendingRepoIds: string[] = [] for (const repoId of readoption.repoIds) { - const newOwner = repoOwnerKey(toSshExecutionHostId(readoption.newTargetId), repoId) + const newOwner = getRepoHostIdentityForParts( + repoId, + toSshExecutionHostId(readoption.newTargetId) + ) const hasNewRow = directSshOwners.has(newOwner) if (!hasNewRow) { pendingRepoIds.push(repoId) continue } - prunedOwners.add(repoOwnerKey(toSshExecutionHostId(readoption.oldTargetId), repoId)) + prunedOwners.add( + getRepoHostIdentityForParts(repoId, toSshExecutionHostId(readoption.oldTargetId)) + ) } if (pendingRepoIds.length > 0) { pendingReadoptions.push({ ...readoption, repoIds: pendingRepoIds }) @@ -60,7 +62,8 @@ export function reconcileReadoptedSshRepoRows( } return { repos: repos.filter( - (repo) => !prunedOwners.has(repoOwnerKey(getRepoExecutionHostId(repo), repo.id)) + (repo) => + !prunedOwners.has(getRepoHostIdentityForParts(repo.id, getRepoExecutionHostId(repo))) ), pendingReadoptions } diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index c644b6db241..9b65267f895 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -42,7 +42,7 @@ import { isSameCodexRestartNoticeAccount } from './codex-restart-notice-account- import { getRepoIdFromWorktreeId, splitWorktreeIdForFilesystem -} from '../../../../shared/worktree-id' +} from '../../../../shared/worktree/id' import { isWslUncPath } from '../../../../shared/wsl-paths' import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' diff --git a/src/renderer/src/store/slices/worktree-catalog-reconciliation.ts b/src/renderer/src/store/slices/worktree-catalog-reconciliation.ts index 1ef339a1808..500f002dc59 100644 --- a/src/renderer/src/store/slices/worktree-catalog-reconciliation.ts +++ b/src/renderer/src/store/slices/worktree-catalog-reconciliation.ts @@ -1,41 +1,14 @@ +import { structuralValuesEqualIgnoringUndefined } from '../../../../shared/structural-value-equality' + type CatalogRow = { id: string } -function isPlainRecord(value: unknown): value is Record { - if (typeof value !== 'object' || value === null) { - return false - } - const prototype = Object.getPrototypeOf(value) - return prototype === Object.prototype || prototype === null -} - -function catalogValuesEqual(left: unknown, right: unknown): boolean { - if (Object.is(left, right)) { - return true - } - if (Array.isArray(left) || Array.isArray(right)) { - if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { - return false - } - return left.every((value, index) => catalogValuesEqual(value, right[index])) - } - if (!isPlainRecord(left) || !isPlainRecord(right)) { - return false - } - const keys = new Set([...Object.keys(left), ...Object.keys(right)]) - for (const key of keys) { - if (!catalogValuesEqual(left[key], right[key])) { - return false - } - } - return true -} - // NOTHING HITS THIS TODAY: both callers key on ids that are unique by // construction, so buckets stay at 1-3. It only bounds the damage if that ever // changes — a same-id bucket costs one deep compare per candidate, so an -// unbounded one is O(k^2). A cap beats a second index that would have to stay in -// step with catalogValuesEqual, and reuse is only an optimization: dropping a -// match past the window costs object identity, never correctness. +// unbounded one is O(k^2). A cap beats an index keyed on a second walk that +// would have to stay in step with structuralValuesEqualIgnoringUndefined, and +// reuse is only an optimization: dropping a match past the window costs object +// identity, never correctness. const MAX_DUPLICATE_ID_SCAN = 8 export function reuseEqualCatalogRows( @@ -62,7 +35,7 @@ export function reuseEqualCatalogRows( const scanLimit = Math.min(candidates.length, MAX_DUPLICATE_ID_SCAN) for (let index = 0; index < scanLimit; index++) { const candidate = candidates[index] - if (candidate !== undefined && catalogValuesEqual(candidate, row)) { + if (candidate !== undefined && structuralValuesEqualIgnoringUndefined(candidate, row)) { candidates.splice(index, 1) return candidate } diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 5d06bb61c0a..68f712e9b4b 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -22,7 +22,7 @@ import type { WorkspaceKey } from '../../../../shared/types' import type { TaskSourceContext } from '../../../../shared/task-source-context' -import type { WorktreeForceDeleteReason } from '../../../../shared/worktree-removal' +import type { WorktreeForceDeleteReason } from '../../../../shared/worktree/removal' import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector' import type { ExecutionHostId } from '../../../../shared/execution-host' import type { @@ -34,9 +34,9 @@ import type { PendingWorktreeCreation, WorktreeCreationPhase } from '@/lib/pending-worktree-creation' -import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import type { AppState } from '../types' -export { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' +export { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' export type WorktreeDeleteState = { isDeleting: boolean diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 68d4160dbbc..3165d59fc87 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -28,7 +28,7 @@ import { type WorktreeSlice } from './worktree-helpers' import { projectWorktreeTabModelReconciliation } from './tabs' -import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' +import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree/id' import { remapClosedTerminalTabSnapshotCwds, type ClosedTerminalTabSnapshot @@ -114,7 +114,7 @@ import { classifyWorktreeForceDeleteReason, getLockedWorktreeRemovalReason, isLockedWorktreeRemovalError -} from '../../../../shared/worktree-removal' +} from '../../../../shared/worktree/removal' import { FolderWorkspaceActivityPersistence } from './folder-workspace-activity-persistence' import { createDetectedWorktreeRefreshLeaseRegistry, diff --git a/src/shared/agent-scratch-worktrees.ts b/src/shared/agent-scratch-worktrees.ts index c9add6692b9..d7dbf0dce99 100644 --- a/src/shared/agent-scratch-worktrees.ts +++ b/src/shared/agent-scratch-worktrees.ts @@ -2,7 +2,7 @@ import { normalizeRuntimePathForComparison } from './cross-platform-path' import { createWorktreeVisibilitySourceMatcher, type WorktreeVisibilitySourceMatcher -} from './worktree-visibility-sources' +} from './worktree/visibility-sources' /** Why: agent CLIs reserve these repo-root paths for scratch; broader matches * can hide legitimate user worktrees (#9388). */ diff --git a/src/shared/browser-workspace-types.ts b/src/shared/browser-workspace-types.ts new file mode 100644 index 00000000000..67619decd58 --- /dev/null +++ b/src/shared/browser-workspace-types.ts @@ -0,0 +1,155 @@ +export type BrowserHistoryEntry = { + url: string + normalizedUrl: string + title: string + lastVisitedAt: number + visitCount: number +} + +export type BrowserLoadError = { + code: number + description: string + validatedUrl: string +} + +export type BrowserCertificateFailure = { + challengeId: string + browserPageId: string + errorCode: number | null + error: string + origin: string + displayHost: string + canProceed: boolean + observedAt: number +} + +export type BrowserCertificateProceedFailureReason = + | 'expired' + | 'changed' + | 'ineligible' + | 'missing' + | 'navigated' + +export type BrowserCertificateProceedResult = + | { ok: true } + | { ok: false; reason: BrowserCertificateProceedFailureReason } + +// Why: BrowserPage persists the active viewport preset so CDP emulation can be +// reapplied on reload/navigation without the user re-picking from the toolbar. +export type BrowserViewportPresetId = + | 'mobile-s' + | 'mobile-m' + | 'mobile-l' + | 'tablet' + | 'laptop' + | 'laptop-l' + | 'desktop' + +export type BrowserViewportOverride = { + width: number + height: number + deviceScaleFactor: number + mobile: boolean +} + +export type BrowserPage = { + id: string + workspaceId: string + worktreeId: string + url: string + title: string + loading: boolean + faviconUrl: string | null + canGoBack: boolean + canGoForward: boolean + loadError: BrowserLoadError | null + createdAt: number + // Why: remote-owned worktrees can still host client-local fallback browser + // pages until headless remote runtimes support real browser panes. + browserRuntimeEnvironmentId?: string | null + /** Active CDP viewport emulation preset. null = default (fill pane, no CDP override) */ + viewportPresetId?: BrowserViewportPresetId | null +} + +export type BrowserWorkspace = { + id: string + worktreeId: string + /** Stable display label for the outer Orca tab ("Browser 1", "Browser 2", …). + * Optional so sessions persisted before this field was added fall back + * gracefully to the URL-derived label in getBrowserTabLabel. */ + label?: string + // Why: each browser workspace binds to exactly one session profile at creation + // time. The profile determines which Electron partition (and thus which + // cookies/storage) the guest webview uses. Absent means the legacy shared + // partition, which keeps backward compat with workspaces persisted before + // session profiles existed. + sessionProfileId?: string | null + // Why: runtime-created tabs resolve profile partition in main. Persisting it + // keeps isolated storage stable when the renderer profile mirror is stale. + sessionPartition?: string | null + activePageId?: string | null + pageIds?: string[] + // Why: the active page owns real browser chrome state now, but the top-level + // Orca tab strip still renders one workspace entry. Mirror the active page's + // title/url/loading metadata here so existing workspace-level UI can stay + // stable while Phase 2 introduces nested browser pages. + url: string + title: string + loading: boolean + faviconUrl: string | null + canGoBack: boolean + canGoForward: boolean + loadError: BrowserLoadError | null + createdAt: number +} + +export type BrowserTab = BrowserWorkspace + +export type BrowserSessionProfileScope = 'default' | 'isolated' | 'imported' + +export type BrowserSessionUserAgentMode = 'clean' | 'native' + +export type BrowserSessionProfileCreateOptions = { + userAgentMode?: BrowserSessionUserAgentMode +} + +export type BrowserSessionProfileSource = { + browserFamily: + | 'chrome' + | 'chromium' + | 'arc' + | 'edge' + | 'firefox' + | 'safari' + | 'comet' + | 'helium' + | 'manual' + profileName?: string + importedAt: number +} + +export type BrowserSessionProfile = { + id: string + scope: BrowserSessionProfileScope + partition: string + label: string + source: BrowserSessionProfileSource | null + userAgentMode?: BrowserSessionUserAgentMode +} + +export type BrowserCookieImportSummary = { + totalCookies: number + importedCookies: number + skippedCookies: number + googleCookiesSkipped?: number + domains: string[] + warning?: { + code: 'restart-fallback-unavailable' + loadedCookies: number + failedCookies: number + } +} + +export type BrowserCookieImportResult = + | { ok: true; profileId: string; summary: BrowserCookieImportSummary } + | { ok: false; reason: string } diff --git a/src/shared/classified-error.ts b/src/shared/classified-error.ts new file mode 100644 index 00000000000..68e7bcf96a9 --- /dev/null +++ b/src/shared/classified-error.ts @@ -0,0 +1,11 @@ +export type ClassifiedError = { + type: + | 'permission_denied' + | 'not_found' + | 'issues_disabled' + | 'validation_error' + | 'rate_limited' + | 'network_error' + | 'unknown' + message: string +} diff --git a/src/shared/code-search-types.ts b/src/shared/code-search-types.ts new file mode 100644 index 00000000000..6d8a8f1ccd1 --- /dev/null +++ b/src/shared/code-search-types.ts @@ -0,0 +1,33 @@ +// ─── Search ───────────────────────────────────────────── +export type SearchMatch = { + line: number + column: number + matchLength: number + lineContent: string + displayColumn?: number + displayMatchLength?: number +} + +export type SearchFileResult = { + filePath: string + relativePath: string + matches: SearchMatch[] + matchCount?: number +} + +export type SearchResult = { + files: SearchFileResult[] + totalMatches: number + truncated: boolean +} + +export type SearchOptions = { + query: string + rootPath: string + caseSensitive?: boolean + wholeWord?: boolean + useRegex?: boolean + includePattern?: string + excludePattern?: string + maxResults?: number +} diff --git a/src/shared/commit-message-ai-types.ts b/src/shared/commit-message-ai-types.ts new file mode 100644 index 00000000000..4e7e57a165b --- /dev/null +++ b/src/shared/commit-message-ai-types.ts @@ -0,0 +1,30 @@ +import type { TuiAgent } from './tui-agent' + +export type CommitMessageAiModelCapability = { + id: string + label: string + thinkingLevels?: { id: string; label: string }[] + defaultThinkingLevel?: string +} + +export type CommitMessageAiSettings = { + enabled: boolean + /** A TuiAgent id, the literal `'custom'` for a user-supplied command, or null. */ + agentId: TuiAgent | 'custom' | null + /** Per-agent: switching agents preserves the previously-picked model. */ + selectedModelByAgent: Partial> + /** Host-scoped model selections; dynamic agents can expose different models per SSH target. */ + selectedModelByAgentByHost?: Partial>>> + /** Per-agent dynamic models last discovered from the CLI, persisted so main can validate selections. */ + discoveredModelsByAgent?: Partial> + /** Host-scoped dynamic model discovery cache. */ + discoveredModelsByAgentByHost?: Partial< + Record>> + > + /** Per-model: thinking effort depends on the model, not the agent. Keyed by model id. */ + selectedThinkingByModel: Record + /** Optional user-provided suffix appended to the base prompt (style overrides, etc.). */ + customPrompt: string + /** Command template for agentId === 'custom'; {prompt} substitutes the diff prompt via argv, else the prompt is piped via stdin. */ + customAgentCommand: string +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 9218db18cb0..869d949061a 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -17,7 +17,7 @@ import { getDefaultTerminalQuickCommands } from './terminal-quick-commands' import type { VoiceSettings } from './speech-types' import { cloneDefaultWorkspaceStatuses } from './workspace-statuses' import { TASK_PROVIDERS } from './task-providers' -import { DEFAULT_WORKTREE_CARD_PROPERTIES } from './worktree-card-properties' +import { DEFAULT_WORKTREE_CARD_PROPERTIES } from './worktree/card-properties' import { getDefaultSourceControlAiSettings } from './source-control-ai' import { DEFAULT_APP_ICON_ID } from './app-icon' import { DEFAULT_OPEN_IN_APPLICATIONS } from './open-in-applications' @@ -44,7 +44,7 @@ export { getWorktreeCardModeUpdates, isDefaultedCompactWorktreeCardProperties, normalizeWorktreeCardProperties -} from './worktree-card-properties' +} from './worktree/card-properties' export const SCHEMA_VERSION = 1 export const DEFAULT_APP_FONT_FAMILY = 'Geist' diff --git a/src/shared/diff-comment-types.ts b/src/shared/diff-comment-types.ts new file mode 100644 index 00000000000..11e3694dffb --- /dev/null +++ b/src/shared/diff-comment-types.ts @@ -0,0 +1,48 @@ +// ─── Diff line comments ────────────────────────────────────────────── +// Why: users leave review notes on specific lines of the modified side of +// a diff so they can be handed back to an AI agent (pasted into a terminal +// or used to bootstrap a new agent session). Stored on WorktreeMeta so the +// existing persistence layer writes them to orca-data.json automatically. +export type DiffCommentSource = 'diff' | 'markdown' +export type DiffReviewScope = 'unstaged' | 'staged' | 'branch' + +export type MobileDiffReviewFileState = { + key: string + filePath: string + oldPath?: string + scope: DiffReviewScope + lastOpenedAt?: number + lastSeenDiffIdentity?: string + reviewedAt?: number + reviewDiffIdentity?: string +} + +export type MobileDiffReviewState = { + version: 1 + updatedAt?: number + completedAt?: number + files: Record +} + +export type DiffComment = { + id: string + worktreeId: string + filePath: string + /** Undefined means a legacy diff note. */ + source?: DiffCommentSource + /** Exact text selected when creating a markdown note, when available. */ + selectedText?: string + /** Inclusive range start. Must be <= lineNumber when present. */ + startLine?: number + lineNumber: number + body: string + createdAt: number + updatedAt?: number + /** Set after the note has been handed to an agent. Edits clear it. */ + sentAt?: number + scope?: DiffReviewScope + oldPath?: string + diffIdentity?: string + // Reserved for future "comments on the original side" — always 'modified' in v1. + side: 'modified' +} diff --git a/src/shared/external-worktree-inbox.test.ts b/src/shared/external-worktree-inbox.test.ts index b9a72f62f8c..2c29fe4a117 100644 --- a/src/shared/external-worktree-inbox.test.ts +++ b/src/shared/external-worktree-inbox.test.ts @@ -20,7 +20,7 @@ import { buildKnownOrcaWorkspaceLayouts, EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT, toDetectedWorktree -} from './worktree-ownership' +} from './worktree/ownership' const repo: Repo = { id: 'repo-1', diff --git a/src/shared/external-worktree-visibility.ts b/src/shared/external-worktree-visibility.ts index c8badde079b..1531b482d67 100644 --- a/src/shared/external-worktree-visibility.ts +++ b/src/shared/external-worktree-visibility.ts @@ -2,7 +2,7 @@ import { normalizeRuntimePathSeparators } from './cross-platform-path' import { normalizeCustomWorktreeVisibilitySources, normalizeWorktreeVisibilitySourcePreferences -} from './worktree-visibility-sources' +} from './worktree/visibility-sources' import type { ExternalWorktreeVisibility, Repo, WorktreeVisibilityDefaults } from './types' export const EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT = Date.UTC(2026, 4, 23) diff --git a/src/shared/filesystem-entry-types.ts b/src/shared/filesystem-entry-types.ts new file mode 100644 index 00000000000..b81a68f4c2a --- /dev/null +++ b/src/shared/filesystem-entry-types.ts @@ -0,0 +1,28 @@ +// ─── Filesystem ───────────────────────────────────────────── +export type FilesystemPathFlavor = 'posix' | 'win32' + +export type DirEntry = { + name: string + isDirectory: boolean + isSymlink: boolean +} + +export type MarkdownDocument = { + filePath: string + relativePath: string + basename: string + name: string +} + +// ─── Filesystem watcher ───────────────────────────────────── +export type FsChangeEvent = { + kind: 'create' | 'update' | 'delete' | 'rename' | 'overflow' + absolutePath: string + oldAbsolutePath?: string + isDirectory?: boolean +} + +export type FsChangedPayload = { + worktreePath: string + events: FsChangeEvent[] +} diff --git a/src/shared/folder-workspace-types.ts b/src/shared/folder-workspace-types.ts new file mode 100644 index 00000000000..81e8dcf9f98 --- /dev/null +++ b/src/shared/folder-workspace-types.ts @@ -0,0 +1,47 @@ +import type { ExecutionHostId } from './execution-host' +import type { TaskSourceContext } from './task-source-context' +import type { TuiAgent } from './tui-agent' +import type { DiffComment } from './diff-comment-types' +import type { + WorkspaceCreatorProvenance, + WorkspaceLinkedItem, + WorkspaceStatus +} from './worktree/types' + +export type WorkspaceScope = + | { type: 'worktree'; worktreeId: string } + | { type: 'folder'; folderWorkspaceId: string } + +export type WorkspaceKey = `worktree:${string}` | `folder:${string}` + +export type FolderWorkspace = { + id: string + projectGroupId: string + name: string + folderPath: string + /** SSH target ID for folder workspaces whose folder path lives remotely. */ + connectionId?: string | null + /** Renderer-owned host stamp for host-qualified folder catalogs. */ + executionHostId?: ExecutionHostId | null + /** Authenticated client that created this workspace. Missing means unknown legacy origin. */ + creatorProvenance?: WorkspaceCreatorProvenance + linkedTask: WorkspaceLinkedItem | null + linkedTaskSourceContext?: TaskSourceContext | null + comment: string + isArchived: boolean + isUnread: boolean + isPinned: boolean + sortOrder: number + /** User-authored sidebar ordering. Higher values render earlier in Manual sort. */ + manualOrder?: number + workspaceStatus?: WorkspaceStatus + createdWithAgent?: TuiAgent + pendingFirstAgentMessageRename?: boolean + firstAgentMessageRenameError?: string | null + lastActivityAt: number + createdAt: number + updatedAt: number + diffComments?: DiffComment[] +} + +export type FolderWorkspaceLinkedTask = WorkspaceLinkedItem diff --git a/src/shared/git-diff-compare-types.ts b/src/shared/git-diff-compare-types.ts new file mode 100644 index 00000000000..ba1ee97015f --- /dev/null +++ b/src/shared/git-diff-compare-types.ts @@ -0,0 +1,71 @@ +import type { GitBranchChangeStatus } from './git-status-types' +import type { LargeDiffRenderLimit } from './large-diff-render-limit' + +// ─── Git Status ───────────────────────────────────────────── +// Re-exported from git-status-types.ts so mobile shares the wire contract without this desktop aggregate. + +export type GitBranchChangeEntry = { + path: string + status: GitBranchChangeStatus + oldPath?: string + added?: number + removed?: number +} + +export type GitBranchCompareSummary = { + baseRef: string + baseOid: string | null + compareRef: string + headOid: string | null + mergeBase: string | null + changedFiles: number + commitsAhead?: number + status: 'ready' | 'invalid-base' | 'unborn-head' | 'no-merge-base' | 'loading' | 'error' + errorMessage?: string +} + +export type GitBranchCompareResult = { + summary: GitBranchCompareSummary + entries: GitBranchChangeEntry[] +} + +export type GitCommitCompareSummary = { + commitOid: string + parentOid: string | null + compareRef: string + baseRef: string + changedFiles: number + status: 'ready' | 'invalid-commit' | 'error' + errorMessage?: string +} + +export type GitCommitCompareResult = { + summary: GitCommitCompareSummary + entries: GitBranchChangeEntry[] +} + +export type GitDiffTextResult = { + kind: 'text' + originalContent: string + modifiedContent: string + originalIsBinary: false + modifiedIsBinary: false + largeDiffRenderLimit?: LargeDiffRenderLimit +} + +export type GitDiffBinaryResult = { + kind: 'binary' + originalContent: string + modifiedContent: string + /** Legacy flag used by the renderer for any binary format it can preview, including PDFs. */ + isImage?: boolean + /** MIME type for binary preview rendering, e.g. "image/png" or "application/pdf" */ + mimeType?: string + /** True only for a proven deletion — distinct from an empty modified side caused by a read failure or size cap. */ + modifiedDeleted?: boolean +} & ( + | { originalIsBinary: true; modifiedIsBinary: boolean } + | { originalIsBinary: boolean; modifiedIsBinary: true } +) + +export type GitDiffResult = GitDiffTextResult | GitDiffBinaryResult diff --git a/src/shared/git-remote-host-alias.ts b/src/shared/git-remote-host-alias.ts new file mode 100644 index 00000000000..341f9807f21 --- /dev/null +++ b/src/shared/git-remote-host-alias.ts @@ -0,0 +1,44 @@ +// Why: a remote URL can name a forge through an alias host (SSH-over-443, `www.`), and every +// comparison of a probed remote against a pasted URL must fold those to the same identity. + +function normalizeRemoteHost(host: string): string { + return host.trim().toLowerCase() +} + +/** + * Fold a `www.` prefix. Deliberately NOT part of the forge normalizers: those feed + * `getProjectIdentityKey`, so folding there would re-key already-persisted projects on upgrade. + * Only URL-vs-remote comparison may use it. + */ +export function foldWwwHostAlias(host: string): string { + return normalizeRemoteHost(host).replace(/^www\./, '') +} + +export function normalizeGitHubRemoteHost(host: string): string { + const normalizedHost = normalizeRemoteHost(host) + // Why: GitHub documents ssh.github.com as SSH-over-HTTPS for github.com repos. + return normalizedHost === 'ssh.github.com' ? 'github.com' : normalizedHost +} + +export function normalizeGitLabRemoteHost(host: string): string { + const normalizedHost = normalizeRemoteHost(host) + // Why: GitLab documents altssh.gitlab.com as SSH-over-443 for gitlab.com projects. + return normalizedHost === 'altssh.gitlab.com' ? 'gitlab.com' : normalizedHost +} + +/** Comparison-only host fold: the forge alias plus `www.`, for matching a remote to a pasted URL. */ +export function foldComparableGitHubHost(host: string): string { + return normalizeGitHubRemoteHost(foldWwwHostAlias(host)) +} + +export function foldComparableGitLabHost(host: string): string { + return normalizeGitLabRemoteHost(foldWwwHostAlias(host)) +} + +/** + * A host with no dot is an OpenSSH `Host` alias (`git@github-work:owner/repo`) that only + * ~/.ssh/config can expand; `git remote -v` never resolves it, so it is unknown, not wrong. + */ +export function isUnresolvedSshHostAlias(host: string): boolean { + return !normalizeRemoteHost(host).includes('.') +} diff --git a/src/shared/git-remote-identity.test.ts b/src/shared/git-remote-identity.test.ts index c145526e253..cc4fa4dff57 100644 --- a/src/shared/git-remote-identity.test.ts +++ b/src/shared/git-remote-identity.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest' -import { deriveGitRemoteIdentity, normalizeGitRemoteUrl } from './git-remote-identity' +import { normalizeGitHubRemoteHost } from './git-remote-host-alias' +import { + deriveGitRemoteIdentity, + matchGitRemoteKeyParts, + normalizeGitRemoteUrl, + splitGitRemoteKey +} from './git-remote-identity' describe('normalizeGitRemoteUrl', () => { it('normalizes HTTPS and SSH GitHub remotes to the same canonical key', () => { @@ -76,3 +82,32 @@ describe('deriveGitRemoteIdentity', () => { }) }) }) + +describe('splitGitRemoteKey / matchGitRemoteKeyParts', () => { + const github = (key: string | undefined) => splitGitRemoteKey(key, normalizeGitHubRemoteHost) + + it('rejects keys without both a host and a path', () => { + expect(github('github.com')).toBeNull() + expect(github('github.com/')).toBeNull() + expect(github('/example/sample-app')).toBeNull() + expect(github(undefined)).toBeNull() + }) + + it('normalizes the host and lowercases the path tail', () => { + expect(github('SSH.GitHub.com:443/Example/Sample-App')).toEqual({ + host: 'github.com', + tail: 'example/sample-app' + }) + }) + + it('treats a dotless probed host as unknown but a real host as a mismatch', () => { + const target = { host: 'github.com', tail: 'example/sample-app' } + expect(matchGitRemoteKeyParts({ host: 'github-work', tail: target.tail }, target)).toBe( + 'unknown' + ) + expect(matchGitRemoteKeyParts({ host: 'ghe.example.com', tail: target.tail }, target)).toBe( + false + ) + expect(matchGitRemoteKeyParts({ host: 'github-work', tail: 'other/repo' }, target)).toBe(false) + }) +}) diff --git a/src/shared/git-remote-identity.ts b/src/shared/git-remote-identity.ts index 7b457f68b0b..bc221157a2e 100644 --- a/src/shared/git-remote-identity.ts +++ b/src/shared/git-remote-identity.ts @@ -1,9 +1,49 @@ +import { isUnresolvedSshHostAlias } from './git-remote-host-alias' + export type GitRemoteIdentity = { canonicalKey: string remoteName: string remoteUrl: string } +export type GitRemoteKeyParts = { + host: string + tail: string +} + +/** Split a `canonicalKey` into `host` + path tail so the host can be alias-normalized. */ +export function splitGitRemoteKey( + canonicalKey: string | null | undefined, + normalizeHost: (host: string) => string +): GitRemoteKeyParts | null { + const key = canonicalKey?.trim().replace(/\/+$/, '').toLowerCase() ?? '' + const separator = key.indexOf('/') + if (separator <= 0 || separator === key.length - 1) { + return null + } + return { + host: normalizeHost(key.slice(0, separator).replace(/:\d+$/, '')), + tail: key.slice(separator + 1) + } +} + +/** + * Compare a probed remote against a pasted URL. A host-only mismatch is `'unknown'` when the + * probed host is an SSH alias, since `git remote -v` reports it unexpanded. + */ +export function matchGitRemoteKeyParts( + identity: GitRemoteKeyParts, + target: GitRemoteKeyParts +): boolean | 'unknown' { + if (identity.tail !== target.tail) { + return false + } + if (identity.host === target.host) { + return true + } + return isUnresolvedSshHostAlias(identity.host) ? 'unknown' : false +} + type GitRemoteEntry = { name: string url: string diff --git a/src/shared/github-api-availability.test.ts b/src/shared/github/api-availability.test.ts similarity index 98% rename from src/shared/github-api-availability.test.ts rename to src/shared/github/api-availability.test.ts index 6e0ec633ef4..aec7abd4422 100644 --- a/src/shared/github-api-availability.test.ts +++ b/src/shared/github/api-availability.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { classifyGitHubUnavailable, isGitHubUnavailableError } from './github-api-availability' +import { classifyGitHubUnavailable, isGitHubUnavailableError } from './api-availability' describe('classifyGitHubUnavailable', () => { it('classifies HTTP 5xx outages as server_error', () => { diff --git a/src/shared/github-api-availability.ts b/src/shared/github/api-availability.ts similarity index 100% rename from src/shared/github-api-availability.ts rename to src/shared/github/api-availability.ts diff --git a/src/shared/github-auth-types.ts b/src/shared/github/auth-types.ts similarity index 100% rename from src/shared/github-auth-types.ts rename to src/shared/github/auth-types.ts diff --git a/src/shared/github-check-details-deadline.ts b/src/shared/github/check-details-deadline.ts similarity index 100% rename from src/shared/github-check-details-deadline.ts rename to src/shared/github/check-details-deadline.ts diff --git a/src/shared/github/check-types.ts b/src/shared/github/check-types.ts new file mode 100644 index 00000000000..6a81094723c --- /dev/null +++ b/src/shared/github/check-types.ts @@ -0,0 +1,70 @@ +export type PRCheckDetail = { + name: string + status: 'queued' | 'in_progress' | 'completed' + conclusion: + | 'success' + | 'failure' + | 'cancelled' + | 'timed_out' + | 'neutral' + | 'skipped' + | 'pending' + // Why: a check suite needing manual action (e.g. a workflow awaiting "Approve + // and run") has no check run and is absent from statusCheckRollup, yet blocks + // auto-merge (GitHub returns "unstable status"). Surface it as its own state. + | 'action_required' + | null + url: string | null + checkRunId?: number + workflowRunId?: number + // Why: the GitLab job trace API is addressed by numeric job id only, so the + // Checks panel cannot load a job log without carrying it on the row. + gitlabJobId?: number +} + +export type PRCheckAnnotation = { + path: string | null + startLine: number | null + endLine: number | null + annotationLevel: string | null + title: string | null + message: string + rawDetails: string | null +} + +export type PRCheckStep = { + name: string + status: string | null + conclusion: string | null + startedAt: string | null + completedAt: string | null +} + +export type PRCheckJob = { + id: number | null + name: string + status: string | null + conclusion: string | null + startedAt: string | null + completedAt: string | null + url: string | null + logTail: string | null + steps: PRCheckStep[] +} + +export type PRCheckRunDetails = { + name: string + status: PRCheckDetail['status'] | (string & {}) | null + conclusion: PRCheckDetail['conclusion'] | (string & {}) | null + url: string | null + detailsUrl: string | null + startedAt: string | null + completedAt: string | null + title: string | null + summary: string | null + text: string | null + annotations: PRCheckAnnotation[] + jobs: PRCheckJob[] +} + +export type GitHubRerunPRChecksResult = { ok: true; count: number } | { ok: false; error: string } diff --git a/src/shared/github/comment-types.ts b/src/shared/github/comment-types.ts new file mode 100644 index 00000000000..6f529ded0ef --- /dev/null +++ b/src/shared/github/comment-types.ts @@ -0,0 +1,91 @@ +import type { GitHubRepositoryIdentity } from './pull-request-types' + +export type GitHubReactionContent = + | '+1' + | '-1' + | 'laugh' + | 'confused' + | 'heart' + | 'hooray' + | 'rocket' + | 'eyes' + +export type GitHubReaction = { + content: GitHubReactionContent + count: number + viewerHasReacted?: boolean +} + +export type PRComment = { + id: number + author: string + authorAvatarUrl: string + body: string + createdAt: string + url: string + reactions?: GitHubReaction[] + /** GraphQL node ID for GitHub comments that support reaction mutations. */ + reactionSubjectId?: string + /** File path for inline review comments (absent for top-level conversation comments). */ + path?: string + /** GraphQL node ID of the review thread — present only for inline review comments. + * Used to resolve/unresolve the thread via GitHub's GraphQL API. */ + threadId?: string + /** Whether the review thread has been resolved. Only meaningful when threadId is set. */ + isResolved?: boolean + /** True when GitHub no longer maps the thread to the current diff. */ + isOutdated?: boolean + /** End line of the review annotation (1-based). */ + line?: number + /** Start line of the review annotation range (1-based). Absent for single-line comments. */ + startLine?: number + /** True when GitHub identifies the author as a bot (REST `user.type === 'Bot'` or + * GraphQL `__typename === 'Bot'`). Preferred over login-string heuristics because + * third-party review bots (e.g. qodo-ai-reviewer, coderabbitai) don't follow a + * predictable naming convention. Absent when the data source can't report it + * (non-GitHub fallbacks via `gh pr view`). */ + isBot?: boolean +} + +export type GitHubIssueTimelineTarget = { + type: 'issue' | 'pr' + number: number + title: string + url: string + repository?: string +} + +export type GitHubIssueTimelineItem = { + id: string + event: + | 'assigned' + | 'unassigned' + | 'mentioned' + | 'cross-referenced' + | 'closed' + | 'reopened' + | 'moved_columns_in_project' + actor: string + actorAvatarUrl: string + createdAt: string + assignee?: string + source?: GitHubIssueTimelineTarget + closer?: GitHubIssueTimelineTarget + stateReason?: string | null + previousColumnName?: string | null + columnName?: string | null + projectName?: string | null +} + +export type GitHubCommentResult = { ok: true; comment: PRComment } | { ok: false; error: string } + +export type GitHubPRReviewCommentInput = { + repoPath: string + prRepo?: GitHubRepositoryIdentity | null + prNumber: number + commitId: string + path: string + line: number + startLine?: number + body: string +} diff --git a/src/shared/github-links.ts b/src/shared/github/links.ts similarity index 100% rename from src/shared/github-links.ts rename to src/shared/github/links.ts diff --git a/src/shared/github-project-group-sort.ts b/src/shared/github/project-group-sort.ts similarity index 99% rename from src/shared/github-project-group-sort.ts rename to src/shared/github/project-group-sort.ts index 923b8aacf49..707fbfb35e4 100644 --- a/src/shared/github-project-group-sort.ts +++ b/src/shared/github/project-group-sort.ts @@ -6,7 +6,7 @@ import type { GitHubProjectRow, GitHubProjectSort, GitHubProjectTable -} from './github-project-types' +} from './project-types' export type ProjectGroup = { /** Stable key used for React reconciliation. */ diff --git a/src/shared/github-project-identity.test.ts b/src/shared/github/project-identity.test.ts similarity index 90% rename from src/shared/github-project-identity.test.ts rename to src/shared/github/project-identity.test.ts index 40c96a316dd..cc340c2be65 100644 --- a/src/shared/github-project-identity.test.ts +++ b/src/shared/github/project-identity.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { githubProjectHost, githubProjectIdentityKey } from './github-project-identity' +import { githubProjectHost, githubProjectIdentityKey } from './project-identity' describe('GitHub project identity', () => { it('keeps legacy github.com keys while isolating Enterprise hosts and ports', () => { diff --git a/src/shared/github-project-identity.ts b/src/shared/github/project-identity.ts similarity index 92% rename from src/shared/github-project-identity.ts rename to src/shared/github/project-identity.ts index dbe7c478504..5de8dcf5517 100644 --- a/src/shared/github-project-identity.ts +++ b/src/shared/github/project-identity.ts @@ -1,4 +1,4 @@ -import { isDefaultGitHubHost } from './github-repository-identity-key' +import { isDefaultGitHubHost } from './repository-identity-key' export type GitHubProjectIdentity = { owner: string diff --git a/src/shared/github-project-ref-input.test.ts b/src/shared/github/project-ref-input.test.ts similarity index 97% rename from src/shared/github-project-ref-input.test.ts rename to src/shared/github/project-ref-input.test.ts index cd0ece5f5e8..08f71c3e44a 100644 --- a/src/shared/github-project-ref-input.test.ts +++ b/src/shared/github/project-ref-input.test.ts @@ -5,7 +5,7 @@ import { getGitHubProjectRefInputByteLength, hasBoundedGitHubProjectRefInputText, isGitHubProjectRefInputTooLarge -} from './github-project-ref-input' +} from './project-ref-input' describe('GitHub project reference input limits', () => { it('allows normal project references below the byte budget', () => { diff --git a/src/shared/github-project-ref-input.ts b/src/shared/github/project-ref-input.ts similarity index 95% rename from src/shared/github-project-ref-input.ts rename to src/shared/github/project-ref-input.ts index 5b0ee6c8d7e..8295617a0d1 100644 --- a/src/shared/github-project-ref-input.ts +++ b/src/shared/github/project-ref-input.ts @@ -1,4 +1,4 @@ -import { getClipboardTextByteLength, isClipboardTextByteLengthOverLimit } from './clipboard-text' +import { getClipboardTextByteLength, isClipboardTextByteLengthOverLimit } from '../clipboard-text' export const GITHUB_PROJECT_REF_INPUT_MAX_BYTES = 2 * 1024 export const GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR = 'Project reference is too large to resolve.' diff --git a/src/shared/github-project-types.ts b/src/shared/github/project-types.ts similarity index 99% rename from src/shared/github-project-types.ts rename to src/shared/github/project-types.ts index 9175c4b27b1..6560e410474 100644 --- a/src/shared/github-project-types.ts +++ b/src/shared/github/project-types.ts @@ -9,7 +9,7 @@ import type { GitHubIssueUpdate, GitHubWorkItemDetails, PRComment -} from './types' +} from '../types' export type GitHubProjectViewLayout = 'TABLE_LAYOUT' | 'BOARD_LAYOUT' | 'ROADMAP_LAYOUT' export type GitHubProjectOwnerType = 'organization' | 'user' diff --git a/src/shared/github-pr-auto-merge-availability.test.ts b/src/shared/github/pull-request-auto-merge-availability.test.ts similarity index 98% rename from src/shared/github-pr-auto-merge-availability.test.ts rename to src/shared/github/pull-request-auto-merge-availability.test.ts index 2e22a3bd3f2..8cfe706476e 100644 --- a/src/shared/github-pr-auto-merge-availability.test.ts +++ b/src/shared/github/pull-request-auto-merge-availability.test.ts @@ -3,7 +3,7 @@ import { canEnableGitHubPRAutoMerge, canShowGitHubPRAutoMergeControl, type GitHubPRAutoMergeAvailabilityInput -} from './github-pr-auto-merge-availability' +} from './pull-request-auto-merge-availability' function pr( overrides: Partial = {} diff --git a/src/shared/github-pr-auto-merge-availability.ts b/src/shared/github/pull-request-auto-merge-availability.ts similarity index 99% rename from src/shared/github-pr-auto-merge-availability.ts rename to src/shared/github/pull-request-auto-merge-availability.ts index 67b6e25ad72..90500d1618e 100644 --- a/src/shared/github-pr-auto-merge-availability.ts +++ b/src/shared/github/pull-request-auto-merge-availability.ts @@ -1,4 +1,4 @@ -import type { PRMergeableState, PRReviewDecision, PRState } from './types' +import type { PRMergeableState, PRReviewDecision, PRState } from '../types' export type GitHubPRAutoMergeAvailabilityInput = { state: PRState | 'open' | 'closed' | 'merged' | 'draft' diff --git a/src/shared/github-pr-for-branch-outcome.test.ts b/src/shared/github/pull-request-for-branch-outcome.test.ts similarity index 88% rename from src/shared/github-pr-for-branch-outcome.test.ts rename to src/shared/github/pull-request-for-branch-outcome.test.ts index 3eaec4a99c7..2eff1b7ae33 100644 --- a/src/shared/github-pr-for-branch-outcome.test.ts +++ b/src/shared/github/pull-request-for-branch-outcome.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { PRInfo, PRRefreshOutcome } from './types' -import { normalizeGitHubPRForBranchOutcome } from './github-pr-for-branch-outcome' +import type { PRInfo, PRRefreshOutcome } from '../types' +import { normalizeGitHubPRForBranchOutcome } from './pull-request-for-branch-outcome' const PR = { number: 42, diff --git a/src/shared/github-pr-for-branch-outcome.ts b/src/shared/github/pull-request-for-branch-outcome.ts similarity index 89% rename from src/shared/github-pr-for-branch-outcome.ts rename to src/shared/github/pull-request-for-branch-outcome.ts index 90b5661f884..b6cdc180609 100644 --- a/src/shared/github-pr-for-branch-outcome.ts +++ b/src/shared/github/pull-request-for-branch-outcome.ts @@ -1,4 +1,4 @@ -import type { PRInfo, PRRefreshOutcome } from './types' +import type { PRInfo, PRRefreshOutcome } from '../types' export type GitHubPRForBranchResponse = PRRefreshOutcome | PRInfo | null diff --git a/src/shared/github-pr-merge-methods.test.ts b/src/shared/github/pull-request-merge-methods.test.ts similarity index 97% rename from src/shared/github-pr-merge-methods.test.ts rename to src/shared/github/pull-request-merge-methods.test.ts index f4705d6b00f..550bb00b0a5 100644 --- a/src/shared/github-pr-merge-methods.test.ts +++ b/src/shared/github/pull-request-merge-methods.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { normalizeGitHubPRMergeMethodSettings, resolveGitHubPRMergeMethods -} from './github-pr-merge-methods' +} from './pull-request-merge-methods' describe('GitHub PR merge methods', () => { it('keeps the historical squash-first fallback when repository metadata is missing', () => { diff --git a/src/shared/github-pr-merge-methods.ts b/src/shared/github/pull-request-merge-methods.ts similarity index 99% rename from src/shared/github-pr-merge-methods.ts rename to src/shared/github/pull-request-merge-methods.ts index a9372b461f6..2072706a41b 100644 --- a/src/shared/github-pr-merge-methods.ts +++ b/src/shared/github/pull-request-merge-methods.ts @@ -1,4 +1,4 @@ -import type { GitHubPRMergeMethod, GitHubPRMergeMethodSettings } from './types' +import type { GitHubPRMergeMethod, GitHubPRMergeMethodSettings } from '../types' export const GITHUB_PR_MERGE_METHODS = ['squash', 'merge', 'rebase'] as const diff --git a/src/shared/github/pull-request-refresh-types.ts b/src/shared/github/pull-request-refresh-types.ts new file mode 100644 index 00000000000..261773b327f --- /dev/null +++ b/src/shared/github/pull-request-refresh-types.ts @@ -0,0 +1,121 @@ +import type { RepoKind } from '../repo-types' +import type { CheckStatus, PRInfo, PRMergeableState, PRState } from './pull-request-types' + +/** + * Discriminates a classified GitHub PR-refresh failure. The renderer maps these + * to stable, non-destructive empty-state copy; a `hard` subset (auth, permission, + * repo_unavailable, gh_unavailable) means the existing-review lookup is currently + * impossible and must hide the Create composer. + */ +export type PRRefreshErrorType = + | 'rate_limited' + | 'auth' + | 'network' + | 'permission' + | 'repo_unavailable' + | 'gh_unavailable' + | 'server_error' + | 'unknown' + +// Backward-compatible name used by outage-copy consumers added on main. +export type PRRefreshUpstreamErrorType = PRRefreshErrorType + +export type PRRefreshOutcome = + | { kind: 'found'; pr: PRInfo; fetchedAt: number } + | { kind: 'no-pr'; fetchedAt: number } + | { + kind: 'upstream-error' + errorType: PRRefreshErrorType + message: string + fetchedAt: number + // Unified retry schedule (see docs/reference/pr-panel-refresh-guidance.md). + // `nextAutoRetryAt`: earliest time main expects to auto-retry this key. + // `retryDisabledUntil`: earliest time a manual Retry / refreshPRNow is + // accepted (rate-limit gates only, never ordinary network/auth backoff). + nextAutoRetryAt?: number + retryDisabledUntil?: number + } + +export type GitHubPRRefreshReason = 'visible' | 'active' | 'post-push' | 'manual' | 'swr' + +export type GitHubPRRefreshEnqueueResult = + | { kind: 'queued' } + | { kind: 'skipped'; skippedReason: 'validation-denied' | 'validation-backoff' } + | { kind: 'fallback' } + +export type GitHubPRRefreshAlias = { + cacheKey: string + repoId?: string + repoPath: string + branch: string + worktreeId?: string + connectionId?: string | null + executionHostId?: string | null + linkedPRNumber?: number | null + fallbackPRNumber?: number | null + fallbackPRSource?: 'explicit' | 'pr-cache' | 'hosted-review' | null + // Why: request-time worktree HEAD. Merged branch-matched PRs are only visible + // for heads that belong to the PR, and refresh consumers need this snapshot to + // clear a durable linked PR once main confirms the head diverged. + currentHeadOid?: string | null +} + +export type GitHubPRRefreshCandidate = GitHubPRRefreshAlias & { + repoKind: RepoKind + repoId: string + isBare?: boolean + isArchived?: boolean + connectionId?: string | null + executionHostId?: string | null + connectionState?: 'connected' | 'disconnected' | 'unknown' + cachedFetchedAt?: number | null + cachedHasPR?: boolean | null + cachedPRState?: PRState | null + cachedChecksStatus?: CheckStatus | null + cachedMergeable?: PRMergeableState | null + cachedMergeStateStatus?: string | null + localGitOptions?: { wslDistro?: string } +} + +export type GitHubPRRefreshSkippedReason = + | 'fresh' + | 'not-git' + | 'bare' + | 'archived' + | 'disconnected' + | 'remote' + | 'rate-limit' + | 'capacity' + +type GitHubPRRefreshEventBase = { + sequence: number + reason: GitHubPRRefreshReason + aliases: GitHubPRRefreshAlias[] + requestStartedAt?: number +} + +export type GitHubPRRefreshEvent = + | (GitHubPRRefreshEventBase & { + outcome: PRRefreshOutcome + status?: never + pausedUntil?: never + skippedReason?: never + }) + | (GitHubPRRefreshEventBase & { + status: 'queued' | 'in-flight' + outcome?: never + pausedUntil?: never + skippedReason?: never + }) + | (GitHubPRRefreshEventBase & { + status: 'paused' + pausedUntil: number + skippedReason: 'rate-limit' + outcome?: never + }) + | (GitHubPRRefreshEventBase & { + status: 'skipped' + skippedReason: GitHubPRRefreshSkippedReason + outcome?: never + pausedUntil?: never + }) diff --git a/src/shared/github/pull-request-types.ts b/src/shared/github/pull-request-types.ts new file mode 100644 index 00000000000..6024d702bf3 --- /dev/null +++ b/src/shared/github/pull-request-types.ts @@ -0,0 +1,157 @@ +export type PRState = 'open' | 'closed' | 'merged' | 'draft' +export type IssueState = 'open' | 'closed' +export type CheckStatus = 'pending' | 'success' | 'failure' | 'neutral' + +export type PRMergeableState = 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN' +export type PRReviewDecision = 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED' + +export type PRConflictSummary = { + baseRef: string + baseCommit: string + commitsBehind: number + files: string[] + localMergeState?: 'clean' +} + +// Why: host must survive renderer/RPC boundaries so Enterprise review actions +// cannot silently fall back to a same-named repository on github.com. +export type GitHubRepositoryIdentity = { owner: string; repo: string; host?: string } + +export type GitHubPRMergeMethod = 'merge' | 'squash' | 'rebase' + +export type GitHubPRMergeMethodSettings = { + defaultMethod: GitHubPRMergeMethod + allowedMethods: Record +} + +export type GitHubPRStackEntry = { + position: number + number: number + title: string + url: string + updatedAt?: string + state: PRState + checksStatus: CheckStatus + mergeable: PRMergeableState + reviewDecision?: PRReviewDecision | null + mergeStateStatus?: string | null + headRefName?: string + headSha?: string +} + +export type GitHubPRStack = { + number: number + position: number + size: number + baseRefName: string + baseSha?: string + entries?: GitHubPRStackEntry[] +} + +export type PRInfo = { + number: number + title: string + state: PRState + url: string + checksStatus: CheckStatus + updatedAt: string + mergeable: PRMergeableState + reviewDecision?: PRReviewDecision | null + autoMergeEnabled?: boolean + autoMergeAllowed?: boolean | null + mergeQueueRequired?: boolean | null + mergeMethodSettings?: GitHubPRMergeMethodSettings + mergeStateStatus?: string | null + /** GitHub-registered stack metadata. Absent for ordinary dependent PR chains. */ + stack?: GitHubPRStack + // Why: check-runs are keyed by the PR head commit, not the mutable branch name. + // Keeping the head SHA in cached PR metadata lets the checks panel poll the + // correct commit without re-querying GitHub or guessing from local branch refs. + headSha?: string + // Why: a merged branch-matched PR stays visible when the worktree head is one + // of the PR's own commits (behind update-branch/web commits). Cache staleness + // checks must honor that confirmation without re-querying GitHub. + confirmedContainedHeadOid?: string + // Why: the worktree HEAD OID this merged linked PR was confirmed to have + // diverged from (a definite not-contained probe). Head-scoped, not a bare + // boolean, so a PR-number-coalesced refresh broadcast cannot clear a sibling + // worktree whose own head is still on the PR's line of work. Clearing a + // durable linked PR requires this positive signal for that exact head, never + // the mere absence of a containment confirmation after a rate-limit/error. + headDivergedFromMergedPRAtOid?: string + /** Target branch name for PR-created worktree compare-base repair. */ + baseRefName?: string + /** PR head branch name. Lets linked-PR consumers detect that the worktree + * has switched to a different branch and the durable link is stale. */ + headRefName?: string + prRepo?: GitHubRepositoryIdentity + headRepo?: GitHubRepositoryIdentity + conflictSummary?: PRConflictSummary +} + +export type IssueInfo = { + number: number + title: string + state: IssueState + url: string + labels: string[] + /** Full markdown body when fetched through the single-issue endpoint. */ + description?: string +} + +export type GitHubViewer = { + login: string + email: string | null +} + +export type GitHubAssignableUser = { + login: string + name: string | null + avatarUrl: string +} + +export type ProviderCheckSummary = { + state: 'success' | 'failure' | 'pending' | 'neutral' | 'none' + total: number + passed: number + failed: number + pending: number + neutral: number +} + +export type GitHubPRReviewSummary = { + login: string + state?: string | null + avatarUrl?: string | null +} + +export type GitHubPRFileViewedState = 'DISMISSED' | 'VIEWED' | 'UNVIEWED' + +export type GitHubPRFile = { + path: string + oldPath?: string + status: 'added' | 'modified' | 'removed' | 'renamed' | 'copied' | 'changed' | 'unchanged' + additions: number + deletions: number + /** GitHub marks files above its diff size limit as binary-like; we skip content fetches for these. */ + isBinary: boolean + /** Modified-side line numbers that GitHub accepts for inline review comments. */ + reviewCommentLineNumbers?: number[] + /** GitHub's per-viewer review state. DISMISSED means new changes arrived after the file was viewed. */ + viewerViewedState?: GitHubPRFileViewedState +} + +export type GitHubPRFileContents = { + original: string + modified: string + originalIsBinary: boolean + modifiedIsBinary: boolean + originalTooLarge?: boolean + modifiedTooLarge?: boolean +} + +// Why: declared here as a shared shape so IPC return envelopes and renderer +// slices can reference the same structural type without importing from main. +// Aliased as `OwnerRepo` in `src/main/github/gh-utils.ts` so main call sites +// can continue using the short local name. +export type GitHubOwnerRepo = GitHubRepositoryIdentity diff --git a/src/shared/github/rate-limit-types.ts b/src/shared/github/rate-limit-types.ts new file mode 100644 index 00000000000..45ae8335769 --- /dev/null +++ b/src/shared/github/rate-limit-types.ts @@ -0,0 +1,26 @@ +/** + * GitHub API rate-limit buckets surfaced in the TaskPage header so users can + * see remaining budget before they hit the wall. `core` = REST (5000/hr), + * `search` = Search API (30/min — hit by countWorkItems), `graphql` = + * GraphQL (5000 points/hr — hit by project-view + discovery). All three are + * the buckets this app actually stresses; other buckets (e.g. code_search) + * are not surfaced because we don't touch them. + */ +export type GitHubRateLimitBucket = { + remaining: number + limit: number + /** Unix epoch seconds when the window resets. */ + resetAt: number +} + +export type GitHubRateLimitSnapshot = { + core: GitHubRateLimitBucket + search: GitHubRateLimitBucket + graphql: GitHubRateLimitBucket + /** Unix epoch ms the snapshot was produced (for "fetched Xs ago" copy). */ + fetchedAt: number +} + +export type GetRateLimitResult = + | { ok: true; snapshot: GitHubRateLimitSnapshot } + | { ok: false; error: string } diff --git a/src/shared/github-repository-identity-key.test.ts b/src/shared/github/repository-identity-key.test.ts similarity index 84% rename from src/shared/github-repository-identity-key.test.ts rename to src/shared/github/repository-identity-key.test.ts index ef51d276889..9e2726ffb89 100644 --- a/src/shared/github-repository-identity-key.test.ts +++ b/src/shared/github/repository-identity-key.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { githubRepoIdentityKey, isDefaultGitHubHost } from './github-repository-identity-key' +import { githubRepoIdentityKey, isDefaultGitHubHost } from './repository-identity-key' describe('GitHub repository identity', () => { it('normalizes case and harmless surrounding whitespace without merging GHES hosts', () => { diff --git a/src/shared/github-repository-identity-key.ts b/src/shared/github/repository-identity-key.ts similarity index 100% rename from src/shared/github-repository-identity-key.ts rename to src/shared/github/repository-identity-key.ts diff --git a/src/shared/github/work-item-types.ts b/src/shared/github/work-item-types.ts new file mode 100644 index 00000000000..437a1af20f0 --- /dev/null +++ b/src/shared/github/work-item-types.ts @@ -0,0 +1,129 @@ +import type { ClassifiedError } from '../classified-error' +import type { PRCheckDetail } from './check-types' +import type { GitHubIssueTimelineItem, PRComment } from './comment-types' +import type { + GitHubAssignableUser, + GitHubOwnerRepo, + GitHubPRFile, + GitHubPRMergeMethodSettings, + GitHubPRReviewSummary, + GitHubRepositoryIdentity, + PRMergeableState, + PRReviewDecision, + ProviderCheckSummary +} from './pull-request-types' + +export type GitHubWorkItem = { + id: string + type: 'issue' | 'pr' + number: number + title: string + state: 'open' | 'closed' | 'merged' | 'draft' + url: string + labels: string[] + updatedAt: string + author: string | null + // Why: GHE user logins don't exist on github.com, so the github.com/{login}.png + // fallback 404s. Carry the API-provided avatar_url so github.com + Enterprise + // both render; absent on the gh-pr-view path (gh omits avatar), then the UI + // falls back to the login URL and finally an initials placeholder. See #8784. + authorAvatarUrl?: string + branchName?: string + baseRefName?: string + // Why: PR checks are keyed by head commit; carrying this lets task rows use + // the cached check-runs endpoint instead of one `gh pr checks` call per row. + headSha?: string + prRepo?: GitHubRepositoryIdentity + additions?: number + deletions?: number + changedFiles?: number + reviewDecision?: PRReviewDecision | null + reviewRequests?: GitHubAssignableUser[] + latestReviews?: GitHubPRReviewSummary[] + assignees?: GitHubAssignableUser[] + checksSummary?: ProviderCheckSummary + mergeable?: PRMergeableState + autoMergeEnabled?: boolean + autoMergeAllowed?: boolean | null + mergeQueueRequired?: boolean | null + mergeMethodSettings?: GitHubPRMergeMethodSettings + mergeStateStatus?: string | null + maintainerCanModify?: boolean + // Why: true when a PR's head lives on a fork (headRepositoryOwner !== selected repo owner). + // The Start-from picker passes this to resolvePrBase so fork heads use + // refs/pull//head for creation and a separate PR-head push target. + isCrossRepository?: boolean + /** Why: required because the cross-repo view merges items from every selected + * repo — the table row's repo pill and the "open in browser" fallback need + * to know which repo an item came from. Stamped by the renderer fetcher + * (`fetchWorkItems`) and by optimistic stubs on the new-issue path. */ + repoId: string +} + +export type GitHubWorkItemDetails = { + // Why: main-process doesn't know Orca's Repo.id, so this inner item omits + // repoId. The renderer stamps it when routing the details through the store. + item: Omit + body: string + comments: PRComment[] + /** Issue-only provider activity such as assignment, references, project moves, and state changes. */ + timelineItems?: GitHubIssueTimelineItem[] + /** Only set for PRs. Head/base SHAs used by the Files tab to fetch per-file content. */ + headSha?: string + baseSha?: string + /** GraphQL node ID required by GitHub's file-viewed mutations. Only set for PRs. */ + pullRequestId?: string + checks?: PRCheckDetail[] + files?: GitHubPRFile[] + /** Only set for PRs. True when the file fetch failed (rate limit, auth, + * unresolved remote) rather than the PR genuinely having no changed files. */ + filesUnavailable?: boolean + participants?: GitHubAssignableUser[] + /** Logins of current assignees. Only set for issues. */ + assignees?: string[] +} + +/** + * Envelope for `gh:listWorkItems`. Carries resolved issue/PR sources so the + * renderer can render the "Issues from owner/repo" indicator without an + * extra IPC round-trip, and per-source classified errors so the UI can show + * a retryable banner when (e.g.) a private upstream 403s. + * + * Why piggyback instead of adding `gh:resolveWorkItemSources`: the renderer + * already round-trips this endpoint on every Tasks refresh, and the source + * data is a 2-field-per-side metadata add — cheaper than another IPC call. + * + * Invariant: `items` always contains whatever succeeded; `errors.issues` indicates + * the issues-side fetch failed, but any PR-side items that succeeded are still + * present in `items`. Consumers should render `items` alongside the error banner. + */ +export type ListWorkItemsResult = { + items: T[] + sources: { + issues: GitHubOwnerRepo | null + prs: GitHubOwnerRepo | null + /** Raw `origin` remote resolved for this repo, independent of the + * user's preference. Required-nullable so the renderer can compare raw + * remote candidates without inferring origin from the effective PR + * source. */ + originCandidate: GitHubOwnerRepo | null + /** Raw `upstream` remote resolved for this repo, independent of the + * user's preference. Present so the renderer's issue-source selector + * can always decide whether to render (upstream exists & differs from + * origin) and show both slugs in its tooltips, even when the user has + * picked 'origin' and `sources.issues` has collapsed onto origin. */ + upstreamCandidate: GitHubOwnerRepo | null + } + errors?: { + issues?: ClassifiedError + prs?: ClassifiedError + } + /** True when the user's per-repo preference was `'upstream'` but no upstream + * remote is configured, so the resolver fell back to origin. Renderer uses + * this to surface a one-time-per-session toast. Omitted when absent so + * existing consumers and test fixtures don't care about it. + * Typed as `?: true` (not `?: boolean`) to encode the invariant "present + * iff fell-back" — an explicit `false` write would be a bug, so make it a + * compile error. */ + issueSourceFellBack?: true +} diff --git a/src/shared/github-work-items-query-bounds.test.ts b/src/shared/github/work-items-query-bounds.test.ts similarity index 95% rename from src/shared/github-work-items-query-bounds.test.ts rename to src/shared/github/work-items-query-bounds.test.ts index 54839d873c7..9b8bea74b4e 100644 --- a/src/shared/github-work-items-query-bounds.test.ts +++ b/src/shared/github/work-items-query-bounds.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES, isGitHubWorkItemsQueryTooLarge -} from './github-work-items-query-bounds' +} from './work-items-query-bounds' describe('shared GitHub work item query bounds', () => { it('allows normal GitHub search syntax', () => { diff --git a/src/shared/github-work-items-query-bounds.ts b/src/shared/github/work-items-query-bounds.ts similarity index 87% rename from src/shared/github-work-items-query-bounds.ts rename to src/shared/github/work-items-query-bounds.ts index 85616688a47..be09c66e624 100644 --- a/src/shared/github-work-items-query-bounds.ts +++ b/src/shared/github/work-items-query-bounds.ts @@ -1,4 +1,4 @@ -import { isClipboardTextByteLengthOverLimit } from './clipboard-text' +import { isClipboardTextByteLengthOverLimit } from '../clipboard-text' export const GITHUB_WORK_ITEMS_QUERY_MAX_BYTES = 8 * 1024 diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts new file mode 100644 index 00000000000..111f22ee8e2 --- /dev/null +++ b/src/shared/global-settings-types.ts @@ -0,0 +1,470 @@ +import type { ExecutionHostId } from './execution-host' +import type { GitHubProjectSettings } from './github/project-types' +import type { VoiceSettings } from './speech-types' +import type { GitLabProjectSettings } from './gitlab-types' +import type { TaskProvider } from './task-providers' +import type { KeybindingOverrides, TerminalShortcutPolicy } from './keybindings' +import type { AppIconId } from './app-icon' +import type { SourceControlAiSettings } from './source-control-ai-types' +import type { ClaudeAgentTeamsMode } from './claude-agent-teams-tmux-compat' +import type { TerminalCustomTheme } from './terminal-custom-themes' +import type { UiLanguage } from './ui-language' +import type { GlobalWindowsRuntimeDefault } from './project-execution-runtime' +import type { PersistedNativeChatSessionOptions } from './native-chat-session-options' +import type { ComputerAwakeMode } from './computer-awake-mode' +import type { CommitMessageAiSettings } from './commit-message-ai-types' +import type { HostSettingOverrides } from './host-setting-overrides' +import type { + ClaudeManagedAccount, + ClaudeManagedAccountRuntimeSelection, + CodexManagedAccount, + CodexManagedAccountRuntimeSelection +} from './managed-account-types' +import type { NotificationSettings } from './notification-settings-types' +import type { CtrlTabOrderMode } from './tab-types' +import type { TerminalColorOverrides } from './terminal-color-overrides' +import type { TerminalQuickCommand } from './terminal-quick-command-types' +import type { TuiAgent } from './tui-agent' +import type { + AgentDashboardMode, + BranchPrefixStrategy, + FloatingTerminalTriggerLocation, + LeftSidebarAppearanceMode, + OpenInApplication, + SourceControlGroupOrder, + SourceControlViewMode, + TaskViewPresetId +} from './ui-chrome-types' +import type { SetupScriptLaunchMode } from './worktree/launch-types' +import type { + CustomWorktreeVisibilitySource, + ExternalWorktreeVisibility, + WorktreeVisibilitySourcePreferences +} from './repo-types' + +export type WorktreeVisibilityDefaults = { + /** Default for worktrees outside a recognized source. */ + external?: ExternalWorktreeVisibility + /** Host-owned roots applied to every repository on that host. */ + customSources?: CustomWorktreeVisibilitySource[] + /** Defaults for built-in and host-owned custom sources. */ + sourcePreferences?: WorktreeVisibilitySourcePreferences +} + +export type GlobalSettings = { + workspaceDir: string + /** Host-owned defaults used when a repository has no explicit visibility override. */ + worktreeVisibilityDefaults?: WorktreeVisibilityDefaults + /** Per-host overrides keyed by ExecutionHostId. Effective value for a + * host-varying setting is `host override ?? client default`. */ + hostSettingOverrides?: Partial> + nestWorkspaces: boolean + workspaceDirHistory?: OrcaWorkspaceLayout[] + refreshLocalBaseRefOnWorktreeCreate: boolean + /** Set once the user dismisses the "local main is behind" suggestion toast, so + * the nudge to enable refreshLocalBaseRefOnWorktreeCreate never shows again. */ + localBaseRefSuggestionDismissed: boolean + /** When enabled, Orca renames a workspace's auto-generated creature branch to + * a short name derived from the first prompt once work begins. Users can + * still turn this off from global Git settings. */ + autoRenameBranchFromWork: boolean + /** One-shot migration guard for the default-on rollout. Existing profiles + * without the guard are flipped on once; later explicit opt-outs stick. */ + autoRenameBranchFromWorkDefaultedOn?: boolean + branchPrefix: BranchPrefixStrategy + branchPrefixCustom: string + theme: 'system' | 'dark' | 'light' + /** Controls the left sidebar surface without changing terminal brightness. */ + leftSidebarAppearanceMode: LeftSidebarAppearanceMode + leftSidebarTintColor?: string + leftSidebarTintOpacity?: number + uiLanguage: UiLanguage + appIcon: AppIconId + appFontFamily: string + editorAutoSave: boolean + editorAutoSaveDelayMs: number + editorMinimapEnabled: boolean + /** Opt-in code-editor font; empty (the default) keeps following `terminalFontFamily`. */ + editorFontFamily?: string + /** Defaults on for profiles saved before file-editor wrapping became configurable. */ + editorWordWrap?: boolean + /** Persisted opt-out for browser spellcheck noise in rich Markdown editing surfaces. */ + richMarkdownSpellcheckEnabled?: boolean + /** Whether local markdown review note controls and the review panel are shown. */ + markdownReviewToolsEnabled: boolean + /** Why: mirrors terminal selection-paste muscle memory without mutating the + * normal system clipboard; Linux and macOS enable it by default, Windows + * leaves middle-click semantics unchanged unless the user opts in. */ + primarySelectionMiddleClickPaste?: boolean + /** One-shot migration guard for turning the Linux default on for profiles + * that persisted the earlier off-by-default value. */ + primarySelectionMiddleClickPasteDefaultedForLinux?: boolean + /** One-shot migration guard for widening the terminal-style default to + * Linux/macOS while preserving later explicit opt-outs. */ + primarySelectionMiddleClickPasteDefaultedForTerminalDefaults?: boolean + terminalFontSize: number + terminalFontFamily: string + terminalFontWeight: number + terminalFontWeightBold: number + terminalLineHeight: number + terminalScrollSensitivity: number + terminalFastScrollSensitivity: number + terminalTuiScrollSensitivity: number + /** One-shot migration guard for moving inherited TUI wheel reports from 3 to 1. */ + terminalTuiScrollSensitivityDefaultedToOne?: boolean + /** Terminal renderer policy. + * - 'auto': try xterm WebGL and fall back to DOM when unsupported or risky. + * - 'on': always try xterm WebGL. + * - 'off': keep terminal rendering on xterm's DOM renderer. */ + terminalGpuAcceleration: 'auto' | 'on' | 'off' + /** Whether to enable programming-ligatures rendering via + * `@xterm/addon-ligatures`. + * - `'auto'` (default): enabled only when the configured font is known to + * ship ligatures (Fira Code, JetBrains Mono, Cascadia Code, etc.). This + * keeps the out-of-the-box experience right for users who install a + * ligature font without touching settings. + * - `'on'` / `'off'`: explicit override. Never changes when the user + * switches fonts, so "off" always stays off. */ + terminalLigatures: 'auto' | 'on' | 'off' + terminalCursorStyle: 'bar' | 'block' | 'underline' + /** One-shot migration guard for moving inherited cursor defaults to block. */ + terminalCursorStyleDefaultedToBlock?: boolean + terminalCursorBlink: boolean + terminalThemeDark: string + terminalCustomThemes?: TerminalCustomTheme[] + terminalDividerColorDark: string + terminalUseSeparateLightTheme: boolean + terminalThemeLight: string + terminalDividerColorLight: string + terminalInactivePaneOpacity: number + terminalActivePaneOpacity: number + terminalPaneOpacityTransitionMs: number + terminalDividerThicknessPx: number + terminalBackgroundOpacity?: number + terminalColorOverrides?: TerminalColorOverrides + terminalPaddingX?: number + terminalPaddingY?: number + terminalMouseHideWhileTyping?: boolean + terminalWordSeparator?: string + terminalCursorOpacity?: number + terminalQuickCommands?: TerminalQuickCommand[] + windowBackgroundBlur?: boolean + /** Windows-only: close (X) hides to tray instead of quitting; the tray icon is always present regardless. */ + minimizeToTrayOnClose?: boolean + /** macOS: toggles the additive menu-bar entry (Orca survives last-window close); doesn't change Dock behavior. */ + showMenuBarIcon?: boolean + /** Windows convention: right-click pastes; macOS/Linux keep the context menu. */ + terminalRightClickToPaste: boolean + /** One-shot guard distinguishing the old global true default from a per-platform choice. */ + terminalRightClickToPasteDefaultedForPlatform?: boolean + /** Windows-only: COMSPEC always points to cmd.exe, so this explicit shell (default 'powershell.exe') overrides it. */ + terminalWindowsShell: string + /** Pins the WSL distro for terminals/agent scans instead of WSL's current global default. */ + terminalWindowsWslDistro?: string | null + /** Account/auth location; auto follows the global Windows runtime while host/wsl pin it. */ + localAccountRuntime: 'auto' | 'host' | 'wsl' + localAccountWslDistro?: string | null + /** One-shot guard for migrating the legacy host default to auto. */ + localAccountRuntimeDefaultedToAutoForAllUsers?: boolean + /** Independent from the terminal shell so users can inspect Windows vs WSL agent PATH state without changing it. */ + localAgentRuntime?: 'host' | 'wsl' + localAgentWslDistro?: string | null + /** Why: global is only the default policy; project-level runtime preference wins. */ + localWindowsRuntimeDefault: GlobalWindowsRuntimeDefault + /** 'auto' resolves to PowerShell 7+ when present, else falls back to inbox Windows PowerShell. */ + terminalWindowsPowerShellImplementation: 'auto' | 'powershell.exe' | 'pwsh.exe' + terminalFocusFollowsMouse: boolean + /** X11/gnome-terminal "copy on select": selecting text auto-copies to the clipboard; default off. */ + terminalClipboardOnSelect: boolean + /** Enables OSC 52 clipboard writes for TUIs (tmux/Zellij/nvim, incl. over SSH); default on. Clipboard *queries* stay blocked and payload size is capped, so this is write-only exposure. */ + terminalAllowOsc52Clipboard: boolean + /** One-shot stamp: profiles saved under the old off default get flipped on once, after which an explicit opt-out sticks. */ + terminalAllowOsc52ClipboardDefaultedOnForAllUsers?: boolean + /** Experimental Claude Agent Teams; native panes use a tmux-compatible shim so teammate output stays on the normal PTY path. */ + claudeAgentTeamsMode?: ClaudeAgentTeamsMode + /** Where the repo setup script runs on workspace create; defaults to a background "Setup" tab to keep the main terminal usable. */ + setupScriptLaunchMode: SetupScriptLaunchMode + terminalScrollbackRows: number + /** Optional app-level proxy for Electron networking and local PTYs; empty preserves system/inherited proxy env. */ + httpProxyUrl?: string + /** Optional semicolon/comma/newline-separated bypass rules for httpProxyUrl. */ + httpProxyBypassRules?: string + /** Why: corporate TLS-intercepting proxies can break HTTP/2 downloads; opt-in Chromium process-wide HTTP/1.1 switch. */ + electronHttp1CompatibilityMode?: boolean + /** Opt-in in-app browsing (isolated guest surface); default keeps links opening in the system browser. */ + openLinksInApp: boolean + /** Worktree-scoped localhost hostnames to distinguish tabs; opt-in since a non-localhost host can break apps binding cookies/sessions to localhost. */ + localhostWorktreeLabelsEnabled?: boolean + /** Tracks the one-time first-use prompt for terminal link routing (avoid silently changing where links open). */ + openLinksInAppPreferencePrompted: boolean + /** Opt-in: Shift+modifier click inverts openLinksInApp instead of always forcing the system browser. Off keeps the historical one-way escape hatch. */ + openLinksInAppModifierInverts?: boolean + /** Show terminal link actions on plain click; off restores modifier-click-only terminal links. */ + terminalLinkActionPopoverEnabled?: boolean + /** Opt-in: open new coding-agent tabs in native chat instead of the raw terminal; optional for legacy settings. */ + openAgentTabsInChatByDefault?: boolean + /** Experimental native chat surface for Claude/Codex sessions; off by default. */ + experimentalNativeChat?: boolean + /** Last explicit native-chat model + option selections; live panes need an applied/dispatched record before showing a value. */ + nativeChatSessionOptions?: PersistedNativeChatSessionOptions + /** Extra launcher rows for the worktree "Open in" submenu. VS Code is always shown first. */ + openInApplications?: OpenInApplication[] + /** Deprecated: migration/backward-compat only. Use PersistedUIState.rightSidebarOpen. */ + rightSidebarOpenByDefault: boolean + showGitIgnoredFiles?: boolean + /** Preferred Source Control changes layout. Per-user, not per-workspace. */ + sourceControlViewMode: SourceControlViewMode + /** Preferred Source Control group order. Per-user, not per-workspace. */ + sourceControlGroupOrder: SourceControlGroupOrder + /** Compare base defaults to the branch upstream instead of the repo default; affects only the compare/diff view, not the PR/rebase target. Per-user. */ + sourceControlCompareAgainstUpstream: boolean + /** Whether to show the Orca app name in the titlebar. */ + showTitlebarAppName: boolean + /** Hides the Tasks sidebar button (also removes it from keyboard navigation). */ + showTasksButton: boolean + /** Only toggles the sidebar shortcut; Automations stay reachable from Settings/View menu. */ + showAutomationsButton?: boolean + /** Deprecated: Artifacts are always available. Use showArtifactsButton for sidebar visibility. */ + artifactsEnabled?: boolean + /** Capability gate for agent-driven publishing; off until granted, enforced in main, not just the UI. */ + artifactSharingEnabled?: boolean + /** Only toggles the sidebar shortcut; Artifacts stay reachable from Settings. */ + showArtifactsButton?: boolean + /** Only toggles the sidebar shortcut; Orca Mobile stays reachable from Settings. */ + showMobileButton?: boolean + /** Pinned workspaces show in one sidebar location by default; opt in to also show them in their natural groups. */ + showPinnedWorktreesInGroups?: boolean + /** How Ctrl+Tab picks the next visible tab; optional (older profiles), readers default to MRU. */ + ctrlTabOrderMode?: CtrlTabOrderMode + /** Orca-first keeps app shortcuts from TUIs; terminal-first is opt-in to let shell/TUI bindings win. */ + terminalShortcutPolicy?: TerminalShortcutPolicy + /** Floating Workspace: global surface for terminal/browser/markdown tabs outside repo/worktree context. */ + floatingTerminalEnabled: boolean + /** One-shot migration flag for the floating-workspace default-on rollout; after migration an explicit off sticks. */ + floatingTerminalDefaultedForAllUsers?: boolean + /** Start dir for new floating-workspace terminal tabs; empty or '~' = home dir. */ + floatingTerminalCwd: string + /** Picker-approved floating-workspace dirs reauthorized across restarts; renderer text alone must not populate this. */ + floatingTerminalTrustedCwds?: string[] + /** One-shot migration marker for legacy floating workspace cwd trust grants. */ + floatingTerminalCwdMigratedToAppWorkspace?: boolean + /** Where the Floating Workspace toggle is shown; defaults to the floating button for discoverability. */ + floatingTerminalTriggerLocation: FloatingTerminalTriggerLocation + /** Legacy keyboard-shortcut overrides; new writes go to ~/.orca/keybindings.json, migrated once when present. */ + keybindings?: KeybindingOverrides + diffDefaultView: 'inline' | 'side-by-side' + diffWordWrap: boolean + combinedDiffFileTreeVisibleByDefault: boolean + /** Bot-marked comment-author logins (stored lowercased); escape hatch for review bots on regular accounts that defeat provider metadata/heuristics. */ + prBotAuthorOverrides: string[] + notifications: NotificationSettings + /** Countdown after a Claude agent goes idle showing time left before the prompt cache expires. */ + promptCacheTimerEnabled: boolean + /** Prompt-cache TTL (ms); only 300000 (5 min standard) or 3600000 (1 hr, extended-TTL plans). */ + promptCacheTtlMs: number + /** Why: durable main-owned pref so Orca can prepare shared ~/.codex before the renderer hydrates. */ + codexManagedAccounts: CodexManagedAccount[] + activeCodexManagedAccountId: string | null + activeCodexManagedAccountIdsByRuntime?: CodexManagedAccountRuntimeSelection + /** Why: persist only per-account auth (not a CLAUDE_CONFIG_DIR swap) so switching accounts doesn't fork Claude's shared chat/session context. */ + claudeManagedAccounts: ClaudeManagedAccount[] + activeClaudeManagedAccountId: string | null + activeClaudeManagedAccountIdsByRuntime?: ClaudeManagedAccountRuntimeSelection + /** Per-worktree shell history file so ArrowUp doesn't surface other worktrees' commands. Defaults to true. */ + terminalScopeHistoryByWorktree: boolean + /** Kill switch for hidden terminal view parking: unmount long-hidden panes while a pane-less watcher keeps PTY side effects alive. */ + terminalHiddenViewParking?: boolean + /** Kill switch for SSH terminal parking (C1): SSH panes park like local ones; reveal restores from main's headless model, falling back to relay replay. */ + terminalSshViewParking?: boolean + /** Kill switch for the hidden-worktree retention budget (C1): force-parks the least-recently-hidden un-parkable worktrees beyond a count budget or TTL. */ + terminalHiddenWorktreeRetentionBudget?: boolean + /** Kill switch for the browser-guest worktree retention budget: destroys the least-recently-activated hidden worktrees' webview guests beyond an LRU count budget. */ + browserGuestWorktreeRetentionBudget?: boolean + /** Kill switch for main-process PTY side-effect authority; on (default) = title/bell/agent facts via pty:sideEffect channel, not renderer byte parsing. */ + terminalMainSideEffectAuthority?: boolean + /** Kill switch for main's hidden-delivery gate (Phase 4): drops PTY bytes to hidden views after model ingestion; requires terminalMainSideEffectAuthority. */ + terminalHiddenDeliveryGate?: boolean + /** Kill switch for main's model query responder (Phase 5); active only when both Phase-4 gates are also on. */ + terminalModelQueryAuthority?: boolean + /** Which agent to pre-select in the new-workspace composer. + * - null: auto (first detected agent) + * - 'blank': blank terminal (no agent launched) + * - TuiAgent: a specific agent id */ + defaultTuiAgent: TuiAgent | 'blank' | null + /** Agents hidden from picker/auto-launch; detection stays a raw PATH snapshot. */ + disabledTuiAgents: TuiAgent[] + /** Master switch for the experimental plugin system. Off by default: no + * discovery, no panels, no plugin code paths run at all. */ + pluginSystemEnabled: boolean + /** Qualified plugin keys (`publisher.id`) the user disabled. Discovered + * plugins stay listed but are not activated. */ + disabledPlugins: string[] + /** Consent records: qualified plugin key → capability/worker-trust fingerprint. + * A plugin whose current fingerprint differs is pending again, so an update + * crossing either trust boundary re-prompts before code runs. Absent key = + * never consented. */ + pluginConsents: Record + /** Local directories loaded as dev-mode plugins (manifest hot-reload). */ + devPluginPaths: string[] + /** One-shot guard: start Claude Agent Teams hidden for existing profiles without overriding later opt-ins. */ + claudeAgentTeamsDefaultDisabledMigrated?: boolean + /** Why: worktree deletion is destructive (rm -rf of the working dir), so confirm by default. */ + skipDeleteWorktreeConfirm: boolean + /** Why: closing a terminal with child processes kills foreground work; keep this skip separate from other confirmations. */ + skipCloseTerminalWithRunningProcessConfirm: boolean + /** Why: deleting an automation also deletes its run history; keep this skip separate from worktree deletion. */ + skipDeleteAutomationConfirm: boolean + /** Why: deleting an artifact breaks a public link others may already hold; keep this skip separate from local deletions. */ + skipDeleteArtifactConfirm: boolean + /** Why: a Codex rate-limit reset spends a scarce credit on the live account; keep this skip separate from local confirmations. */ + skipCodexRateLimitResetConfirm: boolean + /** Default preset in the new-workspace GitHub task view. */ + defaultTaskViewPreset: TaskViewPresetId + /** Persisted last-used task source so Tasks reopens to the same provider instead of defaulting to GitHub. */ + defaultTaskSource: TaskProvider + /** Persisted visible task providers; hides unused providers from Tasks chrome and sidebar shortcuts. */ + visibleTaskProviders: TaskProvider[] + /** Why: one-shot guard to make Jira visible for existing profiles once, without re-adding after a later opt-out. */ + visibleTaskProvidersDefaultedForJira: boolean + /** Persisted repo selection (cross-repo tasks view). null = sticky-all (includes future-added repos); + * string[] = frozen curated subset (ineligible ids dropped on load; empty after drop is treated as null). */ + defaultRepoSelection: string[] | null + /** Persisted Linear team selection (tasks view). Same nullable-array pattern as + * defaultRepoSelection: null = sticky-all, string[] = frozen subset of team IDs. */ + defaultLinearTeamSelection: string[] | null + /** Session cookie for OpenCode Go rate-limit fetching. Stored encrypted. */ + opencodeSessionCookie: string + /** Optional OpenCode Go workspace ID override; when set, skips the workspaces lookup and fetches usage directly. */ + opencodeWorkspaceId: string + /** Optional MiniMax group id. When empty, the usage fetcher extracts minimax_group_id_v2 from the cookie. */ + minimaxGroupId: string + /** Comma-separated MiniMax model names to show in the status bar usage window. */ + minimaxUsageModels: string + /** Extract OAuth credentials from the local Gemini CLI for rate-limit fetching. Off by default (explicit opt-in). */ + geminiCliOAuthEnabled: boolean + /** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */ + agentCmdOverrides: Partial> + /** Custom CODEX_HOME for Codex session-history discovery (defaults to ~/.codex). + * History-only: does not change which account/config/hooks Orca uses. */ + codexSessionSourceHome?: { + /** Absolute host path; empty/undefined falls back to ~/.codex. */ + host?: string + /** Per-WSL-distro absolute Linux path; missing distro falls back to /.codex. */ + wsl?: Record + } + /** Per-agent default CLI arguments appended after the binary/path and before prompts. */ + agentDefaultArgs?: Partial> + /** Per-agent launch environment defaults used when yolo mode is exposed as env. */ + agentDefaultEnv?: Partial>> + /** One-shot guard for adding yolo-mode default args to untouched agent launch profiles. */ + agentYoloDefaultsMigrated?: boolean + /** Why: disabling must persist so startup doesn't reinstall global agent hook entries the user just removed. */ + agentStatusHooksEnabled: boolean + /** Dismissed freshness tuples: no write authority, just suppress re-nudging the same official placement/revision. */ + dismissedSkillFreshnessNudges?: string[] + /** Why: generated tab titles are subjective, so they stay opt-in and manual renames win. */ + tabAutoGenerateTitle: boolean + /** Why: pinned tabs can still be closed via keyboard/native-menu; this gates that behind a confirmation. Defaults on. */ + confirmClosePinnedTab: boolean + /** When true, Orca requests local awake assertions while hook-reported agents are working. */ + keepComputerAwakeWhileAgentsRun: boolean + /** Optional for mixed-version compatibility; the legacy boolean maps true to Auto. */ + computerAwakeMode?: ComputerAwakeMode + /** macOS Option key: compose layout chars (@ German, € French) vs act as Meta/Esc for readline. + * 'auto' (default) = layout-aware via navigator.keyboard.getLayoutMap() (US → Meta, else compose); + * 'false' = compose; 'true' = Meta on both Option keys; 'left'/'right' = only that key is Meta. + * See docs/terminal-option-key-layout-aware-default.md. */ + terminalMacOptionAsAlt: 'auto' | 'true' | 'false' | 'left' | 'right' + /** One-shot migration guard for the 'auto' rollout. Old default 'true' was ambiguous (explicit vs default); + * on first upgrade launch, reset a persisted 'true' to 'auto' so non-US keyboards aren't broken by the stale default. */ + terminalMacOptionAsAltMigrated: boolean + /** Whether macOS terminal input maps the physical JIS Yen (¥) key to backslash, per common terminal expectation. */ + terminalJISYenToBackslash: boolean + experimentalMobile: boolean + /** Why: iOS Simulator is default-on for capable macOS hosts; this is the durable off switch (hides UI, blocks CLI attach). */ + mobileEmulatorEnabled?: boolean + /** Preferred iOS Simulator UDID for UI auto-attach and agent CLI attach. */ + mobileEmulatorDefaultDeviceUdid?: string | null + /** Explicit Android SDK root for when auto-discovery (ANDROID_HOME / default path) fails; null (default) auto-discovers. */ + androidSdkPath?: string | null + /** Auto-restore window (ms) for a phone-fit PTY after the last mobile subscriber leaves. + * `null` (default) holds phone size indefinitely; a finite value schedules restore. + * Clamped on read to [5_000ms, 60min]. See docs/mobile-fit-hold.md. */ + mobileAutoRestoreFitMs: number | null + /** Preferred mobile pairing path for new QR codes. Missing/'automatic' = Anywhere (Relay + local); + * explicit 'local-only' = same-network only. */ + mobilePairingConnectionMode?: 'automatic' | 'local-only' + /** Explicit custom address restored when generating future mobile pairing codes. */ + mobilePairingCustomAddress?: string | null + /** Saved custom addresses available in both mobile pairing pickers. */ + mobilePairingCustomAddresses?: string[] + /** Experimental: floating animated pet in the bottom-right corner. Opt-in cosmetic; + * off never mounts the overlay, and toggling takes effect instantly (renderer-side). */ + experimentalPet: boolean + /** Legacy persisted key from before the sidekick -> pet rename; read only during migration, new writes use experimentalPet. */ + experimentalSidekick?: boolean + /** Experimental: left-sidebar Agents view — threaded feed of agent completions, blocking/unread state, worktree creation. */ + experimentalActivity: boolean + /** Experimental: pop-out Kanban dashboard for monitoring and opening agent terminals across worktrees. */ + experimentalAgentDashboardPopout?: boolean + /** How the Agent Dashboard opens: an in-window companion board or a separate pop-out window. Defaults to in-window. */ + experimentalAgentDashboardMode?: AgentDashboardMode + /** Includes stale quiet agents as a fourth Agent Dashboard column. */ + experimentalAgentDashboardShowIdle?: boolean + /** One-shot migration guard for defaulting the Agents view off; later explicit opt-ins persist normally. */ + experimentalActivityDefaultedOffForAllUsers?: boolean + /** Experimental: persistent terminal-pane attention ring for bell + agent-completion events. Opt-in while tuning signal/noise. */ + experimentalTerminalAttention: boolean + /** Experimental: automatically sleep completed, resumable background agent terminals. */ + experimentalAgentHibernation?: boolean + /** Milliseconds a completed agent must stay idle before hibernation can be considered. */ + agentHibernationIdleMs?: number + /** Experimental: opt-in preview of the updated worktree-card layout and metadata behavior. */ + experimentalNewWorktreeCardStyle?: boolean + /** Experimental: per-workspace on-demand environment recipes and setup surface. */ + experimentalEphemeralVms?: boolean + /** Compact worktree cards: hide the metadata row when title and branch say the same thing. */ + compactWorktreeCards: boolean + /** Legacy persisted key from the Experimental rollout; new writes use compactWorktreeCards. */ + experimentalCompactWorktreeCards?: boolean + /** Active non-local runtime environment for client-routed RPC; null keeps local desktop behavior. */ + activeRuntimeEnvironmentId?: string | null + /** GitHub Project mode state (pinned/recent/active project, last view per project). + * Optional for pre-feature profiles; the persistence merge hydrates the default. */ + githubProjects?: GitHubProjectSettings + /** AI commit-message config (agent, model, per-model thinking, prompt suffix). Optional to avoid migrating existing profiles. */ + commitMessageAi?: CommitMessageAiSettings + /** Source-control AI generation settings for commit messages and hosted-review drafts. */ + sourceControlAi?: SourceControlAiSettings + /** GitLab project preferences (pinned + recent paths). Optional for pre-GitLab profiles; persistence merge fills the default. */ + gitlabProjects?: GitLabProjectSettings + /** Anonymous product-telemetry state; optional until the one-shot Store.load() migration populates it. + * Holds only consent + identity, not volatile counters — those would amplify the debounced settings write. */ + telemetry?: { + /** New users: true at install. Existing users: null until they resolve the first-launch banner. */ + optedIn: boolean | null + /** Anonymous UUID v4. Generated on first run. Stable across launches; not surfaced in the UI. */ + installId: string + /** Cohort marker: true for pre-existing profiles (gates the opt-in banner), false for fresh installs. */ + existedBeforeTelemetryRelease: boolean + } + /** One-shot cohort marker for the tab-switch keybinding swap. 'pending' = + * pre-existing install (seed pins old chords, then flips to 'done'); 'done' = fresh install. */ + tabSwitchKeybindingSeed?: 'pending' | 'done' + /** Local voice/dictation config. Optional for pre-voice profiles; getDefaultSettings() hydrates defaults via the persistence merge. */ + voice?: VoiceSettings +} + +export type OrcaWorkspaceLayout = { + path: string + nestWorkspaces: boolean +} + +export type GhosttyImportPreview = { + found: boolean + configPath?: string + configPaths?: string[] + diff: Partial + unsupportedKeys: string[] + error?: string +} diff --git a/src/shared/host-setting-overrides.ts b/src/shared/host-setting-overrides.ts index 908da41fd0e..5d7f0693d3a 100644 --- a/src/shared/host-setting-overrides.ts +++ b/src/shared/host-setting-overrides.ts @@ -1,5 +1,17 @@ import type { ExecutionHostId } from './execution-host' -import type { GlobalSettings, HostSettingOverrides } from './types' +import type { GlobalSettings } from './types' + +/** Per-host overrides for client preferences that genuinely vary by execution + * host. NARROW by design: only settings whose value is meaningless to share + * across hosts belong here. + * - `displayLabel`: a client-side rename for the host shown in sidebar/pickers. + * - `defaultWorktreeLocation`: the host's root worktree directory; a remote + * SSH/runtime host has a different filesystem layout than the local Mac, so + * the client `workspaceDir` default cannot apply unchanged. */ +export type HostSettingOverrides = { + displayLabel?: string + defaultWorktreeLocation?: string +} // Why: per-host preferences follow `effective = host override ?? client default`. // These pure helpers centralize that rule so the UI, registry, and tests share a diff --git a/src/shared/issue-link-input.ts b/src/shared/issue-link-input.ts index 37d98f55072..2f2890a633f 100644 --- a/src/shared/issue-link-input.ts +++ b/src/shared/issue-link-input.ts @@ -1,5 +1,5 @@ -import { parseGitHubIssueOrPRLink } from './github-links' -import { parseLinearIssueInput } from './linear-links' +import { parseGitHubIssueOrPRLink } from './github/links' +import { parseLinearIssueInput } from './linear/links' import type { WorkspaceSourceProvider } from './new-workspace/workspace-source' // Why: narrows the canonical provider union instead of minting a parallel one, diff --git a/src/shared/issue-mutation-types.ts b/src/shared/issue-mutation-types.ts new file mode 100644 index 00000000000..421befcdb90 --- /dev/null +++ b/src/shared/issue-mutation-types.ts @@ -0,0 +1,42 @@ +export type GitHubCreateIssueFields = { + labels?: string[] + assignees?: string[] +} + +export type GitHubCreateIssueResult = + | { ok: true; number: number; url: string; bodySaveWarning?: string } + | { ok: false; error: string } + +export type GitHubIssueCloseReason = 'completed' | 'not_planned' | 'duplicate' + +export type GitHubIssueUpdate = { + state?: 'open' | 'closed' + stateReason?: GitHubIssueCloseReason + duplicateOf?: number + title?: string + // Why: body writes use the REST issue endpoint instead of `gh issue edit` + // because that command does not consistently cover every body-edit case the + // dialog needs. + body?: string + addLabels?: string[] + removeLabels?: string[] + addAssignees?: string[] + removeAssignees?: string[] +} + +export type GitHubPullRequestStateUpdate = { + state: 'open' | 'closed' +} + +export type LinearIssueUpdate = { + stateId?: string + title?: string + description?: string + assigneeId?: string | null + estimate?: number | null + priority?: number + dueDate?: string | null + labelIds?: string[] + projectId?: string | null + parentId?: string | null +} diff --git a/src/shared/linear-agent-access.ts b/src/shared/linear/agent-access.ts similarity index 96% rename from src/shared/linear-agent-access.ts rename to src/shared/linear/agent-access.ts index 69169268767..5e69edc0c55 100644 --- a/src/shared/linear-agent-access.ts +++ b/src/shared/linear/agent-access.ts @@ -92,15 +92,15 @@ export type { LinearWriteIssueRef, LinearIssueTaskUpdateResult, LinearSaveIssueResult -} from './linear-agent-result-types' -export type { LinearIssueActivityEntry, LinearIssueActivityValue } from './linear-issue-activity' -export type { LinearInlineMedia } from './linear-inline-media' -export type { LinearMcpIssueListRequest, LinearMcpIssueListResult } from './linear-mcp-issue-list' +} from './agent-result-types' +export type { LinearIssueActivityEntry, LinearIssueActivityValue } from './issue-activity' +export type { LinearInlineMedia } from './inline-media' +export type { LinearMcpIssueListRequest, LinearMcpIssueListResult } from './mcp-issue-list' export type { LinearIssueRelationship, LinearIssueRelationWriteRequest, LinearIssueRelationWriteResult -} from './linear-issue-relation-write' +} from './issue-relation-write' export type LinearWriteTargetRequest = { input?: string diff --git a/src/shared/linear-agent-result-types.ts b/src/shared/linear/agent-result-types.ts similarity index 97% rename from src/shared/linear-agent-result-types.ts rename to src/shared/linear/agent-result-types.ts index ae04c842d4d..d8b0c016f12 100644 --- a/src/shared/linear-agent-result-types.ts +++ b/src/shared/linear/agent-result-types.ts @@ -4,9 +4,9 @@ import type { LinearIssueInclude, LinearIssueListFilter, LinearIssueTaskUpdateRequest -} from './linear-agent-access' -import type { LinearInlineMedia } from './linear-inline-media' -import type { LinearIssueActivityEntry } from './linear-issue-activity' +} from './agent-access' +import type { LinearInlineMedia } from './inline-media' +import type { LinearIssueActivityEntry } from './issue-activity' export type LinearIssueSummary = { id: string diff --git a/src/shared/linear-inline-media.test.ts b/src/shared/linear/inline-media.test.ts similarity index 95% rename from src/shared/linear-inline-media.test.ts rename to src/shared/linear/inline-media.test.ts index 0e59e906f3d..6e633d1e8bf 100644 --- a/src/shared/linear-inline-media.test.ts +++ b/src/shared/linear/inline-media.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { extractLinearInlineMedia } from './linear-inline-media' +import { extractLinearInlineMedia } from './inline-media' describe('Linear inline media extraction', () => { it('extracts markdown and HTML media URLs with source metadata', () => { diff --git a/src/shared/linear-inline-media.ts b/src/shared/linear/inline-media.ts similarity index 100% rename from src/shared/linear-inline-media.ts rename to src/shared/linear/inline-media.ts diff --git a/src/shared/linear-issue-activity.ts b/src/shared/linear/issue-activity.ts similarity index 89% rename from src/shared/linear-issue-activity.ts rename to src/shared/linear/issue-activity.ts index 1a9ff4d675c..13e97e52b56 100644 --- a/src/shared/linear-issue-activity.ts +++ b/src/shared/linear/issue-activity.ts @@ -1,4 +1,4 @@ -import type { LinearNamedEntity, LinearUserSummary } from './linear-agent-result-types' +import type { LinearNamedEntity, LinearUserSummary } from './agent-result-types' export type LinearIssueActivityActor = LinearUserSummary & { kind: 'user' | 'bot' | 'system' diff --git a/src/shared/linear-issue-attribute-filter.test.ts b/src/shared/linear/issue-attribute-filter.test.ts similarity index 99% rename from src/shared/linear-issue-attribute-filter.test.ts rename to src/shared/linear/issue-attribute-filter.test.ts index 57c0ecba441..f0d5b1f952b 100644 --- a/src/shared/linear-issue-attribute-filter.test.ts +++ b/src/shared/linear/issue-attribute-filter.test.ts @@ -7,7 +7,7 @@ import { linearIssueAttributeFilterSignature, parseLinearIssueAttributeFilter, type LinearIssueAttributeFilter -} from './linear-issue-attribute-filter' +} from './issue-attribute-filter' const sample: LinearIssueAttributeFilter = { stateIds: ['b', 'a', 'a', ' c '], diff --git a/src/shared/linear-issue-attribute-filter.ts b/src/shared/linear/issue-attribute-filter.ts similarity index 100% rename from src/shared/linear-issue-attribute-filter.ts rename to src/shared/linear/issue-attribute-filter.ts diff --git a/src/shared/linear-issue-read-limits.ts b/src/shared/linear/issue-read-limits.ts similarity index 100% rename from src/shared/linear-issue-read-limits.ts rename to src/shared/linear/issue-read-limits.ts diff --git a/src/shared/linear-issue-relation-write.ts b/src/shared/linear/issue-relation-write.ts similarity index 86% rename from src/shared/linear-issue-relation-write.ts rename to src/shared/linear/issue-relation-write.ts index 9bdb9d1aece..c95b66173f4 100644 --- a/src/shared/linear-issue-relation-write.ts +++ b/src/shared/linear/issue-relation-write.ts @@ -1,9 +1,9 @@ -import type { LinearCurrentIssueContextHints } from './linear-agent-access' +import type { LinearCurrentIssueContextHints } from './agent-access' import type { LinearIssueRelation, LinearIssueSummary, LinearWriteIssueRef -} from './linear-agent-result-types' +} from './agent-result-types' export type LinearIssueRelationship = 'blocks' | 'blockedBy' | 'relatedTo' | 'duplicateOf' diff --git a/src/shared/linear/issue-types.ts b/src/shared/linear/issue-types.ts new file mode 100644 index 00000000000..db1ba1a068d --- /dev/null +++ b/src/shared/linear/issue-types.ts @@ -0,0 +1,52 @@ +import type { LinearProjectSummary } from './project-types' + +export type LinearIssue = { + id: string + workspaceId?: string + workspaceName?: string + identifier: string + title: string + branchName?: string + description?: string + url: string + state: { + name: string + type: string + color: string + } + team: { + id: string + name: string + key: string + } + project?: LinearProjectSummary + subIssues?: LinearIssueChildSummary[] + labels: string[] + labelIds: string[] + assignee?: { + id: string + displayName: string + avatarUrl?: string + } + estimate?: number | null + priority: number + dueDate?: string | null + updatedAt: string +} + +export type LinearIssueChildSummary = { + id: string + identifier: string + title: string + url: string +} + +export type LinearComment = { + id: string + body: string + createdAt: string + user?: { + displayName: string + avatarUrl?: string + } +} diff --git a/src/shared/linear-issue-view-resume-state.test.ts b/src/shared/linear/issue-view-resume-state.test.ts similarity index 99% rename from src/shared/linear-issue-view-resume-state.test.ts rename to src/shared/linear/issue-view-resume-state.test.ts index 806977e28be..645d3a6ac6a 100644 --- a/src/shared/linear-issue-view-resume-state.test.ts +++ b/src/shared/linear/issue-view-resume-state.test.ts @@ -3,7 +3,7 @@ import { emptyLinearIssueAttributeFilter, LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH, type LinearIssueAttributeFilter -} from './linear-issue-attribute-filter' +} from './issue-attribute-filter' import { defaultLinearIssueViewResumeState, LINEAR_DISPLAY_PROPERTIES, @@ -13,7 +13,7 @@ import { serializeLinearIssueViewResumeState, setLinearWorkspaceIssueFilter, type LinearIssueViewResumeState -} from './linear-issue-view-resume-state' +} from './issue-view-resume-state' function filter(overrides: Partial = {}): LinearIssueAttributeFilter { return { ...emptyLinearIssueAttributeFilter(), ...overrides } diff --git a/src/shared/linear-issue-view-resume-state.ts b/src/shared/linear/issue-view-resume-state.ts similarity index 99% rename from src/shared/linear-issue-view-resume-state.ts rename to src/shared/linear/issue-view-resume-state.ts index 82c03f87c2e..5330b5546ab 100644 --- a/src/shared/linear-issue-view-resume-state.ts +++ b/src/shared/linear/issue-view-resume-state.ts @@ -9,7 +9,7 @@ import { parseLinearIssueAttributeFilter, LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH, type LinearIssueAttributeFilter -} from './linear-issue-attribute-filter' +} from './issue-attribute-filter' export const LINEAR_VIEW_MODES = ['list', 'board'] as const export const LINEAR_GROUP_BY_OPTIONS = ['none', 'status', 'assignee', 'priority', 'team'] as const diff --git a/src/shared/linear-links.test.ts b/src/shared/linear/links.test.ts similarity index 99% rename from src/shared/linear-links.test.ts rename to src/shared/linear/links.test.ts index 24ac962df6e..48005d93d06 100644 --- a/src/shared/linear-links.test.ts +++ b/src/shared/linear/links.test.ts @@ -8,7 +8,7 @@ import { buildLinearWorkspaceApiSettingsUrl, getLinearOrganizationUrlKeyFromIssueUrl, parseLinearIssueInput -} from './linear-links' +} from './links' describe('linear links', () => { it('builds team URLs from workspace and team keys', () => { diff --git a/src/shared/linear-links.ts b/src/shared/linear/links.ts similarity index 99% rename from src/shared/linear-links.ts rename to src/shared/linear/links.ts index bcbb7180e36..ce457f0a419 100644 --- a/src/shared/linear-links.ts +++ b/src/shared/linear/links.ts @@ -1,4 +1,4 @@ -import type { LinearConnectionStatus, LinearIssue, LinearWorkspace } from './types' +import type { LinearConnectionStatus, LinearIssue, LinearWorkspace } from '../types' export function buildLinearTeamUrl(args: { organizationUrlKey?: string | null diff --git a/src/shared/linear-mcp-issue-list.ts b/src/shared/linear/mcp-issue-list.ts similarity index 90% rename from src/shared/linear-mcp-issue-list.ts rename to src/shared/linear/mcp-issue-list.ts index 6955eef16ab..29a21ddb323 100644 --- a/src/shared/linear-mcp-issue-list.ts +++ b/src/shared/linear/mcp-issue-list.ts @@ -1,5 +1,5 @@ -import type { LinearErrorCode } from './linear-agent-access' -import type { LinearIssueSummary, LinearWorkspaceCandidate } from './linear-agent-result-types' +import type { LinearErrorCode } from './agent-access' +import type { LinearIssueSummary, LinearWorkspaceCandidate } from './agent-result-types' export type LinearMcpIssueListRequest = { team?: string diff --git a/src/shared/linear-project-list-format.ts b/src/shared/linear/project-list-format.ts similarity index 94% rename from src/shared/linear-project-list-format.ts rename to src/shared/linear/project-list-format.ts index f6cd3cd0903..b739c788e5c 100644 --- a/src/shared/linear-project-list-format.ts +++ b/src/shared/linear/project-list-format.ts @@ -1,4 +1,4 @@ -import type { LinearProjectListResult } from './linear-agent-result-types' +import type { LinearProjectListResult } from './agent-result-types' // Why: non-JSON project output aligns ids with the existing compact Linear tables. const LINEAR_PROJECT_NAME_COLUMN_WIDTH = 28 diff --git a/src/shared/linear/project-types.ts b/src/shared/linear/project-types.ts new file mode 100644 index 00000000000..a1f914ddefd --- /dev/null +++ b/src/shared/linear/project-types.ts @@ -0,0 +1,107 @@ +export type LinearProjectSummary = { + id: string + slugId?: string + workspaceId?: string + workspaceName?: string + name: string + url?: string + color?: string + icon?: string + description?: string + content?: string + status?: LinearProjectStatusSummary + health?: string | null + priority?: number | null + priorityLabel?: string | null + lead?: LinearProjectMemberSummary + members?: LinearProjectMemberSummary[] + teams?: { + id: string + name: string + key?: string + }[] + labels?: { + id: string + name: string + color?: string + }[] + startDate?: string | null + targetDate?: string | null + createdAt?: string + updatedAt?: string + completedAt?: string | null + canceledAt?: string | null + startedAt?: string | null + progress?: number | null + scope?: number | null + issueCount?: number + completedIssueCount?: number +} + +export type LinearProjectStatusSummary = { + id: string + name: string + type?: string + color?: string +} + +export type LinearProjectMemberSummary = { + id: string + displayName: string + avatarUrl?: string +} + +export type LinearProjectMilestoneSummary = { + id: string + name: string + status?: string + targetDate?: string | null + progress?: number | null +} + +export type LinearProjectResourceSummary = { + id: string + title: string + url: string + type?: string +} + +export type LinearProjectUpdateSummary = { + id: string + body?: string + health?: string | null + url?: string + createdAt?: string + updatedAt?: string + user?: LinearProjectMemberSummary +} + +export type LinearProjectDetail = LinearProjectSummary & { + milestones?: LinearProjectMilestoneSummary[] + resources?: LinearProjectResourceSummary[] + latestUpdate?: LinearProjectUpdateSummary +} + +export type LinearCustomViewModel = 'issue' | 'project' + +export type LinearCustomViewSummary = { + id: string + workspaceId?: string + workspaceName?: string + name: string + description?: string + model: LinearCustomViewModel + url?: string + color?: string + icon?: string + shared?: boolean + team?: { + id: string + name?: string + key?: string + } + owner?: LinearProjectMemberSummary + creator?: LinearProjectMemberSummary + createdAt?: string + updatedAt?: string +} diff --git a/src/shared/linear-uuid.ts b/src/shared/linear/uuid.ts similarity index 100% rename from src/shared/linear-uuid.ts rename to src/shared/linear/uuid.ts diff --git a/src/shared/linear/workspace-types.ts b/src/shared/linear/workspace-types.ts new file mode 100644 index 00000000000..802842bb72f --- /dev/null +++ b/src/shared/linear/workspace-types.ts @@ -0,0 +1,73 @@ +export type LinearViewer = { + displayName: string + email: string | null + organizationId?: string + organizationName: string + organizationUrlKey?: string +} + +export type LinearWorkspace = LinearViewer & { + id: string + organizationId: string + isLegacy?: true + credentialRevision?: number +} + +export type LinearWorkspaceSelection = (string & {}) | 'all' +export type LinearWorkspaceSelector = LinearWorkspaceSelection | undefined +export type LinearConcreteWorkspaceId = string + +export type LinearWorkspaceError = { + workspaceId: string + workspaceName?: string + type: 'auth' | 'rate_limited' | 'network' | 'unknown' + message: string +} + +export type LinearCollectionResult = { + items: T[] + errors?: LinearWorkspaceError[] + hasMore?: boolean +} + +export type LinearConnectionStatus = { + connected: boolean + viewer: LinearViewer | null + workspaces?: LinearWorkspace[] + activeWorkspaceId?: string | null + selectedWorkspaceId?: LinearWorkspaceSelection | null + // Set when a stored token file exists but could not be decrypted, so the + // UI can explain reads failing while the connection still looks saved. + credentialError?: string +} + +export type LinearWorkflowState = { + id: string + name: string + type: string + color: string + position: number +} + +export type LinearLabel = { + id: string + name: string + color: string +} + +export type LinearMember = { + id: string + displayName: string + name?: string + email?: string + avatarUrl?: string +} + +export type LinearTeam = { + id: string + workspaceId?: string + workspaceName?: string + name: string + key: string + url?: string +} diff --git a/src/shared/managed-account-types.ts b/src/shared/managed-account-types.ts new file mode 100644 index 00000000000..7239c62e6ad --- /dev/null +++ b/src/shared/managed-account-types.ts @@ -0,0 +1,95 @@ +export type CodexManagedAccount = { + id: string + email: string + managedHomePath: string + managedHomeRuntime?: 'host' | 'wsl' + wslDistro?: string | null + wslLinuxHomePath?: string | null + providerAccountId?: string | null + workspaceLabel?: string | null + workspaceAccountId?: string | null + createdAt: number + updatedAt: number + lastAuthenticatedAt: number +} + +export type CodexManagedAccountSummary = { + id: string + email: string + managedHomeRuntime?: 'host' | 'wsl' + wslDistro?: string | null + providerAccountId?: string | null + workspaceLabel?: string | null + workspaceAccountId?: string | null + createdAt: number + updatedAt: number + lastAuthenticatedAt: number +} + +/** Live, read-only identity of the user's real ~/.codex used by the + * system-default (activeAccountId:null) Codex account. Orca reads this to + * display and attribute the system default; it never writes ~/.codex. */ +export type CodexSystemDefaultIdentity = { + /** True when ~/.codex/auth.json exists (signed in via a token file). */ + hasAuth: boolean + /** 'oauth' = ChatGPT sign-in with an id token (has ChatGPT usage); + * 'api-key' = env-key/custom provider (no ChatGPT usage); + * 'none' = signed out or identity could not be resolved. */ + authKind: 'oauth' | 'api-key' | 'none' + email: string | null + providerAccountId: string | null + workspaceLabel: string | null +} + +export type CodexRateLimitAccountsState = { + accounts: CodexManagedAccountSummary[] + activeAccountId: string | null + activeAccountIdsByRuntime?: CodexManagedAccountRuntimeSelection + /** Resolved identity of the host system-default (real ~/.codex) account. + * Omitted for runtimes where it is not resolved (e.g. per-distro WSL). */ + systemDefault?: CodexSystemDefaultIdentity +} + +export type CodexManagedAccountRuntimeSelection = { + host: string | null + wsl: Record +} + +export type ClaudeManagedAccount = { + id: string + email: string + managedAuthPath: string + managedAuthRuntime?: 'host' | 'wsl' + wslDistro?: string | null + wslLinuxAuthPath?: string | null + authMethod: 'subscription-oauth' | 'unknown' + organizationUuid?: string | null + organizationName?: string | null + createdAt: number + updatedAt: number + lastAuthenticatedAt: number +} + +export type ClaudeManagedAccountSummary = { + id: string + email: string + managedAuthRuntime?: 'host' | 'wsl' + wslDistro?: string | null + authMethod: 'subscription-oauth' | 'unknown' + organizationUuid?: string | null + organizationName?: string | null + createdAt: number + updatedAt: number + lastAuthenticatedAt: number +} + +export type ClaudeRateLimitAccountsState = { + accounts: ClaudeManagedAccountSummary[] + activeAccountId: string | null + activeAccountIdsByRuntime?: ClaudeManagedAccountRuntimeSelection +} + +export type ClaudeManagedAccountRuntimeSelection = { + host: string | null + wsl: Record +} diff --git a/src/shared/manual-repo-order.ts b/src/shared/manual-repo-order.ts index 9b5bfee39a1..9813412c676 100644 --- a/src/shared/manual-repo-order.ts +++ b/src/shared/manual-repo-order.ts @@ -1,8 +1,9 @@ import { getRepoExecutionHostId, normalizeExecutionHostId } from './execution-host' +import { getRepoHostIdentityForParts } from './repo-host-identity' import type { ManualRepoOrderEntry, Repo } from './types' function getEntryKey(entry: ManualRepoOrderEntry): string { - return `${entry.hostId}\0${entry.repoId}` + return getRepoHostIdentityForParts(entry.repoId, entry.hostId) } export function normalizeManualRepoOrder(value: unknown): ManualRepoOrderEntry[] { diff --git a/src/shared/new-workspace/github-links.ts b/src/shared/new-workspace/github-links.ts index dc6cbd050ab..a838c4ec2f7 100644 --- a/src/shared/new-workspace/github-links.ts +++ b/src/shared/new-workspace/github-links.ts @@ -2,10 +2,10 @@ import { type GitHubIssueOrPRLink, parseGitHubIssueOrPRLink, parseGitHubIssueOrPRNumber -} from '../github-links' +} from '../github/links' import { isWorkItemLinkQueryTooLarge } from './work-item-link-query-bounds' -export * from '../github-links' +export * from '../github/links' const HTTP_URL_PREFIX_RE = /^https?:\/\//i diff --git a/src/shared/new-workspace/smart-workspace-linear-intent.ts b/src/shared/new-workspace/smart-workspace-linear-intent.ts index 0995cdd9cb9..ec77fcb2fbd 100644 --- a/src/shared/new-workspace/smart-workspace-linear-intent.ts +++ b/src/shared/new-workspace/smart-workspace-linear-intent.ts @@ -4,7 +4,7 @@ import { parseLinearIssueUrlIntent, type LinearIssueUrlIntent, type ParsedLinearIssueInput -} from '../linear-links' +} from '../linear/links' import { isSmartWorkspaceSourceQueryWithinLimit } from './smart-workspace-source-query' type SmartWorkspaceLinearMode = 'smart' | 'linear' | (string & {}) diff --git a/src/shared/new-workspace/workspace-source.ts b/src/shared/new-workspace/workspace-source.ts index 69b9e23a186..db15573d9ab 100644 --- a/src/shared/new-workspace/workspace-source.ts +++ b/src/shared/new-workspace/workspace-source.ts @@ -1,4 +1,4 @@ -import { getLinearOrganizationUrlKeyFromIssueUrl } from '../linear-links' +import { getLinearOrganizationUrlKeyFromIssueUrl } from '../linear/links' import type { FolderWorkspaceLinkedTask, JiraIssue, LinearIssue } from '../types' import { getLinkedWorkItemSuggestedName, diff --git a/src/shared/notification-settings-types.ts b/src/shared/notification-settings-types.ts new file mode 100644 index 00000000000..fe4ef74015c --- /dev/null +++ b/src/shared/notification-settings-types.ts @@ -0,0 +1,106 @@ +import type { AgentStatusState, AgentType } from './agent-status-types' + +export type NotificationSettings = { + enabled: boolean + agentTaskComplete: boolean + terminalBell: boolean + suppressWhenFocused: boolean + customSoundId: + | 'system' + | 'two-tone' + | 'bong' + | 'thump' + | 'blip' + | 'sonar' + | 'blop' + | 'ding' + | 'clack' + | 'beep' + | 'custom' + customSoundPath: string | null + customSoundVolume: number +} + +export type NotificationEventSource = 'agent-task-complete' | 'terminal-bell' | 'test' + +export type NotificationDispatchRequest = { + source: NotificationEventSource + notificationId?: string + /** Why: useful for fast native failures, but macOS can still drop notifications after 'show'. */ + requireDisplayConfirmation?: boolean + worktreeId?: string + /** Stable `${tabId}:${leafId}` terminal pane key for click-to-focus routing. */ + paneKey?: string + repoLabel?: string + worktreeLabel?: string + hasMultipleActiveRepos?: boolean + terminalTitle?: string + isActiveWorktree?: boolean + agentType?: AgentType + agentState?: AgentStatusState + agentPrompt?: string + agentToolName?: string + agentToolInput?: string + agentLastAssistantMessage?: string + agentInterrupted?: boolean +} + +export type NotificationDispatchResult = { + delivered: boolean + /** Why delivery was skipped (set when delivered is false); 'blocked-by-system' = macOS would silently swallow it. */ + reason?: + | 'disabled' + | 'source-disabled' + | 'suppressed-focus' + | 'cooldown' + | 'not-supported' + | 'not-displayed' + | 'blocked-by-system' + | 'invalid-request' +} + +export type NotificationDismissResult = { + dismissed: number +} + +export type NotificationSoundResult = { + played: boolean + reason?: + | 'missing-path' + | 'invalid-path' + | 'unsupported-type' + | 'too-large' + | 'read-failed' + | 'playback-failed' + | 'deduped' +} + +export type NotificationSoundDataResult = + | { + ok: true + data: Uint8Array + mimeType: string + path: string + } + | { + ok: false + reason: Exclude + } + +export type NotificationSoundPathResult = + | { ok: true; path: string } + | { ok: false; reason: 'missing-path' | 'invalid-path' | 'unsupported-type' } + +export type NotificationPermissionStatusResult = { + supported: boolean + platform: NodeJS.Platform + requested: boolean +} + +/** macOS notification permission outcome: authoritative native UNUserNotificationCenter readout, else a weaker + * delivery-probe fallback; 'awaiting-decision' = permission dialog unanswered. */ +export type NotificationDeliveryProbeResult = { + state: 'delivered' | 'blocked' | 'awaiting-decision' | 'unsupported' + /** True when the state comes from the native authorization readout (vs. the delivery-probe fallback). */ + authoritative: boolean +} diff --git a/src/shared/onboarding-state-types.ts b/src/shared/onboarding-state-types.ts new file mode 100644 index 00000000000..669c70b94f3 --- /dev/null +++ b/src/shared/onboarding-state-types.ts @@ -0,0 +1,30 @@ +// Subset of onboarding Ghostty DiscoveryState statuses that emit telemetry; UI-only 'idle'/'detecting' don't. +export type DiscoveryStatusEmitted = 'found' | 'absent' | 'imported' + +export type OnboardingOutcome = 'completed' | 'dismissed' + +export type OnboardingChecklistState = { + addedRepo: boolean + choseAgent: boolean + ranFirstAgent: boolean + ranSecondAgentOnSameTask: boolean + triedCmdJ: boolean + shapedSidebar: boolean + reviewedDiff: boolean + openedPr: boolean + addedFolder: boolean + openedFile: boolean + ranAgentOnFile: boolean + // Why: UI state flag (panel visibility), not an activation event; telemetry checklist enum omits it. + dismissed: boolean +} + +export type OnboardingState = { + // Why: step meanings change when pages are removed; version marker prevents migration re-running on new progress. + flowVersion: number + closedAt: number | null + outcome: OnboardingOutcome | null + // Sentinel -1 = not started; 1..5 = highest finished wizard step. number (not union) because callers clamp via Math.max/min. + lastCompletedStep: number + checklist: OnboardingChecklistState +} diff --git a/src/shared/orca-yaml-hook-types.ts b/src/shared/orca-yaml-hook-types.ts new file mode 100644 index 00000000000..42122784490 --- /dev/null +++ b/src/shared/orca-yaml-hook-types.ts @@ -0,0 +1,78 @@ +export type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default' +export type SetupAgentStartupPolicy = 'start-immediately' | 'wait-for-setup' +export type HookCommandSourcePolicy = 'shared-only' | 'local-only' | 'run-both' + +// ─── Hooks (orca.yaml) ────────────────────────────────────────────── +export type OrcaHooks = { + scripts: { + setup?: string // Runs after worktree is created + archive?: string // Runs before worktree is archived + } + issueCommand?: string // Shared default command for linked GitHub issues + defaultTabs?: OrcaDefaultTabTemplate[] // Terminal tabs to create once for a new worktree + environmentRecipes?: OrcaVmRecipe[] // Project-scoped per-workspace environment recipes + environmentRecipeDiagnostics?: OrcaVmRecipeDiagnostic[] // Non-fatal validation issues from environmentRecipes + worktree?: OrcaWorktreeDefaults // Project-scoped defaults applied when a worktree is created +} + +export type OrcaWorktreeDefaults = { + // Why: shared (symlinked) rather than copied — large rebuildable dirs like + // node_modules should be one install serving every worktree. + sharedDirectories?: string[] +} + +export type OrcaDefaultTabTemplate = { + title?: string + color?: string + command?: string +} + +export type EphemeralVmCheckoutMode = 'orca-worktree' | 'provisioned-root' + +export type OrcaVmRecipe = { + id: string + name: string + create: string + checkoutMode?: EphemeralVmCheckoutMode + description?: string + suspend?: string + resume?: string + destroy?: string + destroyDisabled?: boolean +} + +export type OrcaVmRecipeDiagnostic = { + index: number + field?: string + message: string +} + +export type RepoHookSettings = { + // Why: persisted data may still include the old mode field from the earlier + // hook UI. Keep it in the shape so existing local state reads without a migration. + mode: 'auto' | 'override' + setupRunPolicy?: SetupRunPolicy + setupAgentStartupPolicy?: SetupAgentStartupPolicy + commandSourcePolicy?: HookCommandSourcePolicy + scripts: { + setup: string + archive: string + } +} + +export type PersistedTrustedOrcaHookEntry = { + contentHash: string + approvedAt: number +} + +export type PersistedTrustedOrcaHookRepo = { + all?: { + approvedAt: number + } + setup?: PersistedTrustedOrcaHookEntry + archive?: PersistedTrustedOrcaHookEntry + issueCommand?: PersistedTrustedOrcaHookEntry + vmRecipe?: PersistedTrustedOrcaHookEntry +} + +export type PersistedTrustedOrcaHooks = Record diff --git a/src/shared/persisted-state-types.ts b/src/shared/persisted-state-types.ts new file mode 100644 index 00000000000..da8757cae28 --- /dev/null +++ b/src/shared/persisted-state-types.ts @@ -0,0 +1,100 @@ +import type { ExecutionHostId } from './execution-host' +import type { + RemovedSshTargetTombstone, + SshPtyConsumerRecovery, + SshRemotePtyLease, + SshTarget +} from './ssh-types' +import type { Automation, AutomationRun } from './automations-types' +import type { MigrationUnsupportedPtyEntry } from './agent-status-types' +import type { FeatureInteractionTelemetryBucketState } from './feature-interactions' +import type { CodexResetCreditAttemptLedger } from './codex-reset-credit-attempt-ledger' +import type { DiffComment } from './diff-comment-types' +import type { FolderWorkspace, WorkspaceKey } from './folder-workspace-types' +import type { GlobalSettings } from './global-settings-types' +import type { IssueInfo, PRInfo } from './github/pull-request-types' +import type { OnboardingState } from './onboarding-state-types' +import type { PersistedUIState } from './persisted-ui-state-types' +import type { ProjectGroup } from './project-group-types' +import type { Project, ProjectHostSetup } from './project-types' +import type { Repo } from './repo-types' +import type { SparsePreset } from './worktree/create-types' +import type { WorkspaceLineage, WorktreeLineage } from './worktree/lineage-types' +import type { WorktreeMeta } from './worktree/meta-types' +import type { WorkspaceSessionState } from './workspace-session-state-types' + +export type LegacyPaneKeyAliasEntry = { + ptyId: string + /** Physical pane key retained by the live process; name is persisted for compatibility (UUID keys after detach). */ + legacyPaneKey: string + /** Current logical owner pane key. May belong to another tab after detach. */ + stablePaneKey: string + updatedAt: number +} + +/** Last tab selection a paired client made in a worktree; restores phone navigation across host restarts. */ +export type PersistedMobileClientTabSelection = { + activeTabId: string | null + activeGroupId: string | null + activeTabIdByGroupId: Readonly> +} + +/** deviceId → worktreeId → selection. */ +export type PersistedMobileClientTabSelections = Record< + string, + Record +> + +// ─── Persistence shape ────────────────────────────────────────────── +export type PersistedState = { + schemaVersion: number + repos: Repo[] + projects: Project[] + projectHostSetups: ProjectHostSetup[] + projectGroups: ProjectGroup[] + folderWorkspaces: FolderWorkspace[] + /** Folder-workspace review notes, keyed by FolderWorkspace.id. Top-level, NOT nested in + * folderWorkspaces[]: normalizeFolderWorkspaces rebuilds each record field-by-field, so an + * older build drops nested fields, while unknown top-level keys round-trip untouched. + * + * WRITE-ONLY PROJECTION. FolderWorkspace.diffComments is the single in-memory home; load() + * hydrates from this key and then deletes it from Store state, and buildStateToSave() is the + * only producer of it. Never read Store.state.folderWorkspaceDiffComments outside load(). */ + folderWorkspaceDiffComments?: Record + /** Sparse-checkout presets keyed by repoId. */ + sparsePresetsByRepo: Record + /** Per paired device last tab selection by worktree; keeps mobile navigation across host restarts. */ + mobileClientTabSelectionsByDeviceId?: PersistedMobileClientTabSelections + worktreeMeta: Record + worktreeLineageById: Record + workspaceLineageByChildKey: Record + settings: GlobalSettings + ui: PersistedUIState + githubCache: { + pr: Record + issue: Record + } + /** Legacy single-blob session, kept as the canonical 'local' host partition so an app downgrade still reads its workspace. */ + workspaceSession: WorkspaceSessionState + /** Per-execution-host session partitions for non-'local' hosts (ssh:/runtime:); 'local' stays in workspaceSession so pre-partition builds keep working. */ + workspaceSessionsByHostId?: Partial> + sshTargets: SshTarget[] + /** SSH config aliases the user deleted; suppresses re-import from ~/.ssh/config so a deleted host doesn't reappear. */ + deletedSshConfigAliases: string[] + /** Identity records for removed SSH targets so a re-added host can re-adopt workspaces orphaned on the old target id. */ + removedSshTargetTombstones?: RemovedSshTargetTombstone[] + sshRemotePtyLeases: SshRemotePtyLease[] + /** Main-owned authenticated relay recovery records; never expose through renderer settings APIs. */ + sshPtyConsumerRecoveries?: SshPtyConsumerRecovery[] + /** Live local Claude daemon session ids; seeds the live-PTY gate so early OAuth refresh can't rotate the single-use refresh token out from under a running daemon. */ + claudeLivePtySessionIds?: string[] + migrationUnsupportedPtyEntries: MigrationUnsupportedPtyEntry[] + legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] + automations: Automation[] + automationRuns: AutomationRun[] + onboarding: OnboardingState + /** Main-owned telemetry de-dupe marker; never exposed through PersistedUIState. */ + featureInteractionTelemetryBuckets?: FeatureInteractionTelemetryBucketState + /** Main-owned reset mutation journal. Never expose this through renderer settings APIs. */ + codexResetCreditAttemptLedger?: CodexResetCreditAttemptLedger +} diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts new file mode 100644 index 00000000000..1db7f8e5dfc --- /dev/null +++ b/src/shared/persisted-ui-state-types.ts @@ -0,0 +1,204 @@ +import type { ReleaseChannel } from './release-channel' +import type { WorkspaceCleanupUIState } from './workspace-cleanup' +import type { FeatureTipId } from './feature-tips' +import type { ContextualTourId } from './contextual-tours' +import type { FeatureInteractionState } from './feature-interactions' +import type { UsagePercentageDisplay } from './usage-percentage-display' +import type { StatusBarUsageMode } from './status-bar-usage-mode' +import type { PersistedTrustedOrcaHooks } from './orca-yaml-hook-types' +import type { CustomPet } from './pet-types' +import type { + AgentActivityDisplayMode, + ManualRepoOrderEntry, + ProjectOrderBy, + RightSidebarExplorerView, + RightSidebarTab, + StatusBarItem, + TaskResumeState, + TopLevelView, + VisibleWorkspaceHostIds, + WorkspaceHostOrder, + WorkspaceHostScope, + WorktreeCardProperty +} from './ui-chrome-types' +import type { WorkspaceStatusDefinition } from './worktree/types' + +export type PersistedUIState = { + lastActiveRepoId: string | null + lastActiveWorktreeId: string | null + /** Active top-level view at save time, restored on relaunch; sanitized to 'terminal' if unknown or now-gated. */ + activeView: TopLevelView + sidebarWidth: number + rightSidebarOpen: boolean + rightSidebarTab: RightSidebarTab + rightSidebarExplorerView: RightSidebarExplorerView + rightSidebarWidth: number + markdownTocPanelWidth?: number + combinedDiffFileTreeWidth?: number + groupBy: 'none' | 'workspace-status' | 'repo' | 'pr-status' + sortBy: 'name' | 'smart' | 'recent' | 'repo' | 'manual' + /** Project header ordering in `groupBy: 'repo'`, independent of `sortBy`: 'manual' uses persisted order + header drag, 'recent' by latest visible activity. */ + projectOrderBy: ProjectOrderBy + /** Deprecated; the Active only filter is retired and ignored on hydration. */ + showActiveOnly: boolean + /** Hide sleeping/inactive workspaces from workspace navigation. Off by default. */ + hideSleepingWorkspaces?: boolean + /** Which execution hosts the sidebar shows; `all` = mixed view, specific IDs focus without tearing down other hosts' sessions. */ + workspaceHostScope?: WorkspaceHostScope + /** Which execution hosts the sidebar shows; `null` = sticky all-hosts so new hosts appear automatically. */ + visibleWorkspaceHostIds?: VisibleWorkspaceHostIds + /** User-defined sidebar order for host sections; missing/new hosts append in discovered order. */ + workspaceHostOrder?: WorkspaceHostOrder + /** Desktop-owned all-host repo order; host-qualified identities keep a manual cross-host interleaving while each host owns its local permutation. */ + manualRepoOrder?: ManualRepoOrderEntry[] + /** Deprecated legacy positive-form setting. Ignored on hydration. */ + showSleepingWorkspaces?: boolean + /** Deprecated legacy name used by a short-lived build. Ignored on hydration. */ + showInactiveWorkspaces?: boolean + /** Hide the repo's checked-out branch from workspace nav (sidebar, Cmd+J); folder-mode repos are unaffected (empty-branch worktrees excluded). */ + hideDefaultBranchWorkspace: boolean + /** Hide workspaces created by automation new-per-run dispatches. */ + hideAutomationGeneratedWorkspaces?: boolean + /** Hide workspaces created through `orca worktree create`. */ + hideCliCreatedWorkspaces?: boolean + /** Hide workspaces sitting on a detached HEAD; folder workspaces (no head at all) are unaffected. */ + hideDetachedHeadWorkspaces?: boolean + /** Hide workspaces with known provenance from another paired device or the host UI. */ + hideWorkspacesFromOtherDevices?: boolean + /** Keep each project's main workspace out of the "Hide sleeping" sweep. Absent means on (#8873). */ + alwaysShowDefaultBranchWorkspace?: boolean + /** Per-worktree Explorer dotfile visibility. Missing entries inherit the default: show. */ + showDotfilesByWorktree?: Record + filterRepoIds: string[] + collapsedGroups: string[] + uiZoomLevel: number + editorFontZoomLevel: number + worktreeCardProperties: WorktreeCardProperty[] + /** One-shot migration flag for deriving card properties from the two worktree card modes. */ + _worktreeCardModeDefaulted?: boolean + agentActivityDisplayMode?: AgentActivityDisplayMode + workspaceStatuses?: WorkspaceStatusDefinition[] + workspaceBoardOpacity?: number + workspaceBoardColumnWidth?: number + syncTaskStatusFromWorkspaceBoard?: boolean + /** One-shot migration flag for a short-lived build that persisted default statuses in reverse order; once stamped, ordering is never re-inferred from IDs/labels. */ + _workspaceStatusesDefaultOrderMigrated?: boolean + /** One-shot repair flag for the exact default payload a short-lived build persisted in reverse workflow order. */ + _workspaceStatusesReorderedDefaultRepaired?: boolean + /** One-shot migration flag for default status workflow labels/visuals; only exact legacy defaults migrate, customized statuses preserved. */ + _workspaceStatusesDefaultWorkflowMigrated?: boolean + /** One-shot migration flag for the old default status visuals; once stamped, user-authored colors/icons are preserved. */ + _workspaceStatusesDefaultVisualsMigrated?: boolean + /** One-shot migration flag for adding the default-on Ports status item. */ + _portsStatusBarDefaultAdded?: boolean + /** One-shot migration flag for adding the default-on Kimi status item. */ + _kimiStatusBarDefaultAdded?: boolean + /** One-shot migration flag for adding the default-on MiniMax status item. */ + _minimaxStatusBarDefaultAdded?: boolean + /** One-shot migration flag for adding the default-on Antigravity status item. */ + _antigravityStatusBarDefaultAdded?: boolean + /** One-shot migration flag for adding the default-on Grok status item. */ + _grokStatusBarDefaultAdded?: boolean + statusBarItems: StatusBarItem[] + statusBarVisible: boolean + /** Why: this is client-side presentation, not a provider/account or execution-host setting. */ + usagePercentageDisplay?: UsagePercentageDisplay + /** Client-side footer presentation; verbose preserves the pre-roster all-window default. */ + statusBarUsageMode?: StatusBarUsageMode + dismissedUpdateVersion: string | null + lastUpdateCheckAt: number | null + /** Dev-only update channel override; absent means the build's own channel. */ + releaseChannelOverride?: ReleaseChannel | null + pendingUpdateNudgeId?: string | null + dismissedUpdateNudgeId?: string | null + /** Whether Orca already tried triggering the macOS notification permission dialog; prevents re-firing every launch. */ + notificationPermissionRequested?: boolean + /** Once the "your sessions won't be interrupted" reassurance card is seen, never show it again. */ + updateReassuranceSeen?: boolean + /** Per-paneKey "row visited" timestamps that mute seen inline-agent rows; persisted because rows survive restart, else acked rows return bold. Renderer-owned via ui:set. */ + acknowledgedAgentsByPaneKey?: Record + /** User-hidden setup-guide sidebar entry; a reversible declutter pref (Help menu stays available), not completion. */ + setupGuideSidebarDismissed?: boolean + /** One-shot marker for the browser setup-guide milestone; profiles missing it are evaluated once in the renderer (completion needs runtime probes). */ + setupGuideBrowserMilestoneMigrated?: boolean + /** Existing users who completed/dismissed the pre-browser checklist stay complete after the browser milestone is added. */ + setupGuideBrowserMilestoneLegacyComplete?: boolean + /** User-dismissed browser import toolbar hint; import stays available from Settings > Browser and the overflow menu. */ + browserImportHintHidden?: boolean + /** Why: Windows-only. Set once on first hide to tray so the "Orca is still running" notice shows only once. */ + trayMinimizeNoticeShown?: boolean + /** Set by the OSC 52 default-on migration when it overrode a persisted `false`; the renderer shows one notice and clears it. */ + osc52ClipboardDefaultOnNoticePending?: boolean + /** User dismissed the first-run Mobile Emulator intro; reversible only by re-enabling the feature in Settings. */ + mobileEmulatorTabIntroDismissed?: boolean + /** User deferred the in-pane Mobile Emulator CLI + skill setup guide. */ + mobileEmulatorAgentSetupDismissed?: boolean + /** One-shot rollout notice for manual project ordering default; absent or true keeps the sidebar callout hidden. */ + projectOrderManualDefaultNoticeDismissed?: boolean + /** One-shot notice that usage meters show percent used, not remaining; absent resolves on load (new profiles dismissed, upgraded see it once). */ + usagePercentageDisplayChangeNoticeDismissed?: boolean + /** User-hidden empty-state usage CTA; permanently hides the "Connect AI accounts" prompt even if providers are later disconnected. */ + usageEmptyStateDismissed?: boolean + /** URL for new browser tabs; null = blank tab. */ + browserDefaultUrl?: string | null + browserDefaultSearchEngine?: 'google' | 'duckduckgo' | 'bing' | 'kagi' | null + /** Electron browser zoom level applied when a new local browser tab is created. */ + browserDefaultZoomLevel?: number + /** Optional Kagi private-session link used only when Kagi is the search engine. */ + browserKagiSessionLink?: string | null + /** Saved window bounds so the app restores last position/size instead of maximizing each launch. */ + windowBounds?: { x: number; y: number; width: number; height: number } | null + /** Whether the window was maximized when it was last closed. */ + windowMaximized?: boolean + /** Saved bounds for the pop-out dashboard window so it restores to its last + * position/size. Independent of the main window's bounds. */ + dashboardPopoutBounds?: { x: number; y: number; width: number; height: number } | null + /** One-shot flag: 'recent' once meant the smart sort (v1→v2 rename), migrated to 'smart' once so the new last-activity 'recent' isn't re-clobbered. */ + _sortBySmartMigrated?: boolean + /** LEGACY inline-agents flag, stamped unconditionally every load so it can't gate migration; kept only for rollback forward-compat (real gate: _inlineAgentsDefaultedForAllUsers). */ + _inlineAgentsDefaultedForExperiment?: boolean + /** One-shot flag for the inline-agents default-on rollout; distinct from _inlineAgentsDefaultedForExperiment, which was stamped every load and is permanently dirty. */ + _inlineAgentsDefaultedForAllUsers?: boolean + /** One-shot migration flag for split-out card properties, set once so later deliberate unchecks of Linear issue/Ports stick across restarts. */ + _expandedWorktreeCardPropertiesDefaulted?: boolean + /** One-shot backfill flag for 'jira-issue', which joined the defaults after the expansion migration had already stamped upgraded profiles. */ + _jiraIssueWorktreeCardPropertyDefaulted?: boolean + /** totalAgentsSpawned snapshot at first sighting of the current app version, so the nag counts agents since last update (not from zero). */ + starNagBaselineAgents?: number | null + /** App version that set the current baseline; a version change re-captures the baseline on next spawn, restarting the nag countdown. */ + starNagAppVersion?: string | null + /** Next agents-since-baseline threshold that fires the star-nag; starts at 35, doubles per dismissal without starring. */ + starNagNextThreshold?: number + /** Once the user has starred Orca (any entry point), permanently suppress the nag. */ + starNagCompleted?: boolean + /** Timestamp until which nonterminal dismissals suppress threshold prompts (force-show bypasses for dev/testing). */ + starNagDeferredUntil?: number | null + /** App version that consumed the first value-moment ask; main-owned so remote/web clients can't spoof the once-per-version cap. */ + starNagAgentValueMomentAppVersion?: string | null + trustedOrcaHooks?: PersistedTrustedOrcaHooks + setupScriptPromptDismissedRepoIds?: string[] + /** Pet overlay visibility, separate from the experimentalPet settings flag so "Hide pet" is a reversible dismiss; absent = true. */ + petVisible?: boolean + /** Active pet id (bundled id or custom UUID); unknown ids fall back to the default on read so a removed custom pet doesn't blank the overlay. */ + petId?: string + /** Metadata index for user-uploaded pet images; bytes live under legacy userData/sidekicks/custom/. */ + customPets?: CustomPet[] + /** Pet overlay size in CSS pixels (square); clamped to [PET_SIZE_MIN, PET_SIZE_MAX] on read. */ + petSize?: number + /** Legacy keys from before the sidekick -> pet rename; read only during migration, new writes use pet* above. */ + sidekickVisible?: boolean + sidekickId?: string + customSidekicks?: CustomPet[] + sidekickSize?: number + /** Page-position state for Tasks: only transient tabs/searches (source/repo/team/project selections use their own settings paths). */ + taskResumeState?: TaskResumeState + workspaceCleanup?: WorkspaceCleanupUIState + /** Feature tips already surfaced; startup opens the tips modal only when a current tip id is missing here. */ + featureTipsSeenIds?: FeatureTipId[] + /** Feature ids the user has actually used; education surfaces skip teaching already-discovered features. */ + featureInteractions?: FeatureInteractionState + /** Contextual tours already surfaced; unknown ids ignored on hydration for downgrade/upgrade forward-compat. */ + contextualToursSeenIds?: ContextualTourId[] + /** Whether this profile may receive automatic contextual tours; missing = renderer hasn't classified the profile yet. */ + contextualToursAutoEligible?: boolean +} diff --git a/src/shared/pet-types.ts b/src/shared/pet-types.ts new file mode 100644 index 00000000000..c036d845673 --- /dev/null +++ b/src/shared/pet-types.ts @@ -0,0 +1,36 @@ +export const PET_SIZE_MIN = 60 +export const PET_SIZE_MAX = 360 +export const PET_SIZE_DEFAULT = 180 + +/** User-uploaded pet image metadata; renderer fetches bytes from main via pet:read (id, fileName), never learning the on-disk path. */ +export type CustomPet = { + id: string + label: string + fileName: string + /** MIME type for the renderer's Blob Content-Type — esp. image/svg+xml, which browsers won't render from a misdeclared blob URL. */ + mimeType: string + /** Storage layout: `image` = legacy flat file `custom/.`; `bundle` = `.codex-pet` expanded into `custom//`; absent = legacy `image`. */ + kind?: 'image' | 'bundle' + /** Sprite-sheet metadata; present iff from a `.codex-pet` bundle with a manifest frame layout. Dims derived in main so the renderer needn't probe the image. */ + sprite?: { + frameWidth: number + frameHeight: number + columns: number + rows: number + sheetWidth: number + sheetHeight: number + fps: number + defaultAnimation?: string + animations?: Record + } + /** Manifest-declared fps kept even when frames are auto-detected, so playback honors the bundle's speed instead of a hardcoded 8 fps. */ + spriteFps?: number +} + +/** One animation strip in a sprite sheet: `row` = 0-based y-index, `frames` = consecutive cells played left-to-right. */ +export type SpriteAnimation = { + row: number + frames: number + /** Per-frame holds in ms (length === frames). Absent means uniform sheet fps. */ + frameDurationsMs?: number[] +} diff --git a/src/shared/process-stats-types.ts b/src/shared/process-stats-types.ts new file mode 100644 index 00000000000..ec0187864ab --- /dev/null +++ b/src/shared/process-stats-types.ts @@ -0,0 +1,74 @@ +// ─── Stats ────────────────────────────────────────────────────────── + +export type StatsSummary = { + totalAgentsSpawned: number + totalPRsCreated: number + totalAgentTimeMs: number + // Sourced from aggregates, not the event log, so it survives event trimming. + firstEventAt: number | null // timestamp of first-ever event, for "tracking since..." +} + +// ─── Memory dashboard ────────────────────────────────────────────── + +/** cpu is percent of a single core — can exceed 100 on multi-core. memory is in bytes. */ +export type UsageValues = { + cpu: number + memory: number +} + +export type ProcessMemoryMetric = 'rss' | 'working-set' + +export type HostAvailableMemorySource = 'memory-pressure' | 'proc-meminfo' | 'free-memory' + +/** The top-level cpu/memory are the sum of main + renderer + other. */ +export type AppMemory = UsageValues & { + main: UsageValues + renderer: UsageValues + other: UsageValues + /** Oldest-first memory samples (bytes) for the whole Orca app; empty before the first snapshot. */ + history: number[] +} + +export type SessionMemory = UsageValues & { + sessionId: string + paneKey: string | null + pid: number +} + +/** The top-level cpu/memory are the sum of sessions. */ +export type WorktreeMemory = UsageValues & { + worktreeId: string + worktreeName: string + repoId: string + repoName: string + sessions: SessionMemory[] + /** Oldest-first memory samples (bytes) for this worktree's tracked subtrees. */ + history: number[] +} + +export type HostMemory = { + totalMemory: number + /** Immediately free memory reported by Node's host API. */ + freeMemory: number + /** Memory available without material pressure, or freeMemory when unavailable. */ + availableMemory: number + availableMemorySource: HostAvailableMemorySource + /** totalMemory - availableMemory. */ + usedMemory: number + memoryUsagePercent: number + cpuCoreCount: number + loadAverage1m: number +} + +export type MemorySnapshot = { + app: AppMemory + worktrees: WorktreeMemory[] + host: HostMemory + /** Per-process byte metric used by app, session, worktree, history, and totalMemory values. */ + processMemoryMetric: ProcessMemoryMetric + /** Sum of app + all tracked worktree sessions. Percent of a single core, so may exceed 100 on multi-core machines. */ + totalCpu: number + /** Sum of per-process samples. Shared pages may repeat, so this can exceed host.totalMemory. */ + totalMemory: number + collectedAt: number +} diff --git a/src/shared/project-group-types.ts b/src/shared/project-group-types.ts new file mode 100644 index 00000000000..43bd889a67d --- /dev/null +++ b/src/shared/project-group-types.ts @@ -0,0 +1,60 @@ +export type ProjectGroupCreatedFrom = 'manual' | 'folder-scan' | 'migration' + +export type ProjectGroup = { + id: string + name: string + parentPath: string | null + /** SSH target ID for folder-backed groups imported from a remote root. */ + connectionId?: string | null + /** Renderer-owned host stamp for groups fetched from a runtime environment. */ + executionHostId?: string | null + parentGroupId: string | null + createdFrom: ProjectGroupCreatedFrom + tabOrder: number + isCollapsed: boolean + color: string | null + createdAt: number + updatedAt: number +} + +export type NestedRepoScanOptions = { + maxDepth?: number + maxRepos?: number + timeoutMs?: number | null +} + +export type NestedRepoCandidate = { + path: string + displayName: string + depth: number +} + +export type NestedRepoScanResult = { + selectedPath: string + selectedPathKind: 'git_repo' | 'non_git_folder' + repos: NestedRepoCandidate[] + truncated: boolean + timedOut: boolean + stopped: boolean + durationMs: number + maxDepth: number + maxRepos: number + timeoutMs: number | null +} + +export type ProjectGroupImportMode = 'group' | 'separate' + +export type ProjectGroupImportProjectResult = { + path: string + projectId?: string + status: 'imported' | 'already-known' | 'failed' + error?: string +} + +export type ProjectGroupImportResult = { + group?: ProjectGroup + projects: ProjectGroupImportProjectResult[] + importedCount: number + alreadyKnownCount: number + failedCount: number +} diff --git a/src/shared/project-host-setup-projection.test.ts b/src/shared/project-host-setup-projection.test.ts index c7573c50784..d0456d392de 100644 --- a/src/shared/project-host-setup-projection.test.ts +++ b/src/shared/project-host-setup-projection.test.ts @@ -628,3 +628,39 @@ describe('isProjectRemoteIdentityPending', () => { ).toBe(false) }) }) + +describe('derived project identity stability', () => { + const base = { id: 'r', path: '/r', displayName: 'r' } as const + + // Why pinned: this id is the persisted Project primary key, so folding a host alias here would + // silently re-key existing projects on upgrade and drop their runtime preference. + it('keeps a www. remote provider-neutral instead of folding it onto github.com', () => { + const projection = projectHostSetupProjectionFromRepos([ + repo({ + ...base, + gitRemoteIdentity: { + canonicalKey: 'www.github.com/acme/app', + remoteName: 'origin', + remoteUrl: 'https://www.github.com/acme/app.git' + } + }) + ]) + expect(projection.projects.map((project) => project.id)).toEqual([ + 'git:www.github.com/acme/app' + ]) + }) + + it('still folds the documented ssh.github.com alias onto github.com', () => { + const projection = projectHostSetupProjectionFromRepos([ + repo({ + ...base, + gitRemoteIdentity: { + canonicalKey: 'ssh.github.com/acme/app', + remoteName: 'origin', + remoteUrl: 'ssh://git@ssh.github.com:443/acme/app.git' + } + }) + ]) + expect(projection.projects.map((project) => project.id)).toEqual(['github:acme/app']) + }) +}) diff --git a/src/shared/project-host-setup-projection.ts b/src/shared/project-host-setup-projection.ts index 00719497740..1db66b2d2c9 100644 --- a/src/shared/project-host-setup-projection.ts +++ b/src/shared/project-host-setup-projection.ts @@ -1,5 +1,6 @@ import { getRepoExecutionHostId } from './execution-host' -import { githubRepoIdentityKey, isDefaultGitHubHost } from './github-repository-identity-key' +import { normalizeGitHubRemoteHost } from './git-remote-host-alias' +import { githubRepoIdentityKey, isDefaultGitHubHost } from './github/repository-identity-key' import type { Project, ProjectHostSetup, @@ -120,11 +121,6 @@ function getProjectId( return getProjectIdentityKey(repo) } -function normalizeGitHubRemoteHost(host: string): string { - const normalizedHost = host.toLowerCase() - return normalizedHost === 'ssh.github.com' ? 'github.com' : normalizedHost -} - function isGitHubRemoteHost(host: string): boolean { const hostname = host.toLowerCase().replace(/:\d+$/, '') // A generic git remote is provider-neutral. Only infer GHES when the host diff --git a/src/shared/project-identity-succession.test.ts b/src/shared/project-identity-succession.test.ts new file mode 100644 index 00000000000..be9cda1c4ed --- /dev/null +++ b/src/shared/project-identity-succession.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import { carryProjectStateThroughIdentityChange } from './project-identity-succession' +import type { Project } from './types' + +const makeProject = (overrides: Partial = {}): Project => ({ + id: 'project-1', + displayName: 'Project', + badgeColor: '#737373', + sourceRepoIds: [], + createdAt: 1, + updatedAt: 1, + ...overrides +}) + +describe('carryProjectStateThroughIdentityChange', () => { + it('keeps the exact-id match and reports no remap', () => { + const projected = makeProject({ id: 'git:host/acme/app', sourceRepoIds: ['r1'] }) + const previous = makeProject({ + id: 'git:host/acme/app', + sourceRepoIds: ['r1'], + updatedAt: 50, + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + + const result = carryProjectStateThroughIdentityChange([projected], [previous]) + + expect(result.projects[0]).toMatchObject({ + id: 'git:host/acme/app', + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + updatedAt: 50 + }) + expect(result.remappedProjectIds.size).toBe(0) + }) + + it('adopts a prior row through a changed identity key by repo overlap', () => { + const projected = makeProject({ id: 'github:acme/app', sourceRepoIds: ['r1'] }) + const previous = makeProject({ + id: 'repo:r1', + sourceRepoIds: ['r1'], + localWindowsRuntimePreference: { kind: 'windows-host' } + }) + + const result = carryProjectStateThroughIdentityChange([projected], [previous]) + + expect(result.projects[0]?.localWindowsRuntimePreference).toEqual({ kind: 'windows-host' }) + expect([...result.remappedProjectIds]).toEqual([['repo:r1', 'github:acme/app']]) + }) + + it('ignores prior rows that still exist under their own id', () => { + const kept = makeProject({ id: 'git:host/acme/app', sourceRepoIds: ['r1'] }) + const renamed = makeProject({ id: 'git:host/acme/tool', sourceRepoIds: ['r2'] }) + const previousKept = makeProject({ + id: 'git:host/acme/app', + sourceRepoIds: ['r1', 'r2'], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + + const result = carryProjectStateThroughIdentityChange([kept, renamed], [previousKept]) + + expect(result.projects[1]?.localWindowsRuntimePreference).toBeUndefined() + expect(result.remappedProjectIds.size).toBe(0) + }) + + it('prefers the prior row sharing the most repos over the more recently updated one', () => { + const projected = makeProject({ id: 'git:host/acme/merged', sourceRepoIds: ['r1', 'r2'] }) + const wide = makeProject({ + id: 'git:host/acme/wide', + sourceRepoIds: ['r1', 'r2'], + updatedAt: 10, + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + const narrow = makeProject({ + id: 'git:host/acme/narrow', + sourceRepoIds: ['r2'], + updatedAt: 900, + localWindowsRuntimePreference: { kind: 'windows-host' } + }) + + const result = carryProjectStateThroughIdentityChange([projected], [wide, narrow]) + + expect(result.projects[0]?.localWindowsRuntimePreference).toEqual({ + kind: 'wsl', + distro: 'Ubuntu' + }) + expect([...result.remappedProjectIds.keys()]).toEqual(['git:host/acme/wide']) + }) + + it('breaks an equal-overlap tie by newest updatedAt, then lowest prior id', () => { + const projected = makeProject({ id: 'git:host/acme/merged', sourceRepoIds: ['r1', 'r2'] }) + const older = makeProject({ + id: 'git:host/acme/a', + sourceRepoIds: ['r1'], + updatedAt: 100, + localWindowsRuntimePreference: { kind: 'windows-host' } + }) + const newer = makeProject({ + id: 'git:host/acme/b', + sourceRepoIds: ['r2'], + updatedAt: 200, + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + + expect( + carryProjectStateThroughIdentityChange([projected], [older, newer]).projects[0] + ?.localWindowsRuntimePreference + ).toEqual({ kind: 'wsl', distro: 'Ubuntu' }) + + const sameStamp = { ...newer, updatedAt: 100 } + // Same overlap and same updatedAt: the lexicographically lowest prior id wins, both orders. + for (const previous of [ + [older, sameStamp], + [sameStamp, older] + ]) { + const result = carryProjectStateThroughIdentityChange([projected], previous) + expect(result.projects[0]?.localWindowsRuntimePreference).toEqual({ kind: 'windows-host' }) + expect([...result.remappedProjectIds.keys()]).toEqual(['git:host/acme/a']) + } + }) + + it('lets one prior row be claimed by only one surviving project', () => { + const left = makeProject({ id: 'git:host/acme/left', sourceRepoIds: ['r1'] }) + const right = makeProject({ id: 'git:host/acme/right', sourceRepoIds: ['r2'] }) + const previous = makeProject({ + id: 'repo:r1', + sourceRepoIds: ['r1', 'r2'], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + + const result = carryProjectStateThroughIdentityChange([left, right], [previous]) + + expect(result.projects[0]?.localWindowsRuntimePreference).toEqual({ + kind: 'wsl', + distro: 'Ubuntu' + }) + expect(result.projects[1]?.localWindowsRuntimePreference).toBeUndefined() + expect([...result.remappedProjectIds]).toEqual([['repo:r1', 'git:host/acme/left']]) + }) +}) diff --git a/src/shared/project-identity-succession.ts b/src/shared/project-identity-succession.ts new file mode 100644 index 00000000000..815d1b58f6d --- /dev/null +++ b/src/shared/project-identity-succession.ts @@ -0,0 +1,91 @@ +import type { Project } from './types' + +export type ProjectIdentitySuccession = { + /** Projected projects with user-set state carried forward from their predecessor row. */ + projects: readonly Project[] + /** Predecessor project id -> surviving project id, for rows whose identity key changed. */ + remappedProjectIds: ReadonlyMap +} + +function carryUserState(projected: Project, previous: Project): Project { + return previous.localWindowsRuntimePreference + ? { + ...projected, + localWindowsRuntimePreference: previous.localWindowsRuntimePreference, + updatedAt: Math.max(projected.updatedAt, previous.updatedAt) + } + : projected +} + +function countSharedRepoIds(sourceRepoIds: readonly string[], other: ReadonlySet): number { + return sourceRepoIds.reduce((count, repoId) => (other.has(repoId) ? count + 1 : count), 0) +} + +function compareStrings(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +/** + * Carries persisted per-project user state (and the rows that point at it) across a + * project id change. Project ids are derived from repo identity, so `repo:` -> `git:` -> + * `github:` promotion and remote re-probes rewrite them mid-session; matching only on id + * would drop the user's runtime preference and leave a ghost row behind. + * + * Predecessor rule when several prior rows overlap a surviving project: most shared + * `sourceRepoIds` wins, then newest `updatedAt`, then lowest prior id (then lowest new id). + * Each prior row is claimed by at most one surviving project, so two distinct user + * preferences are never merged into one — the unclaimed row keeps its own identity. + */ +export function carryProjectStateThroughIdentityChange( + projectedProjects: readonly Project[], + previousProjects: readonly Project[] +): ProjectIdentitySuccession { + const previousById = new Map(previousProjects.map((project) => [project.id, project])) + const projectedIds = new Set(projectedProjects.map((project) => project.id)) + // Why: a prior row that still exists under its own id is live, not a predecessor. + const orphanedPrevious = previousProjects.filter((project) => !projectedIds.has(project.id)) + const unmatched = projectedProjects.filter((project) => !previousById.has(project.id)) + const candidates = unmatched.flatMap((project) => { + const repoIds = new Set(project.sourceRepoIds) + return orphanedPrevious + .map((previous) => ({ + project, + previous, + shared: countSharedRepoIds(previous.sourceRepoIds, repoIds) + })) + .filter((candidate) => candidate.shared > 0) + }) + candidates.sort( + (left, right) => + right.shared - left.shared || + right.previous.updatedAt - left.previous.updatedAt || + compareStrings(left.previous.id, right.previous.id) || + compareStrings(left.project.id, right.project.id) + ) + const claimedPreviousIds = new Set() + const predecessorByProjectId = new Map() + for (const candidate of candidates) { + if ( + claimedPreviousIds.has(candidate.previous.id) || + predecessorByProjectId.has(candidate.project.id) + ) { + continue + } + claimedPreviousIds.add(candidate.previous.id) + predecessorByProjectId.set(candidate.project.id, candidate.previous) + } + const remappedProjectIds = new Map() + const projects = projectedProjects.map((project) => { + const exact = previousById.get(project.id) + if (exact) { + return carryUserState(project, exact) + } + const predecessor = predecessorByProjectId.get(project.id) + if (!predecessor) { + return project + } + remappedProjectIds.set(predecessor.id, project.id) + return carryUserState(project, predecessor) + }) + return { projects, remappedProjectIds } +} diff --git a/src/shared/project-types.ts b/src/shared/project-types.ts new file mode 100644 index 00000000000..f5704c504ef --- /dev/null +++ b/src/shared/project-types.ts @@ -0,0 +1,142 @@ +import type { ExecutionHostId } from './execution-host' +import type { RepoIcon } from './repo-icon' +import type { GitRemoteIdentity } from './git-remote-identity' +import type { LocalWindowsRuntimePreference } from './project-execution-runtime' +import type { RepoHookSettings } from './orca-yaml-hook-types' +import type { RepoSourceControlAiOverrides } from './source-control-ai-types' +import type { Repo, RepoKind } from './repo-types' + +export type ProjectProviderIdentity = { + provider: 'github' + owner: string + repo: string + host?: string +} + +export type Project = { + id: string + displayName: string + badgeColor: string + repoIcon?: RepoIcon | null + kind?: RepoKind + providerIdentity?: ProjectProviderIdentity + gitRemoteIdentity?: GitRemoteIdentity + /** Local Windows projects inherit the global runtime default unless this override is set. */ + localWindowsRuntimePreference?: LocalWindowsRuntimePreference + sourceRepoIds: string[] + createdAt: number + updatedAt: number +} + +export type ProjectUpdateArgs = { + projectId: string + updates: Partial> +} + +export type ProjectHostSetupState = 'ready' | 'not-set-up' | 'setting-up' | 'error' | 'unsupported' +export type ProjectHostSetupMethod = + | 'legacy-repo' + | 'imported-existing-folder' + | 'cloned' + | 'provisioned' +export type RepoProjectHostSetupMethod = Extract< + ProjectHostSetupMethod, + 'imported-existing-folder' | 'cloned' +> + +export type ProjectHostSetup = { + id: string + projectId: string + hostId: ExecutionHostId + repoId: string + path: string + displayName: string + kind?: RepoKind + connectionId?: string | null + executionHostId?: ExecutionHostId | null + /** Renderer projection of the paired runtime that owns this setup's transport. */ + runtimeOwnerEnvironmentId?: string + worktreeBasePath?: string + hookSettings?: RepoHookSettings + gitUsername?: string + setupState: ProjectHostSetupState + setupMethod: ProjectHostSetupMethod + sourceControlAi?: RepoSourceControlAiOverrides + createdAt: number + updatedAt: number +} + +export type ProjectHostSetupExistingFolderArgs = { + projectId: string + projectProviderIdentity?: ProjectProviderIdentity + hostId: ExecutionHostId + path: string + kind?: RepoKind + displayName?: string + setupMethod?: RepoProjectHostSetupMethod +} + +export type ProjectHostSetupCreateArgs = { + projectId: string + hostId: ExecutionHostId + setupId?: string + path?: string + kind?: RepoKind + displayName?: string + worktreeBasePath?: string + gitUsername?: string + setupState?: ProjectHostSetupState + setupMethod?: Exclude +} + +export type ProjectHostSetupCloneArgs = { + projectId: string + projectProviderIdentity?: ProjectProviderIdentity + hostId: ExecutionHostId + url: string + destination: string + displayName?: string +} + +export type ProjectHostSetupUpdateArgs = { + setupId: string + updates: Partial< + Pick< + ProjectHostSetup, + | 'displayName' + | 'path' + | 'worktreeBasePath' + | 'setupState' + | 'setupMethod' + | 'gitUsername' + | 'kind' + > + > +} + +export type ProjectHostSetupDeleteArgs = { + setupId: string +} + +export type ProjectHostSetupResult = { + project: Project + setup: ProjectHostSetup + repo: Repo +} + +export type ProjectHostSetupCreateResult = { + project: Project + setup: ProjectHostSetup +} + +export type ProjectHostSetupUpdateResult = { + project: Project + setup: ProjectHostSetup + repo?: Repo +} + +export type ProjectHostSetupDeleteResult = { + project: Project + setup: ProjectHostSetup + repo?: Repo +} diff --git a/src/shared/remote-workspace-session-projection.ts b/src/shared/remote-workspace-session-projection.ts index 647b577dba6..dc9a4019a8b 100644 --- a/src/shared/remote-workspace-session-projection.ts +++ b/src/shared/remote-workspace-session-projection.ts @@ -1,7 +1,7 @@ import { getDefaultWorkspaceSession } from './constants' import type { RemoteWorkspaceSession, RemoteWorkspaceTerminalTab } from './remote-workspace-types' import type { TerminalTab, WorkspaceSessionState } from './types' -import { splitWorktreeId } from './worktree-id' +import { splitWorktreeId } from './worktree/id' type ExportOptions = { isTargetWorktree: (worktreeId: string) => boolean diff --git a/src/shared/repo-host-identity.ts b/src/shared/repo-host-identity.ts new file mode 100644 index 00000000000..8309899d4e6 --- /dev/null +++ b/src/shared/repo-host-identity.ts @@ -0,0 +1,5 @@ +export function getRepoHostIdentityForParts(repoId: string, hostId: string): string { + // Why: host ids and repo ids can contain punctuation; NUL keeps the composite + // key collision-free without escaping user/provider-owned strings. + return `${hostId}\0${repoId}` +} diff --git a/src/shared/repo-types.ts b/src/shared/repo-types.ts new file mode 100644 index 00000000000..a6980b45d54 --- /dev/null +++ b/src/shared/repo-types.ts @@ -0,0 +1,131 @@ +import type { RepoIcon } from './repo-icon' +import type { GitHubRepositoryIdentity } from './github/pull-request-types' +import type { RepoHookSettings } from './orca-yaml-hook-types' +import type { ForkSyncMode } from './git-fork-sync' +import type { GitRemoteIdentity } from './git-remote-identity' +import type { RepoSourceControlAiOverrides } from './source-control-ai-types' +import type { RepoProjectHostSetupMethod } from './project-types' + +// ─── Repo ──────────────────────────────────────────────────────────── +export type RepoKind = 'git' | 'folder' + +/** + * Per-repo user choice for where issues are fetched and filed. + * + * Why three states, not two: storage must distinguish "user explicitly chose + * upstream" from "heuristic happens to resolve to upstream right now." Collapsing + * the two would let a remote-topology change (someone removes `upstream`, or + * adds one later) silently move the effective source — the exact silent-source- + * switch class the upstream-issue-source design rejects. + * + * - `'auto'` (or undefined): honor the heuristic in `getIssueOwnerRepo` + * (upstream-if-exists, else origin). Initial state for every repo. + * - `'upstream'`: explicit upstream. Wins over heuristic and future topology + * changes. Falls back to origin if `upstream` remote vanishes, with a toast. + * - `'origin'`: explicit origin. Same precedence. + */ +export type IssueSourcePreference = 'upstream' | 'origin' | 'auto' +export type ExternalWorktreeVisibility = 'hide' | 'show' + +export type BuiltInWorktreeVisibilitySourceId = 'claude' | 'gsd' + +export type CustomWorktreeVisibilitySource = { + id: string + rootPath: string +} + +export type WorktreeVisibilitySourcePreferences = { + builtIn?: Partial> + custom?: Record +} + +export type Repo = { + id: string + path: string + displayName: string + badgeColor: string + repoIcon?: RepoIcon | null + /** Set when the repo is a fork: the upstream/parent owner/repo. Drives the + * fork indicator and the default avatar of same-name forks (renamed forks + * keep their own owner). Absent = not a fork, or fork status not yet + * resolved. */ + upstream?: GitHubRepositoryIdentity | null + addedAt: number + kind?: RepoKind + gitUsername?: string + worktreeBaseRef?: string + /** Optional repo-scoped workspace root override. Relative paths resolve from `path`. */ + worktreeBasePath?: string + hookSettings?: RepoHookSettings + /** SSH target ID for remote repos. null/undefined = local. */ + connectionId?: string | null + /** + * Explicit execution owner for this repo. Runtime-host repos need this + * because they otherwise look identical to local repos (`connectionId: null`). + */ + executionHostId?: 'local' | `ssh:${string}` | `runtime:${string}` | null + /** Per-repo override for issue-source resolution. `undefined` is treated + * identically to `'auto'`; writers leave it undefined on creation so + * existing persisted records stay forward-compatible. */ + issueSourcePreference?: IssueSourcePreference + /** Controls Orca's fork-default-branch sync offer for repos with upstream metadata. */ + forkSyncMode?: ForkSyncMode + /** Canonical identity for the repo remote Orca should use for provider-level grouping. */ + gitRemoteIdentity?: GitRemoteIdentity | null + /** Controls whether worktrees Orca did not create appear in the sidebar. */ + externalWorktreeVisibility?: ExternalWorktreeVisibility + /** True when the repo predates hidden-by-default external worktrees. */ + externalWorktreeVisibilityLegacy?: boolean + /** One-shot guard for the optional existing-user visibility prompt. */ + externalWorktreeVisibilityPromptDismissedAt?: number + /** Hidden external worktree paths acknowledged by Keep hidden on the inbox. */ + externalWorktreeInboxBaselinePaths?: string[] + /** External worktree paths explicitly imported while global visibility stays hide. */ + importedExternalWorktreePaths?: string[] + /** Opt-in repo policy for coding-agent scratch worktrees; absent means hide. */ + agentWorktreeVisibility?: ExternalWorktreeVisibility + /** User-defined roots classified independently from ordinary external worktrees. */ + customWorktreeVisibilitySources?: CustomWorktreeVisibilitySource[] + /** Per-source visibility; absent built-ins inherit the legacy agent policy. */ + worktreeVisibilitySourcePreferences?: WorktreeVisibilitySourcePreferences + /** User permanently opted out of the new-external-worktree inbox for this repo. */ + externalWorktreeDiscoverySuppressedAt?: number + /** Paths (relative to the primary checkout) that should be APFS clone-copied + * on macOS when possible, otherwise symlinked, into newly created worktrees. + * Undefined/empty means no shared paths are created for this repo. */ + symlinkPaths?: string[] + /** Durable sidebar-only repo organization. Execution remains repo-scoped. */ + projectGroupId?: string | null + /** User-authored ordering inside the project group or ungrouped bucket. */ + projectGroupOrder?: number + /** Repo-specific source-control AI overrides. Missing fields inherit global settings. */ + sourceControlAi?: RepoSourceControlAiOverrides + /** Transitional source for ProjectHostSetup.setupMethod while Repo remains compatibility storage. */ + projectHostSetupMethod?: RepoProjectHostSetupMethod +} + +/** + * Envelope returned by the `repos:getBaseRefDefault` IPC handler. + * + * Why: declared in `shared/` rather than colocated with the handler so the + * preload bridge and renderer can import the same named type. Before this + * lived in `src/main/git/repo.ts` — the preload layer cannot import from + * `src/main/`, which forced three sites to inline the same structural shape + * and risk silent drift. + * + * Why `remoteCount`: BaseRefPicker renders a multi-remote hint when the repo + * has more than one configured remote; piggybacking the count on this IPC + * avoids a second round-trip. + * + * Why `defaultBaseRef` (not `default`): `default` is a reserved word and is + * awkward to destructure. + */ +export type BaseRefDefaultResult = { + defaultBaseRef: string | null + remoteCount: number +} + +export type BaseRefSearchResult = { + refName: string + localBranchName: string +} diff --git a/src/shared/shell-path-hydration-types.ts b/src/shared/shell-path-hydration-types.ts new file mode 100644 index 00000000000..bae5efad41f --- /dev/null +++ b/src/shared/shell-path-hydration-types.ts @@ -0,0 +1,14 @@ +// ─── Shell PATH hydration ──────────────────────────────────────────── +// Why: shared so the main-side `HydrationResult` discriminator and the +// telemetry schema in `telemetry-events.ts` stay in lockstep without +// `src/shared/` taking a forbidden import from `src/main/`. A compile-time +// guard in telemetry-events.ts asserts the schema enum matches this alias — +// adding a new failure mode without updating both places fails the build. +export type ShellHydrationFailureReason = + | 'none' + | 'no_shell' + | 'timeout' + | 'spawn_error' + | 'empty_path' + +export type PathSource = 'shell_hydrate' | 'sync_seed_only' diff --git a/src/shared/structural-value-equality.test.ts b/src/shared/structural-value-equality.test.ts new file mode 100644 index 00000000000..57a7a471dd5 --- /dev/null +++ b/src/shared/structural-value-equality.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import { + structuralValuesEqual, + structuralValuesEqualIgnoringUndefined +} from './structural-value-equality' + +const comparators = [ + ['structuralValuesEqual', structuralValuesEqual], + ['structuralValuesEqualIgnoringUndefined', structuralValuesEqualIgnoringUndefined] +] as const + +describe.each(comparators)('%s', (_name, valuesEqual) => { + it('compares primitives and identical references', () => { + expect(valuesEqual('a', 'a')).toBe(true) + expect(valuesEqual('a', 'b')).toBe(false) + expect(valuesEqual(null, null)).toBe(true) + expect(valuesEqual(null, {})).toBe(false) + expect(valuesEqual(undefined, null)).toBe(false) + const fn = (): void => {} + expect(valuesEqual(fn, fn)).toBe(true) + expect(valuesEqual(fn, (): void => {})).toBe(false) + }) + + it('walks arrays element-wise and never mixes them with records', () => { + expect(valuesEqual([1, [2, { a: 'b' }]], [1, [2, { a: 'b' }]])).toBe(true) + expect(valuesEqual([1, 2], [1, 2, 3])).toBe(false) + expect(valuesEqual([1, 2], [2, 1])).toBe(false) + expect(valuesEqual([], {})).toBe(false) + expect(valuesEqual({ 0: 1, length: 1 }, [1])).toBe(false) + }) + + it('walks nested plain records rebuilt by structured clone', () => { + const record = { id: 'a', nested: { labels: ['one', 'two'], flags: { on: true } } } + expect(valuesEqual(record, structuredClone(record))).toBe(true) + expect( + valuesEqual(record, { ...record, nested: { labels: ['one'], flags: { on: true } } }) + ).toBe(false) + }) + + it('walks null-prototype records like literals', () => { + const nullProto: Record = Object.create(null) + nullProto.a = 1 + expect(valuesEqual(nullProto, { a: 1 })).toBe(true) + expect(valuesEqual(nullProto, { a: 2 })).toBe(false) + }) + + it('falls back to reference equality for non-plain objects', () => { + const date = new Date(0) + expect(valuesEqual(date, date)).toBe(true) + expect(valuesEqual(date, new Date(0))).toBe(false) + expect(valuesEqual(new Map([['a', 1]]), new Map([['a', 1]]))).toBe(false) + expect(valuesEqual(new Set([1]), new Set([1]))).toBe(false) + class Row { + x = 1 + } + expect(valuesEqual(new Row(), new Row())).toBe(false) + expect(valuesEqual(new Row(), { x: 1 })).toBe(false) + }) + + it('ignores symbol keys', () => { + const key = Symbol('marker') + expect(valuesEqual({ [key]: 1, a: 1 }, { [key]: 2, a: 1 })).toBe(true) + }) +}) + +describe('structuralValuesEqual', () => { + it('treats an absent key as different from a key holding undefined', () => { + // Why: repo/project merges branch on `'key' in project`, so key presence is load-bearing. + expect(structuralValuesEqual({ a: 1 }, { a: 1, b: undefined })).toBe(false) + expect(structuralValuesEqual({ a: 1, b: undefined }, { a: 1 })).toBe(false) + expect(structuralValuesEqual({ a: 1, b: undefined }, { a: 1, c: undefined })).toBe(false) + expect(structuralValuesEqual({ a: 1, b: undefined }, { a: 1, b: undefined })).toBe(true) + }) + + it('compares leaves with === so NaN is never equal and -0 matches 0', () => { + expect(structuralValuesEqual({ a: Number.NaN }, { a: Number.NaN })).toBe(false) + expect(structuralValuesEqual({ a: 0 }, { a: -0 })).toBe(true) + }) +}) + +describe('structuralValuesEqualIgnoringUndefined', () => { + it('treats an absent key as equal to a key holding undefined', () => { + // Why: locally built worktree rows carry explicit undefined fields the host omits. + expect(structuralValuesEqualIgnoringUndefined({ a: 1 }, { a: 1, b: undefined })).toBe(true) + expect(structuralValuesEqualIgnoringUndefined({ a: 1, b: undefined }, { a: 1 })).toBe(true) + expect( + structuralValuesEqualIgnoringUndefined({ a: 1, b: undefined }, { a: 1, c: undefined }) + ).toBe(true) + expect(structuralValuesEqualIgnoringUndefined({ a: 1 }, { a: 1, b: 2 })).toBe(false) + }) + + it('compares leaves with Object.is so NaN is equal and -0 differs from 0', () => { + expect(structuralValuesEqualIgnoringUndefined({ a: Number.NaN }, { a: Number.NaN })).toBe(true) + expect(structuralValuesEqualIgnoringUndefined({ a: 0 }, { a: -0 })).toBe(false) + }) +}) diff --git a/src/shared/structural-value-equality.ts b/src/shared/structural-value-equality.ts new file mode 100644 index 00000000000..d1c61eb4199 --- /dev/null +++ b/src/shared/structural-value-equality.ts @@ -0,0 +1,80 @@ +// Why: catalog reconcilers compare rows that IPC structured-clone (and main's hydration) rebuild on +// every fetch, so a reference compare reports every row as changed and nothing ever reconciles. +// Only plain records and arrays are walked; anything exotic (Date, Map, class instance) falls back +// to reference equality rather than being mistaken for an empty record. + +type ValueEqualityPolicy = { + // Why: `Object.is` makes NaN equal NaN but 0 unequal -0; `===` does the opposite. + readonly sameValueLeaves: boolean + readonly absentKeyEqualsUndefined: boolean +} + +const STRICT_OWN_KEYS: ValueEqualityPolicy = { + sameValueLeaves: false, + absentKeyEqualsUndefined: false +} + +const UNION_OF_KEYS: ValueEqualityPolicy = { + sameValueLeaves: true, + absentKeyEqualsUndefined: true +} + +function isPlainRecord(value: unknown): value is Record { + if (typeof value !== 'object' || value === null) { + return false + } + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +function valuesEqual(a: unknown, b: unknown, policy: ValueEqualityPolicy): boolean { + if (policy.sameValueLeaves ? Object.is(a, b) : a === b) { + return true + } + if (Array.isArray(a) || Array.isArray(b)) { + return ( + Array.isArray(a) && + Array.isArray(b) && + a.length === b.length && + a.every((item, index) => valuesEqual(item, b[index], policy)) + ) + } + if (!isPlainRecord(a) || !isPlainRecord(b)) { + return false + } + if (policy.absentKeyEqualsUndefined) { + for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) { + if (!valuesEqual(a[key], b[key], policy)) { + return false + } + } + return true + } + const keys = Object.keys(a) + if (keys.length !== Object.keys(b).length) { + return false + } + return keys.every((key) => Object.hasOwn(b, key) && valuesEqual(a[key], b[key], policy)) +} + +/** + * Structural compare where an absent own key differs from a key that is present and holds + * `undefined`, and leaves compare with `===`. + * + * Why the strict key set: the repo/project merges branch on `'localWindowsRuntimePreference' in + * project`, so a key appearing or disappearing is a real change even when its value is `undefined`. + */ +export function structuralValuesEqual(a: unknown, b: unknown): boolean { + return valuesEqual(a, b, STRICT_OWN_KEYS) +} + +/** + * Structural compare that treats an absent own key as equal to a key holding `undefined`, and + * compares leaves with `Object.is`. + * + * Why the loose key set: locally constructed worktree catalog rows carry explicit `undefined` + * fields that the host simply omits, and no consumer of those rows uses the `in` operator. + */ +export function structuralValuesEqualIgnoringUndefined(a: unknown, b: unknown): boolean { + return valuesEqual(a, b, UNION_OF_KEYS) +} diff --git a/src/shared/tab-types.ts b/src/shared/tab-types.ts new file mode 100644 index 00000000000..e321aaaee6f --- /dev/null +++ b/src/shared/tab-types.ts @@ -0,0 +1,66 @@ +import type { AiVaultSessionTitle } from './ai-vault-session-title' + +// ─── Tab Group Layout ─────────────────────────────────────────────── +export type TabGroupSplitDirection = 'horizontal' | 'vertical' + +export type TabGroupLayoutNode = + | { type: 'leaf'; groupId: string } + | { + type: 'split' + direction: TabGroupSplitDirection + first: TabGroupLayoutNode + second: TabGroupLayoutNode + /** Flex ratio of the first child (0–1). Defaults to 0.5 if absent. */ + ratio?: number + } + +// ─── Unified Tab ──────────────────────────────────────────────────── +export type TabContentType = + | 'terminal' + | 'editor' + | 'diff' + | 'conflict-review' + | 'check-details' + | 'browser' + | 'simulator' + +export type WorkspaceVisibleTabType = 'terminal' | 'editor' | 'browser' | 'simulator' +export type CtrlTabOrderMode = 'mru' | 'sequential' + +export type Tab = { + id: string // UUID for terminals, filePath for editors (preserves current convention) + entityId: string // ID of the backing content (terminal tab ID, file path, browser workspace ID) + groupId: string + worktreeId: string + contentType: TabContentType + label: string // display title (auto-derived from PTY or filename) + generatedLabel?: string | null + /** Stable AI Vault conversation name, bound to its provider session identity. */ + aiVaultTitle?: AiVaultSessionTitle | null + quickCommandLabel?: string | null + customLabel: string | null + color: string | null + sortOrder: number + createdAt: number + isPreview?: boolean // preview tabs get replaced by next single-click open + isPinned?: boolean // pinned tabs survive "close others" + /** Why: per-tab rendering mode for coding-agent terminals. `'chat'` shows the + * native chat view as an overlay while the live terminal stays mounted + * underneath; `'terminal'` (the default for legacy/missing) shows the raw + * xterm. Optional so sessions persisted before this field hydrate cleanly. */ + viewMode?: 'terminal' | 'chat' +} + +export type TabGroup = { + id: string + worktreeId: string + activeTabId: string | null + tabOrder: string[] // canonical visual order of tab IDs + /** Per-group MRU stack (oldest → most-recent at the tail). Drives which tab + * becomes active when the current active tab closes: we pop back to the + * previously-active tab instead of jumping to a visual neighbor. Scoped to + * the group so split panes keep independent histories. Optional because + * sessions persisted before this field was added still hydrate cleanly — + * hydration seeds from activeTabId. */ + recentTabIds?: string[] +} diff --git a/src/shared/task-provider-identity.ts b/src/shared/task-provider-identity.ts index cacd28f3232..5d9dbe8c390 100644 --- a/src/shared/task-provider-identity.ts +++ b/src/shared/task-provider-identity.ts @@ -1,4 +1,4 @@ -import { githubRepoIdentityKey } from './github-repository-identity-key' +import { githubRepoIdentityKey } from './github/repository-identity-key' import type { TaskProvider } from './task-providers' import type { ProjectProviderIdentity } from './types' diff --git a/src/shared/terminal-color-overrides.ts b/src/shared/terminal-color-overrides.ts new file mode 100644 index 00000000000..073ad9de2f8 --- /dev/null +++ b/src/shared/terminal-color-overrides.ts @@ -0,0 +1,28 @@ +export type TerminalColorOverrides = { + foreground?: string + background?: string + cursor?: string + cursorAccent?: string + selectionBackground?: string + selectionForeground?: string + black?: string + red?: string + green?: string + yellow?: string + blue?: string + magenta?: string + cyan?: string + white?: string + brightBlack?: string + brightRed?: string + brightGreen?: string + brightYellow?: string + brightBlue?: string + brightMagenta?: string + brightCyan?: string + brightWhite?: string + // Why: xterm.js ITheme does not expose a `bold` key, but Ghostty users + // expect the setting to be preserved so a future renderer CSS override + // or xterm upgrade can honour it without a migration. + bold?: string +} diff --git a/src/shared/terminal-github-pr-link-detector.ts b/src/shared/terminal-github-pr-link-detector.ts index 7d6204ce393..7cc0f3d44ab 100644 --- a/src/shared/terminal-github-pr-link-detector.ts +++ b/src/shared/terminal-github-pr-link-detector.ts @@ -8,8 +8,8 @@ * path. Both paths must share the carry/dedupe semantics or links split across * chunks would resolve differently per authority mode. */ -import type { RepoSlug } from './github-links' -import { parseGitHubIssueOrPRLink } from './github-links' +import type { RepoSlug } from './github/links' +import { parseGitHubIssueOrPRLink } from './github/links' const GITHUB_PR_PATH_MARKER = '/pull/' const TERMINAL_SGR_PATTERN = /\x1b\[[0-?]*[ -/]*m/g diff --git a/src/shared/terminal-quick-command-types.ts b/src/shared/terminal-quick-command-types.ts new file mode 100644 index 00000000000..a887f47b84d --- /dev/null +++ b/src/shared/terminal-quick-command-types.ts @@ -0,0 +1,32 @@ +import type { TuiAgent } from './tui-agent' + +export type TerminalQuickCommandScope = + | { + type: 'global' + } + | { + type: 'repo' + repoId: string + } + +export type TerminalQuickCommandAction = 'terminal-command' | 'agent-prompt' + +export type TerminalQuickCommandBase = { + id: string + label: string + scope?: TerminalQuickCommandScope +} + +export type TerminalCommandQuickCommand = TerminalQuickCommandBase & { + action?: 'terminal-command' + command: string + appendEnter: boolean +} + +export type TerminalAgentQuickCommand = TerminalQuickCommandBase & { + action: 'agent-prompt' + agent: TuiAgent + prompt: string +} + +export type TerminalQuickCommand = TerminalCommandQuickCommand | TerminalAgentQuickCommand diff --git a/src/shared/terminal-startup-cwd.ts b/src/shared/terminal-startup-cwd.ts index a6b3f935e1d..8344abf07cc 100644 --- a/src/shared/terminal-startup-cwd.ts +++ b/src/shared/terminal-startup-cwd.ts @@ -1,7 +1,7 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from './constants' import { resolveRuntimePath } from './cross-platform-path' import { parseWorkspaceKey } from './workspace-scope' -import { splitWorktreeIdForFilesystem } from './worktree-id' +import { splitWorktreeIdForFilesystem } from './worktree/id' export type TerminalStartupCwdMissingDirFallback = { // Why: only local callers can probe the filesystem — SSH/remote worktree diff --git a/src/shared/terminal-tab-types.ts b/src/shared/terminal-tab-types.ts new file mode 100644 index 00000000000..1c455333ea9 --- /dev/null +++ b/src/shared/terminal-tab-types.ts @@ -0,0 +1,88 @@ +import type { AiVaultSessionTitle } from './ai-vault-session-title' +import type { TuiAgent } from './tui-agent' + +// ─── Terminal Tab (legacy — used by persistence and TerminalContentSlice) ─ +export type TerminalTab = { + id: string + ptyId: string | null + worktreeId: string + title: string + /** Stable fallback label for default-named terminals ("Terminal 1", etc.). + * Why: agent CLIs overwrite the live title via OSC updates, but Orca still + * needs the original terminal label for numbering and reset behavior. */ + defaultTitle?: string + /** Stable opt-in label derived from the first known agent prompt. */ + generatedTitle?: string | null + /** Stable AI Vault conversation name, bound to its provider session identity. */ + aiVaultTitle?: AiVaultSessionTitle | null + /** Stable label from the tab-bar Quick Command that created this terminal. */ + quickCommandLabel?: string | null + customTitle: string | null + color: string | null + /** Pinned tabs survive "close others"; host-persisted for remote servers. */ + isPinned?: boolean + /** Per-tab view preference (terminal xterm vs native chat); host-persisted so + * paired clients converge. Optional: older persisted tabs default to 'terminal'. */ + viewMode?: 'terminal' | 'chat' + sortOrder: number + createdAt: number + /** Bumped on shutdown so TerminalPane remounts with a fresh PTY. */ + generation?: number + /** Why: records the shell this tab was opened with (e.g. 'wsl.exe') so the + * PTY and tab icon stay stable even if the default shell setting changes + * later. Older persisted tabs may omit this field. */ + shellOverride?: string + /** Keeps an ephemeral host fallback out of the active project's runtime. */ + forceHostRuntime?: boolean + /** Why: explorer-created terminals can start below the workspace root while + * still belonging to that workspace for tab/session ownership. */ + startupCwd?: string + /** Why: the coding-harness agent Orca launched in this tab. Lets the tab bar + * show the provider icon immediately, before the agent emits its first hook + * event (a freshly-launched, idle agent reports no live status yet). Live + * hook status overrides this once the agent does anything. Plain terminals + * and manually-started agents omit it. */ + launchAgent?: TuiAgent + /** Why: when `setActiveWorktree` bumps generation on all-dead tabs to drive a + * TerminalPane remount, the fresh PTY that results is caused by navigation, + * not by the user doing work. Without this flag the resulting + * `updateTabPtyId` call would call `bumpWorktreeActivity` and flip the + * sidebar's recency sort on every click — the reorder-on-click bug. The + * flag is set by `setActiveWorktree` and consumed by the activation-driven + * PTY lifecycle calls that follow, which then suppress activity bumps and + * `sortEpoch` increments. Split layouts use a numeric count because one tab + * can remount several panes. Never persisted — it is a transient handoff. */ + pendingActivationSpawn?: boolean | number +} + +export type TerminalPaneSplitDirection = 'vertical' | 'horizontal' + +export type TerminalPaneLayoutNode = + | { + type: 'leaf' + leafId: string + } + | { + type: 'split' + direction: TerminalPaneSplitDirection + first: TerminalPaneLayoutNode + second: TerminalPaneLayoutNode + /** Flex ratio of the first child (0–1). Defaults to 0.5 if absent. */ + ratio?: number + } + +export type TerminalLayoutSnapshot = { + root: TerminalPaneLayoutNode | null + activeLeafId: string | null + expandedLeafId: string | null + /** Live PTY IDs per leaf for in-session remounts such as tab-group moves. + * Not used for app restart because PTYs are transient processes. */ + ptyIdsByLeafId?: Record + /** Serialized terminal buffers per leaf for scrollback restoration on restart. */ + buffersByLeafId?: Record + /** Durable scrollback snapshot refs per leaf; raw bytes live outside session JSON. */ + scrollbackRefsByLeafId?: Record + /** User-assigned pane titles, keyed by stable layout leaf UUID. + * Persisted alongside buffers via the existing session:set flow. */ + titlesByLeafId?: Record +} diff --git a/src/shared/tui-agent.ts b/src/shared/tui-agent.ts new file mode 100644 index 00000000000..a885e3dd24c --- /dev/null +++ b/src/shared/tui-agent.ts @@ -0,0 +1,39 @@ +/** All AI coding agents Orca knows how to launch. Used for the agent picker in the new-workspace + * flow and for the default-agent setting. Extend this union as new agents are added. */ +export type TuiAgent = + | 'claude' // Claude Code + | 'claude-agent-teams' // Claude Code Agent Teams via Orca native panes + | 'openclaude' // OpenClaude + | 'codex' // OpenAI Codex + | 'autohand' // Autohand Code CLI + | 'opencode' // OpenCode + | 'mimo-code' + | 'pi' // Pi (pi.dev) + | 'omp' // OMP (omp.sh) + | 'gemini' // Gemini CLI + | 'antigravity' // Google Antigravity CLI + | 'aider' // Aider + | 'goose' // Goose + | 'amp' // Amp + | 'kilo' // Kilocode + | 'kiro' // Kiro + | 'crush' // Charm/Crush + | 'aug' // Augment/Auggie + | 'cline' // Cline + | 'codebuff' // Codebuff + | 'command-code' // Command Code + | 'continue' // Continue + | 'cursor' // Cursor + | 'droid' // Factory Droid + | 'kimi' // Kimi + | 'mistral-vibe' // Mistral Vibe + | 'qwen-code' // Qwen Code + | 'rovo' // Rovo Dev + | 'hermes' // Hermes Agent + | 'openclaw' // OpenClaw + | 'copilot' // GitHub Copilot CLI + | 'grok' // xAI Grok CLI + | 'devin' // Devin CLI + | 'ante' // Ante (Antigma Labs) + | 'trae' // Trae CLI + | 'prime-agent' // Prime Agent (Prime Intellect) diff --git a/src/shared/types.ts b/src/shared/types.ts index ca155a0fdc3..8005dd186ac 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,64 +1,112 @@ -/* eslint-disable max-lines */ -import type { ExecutionHostId } from './execution-host' -import type { - RemovedSshTargetTombstone, - SshPtyConsumerRecovery, - SshRemotePtyLease, - SshTarget -} from './ssh-types' -import type { Automation, AutomationExecutionTargetType, AutomationRun } from './automations-types' -import type { WorkspaceSource } from './workspace-source' -import type { DedicatedRepoChannel, ReleaseBuild, ReleaseChannel } from './release-channel' -import type { GitHubProjectSettings } from './github-project-types' -import type { - AgentStatusState, - AgentType, - MigrationUnsupportedPtyEntry -} from './agent-status-types' -import type { VoiceSettings } from './speech-types' -import type { WorkspaceCleanupUIState } from './workspace-cleanup' -import type { LargeDiffRenderLimit } from './large-diff-render-limit' -import type { GitLabProjectSettings } from './gitlab-types' -import type { TaskProvider } from './task-providers' -import type { FeatureTipId } from './feature-tips' -import type { ContextualTourId } from './contextual-tours' -import type { - FeatureInteractionState, - FeatureInteractionTelemetryBucketState -} from './feature-interactions' -import type { GitBranchChangeStatus } from './git-status-types' -import type { KeybindingOverrides, TerminalShortcutPolicy } from './keybindings' -import type { RepoIcon } from './repo-icon' -import type { AppIconId } from './app-icon' -import type { - RepoSourceControlAiOverrides, - SourceControlAiSettings -} from './source-control-ai-types' -import type { StartupCommandDelivery } from './codex-startup-delivery' -import type { AgentKind, LaunchSource, RequestKind } from './telemetry-events' -import type { SleepingAgentLaunchConfig, SleepingAgentSessionRecord } from './agent-session-resume' -import type { ClaudeAgentTeamsMode } from './claude-agent-teams-tmux-compat' -import type { TerminalCustomTheme } from './terminal-custom-themes' -import type { UiLanguage } from './ui-language' -import type { ForkSyncMode } from './git-fork-sync' -import type { GitRemoteIdentity } from './git-remote-identity' -import type { - GlobalWindowsRuntimeDefault, - LocalWindowsRuntimePreference -} from './project-execution-runtime' -import type { UsagePercentageDisplay } from './usage-percentage-display' -import type { StatusBarUsageMode } from './status-bar-usage-mode' -import type { PersistedNativeChatSessionOptions } from './native-chat-session-options' -import type { CodexResetCreditAttemptLedger } from './codex-reset-credit-attempt-ledger' -import type { TaskSourceContext } from './task-source-context' -import type { SetupRunnerShell } from './setup-runner-command' -import type { AiVaultSessionTitle } from './ai-vault-session-title' -import type { ComputerAwakeMode } from './computer-awake-mode' +/** + * Public barrel for Orca's shared domain types. Declarations live in the + * per-domain modules re-exported below; keep this file re-exports only. + */ + +// Repos, projects, and project groups + +export type { RepoKind, IssueSourcePreference, ExternalWorktreeVisibility } from './repo-types' +export type { BuiltInWorktreeVisibilitySourceId } from './repo-types' +export type { CustomWorktreeVisibilitySource } from './repo-types' +export type { WorktreeVisibilitySourcePreferences, Repo, BaseRefDefaultResult } from './repo-types' +export type { WorktreeVisibilityDefaults } from './global-settings-types' +export type { BaseRefSearchResult } from './repo-types' + +export type { ForkSyncMode, GitForkSyncExpectedUpstream, GitForkSyncResult } from './git-fork-sync' + +export type { ProjectProviderIdentity, Project, ProjectUpdateArgs } from './project-types' +export type { ProjectHostSetupState, ProjectHostSetupMethod } from './project-types' +export type { RepoProjectHostSetupMethod, ProjectHostSetup } from './project-types' +export type { ProjectHostSetupExistingFolderArgs } from './project-types' +export type { ProjectHostSetupCreateArgs, ProjectHostSetupCloneArgs } from './project-types' +export type { ProjectHostSetupUpdateArgs, ProjectHostSetupDeleteArgs } from './project-types' +export type { ProjectHostSetupResult, ProjectHostSetupCreateResult } from './project-types' +export type { ProjectHostSetupUpdateResult, ProjectHostSetupDeleteResult } from './project-types' + +export type { ProjectGroupCreatedFrom, ProjectGroup } from './project-group-types' +export type { NestedRepoScanOptions, NestedRepoCandidate } from './project-group-types' +export type { NestedRepoScanResult, ProjectGroupImportMode } from './project-group-types' +export type { ProjectGroupImportProjectResult } from './project-group-types' +export type { ProjectGroupImportResult } from './project-group-types' + +export type { WorkspaceScope, WorkspaceKey, FolderWorkspace } from './folder-workspace-types' +export type { FolderWorkspaceLinkedTask } from './folder-workspace-types' + +// Worktrees and workspace session state + +export type { GitWorktreeInfo, WorktreeHeadIdentity, WorkspaceStatus } from './worktree/types' +export type { WorkspaceStatusDefinition, Worktree, WorkspaceLinkedItem } from './worktree/types' +export type { CliWorkspaceProvenance, WorkspaceCreatorProvenance } from './worktree/types' +export type { AutomationWorkspaceProvenance } from './worktree/types' +export type { AutomationWorkspaceProvenanceRequest, GitPushTarget } from './worktree/types' +export type { GitHubPrStartPoint, WorktreeOwnership } from './worktree/types' +export type { DetectedWorktreeListSource, DetectedWorktree } from './worktree/types' +export type { DetectedWorktreeListResult } from './worktree/types' + +export type { WorktreeMeta } from './worktree/meta-types' + +export type { WorktreeLineageOrigin } from './worktree/lineage-types' +export type { WorktreeLineageCaptureConfidence } from './worktree/lineage-types' +export type { WorktreeLineageCaptureSource, WorktreeLineageCapture } from './worktree/lineage-types' +export type { WorktreeLineage, WorkspaceLineage } from './worktree/lineage-types' +export type { WorktreeLineageWarningCode, WorktreeLineageWarning } from './worktree/lineage-types' + +export type { WorktreeSetupLaunch, WorktreeStartupLaunch } from './worktree/launch-types' +export type { WorktreeDefaultTabsLaunch, SetupScriptLaunchMode } from './worktree/launch-types' +export type { SetupSplitDirection } from './worktree/launch-types' + +export type { SetupDecision, WorktreeCreateTimingPhase } from './worktree/create-types' +export type { WorktreeCreateTiming, CreateSparseCheckoutRequest } from './worktree/create-types' +export type { SparsePreset, CreateWorktreeArgs } from './worktree/create-types' +export type { AdoptProvisionedRootArgs, CreateWorktreeResult } from './worktree/create-types' +export type { WorktreeCreateBaseFallback, PreservedWorktreeBranch } from './worktree/create-types' +export type { RemoveWorktreeResult, ForceDeleteWorktreeBranchResult } from './worktree/create-types' + +export type { LocalBaseRefRefreshResult } from './worktree/base-ref-drift-types' +export type { LocalBaseRefUpdateSuggestion } from './worktree/base-ref-drift-types' +export type { WorktreeBaseStatusKind } from './worktree/base-ref-drift-types' +export type { WorktreeBaseStatusEvent } from './worktree/base-ref-drift-types' +export type { WorktreeRemoteBranchConflictEvent } from './worktree/base-ref-drift-types' + +export type { PersistedOpenFile, WorkspaceSessionState } from './workspace-session-state-types' +export type { WorkspaceSessionPatch } from './workspace-session-state-types' // Re-exported for backward compat with renderer call sites that import // `WorkspaceCreateTelemetrySource` from '../../../shared/types'. export type { WorkspaceSource as WorkspaceCreateTelemetrySource } from './workspace-source' -export type { TaskProvider } from './task-providers' + +// Tabs, terminals, and the embedded browser + +export type { TabGroupSplitDirection, TabGroupLayoutNode, TabContentType } from './tab-types' +export type { WorkspaceVisibleTabType, CtrlTabOrderMode, Tab, TabGroup } from './tab-types' + +export type { TerminalTab, TerminalPaneSplitDirection } from './terminal-tab-types' +export type { TerminalPaneLayoutNode, TerminalLayoutSnapshot } from './terminal-tab-types' + +export type { TerminalColorOverrides } from './terminal-color-overrides' + +export type { TerminalQuickCommandScope } from './terminal-quick-command-types' +export type { TerminalQuickCommandAction } from './terminal-quick-command-types' +export type { TerminalQuickCommandBase } from './terminal-quick-command-types' +export type { TerminalCommandQuickCommand } from './terminal-quick-command-types' +export type { TerminalAgentQuickCommand } from './terminal-quick-command-types' +export type { TerminalQuickCommand } from './terminal-quick-command-types' + +export type { BrowserHistoryEntry, BrowserLoadError } from './browser-workspace-types' +export type { BrowserCertificateFailure } from './browser-workspace-types' +export type { BrowserCertificateProceedFailureReason } from './browser-workspace-types' +export type { BrowserCertificateProceedResult } from './browser-workspace-types' +export type { BrowserViewportPresetId, BrowserViewportOverride } from './browser-workspace-types' +export type { BrowserPage, BrowserWorkspace, BrowserTab } from './browser-workspace-types' +export type { BrowserSessionProfileScope } from './browser-workspace-types' +export type { BrowserSessionUserAgentMode } from './browser-workspace-types' +export type { BrowserSessionProfileCreateOptions } from './browser-workspace-types' +export type { BrowserSessionProfileSource, BrowserSessionProfile } from './browser-workspace-types' +export type { BrowserCookieImportSummary } from './browser-workspace-types' +export type { BrowserCookieImportResult } from './browser-workspace-types' + +// Git status, diffs, and review comments + export type { GitBranchChangeStatus, GitConflictKind, @@ -74,1972 +122,70 @@ export type { GitUpstreamStatus } from './git-status-types' -// ─── Shell PATH hydration ──────────────────────────────────────────── -// Why: shared so the main-side `HydrationResult` discriminator and the -// telemetry schema in `telemetry-events.ts` stay in lockstep without -// `src/shared/` taking a forbidden import from `src/main/`. A compile-time -// guard in telemetry-events.ts asserts the schema enum matches this alias — -// adding a new failure mode without updating both places fails the build. -export type ShellHydrationFailureReason = - | 'none' - | 'no_shell' - | 'timeout' - | 'spawn_error' - | 'empty_path' +export type { GitBranchChangeEntry, GitBranchCompareSummary } from './git-diff-compare-types' +export type { GitBranchCompareResult, GitCommitCompareSummary } from './git-diff-compare-types' +export type { GitCommitCompareResult, GitDiffTextResult } from './git-diff-compare-types' +export type { GitDiffBinaryResult, GitDiffResult } from './git-diff-compare-types' + +export type { DiffCommentSource, DiffReviewScope } from './diff-comment-types' +export type { MobileDiffReviewFileState, MobileDiffReviewState } from './diff-comment-types' +export type { DiffComment } from './diff-comment-types' + +// GitHub + +export type { + PRState, + IssueState, + CheckStatus, + PRMergeableState +} from './github/pull-request-types' +export type { PRReviewDecision, PRConflictSummary } from './github/pull-request-types' +export type { GitHubRepositoryIdentity, GitHubOwnerRepo } from './github/pull-request-types' +export type { GitHubPRMergeMethod, GitHubPRMergeMethodSettings } from './github/pull-request-types' +export type { + GitHubPRStackEntry, + GitHubPRStack, + PRInfo, + IssueInfo +} from './github/pull-request-types' +export type { + GitHubViewer, + GitHubAssignableUser, + ProviderCheckSummary +} from './github/pull-request-types' +export type { GitHubPRReviewSummary, GitHubPRFileViewedState } from './github/pull-request-types' +export type { GitHubPRFile, GitHubPRFileContents } from './github/pull-request-types' + +export type { + PRRefreshErrorType, + PRRefreshUpstreamErrorType +} from './github/pull-request-refresh-types' +export type { PRRefreshOutcome, GitHubPRRefreshReason } from './github/pull-request-refresh-types' +export type { + GitHubPRRefreshEnqueueResult, + GitHubPRRefreshAlias +} from './github/pull-request-refresh-types' +export type { GitHubPRRefreshCandidate } from './github/pull-request-refresh-types' +export type { + GitHubPRRefreshSkippedReason, + GitHubPRRefreshEvent +} from './github/pull-request-refresh-types' + +export type { PRCheckDetail, PRCheckAnnotation, PRCheckStep } from './github/check-types' +export type { PRCheckJob, PRCheckRunDetails, GitHubRerunPRChecksResult } from './github/check-types' + +export type { GitHubReactionContent, GitHubReaction, PRComment } from './github/comment-types' +export type { GitHubCommentResult, GitHubPRReviewCommentInput } from './github/comment-types' +export type { GitHubIssueTimelineTarget, GitHubIssueTimelineItem } from './github/comment-types' + +export type { GitHubWorkItem, GitHubWorkItemDetails } from './github/work-item-types' +export type { ListWorkItemsResult } from './github/work-item-types' + +export type { GitHubRateLimitBucket, GitHubRateLimitSnapshot } from './github/rate-limit-types' +export type { GetRateLimitResult } from './github/rate-limit-types' + +// GitLab -export type PathSource = 'shell_hydrate' | 'sync_seed_only' - -// ─── Repo ──────────────────────────────────────────────────────────── -export type RepoKind = 'git' | 'folder' - -/** - * Per-repo user choice for where issues are fetched and filed. - * - * Why three states, not two: storage must distinguish "user explicitly chose - * upstream" from "heuristic happens to resolve to upstream right now." Collapsing - * the two would let a remote-topology change (someone removes `upstream`, or - * adds one later) silently move the effective source — the exact silent-source- - * switch class the upstream-issue-source design rejects. - * - * - `'auto'` (or undefined): honor the heuristic in `getIssueOwnerRepo` - * (upstream-if-exists, else origin). Initial state for every repo. - * - `'upstream'`: explicit upstream. Wins over heuristic and future topology - * changes. Falls back to origin if `upstream` remote vanishes, with a toast. - * - `'origin'`: explicit origin. Same precedence. - */ -export type IssueSourcePreference = 'upstream' | 'origin' | 'auto' -export type { ForkSyncMode, GitForkSyncExpectedUpstream, GitForkSyncResult } from './git-fork-sync' -export type ExternalWorktreeVisibility = 'hide' | 'show' - -export type BuiltInWorktreeVisibilitySourceId = 'claude' | 'gsd' - -export type CustomWorktreeVisibilitySource = { - id: string - rootPath: string -} - -export type WorktreeVisibilitySourcePreferences = { - builtIn?: Partial> - custom?: Record -} - -export type WorktreeVisibilityDefaults = { - /** Default for worktrees outside a recognized source. */ - external?: ExternalWorktreeVisibility - /** Host-owned roots applied to every repository on that host. */ - customSources?: CustomWorktreeVisibilitySource[] - /** Defaults for built-in and host-owned custom sources. */ - sourcePreferences?: WorktreeVisibilitySourcePreferences -} - -export type ProjectProviderIdentity = { - provider: 'github' - owner: string - repo: string - host?: string -} - -export type Project = { - id: string - displayName: string - badgeColor: string - repoIcon?: RepoIcon | null - kind?: RepoKind - providerIdentity?: ProjectProviderIdentity - gitRemoteIdentity?: GitRemoteIdentity - /** Local Windows projects inherit the global runtime default unless this override is set. */ - localWindowsRuntimePreference?: LocalWindowsRuntimePreference - sourceRepoIds: string[] - createdAt: number - updatedAt: number -} - -export type ProjectUpdateArgs = { - projectId: string - updates: Partial> -} - -export type ProjectHostSetupState = 'ready' | 'not-set-up' | 'setting-up' | 'error' | 'unsupported' -export type ProjectHostSetupMethod = - | 'legacy-repo' - | 'imported-existing-folder' - | 'cloned' - | 'provisioned' -export type RepoProjectHostSetupMethod = Extract< - ProjectHostSetupMethod, - 'imported-existing-folder' | 'cloned' -> - -export type ProjectHostSetup = { - id: string - projectId: string - hostId: ExecutionHostId - repoId: string - path: string - displayName: string - kind?: RepoKind - connectionId?: string | null - executionHostId?: ExecutionHostId | null - /** Renderer projection of the paired runtime that owns this setup's transport. */ - runtimeOwnerEnvironmentId?: string - worktreeBasePath?: string - hookSettings?: RepoHookSettings - gitUsername?: string - setupState: ProjectHostSetupState - setupMethod: ProjectHostSetupMethod - sourceControlAi?: RepoSourceControlAiOverrides - createdAt: number - updatedAt: number -} - -export type ProjectHostSetupExistingFolderArgs = { - projectId: string - projectProviderIdentity?: ProjectProviderIdentity - hostId: ExecutionHostId - path: string - kind?: RepoKind - displayName?: string - setupMethod?: RepoProjectHostSetupMethod -} - -export type ProjectHostSetupCreateArgs = { - projectId: string - hostId: ExecutionHostId - setupId?: string - path?: string - kind?: RepoKind - displayName?: string - worktreeBasePath?: string - gitUsername?: string - setupState?: ProjectHostSetupState - setupMethod?: Exclude -} - -export type ProjectHostSetupCloneArgs = { - projectId: string - projectProviderIdentity?: ProjectProviderIdentity - hostId: ExecutionHostId - url: string - destination: string - displayName?: string -} - -export type ProjectHostSetupUpdateArgs = { - setupId: string - updates: Partial< - Pick< - ProjectHostSetup, - | 'displayName' - | 'path' - | 'worktreeBasePath' - | 'setupState' - | 'setupMethod' - | 'gitUsername' - | 'kind' - > - > -} - -export type ProjectHostSetupDeleteArgs = { - setupId: string -} - -export type ProjectHostSetupResult = { - project: Project - setup: ProjectHostSetup - repo: Repo -} - -export type ProjectHostSetupCreateResult = { - project: Project - setup: ProjectHostSetup -} - -export type ProjectHostSetupUpdateResult = { - project: Project - setup: ProjectHostSetup - repo?: Repo -} - -export type ProjectHostSetupDeleteResult = { - project: Project - setup: ProjectHostSetup - repo?: Repo -} - -export type Repo = { - id: string - path: string - displayName: string - badgeColor: string - repoIcon?: RepoIcon | null - /** Set when the repo is a fork: the upstream/parent owner/repo. Drives the - * fork indicator and the default avatar of same-name forks (renamed forks - * keep their own owner). Absent = not a fork, or fork status not yet - * resolved. */ - upstream?: GitHubRepositoryIdentity | null - addedAt: number - kind?: RepoKind - gitUsername?: string - worktreeBaseRef?: string - /** Optional repo-scoped workspace root override. Relative paths resolve from `path`. */ - worktreeBasePath?: string - hookSettings?: RepoHookSettings - /** SSH target ID for remote repos. null/undefined = local. */ - connectionId?: string | null - /** - * Explicit execution owner for this repo. Runtime-host repos need this - * because they otherwise look identical to local repos (`connectionId: null`). - */ - executionHostId?: 'local' | `ssh:${string}` | `runtime:${string}` | null - /** Per-repo override for issue-source resolution. `undefined` is treated - * identically to `'auto'`; writers leave it undefined on creation so - * existing persisted records stay forward-compatible. */ - issueSourcePreference?: IssueSourcePreference - /** Controls Orca's fork-default-branch sync offer for repos with upstream metadata. */ - forkSyncMode?: ForkSyncMode - /** Canonical identity for the repo remote Orca should use for provider-level grouping. */ - gitRemoteIdentity?: GitRemoteIdentity | null - /** Controls whether worktrees Orca did not create appear in the sidebar. */ - externalWorktreeVisibility?: ExternalWorktreeVisibility - /** True when the repo predates hidden-by-default external worktrees. */ - externalWorktreeVisibilityLegacy?: boolean - /** One-shot guard for the optional existing-user visibility prompt. */ - externalWorktreeVisibilityPromptDismissedAt?: number - /** Hidden external worktree paths acknowledged by Keep hidden on the inbox. */ - externalWorktreeInboxBaselinePaths?: string[] - /** External worktree paths explicitly imported while global visibility stays hide. */ - importedExternalWorktreePaths?: string[] - /** Opt-in repo policy for coding-agent scratch worktrees; absent means hide. */ - agentWorktreeVisibility?: ExternalWorktreeVisibility - /** User-defined roots classified independently from ordinary external worktrees. */ - customWorktreeVisibilitySources?: CustomWorktreeVisibilitySource[] - /** Per-source visibility; absent built-ins inherit the legacy agent policy. */ - worktreeVisibilitySourcePreferences?: WorktreeVisibilitySourcePreferences - /** User permanently opted out of the new-external-worktree inbox for this repo. */ - externalWorktreeDiscoverySuppressedAt?: number - /** Paths (relative to the primary checkout) that should be APFS clone-copied - * on macOS when possible, otherwise symlinked, into newly created worktrees. - * Undefined/empty means no shared paths are created for this repo. */ - symlinkPaths?: string[] - /** Durable sidebar-only repo organization. Execution remains repo-scoped. */ - projectGroupId?: string | null - /** User-authored ordering inside the project group or ungrouped bucket. */ - projectGroupOrder?: number - /** Repo-specific source-control AI overrides. Missing fields inherit global settings. */ - sourceControlAi?: RepoSourceControlAiOverrides - /** Transitional source for ProjectHostSetup.setupMethod while Repo remains compatibility storage. */ - projectHostSetupMethod?: RepoProjectHostSetupMethod -} - -export type ProjectGroupCreatedFrom = 'manual' | 'folder-scan' | 'migration' - -export type ProjectGroup = { - id: string - name: string - parentPath: string | null - /** SSH target ID for folder-backed groups imported from a remote root. */ - connectionId?: string | null - /** Renderer-owned host stamp for groups fetched from a runtime environment. */ - executionHostId?: string | null - parentGroupId: string | null - createdFrom: ProjectGroupCreatedFrom - tabOrder: number - isCollapsed: boolean - color: string | null - createdAt: number - updatedAt: number -} - -export type WorkspaceScope = - | { type: 'worktree'; worktreeId: string } - | { type: 'folder'; folderWorkspaceId: string } - -export type WorkspaceKey = `worktree:${string}` | `folder:${string}` - -export type FolderWorkspace = { - id: string - projectGroupId: string - name: string - folderPath: string - /** SSH target ID for folder workspaces whose folder path lives remotely. */ - connectionId?: string | null - /** Renderer-owned host stamp for host-qualified folder catalogs. */ - executionHostId?: ExecutionHostId | null - /** Authenticated client that created this workspace. Missing means unknown legacy origin. */ - creatorProvenance?: WorkspaceCreatorProvenance - linkedTask: WorkspaceLinkedItem | null - linkedTaskSourceContext?: TaskSourceContext | null - comment: string - isArchived: boolean - isUnread: boolean - isPinned: boolean - sortOrder: number - /** User-authored sidebar ordering. Higher values render earlier in Manual sort. */ - manualOrder?: number - workspaceStatus?: WorkspaceStatus - createdWithAgent?: TuiAgent - pendingFirstAgentMessageRename?: boolean - firstAgentMessageRenameError?: string | null - lastActivityAt: number - createdAt: number - updatedAt: number - diffComments?: DiffComment[] -} - -export type WorkspaceLinkedItem = { - provider: 'github' | 'gitlab' | 'linear' | 'jira' - type: 'issue' | 'pr' | 'mr' - number: number - title: string - url: string - linearIdentifier?: string - jiraIdentifier?: string - repoId?: string -} - -export type FolderWorkspaceLinkedTask = WorkspaceLinkedItem - -export type NestedRepoScanOptions = { - maxDepth?: number - maxRepos?: number - timeoutMs?: number | null -} - -export type NestedRepoCandidate = { - path: string - displayName: string - depth: number -} - -export type NestedRepoScanResult = { - selectedPath: string - selectedPathKind: 'git_repo' | 'non_git_folder' - repos: NestedRepoCandidate[] - truncated: boolean - timedOut: boolean - stopped: boolean - durationMs: number - maxDepth: number - maxRepos: number - timeoutMs: number | null -} - -export type ProjectGroupImportMode = 'group' | 'separate' - -export type ProjectGroupImportProjectResult = { - path: string - projectId?: string - status: 'imported' | 'already-known' | 'failed' - error?: string -} - -export type ProjectGroupImportResult = { - group?: ProjectGroup - projects: ProjectGroupImportProjectResult[] - importedCount: number - alreadyKnownCount: number - failedCount: number -} - -export type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default' -export type SetupAgentStartupPolicy = 'start-immediately' | 'wait-for-setup' -export type SetupDecision = 'inherit' | 'run' | 'skip' -export type HookCommandSourcePolicy = 'shared-only' | 'local-only' | 'run-both' - -/** - * Envelope returned by the `repos:getBaseRefDefault` IPC handler. - * - * Why: declared in `shared/` rather than colocated with the handler so the - * preload bridge and renderer can import the same named type. Before this - * lived in `src/main/git/repo.ts` — the preload layer cannot import from - * `src/main/`, which forced three sites to inline the same structural shape - * and risk silent drift. - * - * Why `remoteCount`: BaseRefPicker renders a multi-remote hint when the repo - * has more than one configured remote; piggybacking the count on this IPC - * avoids a second round-trip. - * - * Why `defaultBaseRef` (not `default`): `default` is a reserved word and is - * awkward to destructure. - */ -export type BaseRefDefaultResult = { - defaultBaseRef: string | null - remoteCount: number -} - -export type BaseRefSearchResult = { - refName: string - localBranchName: string -} - -// ─── Worktree (git-level) ──────────────────────────────────────────── -export type GitWorktreeInfo = { - path: string - head: string - branch: string - isBare: boolean - isSparse?: boolean - locked?: boolean - lockReason?: string - /** True when Git reports the worktree as prunable (its directory is gone but - * the registration remains). Detected via the `prunable` porcelain field - * (Git ≥ 2.36) or a path-existence probe on older Git. */ - prunable?: boolean - prunableReason?: string - /** True for the repo's main working tree (the first entry from `git worktree list`). - * Linked worktrees created via `git worktree add` have this set to false. */ - isMainWorktree: boolean -} - -/** Head/branch snapshot read from Git metadata files without spawning Git. - * Carries background-worktree freshness when status-only churn includes a - * real head move (external commit/amend/reset) that must not re-enter the - * structural `worktrees:changed` fanout. */ -export type WorktreeHeadIdentity = { - worktreePath: string - head: string - /** Full ref (e.g. `refs/heads/main`), or null for a detached HEAD. */ - branch: string | null -} - -// ─── Worktree (app-level, enriched) ────────────────────────────────── -export type WorkspaceStatus = string - -export type WorkspaceStatusDefinition = { - id: WorkspaceStatus - label: string - color?: string - icon?: string -} - -export type Worktree = { - id: string // `${repoId}::${path}` - instanceId?: string - repoId: string - /** Durable project identity. Optional while legacy repo-only workspaces migrate. */ - projectId?: string - /** Execution host that owns the workspace. Optional for pre-project-host metadata. */ - hostId?: ExecutionHostId - /** Renderer projection of the paired runtime that transports operations to `hostId`. */ - runtimeOwnerEnvironmentId?: string - /** Authenticated client that created this workspace. Missing means unknown legacy origin. */ - creatorProvenance?: WorkspaceCreatorProvenance - /** Host-specific setup used to create/run this workspace. */ - projectHostSetupId?: string - /** Checkout ownership for a recipe-provisioned main workspace. */ - ephemeralVmCheckoutMode?: EphemeralVmCheckoutMode - displayName: string - comment: string - linkedIssue: number | null - linkedPR: number | null - linkedLinearIssue: string | null - linkedLinearIssueWorkspaceId?: string | null - linkedLinearIssueOrganizationUrlKey?: string | null - // Why: parallel slots for non-GitHub work-item references. Kept as separate - // fields (rather than reusing linkedIssue / linkedPR with a provider - // discriminator) so the persistence layer is unambiguous when a user - // has remotes from several providers on the same repo, and so the - // existing GitHub renderer code keeps reading linkedPR / linkedIssue - // unchanged. Optional on the type so existing test fixtures and - // persisted older worktrees that never carried these fields continue - // to typecheck and load without migration. - linkedGitLabMR?: number | null - linkedGitLabIssue?: number | null - linkedBitbucketPR?: number | null - linkedAzureDevOpsPR?: number | null - linkedGiteaPR?: number | null - linkedWorkItem?: WorkspaceLinkedItem | null - linkedTaskSourceContext?: TaskSourceContext | null - isArchived: boolean - isUnread: boolean - isPinned: boolean - sortOrder: number - /** User-authored sidebar ordering. Higher values render earlier in Manual sort. */ - manualOrder?: number - lastActivityAt: number - /** Set once when Orca creates the worktree. Absent for worktrees discovered - * on disk or persisted before this field existed. Used by the sidebar to - * grant newly-created worktrees a short grace window at the top of Recent, - * immune to ambient PTY-bump reordering in other worktrees. */ - createdAt?: number - /** Agent selected when Orca originally created the worktree. Used only to - * seed a replacement terminal if the user later reopens the worktree after - * closing every visible surface. */ - createdWithAgent?: TuiAgent - /** True while an auto-named workspace is waiting for the first agent message - * to drive the branch/title rename. */ - pendingFirstAgentMessageRename?: boolean - /** Holds the last auto-rename generation failure message so the sidebar can - * show a "rename failed" badge. null/undefined when there is no failure - * (never attempted, succeeded, or only a benign skip). */ - firstAgentMessageRenameError?: string | null - sparseDirectories?: string[] - sparseBaseRef?: string - /** ID of the saved preset this worktree was created from, if any. Cleared - * when the worktree is no longer sparse on refresh. */ - sparsePresetId?: string - /** Intended create base for stale-base probes. Persisted metadata, not UI drift state. */ - baseRef?: string - /** Remote/branch Orca should publish review commits to when it created this worktree. */ - pushTarget?: GitPushTarget - /** Path-derived worktree ids this worktree had before folder renames. */ - priorWorktreeIds?: string[] - workspaceStatus?: WorkspaceStatus - diffComments?: DiffComment[] - mobileDiffReview?: MobileDiffReviewState - automationProvenance?: AutomationWorkspaceProvenance - cliProvenance?: CliWorkspaceProvenance -} & GitWorktreeInfo - -/** Provenance for workspaces created through `orca worktree create`. Absent on - * workspaces created before this field existed and on every non-CLI create, so - * consumers must read "missing" as "not CLI-created". */ -export type CliWorkspaceProvenance = { - kind: 'created-by-cli' - createdAt: number - /** Orca terminal the CLI ran inside, when the caller had one — distinguishes - * an agent-issued create from one hand-typed in an external shell. */ - callerTerminalHandle?: string - /** Agent requested via `--agent`, when one was passed. */ - startupAgent?: TuiAgent -} - -export type WorkspaceCreatorProvenance = - | { kind: 'host' } - | { kind: 'paired-device'; deviceId: string } - -export type AutomationWorkspaceProvenance = { - kind: 'created-by-automation' - automationId: string - automationNameSnapshot: string - automationRunId: string - automationRunTitleSnapshot: string - createdAt: number - executionTargetType: AutomationExecutionTargetType - executionTargetId: string - projectId: string - repoId?: string - hostId?: ExecutionHostId -} - -export type AutomationWorkspaceProvenanceRequest = { - automationId: string - automationRunId: string - dispatchToken: string - createRequestId: string -} - -export type GitPushTarget = { - remoteName: string - branchName: string - remoteUrl?: string - /** True when Orca added this remote while preparing a fork-PR worktree. */ - remoteCreated?: boolean -} - -export type GitHubPrStartPoint = { - baseBranch: string - /** Review target branch to use for Source Control compare after creating from a PR head SHA. */ - compareBaseRef?: string - pushTarget?: GitPushTarget - /** Verified PR head commit. Present when checkout can be tied to a stable SHA. */ - headSha?: string - /** Exact local branch name to create/reuse when the PR head is a safe same-repo branch. */ - branchNameOverride?: string - /** Fork PRs: false when "Allow edits from maintainers" is off; a push to the fork may be rejected. */ - maintainerCanModify?: boolean -} - -// ─── Worktree metadata (persisted user-authored fields only) ───────── -export type WorktreeMeta = { - /** Immutable per-workspace-instance ID used to reject stale lineage after path reuse. */ - instanceId?: string - /** See Worktree.projectId. Persisted for project-first workspace ownership. */ - projectId?: string - /** See Worktree.hostId. Persisted for project-first workspace ownership. */ - hostId?: ExecutionHostId - /** See Worktree.projectHostSetupId. Persisted for project-first workspace ownership. */ - projectHostSetupId?: string - /** See Worktree.ephemeralVmCheckoutMode. */ - ephemeralVmCheckoutMode?: EphemeralVmCheckoutMode - /** See Worktree.creatorProvenance. */ - creatorProvenance?: WorkspaceCreatorProvenance - displayName: string - comment: string - linkedIssue: number | null - linkedPR: number | null - linkedLinearIssue: string | null - linkedLinearIssueWorkspaceId?: string | null - linkedLinearIssueOrganizationUrlKey?: string | null - /** Optional for backward compatibility — see Worktree.linkedGitLabMR. */ - linkedGitLabMR?: number | null - /** Optional for backward compatibility — see Worktree.linkedGitLabIssue. */ - linkedGitLabIssue?: number | null - /** Optional for backward compatibility — see Worktree.linkedBitbucketPR. */ - linkedBitbucketPR?: number | null - /** Optional for backward compatibility — see Worktree.linkedAzureDevOpsPR. */ - linkedAzureDevOpsPR?: number | null - /** Optional for backward compatibility — see Worktree.linkedGiteaPR. */ - linkedGiteaPR?: number | null - linkedWorkItem?: WorkspaceLinkedItem | null - linkedTaskSourceContext?: TaskSourceContext | null - isArchived: boolean - isUnread: boolean - isPinned: boolean - sortOrder: number - /** User-authored sidebar ordering. Higher values render earlier in Manual sort. */ - manualOrder?: number - lastActivityAt: number - /** See {@link Worktree.createdAt}. Persisted to orca-data.json. */ - createdAt?: number - /** See {@link Worktree.createdWithAgent}. Persisted to orca-data.json. */ - createdWithAgent?: TuiAgent - /** See {@link Worktree.pendingFirstAgentMessageRename}. */ - pendingFirstAgentMessageRename?: boolean - /** See {@link Worktree.firstAgentMessageRenameError}. */ - firstAgentMessageRenameError?: string | null - sparseDirectories?: string[] - sparseBaseRef?: string - sparsePresetId?: string - /** Intended create base for stale-base probes. Persisted metadata, not UI drift state. */ - baseRef?: string - /** True when Orca checked out a pre-existing local branch that delete must not prune. */ - preserveBranchOnDelete?: boolean - /** See {@link Worktree.pushTarget}. Persisted so refreshed worktree lists keep the target. */ - pushTarget?: GitPushTarget - /** Explicit marker stamped when Orca creates the worktree. */ - orcaCreatedAt?: number - orcaCreationSource?: 'desktop' | 'runtime' | 'cli' | 'ssh' - /** Workspace layout active when Orca created the worktree. */ - orcaCreationWorkspaceLayout?: OrcaWorkspaceLayout - /** User-assigned workspace board status for manual sidebar organization. */ - workspaceStatus?: WorkspaceStatus - diffComments?: DiffComment[] - /** Path-derived worktree ids this worktree had before its folder was renamed - * on disk (the id embeds the path). Lets the daemon's session GC and registry - * hydration recognize sessions minted under an old id instead of reaping - * them. Self-prunes when the worktree is deleted. */ - priorWorktreeIds?: string[] - mobileDiffReview?: MobileDiffReviewState - /** System-owned provenance for workspaces created by automation new-per-run dispatches. */ - automationProvenance?: AutomationWorkspaceProvenance - /** System-owned provenance for workspaces created via `orca worktree create`. */ - cliProvenance?: CliWorkspaceProvenance -} - -export type WorktreeOwnership = 'orca-managed' | 'external' | 'unknown-legacy' | 'agent-scratch' - -export type DetectedWorktreeListSource = 'git' | 'metadata-fallback' | 'session-fallback' - -export type DetectedWorktree = Worktree & { - ownership: WorktreeOwnership - selectedCheckout: boolean - visible: boolean - /** Optional additive source identity; older hosts omit it. */ - visibilitySource?: - | { kind: 'built-in'; id: BuiltInWorktreeVisibilitySourceId } - | { kind: 'custom'; id: string } -} - -export type DetectedWorktreeListResult = { - repoId: string - authoritative: boolean - source: DetectedWorktreeListSource - worktrees: DetectedWorktree[] -} - -export type WorktreeLineageOrigin = 'orchestration' | 'cli' | 'manual' -export type WorktreeLineageCaptureConfidence = 'explicit' | 'inferred' -export type WorktreeLineageCaptureSource = - | 'explicit-cli-flag' - | 'env-workspace' - | 'cwd-context' - | 'terminal-context' - | 'orchestration-context' - | 'active-workspace' - | 'manual-action' - -export type WorktreeLineageCapture = { - source: WorktreeLineageCaptureSource - confidence: WorktreeLineageCaptureConfidence -} - -export type WorktreeLineage = { - worktreeId: string - worktreeInstanceId: string - parentWorktreeId: string - parentWorktreeInstanceId: string - origin: WorktreeLineageOrigin - capture: WorktreeLineageCapture - orchestrationRunId?: string - taskId?: string - coordinatorHandle?: string - createdByTerminalHandle?: string - createdAt: number -} - -export type WorkspaceLineage = { - childWorkspaceKey: WorkspaceKey - childInstanceId?: string | null - parentWorkspaceKey: WorkspaceKey - parentInstanceId?: string | null - origin: WorktreeLineageOrigin - capture: WorktreeLineageCapture - taskId?: string - orchestrationRunId?: string - coordinatorHandle?: string - createdByTerminalHandle?: string - createdAt: number -} - -export type WorktreeLineageWarningCode = - | 'LINEAGE_PARENT_CONTEXT_MISSING' - | 'LINEAGE_PARENT_CONTEXT_CONFLICT' - | 'LINEAGE_PARENT_INSTANCE_STALE' - -export type WorktreeLineageWarning = { - code: WorktreeLineageWarningCode - message: string - details?: Record -} - -// ─── Diff line comments ────────────────────────────────────────────── -// Why: users leave review notes on specific lines of the modified side of -// a diff so they can be handed back to an AI agent (pasted into a terminal -// or used to bootstrap a new agent session). Stored on WorktreeMeta so the -// existing persistence layer writes them to orca-data.json automatically. -export type DiffCommentSource = 'diff' | 'markdown' -export type DiffReviewScope = 'unstaged' | 'staged' | 'branch' - -export type MobileDiffReviewFileState = { - key: string - filePath: string - oldPath?: string - scope: DiffReviewScope - lastOpenedAt?: number - lastSeenDiffIdentity?: string - reviewedAt?: number - reviewDiffIdentity?: string -} - -export type MobileDiffReviewState = { - version: 1 - updatedAt?: number - completedAt?: number - files: Record -} - -export type DiffComment = { - id: string - worktreeId: string - filePath: string - /** Undefined means a legacy diff note. */ - source?: DiffCommentSource - /** Exact text selected when creating a markdown note, when available. */ - selectedText?: string - /** Inclusive range start. Must be <= lineNumber when present. */ - startLine?: number - lineNumber: number - body: string - createdAt: number - updatedAt?: number - /** Set after the note has been handed to an agent. Edits clear it. */ - sentAt?: number - scope?: DiffReviewScope - oldPath?: string - diffIdentity?: string - // Reserved for future "comments on the original side" — always 'modified' in v1. - side: 'modified' -} - -// ─── Tab Group Layout ─────────────────────────────────────────────── -export type TabGroupSplitDirection = 'horizontal' | 'vertical' - -export type TabGroupLayoutNode = - | { type: 'leaf'; groupId: string } - | { - type: 'split' - direction: TabGroupSplitDirection - first: TabGroupLayoutNode - second: TabGroupLayoutNode - /** Flex ratio of the first child (0–1). Defaults to 0.5 if absent. */ - ratio?: number - } - -// ─── Unified Tab ──────────────────────────────────────────────────── -export type TabContentType = - | 'terminal' - | 'editor' - | 'diff' - | 'conflict-review' - | 'check-details' - | 'browser' - | 'simulator' - -export type WorkspaceVisibleTabType = 'terminal' | 'editor' | 'browser' | 'simulator' -export type CtrlTabOrderMode = 'mru' | 'sequential' - -export type Tab = { - id: string // UUID for terminals, filePath for editors (preserves current convention) - entityId: string // ID of the backing content (terminal tab ID, file path, browser workspace ID) - groupId: string - worktreeId: string - contentType: TabContentType - label: string // display title (auto-derived from PTY or filename) - generatedLabel?: string | null - /** Stable AI Vault conversation name, bound to its provider session identity. */ - aiVaultTitle?: AiVaultSessionTitle | null - quickCommandLabel?: string | null - customLabel: string | null - color: string | null - sortOrder: number - createdAt: number - isPreview?: boolean // preview tabs get replaced by next single-click open - isPinned?: boolean // pinned tabs survive "close others" - /** Why: per-tab rendering mode for coding-agent terminals. `'chat'` shows the - * native chat view as an overlay while the live terminal stays mounted - * underneath; `'terminal'` (the default for legacy/missing) shows the raw - * xterm. Optional so sessions persisted before this field hydrate cleanly. */ - viewMode?: 'terminal' | 'chat' -} - -export type TabGroup = { - id: string - worktreeId: string - activeTabId: string | null - tabOrder: string[] // canonical visual order of tab IDs - /** Per-group MRU stack (oldest → most-recent at the tail). Drives which tab - * becomes active when the current active tab closes: we pop back to the - * previously-active tab instead of jumping to a visual neighbor. Scoped to - * the group so split panes keep independent histories. Optional because - * sessions persisted before this field was added still hydrate cleanly — - * hydration seeds from activeTabId. */ - recentTabIds?: string[] -} - -// ─── Terminal Tab (legacy — used by persistence and TerminalContentSlice) ─ -export type TerminalTab = { - id: string - ptyId: string | null - worktreeId: string - title: string - /** Stable fallback label for default-named terminals ("Terminal 1", etc.). - * Why: agent CLIs overwrite the live title via OSC updates, but Orca still - * needs the original terminal label for numbering and reset behavior. */ - defaultTitle?: string - /** Stable opt-in label derived from the first known agent prompt. */ - generatedTitle?: string | null - /** Stable AI Vault conversation name, bound to its provider session identity. */ - aiVaultTitle?: AiVaultSessionTitle | null - /** Stable label from the tab-bar Quick Command that created this terminal. */ - quickCommandLabel?: string | null - customTitle: string | null - color: string | null - /** Pinned tabs survive "close others"; host-persisted for remote servers. */ - isPinned?: boolean - /** Per-tab view preference (terminal xterm vs native chat); host-persisted so - * paired clients converge. Optional: older persisted tabs default to 'terminal'. */ - viewMode?: 'terminal' | 'chat' - sortOrder: number - createdAt: number - /** Bumped on shutdown so TerminalPane remounts with a fresh PTY. */ - generation?: number - /** Why: records the shell this tab was opened with (e.g. 'wsl.exe') so the - * PTY and tab icon stay stable even if the default shell setting changes - * later. Older persisted tabs may omit this field. */ - shellOverride?: string - /** Keeps an ephemeral host fallback out of the active project's runtime. */ - forceHostRuntime?: boolean - /** Why: explorer-created terminals can start below the workspace root while - * still belonging to that workspace for tab/session ownership. */ - startupCwd?: string - /** Why: the coding-harness agent Orca launched in this tab. Lets the tab bar - * show the provider icon immediately, before the agent emits its first hook - * event (a freshly-launched, idle agent reports no live status yet). Live - * hook status overrides this once the agent does anything. Plain terminals - * and manually-started agents omit it. */ - launchAgent?: TuiAgent - /** Why: when `setActiveWorktree` bumps generation on all-dead tabs to drive a - * TerminalPane remount, the fresh PTY that results is caused by navigation, - * not by the user doing work. Without this flag the resulting - * `updateTabPtyId` call would call `bumpWorktreeActivity` and flip the - * sidebar's recency sort on every click — the reorder-on-click bug. The - * flag is set by `setActiveWorktree` and consumed by the activation-driven - * PTY lifecycle calls that follow, which then suppress activity bumps and - * `sortEpoch` increments. Split layouts use a numeric count because one tab - * can remount several panes. Never persisted — it is a transient handoff. */ - pendingActivationSpawn?: boolean | number -} - -export type BrowserHistoryEntry = { - url: string - normalizedUrl: string - title: string - lastVisitedAt: number - visitCount: number -} - -export type BrowserLoadError = { - code: number - description: string - validatedUrl: string -} - -export type BrowserCertificateFailure = { - challengeId: string - browserPageId: string - errorCode: number | null - error: string - origin: string - displayHost: string - canProceed: boolean - observedAt: number -} - -export type BrowserCertificateProceedFailureReason = - | 'expired' - | 'changed' - | 'ineligible' - | 'missing' - | 'navigated' - -export type BrowserCertificateProceedResult = - | { ok: true } - | { ok: false; reason: BrowserCertificateProceedFailureReason } - -// Why: BrowserPage persists the active viewport preset so CDP emulation can be -// reapplied on reload/navigation without the user re-picking from the toolbar. -export type BrowserViewportPresetId = - | 'mobile-s' - | 'mobile-m' - | 'mobile-l' - | 'tablet' - | 'laptop' - | 'laptop-l' - | 'desktop' - -export type BrowserViewportOverride = { - width: number - height: number - deviceScaleFactor: number - mobile: boolean -} - -export type BrowserPage = { - id: string - workspaceId: string - worktreeId: string - url: string - title: string - loading: boolean - faviconUrl: string | null - canGoBack: boolean - canGoForward: boolean - loadError: BrowserLoadError | null - createdAt: number - // Why: remote-owned worktrees can still host client-local fallback browser - // pages until headless remote runtimes support real browser panes. - browserRuntimeEnvironmentId?: string | null - /** Active CDP viewport emulation preset. null = default (fill pane, no CDP override) */ - viewportPresetId?: BrowserViewportPresetId | null -} - -export type BrowserWorkspace = { - id: string - worktreeId: string - /** Stable display label for the outer Orca tab ("Browser 1", "Browser 2", …). - * Optional so sessions persisted before this field was added fall back - * gracefully to the URL-derived label in getBrowserTabLabel. */ - label?: string - // Why: each browser workspace binds to exactly one session profile at creation - // time. The profile determines which Electron partition (and thus which - // cookies/storage) the guest webview uses. Absent means the legacy shared - // partition, which keeps backward compat with workspaces persisted before - // session profiles existed. - sessionProfileId?: string | null - // Why: runtime-created tabs resolve profile partition in main. Persisting it - // keeps isolated storage stable when the renderer profile mirror is stale. - sessionPartition?: string | null - activePageId?: string | null - pageIds?: string[] - // Why: the active page owns real browser chrome state now, but the top-level - // Orca tab strip still renders one workspace entry. Mirror the active page's - // title/url/loading metadata here so existing workspace-level UI can stay - // stable while Phase 2 introduces nested browser pages. - url: string - title: string - loading: boolean - faviconUrl: string | null - canGoBack: boolean - canGoForward: boolean - loadError: BrowserLoadError | null - createdAt: number -} - -export type BrowserTab = BrowserWorkspace - -export type BrowserSessionProfileScope = 'default' | 'isolated' | 'imported' - -export type BrowserSessionUserAgentMode = 'clean' | 'native' - -export type BrowserSessionProfileCreateOptions = { - userAgentMode?: BrowserSessionUserAgentMode -} - -export type BrowserSessionProfileSource = { - browserFamily: - | 'chrome' - | 'chromium' - | 'arc' - | 'edge' - | 'firefox' - | 'safari' - | 'comet' - | 'helium' - | 'manual' - profileName?: string - importedAt: number -} - -export type BrowserSessionProfile = { - id: string - scope: BrowserSessionProfileScope - partition: string - label: string - source: BrowserSessionProfileSource | null - userAgentMode?: BrowserSessionUserAgentMode -} - -export type BrowserCookieImportSummary = { - totalCookies: number - importedCookies: number - skippedCookies: number - googleCookiesSkipped?: number - domains: string[] - warning?: { - code: 'restart-fallback-unavailable' - loadedCookies: number - failedCookies: number - } -} - -export type BrowserCookieImportResult = - | { ok: true; profileId: string; summary: BrowserCookieImportSummary } - | { ok: false; reason: string } - -export type TerminalPaneSplitDirection = 'vertical' | 'horizontal' - -export type TerminalPaneLayoutNode = - | { - type: 'leaf' - leafId: string - } - | { - type: 'split' - direction: TerminalPaneSplitDirection - first: TerminalPaneLayoutNode - second: TerminalPaneLayoutNode - /** Flex ratio of the first child (0–1). Defaults to 0.5 if absent. */ - ratio?: number - } - -export type TerminalLayoutSnapshot = { - root: TerminalPaneLayoutNode | null - activeLeafId: string | null - expandedLeafId: string | null - /** Live PTY IDs per leaf for in-session remounts such as tab-group moves. - * Not used for app restart because PTYs are transient processes. */ - ptyIdsByLeafId?: Record - /** Serialized terminal buffers per leaf for scrollback restoration on restart. */ - buffersByLeafId?: Record - /** Durable scrollback snapshot refs per leaf; raw bytes live outside session JSON. */ - scrollbackRefsByLeafId?: Record - /** User-assigned pane titles, keyed by stable layout leaf UUID. - * Persisted alongside buffers via the existing session:set flow. */ - titlesByLeafId?: Record -} - -/** Minimal subset of OpenFile persisted across restarts. - * Only edit-mode files are saved — diffs, conflict reviews, and other - * transient views are reconstructed on demand from git state. */ -export type PersistedOpenFile = { - filePath: string - relativePath: string - worktreeId: string - language: string - isPreview?: boolean - runtimeEnvironmentId?: string | null - /** SSH target that owns an absolute path outside the worktree. */ - externalSshTargetId?: string - /** Unsaved editor buffer captured for hot exit; presence restores the tab dirty. */ - dirtyDraftContent?: string - /** Signature of the disk content the dirty draft is based on; lets restore - * re-derive a changed-on-disk conflict from ground truth. */ - lastKnownDiskSignature?: string - /** Why: a read-only tab (AI Vault View Log) must survive restart still - * read-only; persisted only when true so old sessions stay writable. */ - readOnly?: boolean - /** Opt-in streaming append for a read-only local log tab. */ - liveTail?: boolean -} - -export type WorkspaceSessionState = { - activeRepoId: string | null - /** Scope-aware active owner for folder workspaces. Legacy worktree UI still reads activeWorktreeId. */ - activeWorkspaceKey?: WorkspaceKey | null - activeWorkspaceExecutionHostId?: ExecutionHostId | null - activeWorktreeId: string | null - activeTabId: string | null - /** Keys may be legacy raw worktree IDs or canonical WorkspaceKey values. */ - tabsByWorktree: Record - terminalLayoutsByTabId: Record - /** Worktree IDs that had at least one tab with a live PTY at shutdown. - * Used on startup to eagerly re-spawn PTY processes so the Active filter - * works immediately after restart. */ - activeWorktreeIdsOnShutdown?: string[] - /** Editor files that were open at shutdown, keyed by worktree ID. - * Only edit-mode files are persisted — diffs and conflict views are - * transient and not restored. */ - openFilesByWorktree?: Record - /** Per-worktree active editor file ID (filePath) at shutdown. */ - activeFileIdByWorktree?: Record - /** Per-file markdown preview front-matter visibility. Absent entry means hidden. */ - markdownFrontmatterVisible?: Record - /** Persisted browser workspaces, keyed by worktree ID. */ - browserTabsByWorktree?: Record - /** Persisted browser pages, keyed by workspace ID. */ - browserPagesByWorkspace?: Record - /** Per-worktree active browser workspace ID at shutdown. */ - activeBrowserTabIdByWorktree?: Record - /** Per-worktree active tab type (terminal vs editor vs browser) at shutdown. */ - activeTabTypeByWorktree?: Record - /** Global browser URL history for address bar autocomplete. */ - browserUrlHistory?: BrowserHistoryEntry[] - /** Per-worktree last-active terminal tab ID at shutdown. */ - activeTabIdByWorktree?: Record - /** Unified tab model — present when saved by a build that includes TabsSlice. - * Read-path checks for this first; falls back to legacy fields if absent. */ - unifiedTabs?: Record - /** Tab group model — present alongside unifiedTabs. */ - tabGroups?: Record - /** Persisted split layout tree per worktree. */ - tabGroupLayouts?: Record - /** Per-worktree focused group at shutdown. */ - activeGroupIdByWorktree?: Record - /** SSH target IDs that were connected at shutdown. Used on startup to - * auto-reconnect before attempting remote PTY reattach. */ - activeConnectionIdsAtShutdown?: string[] - /** Maps tab IDs to their remote relay PTY session IDs. Populated at - * shutdown from renderer state so remote PTYs can be reattached via - * the relay's pty.attach RPC on startup. */ - remoteSessionIdsByTabId?: Record - /** Per-worktree focus-recency timestamps used by the Cmd+J empty-query - * ordering. Separate from worktree.lastActivityAt (background signal) - * and worktreeNavHistory (Back/Forward stack). See - * docs/cmd-j-empty-query-ordering.md. Absent in sessions written by - * older builds — hydration tolerates missing/partial maps and the - * active worktree is seeded on first restore. */ - lastVisitedAtByWorktreeId?: Record - /** Worktrees whose repo-defined default terminal tabs have already been - * considered. Persisted so closing all tabs and re-opening the workspace - * does not recreate the template. */ - defaultTerminalTabsAppliedByWorktreeId?: Record - /** Provider-session resume records captured when workspaces sleep. */ - sleepingAgentSessionsByPaneKey?: Record - /** Host-issued process incarnation for each durable terminal surface. */ - terminalPtyIncarnationsByPaneKey?: Record - /** Monotonic host authority watermark for terminal membership in each repo. */ - terminalTopologyRevisionByRepoId?: Record - /** Legacy per-surface fences migrated into terminalTopologyRevisionByRepoId on load. */ - terminalSurfaceTombstonesByPaneKey?: Record< - string, - { - worktreeId: string - parentTabId: string - leafId: string - ptyId: string - incarnationId: string - retiredAt: number - } - > -} - -export type WorkspaceSessionPatch = Partial - -// ─── GitHub ────────────────────────────────────────────────────────── -export type PRState = 'open' | 'closed' | 'merged' | 'draft' -export type IssueState = 'open' | 'closed' -export type CheckStatus = 'pending' | 'success' | 'failure' | 'neutral' - -export type PRMergeableState = 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN' -export type PRReviewDecision = 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED' - -export type PRConflictSummary = { - baseRef: string - baseCommit: string - commitsBehind: number - files: string[] - localMergeState?: 'clean' -} - -// Why: host must survive renderer/RPC boundaries so Enterprise review actions -// cannot silently fall back to a same-named repository on github.com. -export type GitHubRepositoryIdentity = { owner: string; repo: string; host?: string } - -export type GitHubPRMergeMethod = 'merge' | 'squash' | 'rebase' - -export type GitHubPRMergeMethodSettings = { - defaultMethod: GitHubPRMergeMethod - allowedMethods: Record -} - -export type GitHubPRStackEntry = { - position: number - number: number - title: string - url: string - updatedAt?: string - state: PRState - checksStatus: CheckStatus - mergeable: PRMergeableState - reviewDecision?: PRReviewDecision | null - mergeStateStatus?: string | null - headRefName?: string - headSha?: string -} - -export type GitHubPRStack = { - number: number - position: number - size: number - baseRefName: string - baseSha?: string - entries?: GitHubPRStackEntry[] -} - -export type PRInfo = { - number: number - title: string - state: PRState - url: string - checksStatus: CheckStatus - updatedAt: string - mergeable: PRMergeableState - reviewDecision?: PRReviewDecision | null - autoMergeEnabled?: boolean - autoMergeAllowed?: boolean | null - mergeQueueRequired?: boolean | null - mergeMethodSettings?: GitHubPRMergeMethodSettings - mergeStateStatus?: string | null - /** GitHub-registered stack metadata. Absent for ordinary dependent PR chains. */ - stack?: GitHubPRStack - // Why: check-runs are keyed by the PR head commit, not the mutable branch name. - // Keeping the head SHA in cached PR metadata lets the checks panel poll the - // correct commit without re-querying GitHub or guessing from local branch refs. - headSha?: string - // Why: a merged branch-matched PR stays visible when the worktree head is one - // of the PR's own commits (behind update-branch/web commits). Cache staleness - // checks must honor that confirmation without re-querying GitHub. - confirmedContainedHeadOid?: string - // Why: the worktree HEAD OID this merged linked PR was confirmed to have - // diverged from (a definite not-contained probe). Head-scoped, not a bare - // boolean, so a PR-number-coalesced refresh broadcast cannot clear a sibling - // worktree whose own head is still on the PR's line of work. Clearing a - // durable linked PR requires this positive signal for that exact head, never - // the mere absence of a containment confirmation after a rate-limit/error. - headDivergedFromMergedPRAtOid?: string - /** Target branch name for PR-created worktree compare-base repair. */ - baseRefName?: string - /** PR head branch name. Lets linked-PR consumers detect that the worktree - * has switched to a different branch and the durable link is stale. */ - headRefName?: string - prRepo?: GitHubRepositoryIdentity - headRepo?: GitHubRepositoryIdentity - conflictSummary?: PRConflictSummary -} - -/** - * Discriminates a classified GitHub PR-refresh failure. The renderer maps these - * to stable, non-destructive empty-state copy; a `hard` subset (auth, permission, - * repo_unavailable, gh_unavailable) means the existing-review lookup is currently - * impossible and must hide the Create composer. - */ -export type PRRefreshErrorType = - | 'rate_limited' - | 'auth' - | 'network' - | 'permission' - | 'repo_unavailable' - | 'gh_unavailable' - | 'server_error' - | 'unknown' - -// Backward-compatible name used by outage-copy consumers added on main. -export type PRRefreshUpstreamErrorType = PRRefreshErrorType - -export type PRRefreshOutcome = - | { kind: 'found'; pr: PRInfo; fetchedAt: number } - | { kind: 'no-pr'; fetchedAt: number } - | { - kind: 'upstream-error' - errorType: PRRefreshErrorType - message: string - fetchedAt: number - // Unified retry schedule (see docs/reference/pr-panel-refresh-guidance.md). - // `nextAutoRetryAt`: earliest time main expects to auto-retry this key. - // `retryDisabledUntil`: earliest time a manual Retry / refreshPRNow is - // accepted (rate-limit gates only, never ordinary network/auth backoff). - nextAutoRetryAt?: number - retryDisabledUntil?: number - } - -export type GitHubPRRefreshReason = 'visible' | 'active' | 'post-push' | 'manual' | 'swr' - -export type GitHubPRRefreshEnqueueResult = - | { kind: 'queued' } - | { kind: 'skipped'; skippedReason: 'validation-denied' | 'validation-backoff' } - | { kind: 'fallback' } - -export type GitHubPRRefreshAlias = { - cacheKey: string - repoId?: string - repoPath: string - branch: string - worktreeId?: string - connectionId?: string | null - executionHostId?: string | null - linkedPRNumber?: number | null - fallbackPRNumber?: number | null - fallbackPRSource?: 'explicit' | 'pr-cache' | 'hosted-review' | null - // Why: request-time worktree HEAD. Merged branch-matched PRs are only visible - // for heads that belong to the PR, and refresh consumers need this snapshot to - // clear a durable linked PR once main confirms the head diverged. - currentHeadOid?: string | null -} - -export type GitHubPRRefreshCandidate = GitHubPRRefreshAlias & { - repoKind: RepoKind - repoId: string - isBare?: boolean - isArchived?: boolean - connectionId?: string | null - executionHostId?: string | null - connectionState?: 'connected' | 'disconnected' | 'unknown' - cachedFetchedAt?: number | null - cachedHasPR?: boolean | null - cachedPRState?: PRState | null - cachedChecksStatus?: CheckStatus | null - cachedMergeable?: PRMergeableState | null - cachedMergeStateStatus?: string | null - localGitOptions?: { wslDistro?: string } -} - -export type GitHubPRRefreshSkippedReason = - | 'fresh' - | 'not-git' - | 'bare' - | 'archived' - | 'disconnected' - | 'remote' - | 'rate-limit' - | 'capacity' - -type GitHubPRRefreshEventBase = { - sequence: number - reason: GitHubPRRefreshReason - aliases: GitHubPRRefreshAlias[] - requestStartedAt?: number -} - -export type GitHubPRRefreshEvent = - | (GitHubPRRefreshEventBase & { - outcome: PRRefreshOutcome - status?: never - pausedUntil?: never - skippedReason?: never - }) - | (GitHubPRRefreshEventBase & { - status: 'queued' | 'in-flight' - outcome?: never - pausedUntil?: never - skippedReason?: never - }) - | (GitHubPRRefreshEventBase & { - status: 'paused' - pausedUntil: number - skippedReason: 'rate-limit' - outcome?: never - }) - | (GitHubPRRefreshEventBase & { - status: 'skipped' - skippedReason: GitHubPRRefreshSkippedReason - outcome?: never - pausedUntil?: never - }) - -export type PRCheckDetail = { - name: string - status: 'queued' | 'in_progress' | 'completed' - conclusion: - | 'success' - | 'failure' - | 'cancelled' - | 'timed_out' - | 'neutral' - | 'skipped' - | 'pending' - // Why: a check suite needing manual action (e.g. a workflow awaiting "Approve - // and run") has no check run and is absent from statusCheckRollup, yet blocks - // auto-merge (GitHub returns "unstable status"). Surface it as its own state. - | 'action_required' - | null - url: string | null - checkRunId?: number - workflowRunId?: number - // Why: the GitLab job trace API is addressed by numeric job id only, so the - // Checks panel cannot load a job log without carrying it on the row. - gitlabJobId?: number -} - -export type PRCheckAnnotation = { - path: string | null - startLine: number | null - endLine: number | null - annotationLevel: string | null - title: string | null - message: string - rawDetails: string | null -} - -export type PRCheckStep = { - name: string - status: string | null - conclusion: string | null - startedAt: string | null - completedAt: string | null -} - -export type PRCheckJob = { - id: number | null - name: string - status: string | null - conclusion: string | null - startedAt: string | null - completedAt: string | null - url: string | null - logTail: string | null - steps: PRCheckStep[] -} - -export type PRCheckRunDetails = { - name: string - status: PRCheckDetail['status'] | (string & {}) | null - conclusion: PRCheckDetail['conclusion'] | (string & {}) | null - url: string | null - detailsUrl: string | null - startedAt: string | null - completedAt: string | null - title: string | null - summary: string | null - text: string | null - annotations: PRCheckAnnotation[] - jobs: PRCheckJob[] -} - -export type GitHubRerunPRChecksResult = { ok: true; count: number } | { ok: false; error: string } - -export type GitHubReactionContent = - | '+1' - | '-1' - | 'laugh' - | 'confused' - | 'heart' - | 'hooray' - | 'rocket' - | 'eyes' - -export type GitHubReaction = { - content: GitHubReactionContent - count: number - viewerHasReacted?: boolean -} - -export type PRComment = { - id: number - author: string - authorAvatarUrl: string - body: string - createdAt: string - url: string - reactions?: GitHubReaction[] - /** GraphQL node ID for GitHub comments that support reaction mutations. */ - reactionSubjectId?: string - /** File path for inline review comments (absent for top-level conversation comments). */ - path?: string - /** GraphQL node ID of the review thread — present only for inline review comments. - * Used to resolve/unresolve the thread via GitHub's GraphQL API. */ - threadId?: string - /** Whether the review thread has been resolved. Only meaningful when threadId is set. */ - isResolved?: boolean - /** True when GitHub no longer maps the thread to the current diff. */ - isOutdated?: boolean - /** End line of the review annotation (1-based). */ - line?: number - /** Start line of the review annotation range (1-based). Absent for single-line comments. */ - startLine?: number - /** True when GitHub identifies the author as a bot (REST `user.type === 'Bot'` or - * GraphQL `__typename === 'Bot'`). Preferred over login-string heuristics because - * third-party review bots (e.g. qodo-ai-reviewer, coderabbitai) don't follow a - * predictable naming convention. Absent when the data source can't report it - * (non-GitHub fallbacks via `gh pr view`). */ - isBot?: boolean -} - -export type GitHubIssueTimelineTarget = { - type: 'issue' | 'pr' - number: number - title: string - url: string - repository?: string -} - -export type GitHubIssueTimelineItem = { - id: string - event: - | 'assigned' - | 'unassigned' - | 'mentioned' - | 'cross-referenced' - | 'closed' - | 'reopened' - | 'moved_columns_in_project' - actor: string - actorAvatarUrl: string - createdAt: string - assignee?: string - source?: GitHubIssueTimelineTarget - closer?: GitHubIssueTimelineTarget - stateReason?: string | null - previousColumnName?: string | null - columnName?: string | null - projectName?: string | null -} - -export type GitHubCommentResult = { ok: true; comment: PRComment } | { ok: false; error: string } - -export type IssueInfo = { - number: number - title: string - state: IssueState - url: string - labels: string[] - /** Full markdown body when fetched through the single-issue endpoint. */ - description?: string -} - -export type GitHubViewer = { - login: string - email: string | null -} - -export type GitHubAssignableUser = { - login: string - name: string | null - avatarUrl: string -} - -export type ProviderCheckSummary = { - state: 'success' | 'failure' | 'pending' | 'neutral' | 'none' - total: number - passed: number - failed: number - pending: number - neutral: number -} - -export type GitHubPRReviewSummary = { - login: string - state?: string | null - avatarUrl?: string | null -} - -export type GitHubPRFileViewedState = 'DISMISSED' | 'VIEWED' | 'UNVIEWED' - -export type GitHubWorkItem = { - id: string - type: 'issue' | 'pr' - number: number - title: string - state: 'open' | 'closed' | 'merged' | 'draft' - url: string - labels: string[] - updatedAt: string - author: string | null - // Why: GHE user logins don't exist on github.com, so the github.com/{login}.png - // fallback 404s. Carry the API-provided avatar_url so github.com + Enterprise - // both render; absent on the gh-pr-view path (gh omits avatar), then the UI - // falls back to the login URL and finally an initials placeholder. See #8784. - authorAvatarUrl?: string - branchName?: string - baseRefName?: string - // Why: PR checks are keyed by head commit; carrying this lets task rows use - // the cached check-runs endpoint instead of one `gh pr checks` call per row. - headSha?: string - prRepo?: GitHubRepositoryIdentity - additions?: number - deletions?: number - changedFiles?: number - reviewDecision?: PRReviewDecision | null - reviewRequests?: GitHubAssignableUser[] - latestReviews?: GitHubPRReviewSummary[] - assignees?: GitHubAssignableUser[] - checksSummary?: ProviderCheckSummary - mergeable?: PRMergeableState - autoMergeEnabled?: boolean - autoMergeAllowed?: boolean | null - mergeQueueRequired?: boolean | null - mergeMethodSettings?: GitHubPRMergeMethodSettings - mergeStateStatus?: string | null - maintainerCanModify?: boolean - // Why: true when a PR's head lives on a fork (headRepositoryOwner !== selected repo owner). - // The Start-from picker passes this to resolvePrBase so fork heads use - // refs/pull//head for creation and a separate PR-head push target. - isCrossRepository?: boolean - /** Why: required because the cross-repo view merges items from every selected - * repo — the table row's repo pill and the "open in browser" fallback need - * to know which repo an item came from. Stamped by the renderer fetcher - * (`fetchWorkItems`) and by optimistic stubs on the new-issue path. */ - repoId: string -} - -export type GitHubPRFile = { - path: string - oldPath?: string - status: 'added' | 'modified' | 'removed' | 'renamed' | 'copied' | 'changed' | 'unchanged' - additions: number - deletions: number - /** GitHub marks files above its diff size limit as binary-like; we skip content fetches for these. */ - isBinary: boolean - /** Modified-side line numbers that GitHub accepts for inline review comments. */ - reviewCommentLineNumbers?: number[] - /** GitHub's per-viewer review state. DISMISSED means new changes arrived after the file was viewed. */ - viewerViewedState?: GitHubPRFileViewedState -} - -export type GitHubPRFileContents = { - original: string - modified: string - originalIsBinary: boolean - modifiedIsBinary: boolean - originalTooLarge?: boolean - modifiedTooLarge?: boolean -} - -export type GitHubPRReviewCommentInput = { - repoPath: string - prRepo?: GitHubRepositoryIdentity | null - prNumber: number - commitId: string - path: string - line: number - startLine?: number - body: string -} - -export type GitHubWorkItemDetails = { - // Why: main-process doesn't know Orca's Repo.id, so this inner item omits - // repoId. The renderer stamps it when routing the details through the store. - item: Omit - body: string - comments: PRComment[] - /** Issue-only provider activity such as assignment, references, project moves, and state changes. */ - timelineItems?: GitHubIssueTimelineItem[] - /** Only set for PRs. Head/base SHAs used by the Files tab to fetch per-file content. */ - headSha?: string - baseSha?: string - /** GraphQL node ID required by GitHub's file-viewed mutations. Only set for PRs. */ - pullRequestId?: string - checks?: PRCheckDetail[] - files?: GitHubPRFile[] - /** Only set for PRs. True when the file fetch failed (rate limit, auth, - * unresolved remote) rather than the PR genuinely having no changed files. */ - filesUnavailable?: boolean - participants?: GitHubAssignableUser[] - /** Logins of current assignees. Only set for issues. */ - assignees?: string[] -} - -// ─── Linear ───────────────────────────────────────────────────────── -export type LinearViewer = { - displayName: string - email: string | null - organizationId?: string - organizationName: string - organizationUrlKey?: string -} - -export type LinearWorkspace = LinearViewer & { - id: string - organizationId: string - isLegacy?: true - credentialRevision?: number -} - -export type LinearWorkspaceSelection = (string & {}) | 'all' -export type LinearWorkspaceSelector = LinearWorkspaceSelection | undefined -export type LinearConcreteWorkspaceId = string - -export type LinearWorkspaceError = { - workspaceId: string - workspaceName?: string - type: 'auth' | 'rate_limited' | 'network' | 'unknown' - message: string -} - -export type LinearCollectionResult = { - items: T[] - errors?: LinearWorkspaceError[] - hasMore?: boolean -} - -export type LinearConnectionStatus = { - connected: boolean - viewer: LinearViewer | null - workspaces?: LinearWorkspace[] - activeWorkspaceId?: string | null - selectedWorkspaceId?: LinearWorkspaceSelection | null - // Set when a stored token file exists but could not be decrypted, so the - // UI can explain reads failing while the connection still looks saved. - credentialError?: string -} - -export type LinearIssue = { - id: string - workspaceId?: string - workspaceName?: string - identifier: string - title: string - branchName?: string - description?: string - url: string - state: { - name: string - type: string - color: string - } - team: { - id: string - name: string - key: string - } - project?: LinearProjectSummary - subIssues?: LinearIssueChildSummary[] - labels: string[] - labelIds: string[] - assignee?: { - id: string - displayName: string - avatarUrl?: string - } - estimate?: number | null - priority: number - dueDate?: string | null - updatedAt: string -} - -export type LinearProjectSummary = { - id: string - slugId?: string - workspaceId?: string - workspaceName?: string - name: string - url?: string - color?: string - icon?: string - description?: string - content?: string - status?: LinearProjectStatusSummary - health?: string | null - priority?: number | null - priorityLabel?: string | null - lead?: LinearProjectMemberSummary - members?: LinearProjectMemberSummary[] - teams?: { - id: string - name: string - key?: string - }[] - labels?: { - id: string - name: string - color?: string - }[] - startDate?: string | null - targetDate?: string | null - createdAt?: string - updatedAt?: string - completedAt?: string | null - canceledAt?: string | null - startedAt?: string | null - progress?: number | null - scope?: number | null - issueCount?: number - completedIssueCount?: number -} - -export type LinearProjectStatusSummary = { - id: string - name: string - type?: string - color?: string -} - -export type LinearProjectMemberSummary = { - id: string - displayName: string - avatarUrl?: string -} - -export type LinearProjectMilestoneSummary = { - id: string - name: string - status?: string - targetDate?: string | null - progress?: number | null -} - -export type LinearProjectResourceSummary = { - id: string - title: string - url: string - type?: string -} - -export type LinearProjectUpdateSummary = { - id: string - body?: string - health?: string | null - url?: string - createdAt?: string - updatedAt?: string - user?: LinearProjectMemberSummary -} - -export type LinearProjectDetail = LinearProjectSummary & { - milestones?: LinearProjectMilestoneSummary[] - resources?: LinearProjectResourceSummary[] - latestUpdate?: LinearProjectUpdateSummary -} - -export type LinearCustomViewModel = 'issue' | 'project' - -export type LinearCustomViewSummary = { - id: string - workspaceId?: string - workspaceName?: string - name: string - description?: string - model: LinearCustomViewModel - url?: string - color?: string - icon?: string - shared?: boolean - team?: { - id: string - name?: string - key?: string - } - owner?: LinearProjectMemberSummary - creator?: LinearProjectMemberSummary - createdAt?: string - updatedAt?: string -} - -export type LinearIssueChildSummary = { - id: string - identifier: string - title: string - url: string -} - -export type LinearComment = { - id: string - body: string - createdAt: string - user?: { - displayName: string - avatarUrl?: string - } -} - -// ─── Issue Mutations ──────────────────────────────────────────────── - -export type GitHubCreateIssueFields = { - labels?: string[] - assignees?: string[] -} - -export type GitHubCreateIssueResult = - | { ok: true; number: number; url: string; bodySaveWarning?: string } - | { ok: false; error: string } - -export type GitHubIssueCloseReason = 'completed' | 'not_planned' | 'duplicate' - -export type GitHubIssueUpdate = { - state?: 'open' | 'closed' - stateReason?: GitHubIssueCloseReason - duplicateOf?: number - title?: string - // Why: body writes use the REST issue endpoint instead of `gh issue edit` - // because that command does not consistently cover every body-edit case the - // dialog needs. - body?: string - addLabels?: string[] - removeLabels?: string[] - addAssignees?: string[] - removeAssignees?: string[] -} - -export type GitHubPullRequestStateUpdate = { - state: 'open' | 'closed' -} - -export type LinearIssueUpdate = { - stateId?: string - title?: string - description?: string - assigneeId?: string | null - estimate?: number | null - priority?: number - dueDate?: string | null - labelIds?: string[] - projectId?: string | null - parentId?: string | null -} - -export type ClassifiedError = { - type: - | 'permission_denied' - | 'not_found' - | 'issues_disabled' - | 'validation_error' - | 'rate_limited' - | 'network_error' - | 'unknown' - message: string -} - -// Why: declared here as a shared shape so IPC return envelopes and renderer -// slices can reference the same structural type without importing from main. -// Aliased as `OwnerRepo` in `src/main/github/gh-utils.ts` so main call sites -// can continue using the short local name. -export type GitHubOwnerRepo = GitHubRepositoryIdentity - -// Why: GitLab-specific types live in `./gitlab-types` so they can grow -// independently from the central types file (which is touched by every -// upstream feature). Re-exported here so existing call sites -// (`from '../shared/types'`) keep working without changes. export type { GitLabAssignableUser, GitLabAuthDiagnostic, @@ -2078,6 +224,8 @@ export type { MRState } from './gitlab-types' +// Jira + export type { JiraAuthType, JiraComment, @@ -2103,1890 +251,117 @@ export type { JiraViewer } from './jira-types' -/** - * GitHub API rate-limit buckets surfaced in the TaskPage header so users can - * see remaining budget before they hit the wall. `core` = REST (5000/hr), - * `search` = Search API (30/min — hit by countWorkItems), `graphql` = - * GraphQL (5000 points/hr — hit by project-view + discovery). All three are - * the buckets this app actually stresses; other buckets (e.g. code_search) - * are not surfaced because we don't touch them. - */ -export type GitHubRateLimitBucket = { - remaining: number - limit: number - /** Unix epoch seconds when the window resets. */ - resetAt: number -} +// Linear + +export type { LinearViewer, LinearWorkspace } from './linear/workspace-types' +export type { LinearWorkspaceSelection, LinearWorkspaceSelector } from './linear/workspace-types' +export type { LinearConcreteWorkspaceId, LinearWorkspaceError } from './linear/workspace-types' +export type { LinearCollectionResult, LinearConnectionStatus } from './linear/workspace-types' +export type { LinearWorkflowState, LinearLabel, LinearMember } from './linear/workspace-types' +export type { LinearTeam } from './linear/workspace-types' -export type GitHubRateLimitSnapshot = { - core: GitHubRateLimitBucket - search: GitHubRateLimitBucket - graphql: GitHubRateLimitBucket - /** Unix epoch ms the snapshot was produced (for "fetched Xs ago" copy). */ - fetchedAt: number -} +export type { LinearIssue, LinearIssueChildSummary, LinearComment } from './linear/issue-types' -export type GetRateLimitResult = - | { ok: true; snapshot: GitHubRateLimitSnapshot } - | { ok: false; error: string } +export type { LinearProjectSummary, LinearProjectStatusSummary } from './linear/project-types' +export type { LinearProjectMemberSummary } from './linear/project-types' +export type { LinearProjectMilestoneSummary } from './linear/project-types' +export type { LinearProjectResourceSummary } from './linear/project-types' +export type { LinearProjectUpdateSummary, LinearProjectDetail } from './linear/project-types' +export type { LinearCustomViewModel, LinearCustomViewSummary } from './linear/project-types' -/** - * Envelope for `gh:listWorkItems`. Carries resolved issue/PR sources so the - * renderer can render the "Issues from owner/repo" indicator without an - * extra IPC round-trip, and per-source classified errors so the UI can show - * a retryable banner when (e.g.) a private upstream 403s. - * - * Why piggyback instead of adding `gh:resolveWorkItemSources`: the renderer - * already round-trips this endpoint on every Tasks refresh, and the source - * data is a 2-field-per-side metadata add — cheaper than another IPC call. - * - * Invariant: `items` always contains whatever succeeded; `errors.issues` indicates - * the issues-side fetch failed, but any PR-side items that succeeded are still - * present in `items`. Consumers should render `items` alongside the error banner. - */ -export type ListWorkItemsResult = { - items: T[] - sources: { - issues: GitHubOwnerRepo | null - prs: GitHubOwnerRepo | null - /** Raw `origin` remote resolved for this repo, independent of the - * user's preference. Required-nullable so the renderer can compare raw - * remote candidates without inferring origin from the effective PR - * source. */ - originCandidate: GitHubOwnerRepo | null - /** Raw `upstream` remote resolved for this repo, independent of the - * user's preference. Present so the renderer's issue-source selector - * can always decide whether to render (upstream exists & differs from - * origin) and show both slugs in its tooltips, even when the user has - * picked 'origin' and `sources.issues` has collapsed onto origin. */ - upstreamCandidate: GitHubOwnerRepo | null - } - errors?: { - issues?: ClassifiedError - prs?: ClassifiedError - } - /** True when the user's per-repo preference was `'upstream'` but no upstream - * remote is configured, so the resolver fell back to origin. Renderer uses - * this to surface a one-time-per-session toast. Omitted when absent so - * existing consumers and test fixtures don't care about it. - * Typed as `?: true` (not `?: boolean`) to encode the invariant "present - * iff fell-back" — an explicit `false` write would be a bug, so make it a - * compile error. */ - issueSourceFellBack?: true -} +// Cross-provider issue mutations and task sources -export type LinearWorkflowState = { - id: string - name: string - type: string - color: string - position: number -} +export type { GitHubCreateIssueFields, GitHubCreateIssueResult } from './issue-mutation-types' +export type { GitHubIssueCloseReason, GitHubIssueUpdate } from './issue-mutation-types' +export type { GitHubPullRequestStateUpdate, LinearIssueUpdate } from './issue-mutation-types' -export type LinearLabel = { - id: string - name: string - color: string -} +export type { TaskProvider } from './task-providers' -export type LinearMember = { - id: string - displayName: string - name?: string - email?: string - avatarUrl?: string -} +// orca.yaml hooks, setup policy, and VM recipes -export type LinearTeam = { - id: string - workspaceId?: string - workspaceName?: string - name: string - key: string - url?: string -} +export type { OrcaHooks, OrcaWorktreeDefaults } from './orca-yaml-hook-types' +export type { OrcaDefaultTabTemplate, EphemeralVmCheckoutMode } from './orca-yaml-hook-types' +export type { OrcaVmRecipe, OrcaVmRecipeDiagnostic, RepoHookSettings } from './orca-yaml-hook-types' +export type { SetupRunPolicy, SetupAgentStartupPolicy } from './orca-yaml-hook-types' +export type { HookCommandSourcePolicy, PersistedTrustedOrcaHookEntry } from './orca-yaml-hook-types' +export type { PersistedTrustedOrcaHookRepo } from './orca-yaml-hook-types' +export type { PersistedTrustedOrcaHooks } from './orca-yaml-hook-types' -// ─── Hooks (orca.yaml) ────────────────────────────────────────────── -export type OrcaHooks = { - scripts: { - setup?: string // Runs after worktree is created - archive?: string // Runs before worktree is archived - } - issueCommand?: string // Shared default command for linked GitHub issues - defaultTabs?: OrcaDefaultTabTemplate[] // Terminal tabs to create once for a new worktree - environmentRecipes?: OrcaVmRecipe[] // Project-scoped per-workspace environment recipes - environmentRecipeDiagnostics?: OrcaVmRecipeDiagnostic[] // Non-fatal validation issues from environmentRecipes - worktree?: OrcaWorktreeDefaults // Project-scoped defaults applied when a worktree is created -} +// Settings, persisted UI state, and onboarding -export type OrcaWorktreeDefaults = { - // Why: shared (symlinked) rather than copied — large rebuildable dirs like - // node_modules should be one install serving every worktree. - sharedDirectories?: string[] -} +export type { TaskViewPresetId, OpenInApplication, SourceControlViewMode } from './ui-chrome-types' +export type { SourceControlGroupOrder, LeftSidebarAppearanceMode } from './ui-chrome-types' +export type { BranchPrefixStrategy, FloatingTerminalCwdRequest } from './ui-chrome-types' +export type { AgentDashboardMode, WorktreeCardProperty, WorktreeCardMode } from './ui-chrome-types' +export type { AgentActivityDisplayMode, StatusBarItem } from './ui-chrome-types' +export type { FloatingTerminalTriggerLocation, TaskResumeState } from './ui-chrome-types' +export type { RightSidebarTab, ActiveRightSidebarTab } from './ui-chrome-types' +export type { RightSidebarExplorerView, ProjectOrderBy } from './ui-chrome-types' +export type { WorkspaceHostScope, VisibleWorkspaceHostIds } from './ui-chrome-types' +export type { WorkspaceHostOrder, ManualRepoOrderEntry, TopLevelView } from './ui-chrome-types' -export type OrcaDefaultTabTemplate = { - title?: string - color?: string - command?: string -} +export type { GlobalSettings, OrcaWorkspaceLayout } from './global-settings-types' +export type { GhosttyImportPreview } from './global-settings-types' -export type EphemeralVmCheckoutMode = 'orca-worktree' | 'provisioned-root' +export type { HostSettingOverrides } from './host-setting-overrides' -export type OrcaVmRecipe = { - id: string - name: string - create: string - checkoutMode?: EphemeralVmCheckoutMode - description?: string - suspend?: string - resume?: string - destroy?: string - destroyDisabled?: boolean -} +export type { CommitMessageAiModelCapability } from './commit-message-ai-types' +export type { CommitMessageAiSettings } from './commit-message-ai-types' -export type OrcaVmRecipeDiagnostic = { - index: number - field?: string - message: string -} +export type { NotificationSettings, NotificationEventSource } from './notification-settings-types' +export type { NotificationDispatchRequest } from './notification-settings-types' +export type { NotificationDispatchResult } from './notification-settings-types' +export type { NotificationDismissResult } from './notification-settings-types' +export type { NotificationSoundResult } from './notification-settings-types' +export type { NotificationSoundDataResult } from './notification-settings-types' +export type { NotificationSoundPathResult } from './notification-settings-types' +export type { NotificationPermissionStatusResult } from './notification-settings-types' +export type { NotificationDeliveryProbeResult } from './notification-settings-types' -export type RepoHookSettings = { - // Why: persisted data may still include the old mode field from the earlier - // hook UI. Keep it in the shape so existing local state reads without a migration. - mode: 'auto' | 'override' - setupRunPolicy?: SetupRunPolicy - setupAgentStartupPolicy?: SetupAgentStartupPolicy - commandSourcePolicy?: HookCommandSourcePolicy - scripts: { - setup: string - archive: string - } -} +export type { DiscoveryStatusEmitted, OnboardingOutcome } from './onboarding-state-types' +export type { OnboardingChecklistState, OnboardingState } from './onboarding-state-types' -export type WorktreeSetupLaunch = { - runnerScriptPath: string - envVars: Record - shell?: SetupRunnerShell - command?: string - waitForAgentStartup?: boolean -} +export type { PersistedUIState } from './persisted-ui-state-types' -export type WorktreeStartupLaunch = { - command: string - env?: Record - launchConfig?: SleepingAgentLaunchConfig - launchToken?: string - launchAgent?: TuiAgent - viewMode?: 'terminal' | 'chat' - startupCommandDelivery?: StartupCommandDelivery - telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } -} +export type { LegacyPaneKeyAliasEntry } from './persisted-state-types' +export type { PersistedMobileClientTabSelection } from './persisted-state-types' +export type { PersistedMobileClientTabSelections, PersistedState } from './persisted-state-types' -export type WorktreeDefaultTabsLaunch = { - tabs: OrcaDefaultTabTemplate[] - runCommands: boolean -} +export { PET_SIZE_MIN, PET_SIZE_MAX, PET_SIZE_DEFAULT } from './pet-types' +export type { CustomPet, SpriteAnimation } from './pet-types' -export type WorktreeCreateTimingPhase = { - phase: string - startedAtMs: number - durationMs: number -} +// Agents, managed accounts, and updates -export type WorktreeCreateTiming = { - totalDurationMs: number - phases: WorktreeCreateTimingPhase[] -} +export type { TuiAgent } from './tui-agent' -export type CreateSparseCheckoutRequest = { - directories: string[] - /** Set when the directories came from a saved preset and the user did not - * modify them — recorded on WorktreeMeta so the worktree can show "from - * preset X" later. Cleared if the user edited the textarea. */ - presetId?: string -} +export type { CodexManagedAccount, CodexManagedAccountSummary } from './managed-account-types' +export type { CodexSystemDefaultIdentity } from './managed-account-types' +export type { CodexRateLimitAccountsState } from './managed-account-types' +export type { CodexManagedAccountRuntimeSelection } from './managed-account-types' +export type { ClaudeManagedAccount, ClaudeManagedAccountSummary } from './managed-account-types' +export type { ClaudeRateLimitAccountsState } from './managed-account-types' +export type { ClaudeManagedAccountRuntimeSelection } from './managed-account-types' -/** A reusable per-repo sparse directory list. Saved by the user from the - * composer; surfaced again the next time they create a worktree in the same - * repo. The MVP scope (no preset) is `presetId === undefined`. */ -export type SparsePreset = { - id: string - repoId: string - name: string - directories: string[] - createdAt: number - updatedAt: number -} +export type { ChangelogRelease, ChangelogData, UpdateCheckOptions } from './update-status-types' +export type { UpdateSource, LinuxRootPackageType } from './update-status-types' +export type { LinuxPackageInstallFailureReason } from './update-status-types' +export type { LinuxPackageInstallRecovery } from './update-status-types' +export type { LinuxPackageCommandUnavailableReason } from './update-status-types' +export type { LinuxPackageInstallInstructions, UpdateStatus } from './update-status-types' +export type { ReleaseBuildListResult } from './update-status-types' -export type CreateWorktreeArgs = { - repoId: string - name: string - /** Optional user-facing label to persist separately from the git-safe - * branch/path seed. Used when a workspace is created from a GitHub or - * Linear artifact whose title should remain readable in the sidebar. */ - displayName?: string - baseBranch?: string - /** Source Control compare target when it differs from the checkout start point. */ - compareBaseRef?: string - /** Optional git branch to create, separate from the filesystem-safe worktree - * name. Used when creating from an existing branch whose local branch name - * legitimately contains `/` while the worktree directory must not. */ - branchNameOverride?: string - setupDecision?: SetupDecision - sparseCheckout?: CreateSparseCheckoutRequest - linkedIssue?: number - linkedPR?: number - linkedLinearIssue?: string - linkedLinearIssueWorkspaceId?: string | null - linkedLinearIssueOrganizationUrlKey?: string | null - linkedGitLabIssue?: number - linkedGitLabMR?: number - linkedBitbucketPR?: number | null - linkedAzureDevOpsPR?: number | null - linkedGiteaPR?: number | null - linkedWorkItem?: WorkspaceLinkedItem | null - linkedTaskSourceContext?: TaskSourceContext | null - pushTarget?: GitPushTarget - workspaceStatus?: WorkspaceStatus - manualOrder?: number - /** Parent workspace for in-app creates launched from a folder workspace. */ - parentWorkspace?: WorkspaceKey - /** Agent selected in the create surface. Omitted for blank-shell creates. */ - createdWithAgent?: TuiAgent - /** Set when the renderer knows this auto-generated branch should be renamed - * from the first agent message. */ - pendingFirstAgentMessageRename?: boolean - /** Telemetry-only: which UI surface initiated this create. Threaded from - * the renderer entry point so main can emit `workspace_created` with the - * correct `source`. `unknown` is a valid wire value — an unrecognized - * surface emits `source: 'unknown'` rather than dropping the event, so - * dashboards surface enum-coverage gaps as a slice rather than as - * missing data. Optional on the type so older renderer code paths that - * pre-date this prop default to `unknown` at the IPC boundary instead - * of failing typecheck. */ - telemetrySource?: WorkspaceSource - /** Optional startup command for callers that want the backend to spawn the - * first terminal as soon as the worktree is registered. */ - startup?: WorktreeStartupLaunch - /** Correlates `createWorktree:progress` events back to a specific pending - * creation in the renderer, so concurrent background creates each drive - * their own status surface. Omitted by synchronous callers. */ - creationId?: string - /** Authorizes the host to mint system-owned automation provenance. */ - automationProvenanceRequest?: AutomationWorkspaceProvenanceRequest -} +// Filesystem, search, process stats, and shared error shape -export type AdoptProvisionedRootArgs = CreateWorktreeArgs & { - runtimeId: string - executionHostId: ExecutionHostId - expectedPath: string -} +export type { ShellHydrationFailureReason, PathSource } from './shell-path-hydration-types' + +export type { FilesystemPathFlavor, DirEntry, MarkdownDocument } from './filesystem-entry-types' +export type { FsChangeEvent, FsChangedPayload } from './filesystem-entry-types' -export type CreateWorktreeResult = { - worktree: Worktree & { - parentWorktreeId?: string | null - childWorktreeIds?: string[] - lineage?: WorktreeLineage | null - workspaceLineage?: WorkspaceLineage | null - git?: GitWorktreeInfo - } - lineage?: WorktreeLineage | null - workspaceLineage?: WorkspaceLineage | null - warnings?: WorktreeLineageWarning[] - setup?: WorktreeSetupLaunch - setupReceipt?: { - requested: 'run' | 'skip' | 'inherit' - hookFound: boolean - startupPolicy: 'start-immediately' | 'wait-for-setup' - state: 'running' | 'skipped' | 'not_configured' | 'spawn_failed' - terminalHandle?: string - } - defaultTabs?: WorktreeDefaultTabsLaunch - warning?: string - baseFallback?: WorktreeCreateBaseFallback - initialBaseStatus?: WorktreeBaseStatusEvent - localBaseRefRefresh?: LocalBaseRefRefreshResult - localBaseRefUpdateSuggestion?: LocalBaseRefUpdateSuggestion - startupTerminal?: { - spawned: boolean - handle?: string - tabId?: string - paneKey?: string | null - ptyId?: string | null - surface?: 'visible' | 'background' - } - timing?: WorktreeCreateTiming -} +export type { SearchMatch, SearchFileResult, SearchResult } from './code-search-types' +export type { SearchOptions } from './code-search-types' -export type WorktreeCreateBaseFallback = { - requestedRef: string - localRef: string -} +export type { StatsSummary, UsageValues, ProcessMemoryMetric } from './process-stats-types' +export type { HostAvailableMemorySource, AppMemory, SessionMemory } from './process-stats-types' +export type { WorktreeMemory, HostMemory, MemorySnapshot } from './process-stats-types' -export type PreservedWorktreeBranch = { - branchName: string - head?: string -} - -export type RemoveWorktreeResult = { - preservedBranch?: PreservedWorktreeBranch -} - -export type ForceDeleteWorktreeBranchResult = { - deleted: true -} - -export type LocalBaseRefRefreshResult = { - status: 'updated' | 'skipped_dirty_worktree' | 'skipped_not_fast_forward' | 'skipped_error' - baseRef: string - localBranch: string - ownerWorktreePath?: string -} - -export type LocalBaseRefUpdateSuggestion = { - baseRef: string - localBranch: string - behind: number -} - -export type WorktreeBaseStatusKind = 'checking' | 'current' | 'drift' | 'base_changed' | 'unknown' - -export type WorktreeBaseStatusEvent = { - repoId: string - worktreeId: string - status: WorktreeBaseStatusKind - base: string - /** Configured remote name parsed from `base` (longest-prefix match). Absent - * when classification skipped optimistic reconcile (e.g. legacy fallback). */ - remote?: string - behind?: number - recentSubjects?: string[] -} - -export type WorktreeRemoteBranchConflictEvent = { - repoId: string - worktreeId: string - remote: string - branchName: string -} - -// ─── Updater ───────────────────────────────────────────────────────── - -// Why: the release object sent to the renderer omits `version` (redundant -// with the top-level UpdateStatus.version) to keep one source of truth. -export type ChangelogRelease = { - title: string - description: string - mediaUrl?: string - releaseNotesUrl: string -} - -export type ChangelogData = { - release: ChangelogRelease - releasesBehind: number | null -} - -export type UpdateCheckOptions = { - includePrerelease?: boolean - includePerfPrerelease?: boolean - localBuild?: boolean - /** Dev channel switching; `targetTag` pins an exact build, including older ones. */ - channel?: ReleaseChannel - targetTag?: string -} - -/** Non-release origins for an update. Derived from the dev-channel list so a new - * channel with its own repo cannot be reported as an ordinary release. */ -export type UpdateSource = 'local' | DedicatedRepoChannel - -/** Root-package Linux install formats whose update installs need privilege escalation. */ -export type LinuxRootPackageType = 'deb' | 'rpm' - -export type LinuxPackageInstallFailureReason = - | 'authentication-agent-unavailable' - | 'authentication-denied' - | 'package-install-failed' - -// Why: the renderer must not infer "no polkit agent" from copy alone — main classifies and the card branches on this discriminant. -export type LinuxPackageInstallRecovery = { - kind: 'linux-package-install' - packageType: LinuxRootPackageType - reason: LinuxPackageInstallFailureReason - version: string -} - -/** Why: only these two mean no safe command exists here; every other failure clears recovery entirely. */ -export type LinuxPackageCommandUnavailableReason = 'no-sudo' | 'no-package-manager' - -export type LinuxPackageInstallInstructions = - | { ok: true; command: string; packageFileName: string } - | { ok: false; reason: LinuxPackageCommandUnavailableReason; message: string } - -export type UpdateStatus = ( - | { state: 'idle' } - | { state: 'checking'; userInitiated?: boolean } - | { - state: 'available' - version: string - activeNudgeId?: string - // Why: releaseUrl is not currently populated by the update-available handler - // (it always sends undefined). Kept on the type for the Settings page's - // release-notes link fallback and for potential future use if the main - // process starts extracting release URLs from electron-updater metadata. - releaseUrl?: string - // Why: changelog is always explicitly set by the main process — null means - // the fetch failed or the version wasn't in the JSON (simple mode), and a - // populated object means rich mode. Using `| null` (not `?`) avoids a - // three-state ambiguity (undefined vs null vs present) and makes exhaustive - // checks straightforward. - changelog: ChangelogData | null - } - | { state: 'not-available'; userInitiated?: boolean } - | { state: 'downloading'; percent: number; version: string; activeNudgeId?: string } - | { state: 'downloaded'; version: string; releaseUrl?: string; activeNudgeId?: string } - | { - state: 'error' - message: string - userInitiated?: boolean - activeNudgeId?: string - recovery?: LinuxPackageInstallRecovery - } -) & { source?: UpdateSource } - -export type ReleaseBuildListResult = - | { ok: true; channel: ReleaseChannel; builds: ReleaseBuild[] } - | { ok: false; channel: ReleaseChannel; message: string } - -// ─── Settings ──────────────────────────────────────────────────────── -export type NotificationSettings = { - enabled: boolean - agentTaskComplete: boolean - terminalBell: boolean - suppressWhenFocused: boolean - customSoundId: - | 'system' - | 'two-tone' - | 'bong' - | 'thump' - | 'blip' - | 'sonar' - | 'blop' - | 'ding' - | 'clack' - | 'beep' - | 'custom' - customSoundPath: string | null - customSoundVolume: number -} - -export type CodexManagedAccount = { - id: string - email: string - managedHomePath: string - managedHomeRuntime?: 'host' | 'wsl' - wslDistro?: string | null - wslLinuxHomePath?: string | null - providerAccountId?: string | null - workspaceLabel?: string | null - workspaceAccountId?: string | null - createdAt: number - updatedAt: number - lastAuthenticatedAt: number -} - -export type CodexManagedAccountSummary = { - id: string - email: string - managedHomeRuntime?: 'host' | 'wsl' - wslDistro?: string | null - providerAccountId?: string | null - workspaceLabel?: string | null - workspaceAccountId?: string | null - createdAt: number - updatedAt: number - lastAuthenticatedAt: number -} - -/** Live, read-only identity of the user's real ~/.codex used by the - * system-default (activeAccountId:null) Codex account. Orca reads this to - * display and attribute the system default; it never writes ~/.codex. */ -export type CodexSystemDefaultIdentity = { - /** True when ~/.codex/auth.json exists (signed in via a token file). */ - hasAuth: boolean - /** 'oauth' = ChatGPT sign-in with an id token (has ChatGPT usage); - * 'api-key' = env-key/custom provider (no ChatGPT usage); - * 'none' = signed out or identity could not be resolved. */ - authKind: 'oauth' | 'api-key' | 'none' - email: string | null - providerAccountId: string | null - workspaceLabel: string | null -} - -export type CodexRateLimitAccountsState = { - accounts: CodexManagedAccountSummary[] - activeAccountId: string | null - activeAccountIdsByRuntime?: CodexManagedAccountRuntimeSelection - /** Resolved identity of the host system-default (real ~/.codex) account. - * Omitted for runtimes where it is not resolved (e.g. per-distro WSL). */ - systemDefault?: CodexSystemDefaultIdentity -} - -export type CodexManagedAccountRuntimeSelection = { - host: string | null - wsl: Record -} - -export type ClaudeManagedAccount = { - id: string - email: string - managedAuthPath: string - managedAuthRuntime?: 'host' | 'wsl' - wslDistro?: string | null - wslLinuxAuthPath?: string | null - authMethod: 'subscription-oauth' | 'unknown' - organizationUuid?: string | null - organizationName?: string | null - createdAt: number - updatedAt: number - lastAuthenticatedAt: number -} - -export type ClaudeManagedAccountSummary = { - id: string - email: string - managedAuthRuntime?: 'host' | 'wsl' - wslDistro?: string | null - authMethod: 'subscription-oauth' | 'unknown' - organizationUuid?: string | null - organizationName?: string | null - createdAt: number - updatedAt: number - lastAuthenticatedAt: number -} - -export type ClaudeRateLimitAccountsState = { - accounts: ClaudeManagedAccountSummary[] - activeAccountId: string | null - activeAccountIdsByRuntime?: ClaudeManagedAccountRuntimeSelection -} - -export type ClaudeManagedAccountRuntimeSelection = { - host: string | null - wsl: Record -} - -/** All AI coding agents Orca knows how to launch. Used for the agent picker in the new-workspace - * flow and for the default-agent setting. Extend this union as new agents are added. */ -export type TuiAgent = - | 'claude' // Claude Code - | 'claude-agent-teams' // Claude Code Agent Teams via Orca native panes - | 'openclaude' // OpenClaude - | 'codex' // OpenAI Codex - | 'autohand' // Autohand Code CLI - | 'opencode' // OpenCode - | 'mimo-code' - | 'pi' // Pi (pi.dev) - | 'omp' // OMP (omp.sh) - | 'gemini' // Gemini CLI - | 'antigravity' // Google Antigravity CLI - | 'aider' // Aider - | 'goose' // Goose - | 'amp' // Amp - | 'kilo' // Kilocode - | 'kiro' // Kiro - | 'crush' // Charm/Crush - | 'aug' // Augment/Auggie - | 'cline' // Cline - | 'codebuff' // Codebuff - | 'command-code' // Command Code - | 'continue' // Continue - | 'cursor' // Cursor - | 'droid' // Factory Droid - | 'kimi' // Kimi - | 'mistral-vibe' // Mistral Vibe - | 'qwen-code' // Qwen Code - | 'rovo' // Rovo Dev - | 'hermes' // Hermes Agent - | 'openclaw' // OpenClaw - | 'copilot' // GitHub Copilot CLI - | 'grok' // xAI Grok CLI - | 'devin' // Devin CLI - | 'ante' // Ante (Antigma Labs) - | 'trae' // Trae CLI - | 'prime-agent' // Prime Agent (Prime Intellect) - -export type TaskViewPresetId = 'all' | 'issues' | 'review' | 'my-issues' | 'my-prs' | 'prs' - -/** Where the repo setup script runs when a worktree is created. - * - 'new-tab': open a background tab titled "Setup" and leave focus on the first tab (default). - * - 'split-vertical': split the initial terminal pane with a vertical divider. - * - 'split-horizontal': split the initial terminal pane with a horizontal divider. */ -export type SetupScriptLaunchMode = 'split-vertical' | 'split-horizontal' | 'new-tab' - -/** Direction used when the setup script launch mode is a split. */ -export type SetupSplitDirection = 'vertical' | 'horizontal' - -export type TerminalColorOverrides = { - foreground?: string - background?: string - cursor?: string - cursorAccent?: string - selectionBackground?: string - selectionForeground?: string - black?: string - red?: string - green?: string - yellow?: string - blue?: string - magenta?: string - cyan?: string - white?: string - brightBlack?: string - brightRed?: string - brightGreen?: string - brightYellow?: string - brightBlue?: string - brightMagenta?: string - brightCyan?: string - brightWhite?: string - // Why: xterm.js ITheme does not expose a `bold` key, but Ghostty users - // expect the setting to be preserved so a future renderer CSS override - // or xterm upgrade can honour it without a migration. - bold?: string -} - -export type TerminalQuickCommandScope = - | { - type: 'global' - } - | { - type: 'repo' - repoId: string - } - -export type TerminalQuickCommandAction = 'terminal-command' | 'agent-prompt' - -export type TerminalQuickCommandBase = { - id: string - label: string - scope?: TerminalQuickCommandScope -} - -export type TerminalCommandQuickCommand = TerminalQuickCommandBase & { - action?: 'terminal-command' - command: string - appendEnter: boolean -} - -export type TerminalAgentQuickCommand = TerminalQuickCommandBase & { - action: 'agent-prompt' - agent: TuiAgent - prompt: string -} - -export type TerminalQuickCommand = TerminalCommandQuickCommand | TerminalAgentQuickCommand - -export type OpenInApplication = { - id: string - label: string - command: string -} - -export type SourceControlViewMode = 'list' | 'tree' -export type SourceControlGroupOrder = 'changes-first' | 'staged-first' | 'untracked-first' - -export type LeftSidebarAppearanceMode = 'default' | 'match-terminal' | 'tinted' - -/** Strategy for the prefix prepended to worktree branch names. */ -export type BranchPrefixStrategy = 'git-username' | 'custom' | 'none' - -export type FloatingTerminalCwdRequest = { - path?: string - requireTrusted?: boolean -} - -/** Per-host overrides for client preferences that genuinely vary by execution - * host. NARROW by design: only settings whose value is meaningless to share - * across hosts belong here. - * - `displayLabel`: a client-side rename for the host shown in sidebar/pickers. - * - `defaultWorktreeLocation`: the host's root worktree directory; a remote - * SSH/runtime host has a different filesystem layout than the local Mac, so - * the client `workspaceDir` default cannot apply unchanged. */ -export type HostSettingOverrides = { - displayLabel?: string - defaultWorktreeLocation?: string -} - -/** Presentation mode for the experimental Agent Dashboard. */ -export type AgentDashboardMode = 'in-window' | 'popout' - -export type GlobalSettings = { - workspaceDir: string - /** Host-owned defaults used when a repository has no explicit visibility override. */ - worktreeVisibilityDefaults?: WorktreeVisibilityDefaults - /** Per-host overrides keyed by ExecutionHostId. Effective value for a - * host-varying setting is `host override ?? client default`. */ - hostSettingOverrides?: Partial> - nestWorkspaces: boolean - workspaceDirHistory?: OrcaWorkspaceLayout[] - refreshLocalBaseRefOnWorktreeCreate: boolean - /** Set once the user dismisses the "local main is behind" suggestion toast, so - * the nudge to enable refreshLocalBaseRefOnWorktreeCreate never shows again. */ - localBaseRefSuggestionDismissed: boolean - /** When enabled, Orca renames a workspace's auto-generated creature branch to - * a short name derived from the first prompt once work begins. Users can - * still turn this off from global Git settings. */ - autoRenameBranchFromWork: boolean - /** One-shot migration guard for the default-on rollout. Existing profiles - * without the guard are flipped on once; later explicit opt-outs stick. */ - autoRenameBranchFromWorkDefaultedOn?: boolean - branchPrefix: BranchPrefixStrategy - branchPrefixCustom: string - theme: 'system' | 'dark' | 'light' - /** Controls the left sidebar surface without changing terminal brightness. */ - leftSidebarAppearanceMode: LeftSidebarAppearanceMode - leftSidebarTintColor?: string - leftSidebarTintOpacity?: number - uiLanguage: UiLanguage - appIcon: AppIconId - appFontFamily: string - editorAutoSave: boolean - editorAutoSaveDelayMs: number - editorMinimapEnabled: boolean - /** Opt-in code-editor font; empty (the default) keeps following `terminalFontFamily`. */ - editorFontFamily?: string - /** Defaults on for profiles saved before file-editor wrapping became configurable. */ - editorWordWrap?: boolean - /** Persisted opt-out for browser spellcheck noise in rich Markdown editing surfaces. */ - richMarkdownSpellcheckEnabled?: boolean - /** Whether local markdown review note controls and the review panel are shown. */ - markdownReviewToolsEnabled: boolean - /** Why: mirrors terminal selection-paste muscle memory without mutating the - * normal system clipboard; Linux and macOS enable it by default, Windows - * leaves middle-click semantics unchanged unless the user opts in. */ - primarySelectionMiddleClickPaste?: boolean - /** One-shot migration guard for turning the Linux default on for profiles - * that persisted the earlier off-by-default value. */ - primarySelectionMiddleClickPasteDefaultedForLinux?: boolean - /** One-shot migration guard for widening the terminal-style default to - * Linux/macOS while preserving later explicit opt-outs. */ - primarySelectionMiddleClickPasteDefaultedForTerminalDefaults?: boolean - terminalFontSize: number - terminalFontFamily: string - terminalFontWeight: number - terminalFontWeightBold: number - terminalLineHeight: number - terminalScrollSensitivity: number - terminalFastScrollSensitivity: number - terminalTuiScrollSensitivity: number - /** One-shot migration guard for moving inherited TUI wheel reports from 3 to 1. */ - terminalTuiScrollSensitivityDefaultedToOne?: boolean - /** Terminal renderer policy. - * - 'auto': try xterm WebGL and fall back to DOM when unsupported or risky. - * - 'on': always try xterm WebGL. - * - 'off': keep terminal rendering on xterm's DOM renderer. */ - terminalGpuAcceleration: 'auto' | 'on' | 'off' - /** Whether to enable programming-ligatures rendering via - * `@xterm/addon-ligatures`. - * - `'auto'` (default): enabled only when the configured font is known to - * ship ligatures (Fira Code, JetBrains Mono, Cascadia Code, etc.). This - * keeps the out-of-the-box experience right for users who install a - * ligature font without touching settings. - * - `'on'` / `'off'`: explicit override. Never changes when the user - * switches fonts, so "off" always stays off. */ - terminalLigatures: 'auto' | 'on' | 'off' - terminalCursorStyle: 'bar' | 'block' | 'underline' - /** One-shot migration guard for moving inherited cursor defaults to block. */ - terminalCursorStyleDefaultedToBlock?: boolean - terminalCursorBlink: boolean - terminalThemeDark: string - terminalCustomThemes?: TerminalCustomTheme[] - terminalDividerColorDark: string - terminalUseSeparateLightTheme: boolean - terminalThemeLight: string - terminalDividerColorLight: string - terminalInactivePaneOpacity: number - terminalActivePaneOpacity: number - terminalPaneOpacityTransitionMs: number - terminalDividerThicknessPx: number - terminalBackgroundOpacity?: number - terminalColorOverrides?: TerminalColorOverrides - terminalPaddingX?: number - terminalPaddingY?: number - terminalMouseHideWhileTyping?: boolean - terminalWordSeparator?: string - terminalCursorOpacity?: number - terminalQuickCommands?: TerminalQuickCommand[] - windowBackgroundBlur?: boolean - /** Windows-only: close (X) hides to tray instead of quitting; the tray icon is always present regardless. */ - minimizeToTrayOnClose?: boolean - /** macOS: toggles the additive menu-bar entry (Orca survives last-window close); doesn't change Dock behavior. */ - showMenuBarIcon?: boolean - /** Windows convention: right-click pastes; macOS/Linux keep the context menu. */ - terminalRightClickToPaste: boolean - /** One-shot guard distinguishing the old global true default from a per-platform choice. */ - terminalRightClickToPasteDefaultedForPlatform?: boolean - /** Windows-only: COMSPEC always points to cmd.exe, so this explicit shell (default 'powershell.exe') overrides it. */ - terminalWindowsShell: string - /** Pins the WSL distro for terminals/agent scans instead of WSL's current global default. */ - terminalWindowsWslDistro?: string | null - /** Account/auth location; auto follows the global Windows runtime while host/wsl pin it. */ - localAccountRuntime: 'auto' | 'host' | 'wsl' - localAccountWslDistro?: string | null - /** One-shot guard for migrating the legacy host default to auto. */ - localAccountRuntimeDefaultedToAutoForAllUsers?: boolean - /** Independent from the terminal shell so users can inspect Windows vs WSL agent PATH state without changing it. */ - localAgentRuntime?: 'host' | 'wsl' - localAgentWslDistro?: string | null - /** Why: global is only the default policy; project-level runtime preference wins. */ - localWindowsRuntimeDefault: GlobalWindowsRuntimeDefault - /** 'auto' resolves to PowerShell 7+ when present, else falls back to inbox Windows PowerShell. */ - terminalWindowsPowerShellImplementation: 'auto' | 'powershell.exe' | 'pwsh.exe' - terminalFocusFollowsMouse: boolean - /** X11/gnome-terminal "copy on select": selecting text auto-copies to the clipboard; default off. */ - terminalClipboardOnSelect: boolean - /** Enables OSC 52 clipboard writes for TUIs (tmux/Zellij/nvim, incl. over SSH); default on. Clipboard *queries* stay blocked and payload size is capped, so this is write-only exposure. */ - terminalAllowOsc52Clipboard: boolean - /** One-shot stamp: profiles saved under the old off default get flipped on once, after which an explicit opt-out sticks. */ - terminalAllowOsc52ClipboardDefaultedOnForAllUsers?: boolean - /** Experimental Claude Agent Teams; native panes use a tmux-compatible shim so teammate output stays on the normal PTY path. */ - claudeAgentTeamsMode?: ClaudeAgentTeamsMode - /** Where the repo setup script runs on workspace create; defaults to a background "Setup" tab to keep the main terminal usable. */ - setupScriptLaunchMode: SetupScriptLaunchMode - terminalScrollbackRows: number - /** Optional app-level proxy for Electron networking and local PTYs; empty preserves system/inherited proxy env. */ - httpProxyUrl?: string - /** Optional semicolon/comma/newline-separated bypass rules for httpProxyUrl. */ - httpProxyBypassRules?: string - /** Why: corporate TLS-intercepting proxies can break HTTP/2 downloads; opt-in Chromium process-wide HTTP/1.1 switch. */ - electronHttp1CompatibilityMode?: boolean - /** Opt-in in-app browsing (isolated guest surface); default keeps links opening in the system browser. */ - openLinksInApp: boolean - /** Worktree-scoped localhost hostnames to distinguish tabs; opt-in since a non-localhost host can break apps binding cookies/sessions to localhost. */ - localhostWorktreeLabelsEnabled?: boolean - /** Tracks the one-time first-use prompt for terminal link routing (avoid silently changing where links open). */ - openLinksInAppPreferencePrompted: boolean - /** Opt-in: Shift+modifier click inverts openLinksInApp instead of always forcing the system browser. Off keeps the historical one-way escape hatch. */ - openLinksInAppModifierInverts?: boolean - /** Show terminal link actions on plain click; off restores modifier-click-only terminal links. */ - terminalLinkActionPopoverEnabled?: boolean - /** Opt-in: open new coding-agent tabs in native chat instead of the raw terminal; optional for legacy settings. */ - openAgentTabsInChatByDefault?: boolean - /** Experimental native chat surface for Claude/Codex sessions; off by default. */ - experimentalNativeChat?: boolean - /** Last explicit native-chat model + option selections; live panes need an applied/dispatched record before showing a value. */ - nativeChatSessionOptions?: PersistedNativeChatSessionOptions - /** Extra launcher rows for the worktree "Open in" submenu. VS Code is always shown first. */ - openInApplications?: OpenInApplication[] - /** Deprecated: migration/backward-compat only. Use PersistedUIState.rightSidebarOpen. */ - rightSidebarOpenByDefault: boolean - showGitIgnoredFiles?: boolean - /** Preferred Source Control changes layout. Per-user, not per-workspace. */ - sourceControlViewMode: SourceControlViewMode - /** Preferred Source Control group order. Per-user, not per-workspace. */ - sourceControlGroupOrder: SourceControlGroupOrder - /** Compare base defaults to the branch upstream instead of the repo default; affects only the compare/diff view, not the PR/rebase target. Per-user. */ - sourceControlCompareAgainstUpstream: boolean - /** Whether to show the Orca app name in the titlebar. */ - showTitlebarAppName: boolean - /** Hides the Tasks sidebar button (also removes it from keyboard navigation). */ - showTasksButton: boolean - /** Only toggles the sidebar shortcut; Automations stay reachable from Settings/View menu. */ - showAutomationsButton?: boolean - /** Deprecated: Artifacts are always available. Use showArtifactsButton for sidebar visibility. */ - artifactsEnabled?: boolean - /** Capability gate for agent-driven publishing; off until granted, enforced in main, not just the UI. */ - artifactSharingEnabled?: boolean - /** Only toggles the sidebar shortcut; Artifacts stay reachable from Settings. */ - showArtifactsButton?: boolean - /** Only toggles the sidebar shortcut; Orca Mobile stays reachable from Settings. */ - showMobileButton?: boolean - /** Pinned workspaces show in one sidebar location by default; opt in to also show them in their natural groups. */ - showPinnedWorktreesInGroups?: boolean - /** How Ctrl+Tab picks the next visible tab; optional (older profiles), readers default to MRU. */ - ctrlTabOrderMode?: CtrlTabOrderMode - /** Orca-first keeps app shortcuts from TUIs; terminal-first is opt-in to let shell/TUI bindings win. */ - terminalShortcutPolicy?: TerminalShortcutPolicy - /** Floating Workspace: global surface for terminal/browser/markdown tabs outside repo/worktree context. */ - floatingTerminalEnabled: boolean - /** One-shot migration flag for the floating-workspace default-on rollout; after migration an explicit off sticks. */ - floatingTerminalDefaultedForAllUsers?: boolean - /** Start dir for new floating-workspace terminal tabs; empty or '~' = home dir. */ - floatingTerminalCwd: string - /** Picker-approved floating-workspace dirs reauthorized across restarts; renderer text alone must not populate this. */ - floatingTerminalTrustedCwds?: string[] - /** One-shot migration marker for legacy floating workspace cwd trust grants. */ - floatingTerminalCwdMigratedToAppWorkspace?: boolean - /** Where the Floating Workspace toggle is shown; defaults to the floating button for discoverability. */ - floatingTerminalTriggerLocation: FloatingTerminalTriggerLocation - /** Legacy keyboard-shortcut overrides; new writes go to ~/.orca/keybindings.json, migrated once when present. */ - keybindings?: KeybindingOverrides - diffDefaultView: 'inline' | 'side-by-side' - diffWordWrap: boolean - combinedDiffFileTreeVisibleByDefault: boolean - /** Bot-marked comment-author logins (stored lowercased); escape hatch for review bots on regular accounts that defeat provider metadata/heuristics. */ - prBotAuthorOverrides: string[] - notifications: NotificationSettings - /** Countdown after a Claude agent goes idle showing time left before the prompt cache expires. */ - promptCacheTimerEnabled: boolean - /** Prompt-cache TTL (ms); only 300000 (5 min standard) or 3600000 (1 hr, extended-TTL plans). */ - promptCacheTtlMs: number - /** Why: durable main-owned pref so Orca can prepare shared ~/.codex before the renderer hydrates. */ - codexManagedAccounts: CodexManagedAccount[] - activeCodexManagedAccountId: string | null - activeCodexManagedAccountIdsByRuntime?: CodexManagedAccountRuntimeSelection - /** Why: persist only per-account auth (not a CLAUDE_CONFIG_DIR swap) so switching accounts doesn't fork Claude's shared chat/session context. */ - claudeManagedAccounts: ClaudeManagedAccount[] - activeClaudeManagedAccountId: string | null - activeClaudeManagedAccountIdsByRuntime?: ClaudeManagedAccountRuntimeSelection - /** Per-worktree shell history file so ArrowUp doesn't surface other worktrees' commands. Defaults to true. */ - terminalScopeHistoryByWorktree: boolean - /** Kill switch for hidden terminal view parking: unmount long-hidden panes while a pane-less watcher keeps PTY side effects alive. */ - terminalHiddenViewParking?: boolean - /** Kill switch for SSH terminal parking (C1): SSH panes park like local ones; reveal restores from main's headless model, falling back to relay replay. */ - terminalSshViewParking?: boolean - /** Kill switch for the hidden-worktree retention budget (C1): force-parks the least-recently-hidden un-parkable worktrees beyond a count budget or TTL. */ - terminalHiddenWorktreeRetentionBudget?: boolean - /** Kill switch for the browser-guest worktree retention budget: destroys the least-recently-activated hidden worktrees' webview guests beyond an LRU count budget. */ - browserGuestWorktreeRetentionBudget?: boolean - /** Kill switch for main-process PTY side-effect authority; on (default) = title/bell/agent facts via pty:sideEffect channel, not renderer byte parsing. */ - terminalMainSideEffectAuthority?: boolean - /** Kill switch for main's hidden-delivery gate (Phase 4): drops PTY bytes to hidden views after model ingestion; requires terminalMainSideEffectAuthority. */ - terminalHiddenDeliveryGate?: boolean - /** Kill switch for main's model query responder (Phase 5); active only when both Phase-4 gates are also on. */ - terminalModelQueryAuthority?: boolean - /** Which agent to pre-select in the new-workspace composer. - * - null: auto (first detected agent) - * - 'blank': blank terminal (no agent launched) - * - TuiAgent: a specific agent id */ - defaultTuiAgent: TuiAgent | 'blank' | null - /** Agents hidden from picker/auto-launch; detection stays a raw PATH snapshot. */ - disabledTuiAgents: TuiAgent[] - /** Master switch for the experimental plugin system. Off by default: no - * discovery, no panels, no plugin code paths run at all. */ - pluginSystemEnabled: boolean - /** Qualified plugin keys (`publisher.id`) the user disabled. Discovered - * plugins stay listed but are not activated. */ - disabledPlugins: string[] - /** Consent records: qualified plugin key → capability/worker-trust fingerprint. - * A plugin whose current fingerprint differs is pending again, so an update - * crossing either trust boundary re-prompts before code runs. Absent key = - * never consented. */ - pluginConsents: Record - /** Local directories loaded as dev-mode plugins (manifest hot-reload). */ - devPluginPaths: string[] - /** One-shot guard: start Claude Agent Teams hidden for existing profiles without overriding later opt-ins. */ - claudeAgentTeamsDefaultDisabledMigrated?: boolean - /** Why: worktree deletion is destructive (rm -rf of the working dir), so confirm by default. */ - skipDeleteWorktreeConfirm: boolean - /** Why: closing a terminal with child processes kills foreground work; keep this skip separate from other confirmations. */ - skipCloseTerminalWithRunningProcessConfirm: boolean - /** Why: deleting an automation also deletes its run history; keep this skip separate from worktree deletion. */ - skipDeleteAutomationConfirm: boolean - /** Why: deleting an artifact breaks a public link others may already hold; keep this skip separate from local deletions. */ - skipDeleteArtifactConfirm: boolean - /** Why: a Codex rate-limit reset spends a scarce credit on the live account; keep this skip separate from local confirmations. */ - skipCodexRateLimitResetConfirm: boolean - /** Default preset in the new-workspace GitHub task view. */ - defaultTaskViewPreset: TaskViewPresetId - /** Persisted last-used task source so Tasks reopens to the same provider instead of defaulting to GitHub. */ - defaultTaskSource: TaskProvider - /** Persisted visible task providers; hides unused providers from Tasks chrome and sidebar shortcuts. */ - visibleTaskProviders: TaskProvider[] - /** Why: one-shot guard to make Jira visible for existing profiles once, without re-adding after a later opt-out. */ - visibleTaskProvidersDefaultedForJira: boolean - /** Persisted repo selection (cross-repo tasks view). null = sticky-all (includes future-added repos); - * string[] = frozen curated subset (ineligible ids dropped on load; empty after drop is treated as null). */ - defaultRepoSelection: string[] | null - /** Persisted Linear team selection (tasks view). Same nullable-array pattern as - * defaultRepoSelection: null = sticky-all, string[] = frozen subset of team IDs. */ - defaultLinearTeamSelection: string[] | null - /** Session cookie for OpenCode Go rate-limit fetching. Stored encrypted. */ - opencodeSessionCookie: string - /** Optional OpenCode Go workspace ID override; when set, skips the workspaces lookup and fetches usage directly. */ - opencodeWorkspaceId: string - /** Optional MiniMax group id. When empty, the usage fetcher extracts minimax_group_id_v2 from the cookie. */ - minimaxGroupId: string - /** Comma-separated MiniMax model names to show in the status bar usage window. */ - minimaxUsageModels: string - /** Extract OAuth credentials from the local Gemini CLI for rate-limit fetching. Off by default (explicit opt-in). */ - geminiCliOAuthEnabled: boolean - /** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */ - agentCmdOverrides: Partial> - /** Custom CODEX_HOME for Codex session-history discovery (defaults to ~/.codex). - * History-only: does not change which account/config/hooks Orca uses. */ - codexSessionSourceHome?: { - /** Absolute host path; empty/undefined falls back to ~/.codex. */ - host?: string - /** Per-WSL-distro absolute Linux path; missing distro falls back to /.codex. */ - wsl?: Record - } - /** Per-agent default CLI arguments appended after the binary/path and before prompts. */ - agentDefaultArgs?: Partial> - /** Per-agent launch environment defaults used when yolo mode is exposed as env. */ - agentDefaultEnv?: Partial>> - /** One-shot guard for adding yolo-mode default args to untouched agent launch profiles. */ - agentYoloDefaultsMigrated?: boolean - /** Why: disabling must persist so startup doesn't reinstall global agent hook entries the user just removed. */ - agentStatusHooksEnabled: boolean - /** Dismissed freshness tuples: no write authority, just suppress re-nudging the same official placement/revision. */ - dismissedSkillFreshnessNudges?: string[] - /** Why: generated tab titles are subjective, so they stay opt-in and manual renames win. */ - tabAutoGenerateTitle: boolean - /** Why: pinned tabs can still be closed via keyboard/native-menu; this gates that behind a confirmation. Defaults on. */ - confirmClosePinnedTab: boolean - /** When true, Orca requests local awake assertions while hook-reported agents are working. */ - keepComputerAwakeWhileAgentsRun: boolean - /** Optional for mixed-version compatibility; the legacy boolean maps true to Auto. */ - computerAwakeMode?: ComputerAwakeMode - /** macOS Option key: compose layout chars (@ German, € French) vs act as Meta/Esc for readline. - * 'auto' (default) = layout-aware via navigator.keyboard.getLayoutMap() (US → Meta, else compose); - * 'false' = compose; 'true' = Meta on both Option keys; 'left'/'right' = only that key is Meta. - * See docs/terminal-option-key-layout-aware-default.md. */ - terminalMacOptionAsAlt: 'auto' | 'true' | 'false' | 'left' | 'right' - /** One-shot migration guard for the 'auto' rollout. Old default 'true' was ambiguous (explicit vs default); - * on first upgrade launch, reset a persisted 'true' to 'auto' so non-US keyboards aren't broken by the stale default. */ - terminalMacOptionAsAltMigrated: boolean - /** Whether macOS terminal input maps the physical JIS Yen (¥) key to backslash, per common terminal expectation. */ - terminalJISYenToBackslash: boolean - experimentalMobile: boolean - /** Why: iOS Simulator is default-on for capable macOS hosts; this is the durable off switch (hides UI, blocks CLI attach). */ - mobileEmulatorEnabled?: boolean - /** Preferred iOS Simulator UDID for UI auto-attach and agent CLI attach. */ - mobileEmulatorDefaultDeviceUdid?: string | null - /** Explicit Android SDK root for when auto-discovery (ANDROID_HOME / default path) fails; null (default) auto-discovers. */ - androidSdkPath?: string | null - /** Auto-restore window (ms) for a phone-fit PTY after the last mobile subscriber leaves. - * `null` (default) holds phone size indefinitely; a finite value schedules restore. - * Clamped on read to [5_000ms, 60min]. See docs/mobile-fit-hold.md. */ - mobileAutoRestoreFitMs: number | null - /** Preferred mobile pairing path for new QR codes. Missing/'automatic' = Anywhere (Relay + local); - * explicit 'local-only' = same-network only. */ - mobilePairingConnectionMode?: 'automatic' | 'local-only' - /** Explicit custom address restored when generating future mobile pairing codes. */ - mobilePairingCustomAddress?: string | null - /** Saved custom addresses available in both mobile pairing pickers. */ - mobilePairingCustomAddresses?: string[] - /** Experimental: floating animated pet in the bottom-right corner. Opt-in cosmetic; - * off never mounts the overlay, and toggling takes effect instantly (renderer-side). */ - experimentalPet: boolean - /** Legacy persisted key from before the sidekick -> pet rename; read only during migration, new writes use experimentalPet. */ - experimentalSidekick?: boolean - /** Experimental: left-sidebar Agents view — threaded feed of agent completions, blocking/unread state, worktree creation. */ - experimentalActivity: boolean - /** Experimental: pop-out Kanban dashboard for monitoring and opening agent terminals across worktrees. */ - experimentalAgentDashboardPopout?: boolean - /** How the Agent Dashboard opens: an in-window companion board or a separate pop-out window. Defaults to in-window. */ - experimentalAgentDashboardMode?: AgentDashboardMode - /** Includes stale quiet agents as a fourth Agent Dashboard column. */ - experimentalAgentDashboardShowIdle?: boolean - /** One-shot migration guard for defaulting the Agents view off; later explicit opt-ins persist normally. */ - experimentalActivityDefaultedOffForAllUsers?: boolean - /** Experimental: persistent terminal-pane attention ring for bell + agent-completion events. Opt-in while tuning signal/noise. */ - experimentalTerminalAttention: boolean - /** Experimental: automatically sleep completed, resumable background agent terminals. */ - experimentalAgentHibernation?: boolean - /** Milliseconds a completed agent must stay idle before hibernation can be considered. */ - agentHibernationIdleMs?: number - /** Experimental: opt-in preview of the updated worktree-card layout and metadata behavior. */ - experimentalNewWorktreeCardStyle?: boolean - /** Experimental: per-workspace on-demand environment recipes and setup surface. */ - experimentalEphemeralVms?: boolean - /** Compact worktree cards: hide the metadata row when title and branch say the same thing. */ - compactWorktreeCards: boolean - /** Legacy persisted key from the Experimental rollout; new writes use compactWorktreeCards. */ - experimentalCompactWorktreeCards?: boolean - /** Active non-local runtime environment for client-routed RPC; null keeps local desktop behavior. */ - activeRuntimeEnvironmentId?: string | null - /** GitHub Project mode state (pinned/recent/active project, last view per project). - * Optional for pre-feature profiles; the persistence merge hydrates the default. */ - githubProjects?: GitHubProjectSettings - /** AI commit-message config (agent, model, per-model thinking, prompt suffix). Optional to avoid migrating existing profiles. */ - commitMessageAi?: CommitMessageAiSettings - /** Source-control AI generation settings for commit messages and hosted-review drafts. */ - sourceControlAi?: SourceControlAiSettings - /** GitLab project preferences (pinned + recent paths). Optional for pre-GitLab profiles; persistence merge fills the default. */ - gitlabProjects?: GitLabProjectSettings - /** Anonymous product-telemetry state; optional until the one-shot Store.load() migration populates it. - * Holds only consent + identity, not volatile counters — those would amplify the debounced settings write. */ - telemetry?: { - /** New users: true at install. Existing users: null until they resolve the first-launch banner. */ - optedIn: boolean | null - /** Anonymous UUID v4. Generated on first run. Stable across launches; not surfaced in the UI. */ - installId: string - /** Cohort marker: true for pre-existing profiles (gates the opt-in banner), false for fresh installs. */ - existedBeforeTelemetryRelease: boolean - } - /** One-shot cohort marker for the tab-switch keybinding swap. 'pending' = - * pre-existing install (seed pins old chords, then flips to 'done'); 'done' = fresh install. */ - tabSwitchKeybindingSeed?: 'pending' | 'done' - /** Local voice/dictation config. Optional for pre-voice profiles; getDefaultSettings() hydrates defaults via the persistence merge. */ - voice?: VoiceSettings -} - -export type OrcaWorkspaceLayout = { - path: string - nestWorkspaces: boolean -} - -export type CommitMessageAiModelCapability = { - id: string - label: string - thinkingLevels?: { id: string; label: string }[] - defaultThinkingLevel?: string -} - -export type CommitMessageAiSettings = { - enabled: boolean - /** A TuiAgent id, the literal `'custom'` for a user-supplied command, or null. */ - agentId: TuiAgent | 'custom' | null - /** Per-agent: switching agents preserves the previously-picked model. */ - selectedModelByAgent: Partial> - /** Host-scoped model selections; dynamic agents can expose different models per SSH target. */ - selectedModelByAgentByHost?: Partial>>> - /** Per-agent dynamic models last discovered from the CLI, persisted so main can validate selections. */ - discoveredModelsByAgent?: Partial> - /** Host-scoped dynamic model discovery cache. */ - discoveredModelsByAgentByHost?: Partial< - Record>> - > - /** Per-model: thinking effort depends on the model, not the agent. Keyed by model id. */ - selectedThinkingByModel: Record - /** Optional user-provided suffix appended to the base prompt (style overrides, etc.). */ - customPrompt: string - /** Command template for agentId === 'custom'; {prompt} substitutes the diff prompt via argv, else the prompt is piped via stdin. */ - customAgentCommand: string -} - -export type GhosttyImportPreview = { - found: boolean - configPath?: string - configPaths?: string[] - diff: Partial - unsupportedKeys: string[] - error?: string -} - -// Subset of onboarding Ghostty DiscoveryState statuses that emit telemetry; UI-only 'idle'/'detecting' don't. -export type DiscoveryStatusEmitted = 'found' | 'absent' | 'imported' - -export type NotificationEventSource = 'agent-task-complete' | 'terminal-bell' | 'test' - -export type NotificationDispatchRequest = { - source: NotificationEventSource - notificationId?: string - /** Why: useful for fast native failures, but macOS can still drop notifications after 'show'. */ - requireDisplayConfirmation?: boolean - worktreeId?: string - /** Stable `${tabId}:${leafId}` terminal pane key for click-to-focus routing. */ - paneKey?: string - repoLabel?: string - worktreeLabel?: string - hasMultipleActiveRepos?: boolean - terminalTitle?: string - isActiveWorktree?: boolean - agentType?: AgentType - agentState?: AgentStatusState - agentPrompt?: string - agentToolName?: string - agentToolInput?: string - agentLastAssistantMessage?: string - agentInterrupted?: boolean -} - -export type NotificationDispatchResult = { - delivered: boolean - /** Why delivery was skipped (set when delivered is false); 'blocked-by-system' = macOS would silently swallow it. */ - reason?: - | 'disabled' - | 'source-disabled' - | 'suppressed-focus' - | 'cooldown' - | 'not-supported' - | 'not-displayed' - | 'blocked-by-system' - | 'invalid-request' -} - -export type NotificationDismissResult = { - dismissed: number -} - -export type NotificationSoundResult = { - played: boolean - reason?: - | 'missing-path' - | 'invalid-path' - | 'unsupported-type' - | 'too-large' - | 'read-failed' - | 'playback-failed' - | 'deduped' -} - -export type NotificationSoundDataResult = - | { - ok: true - data: Uint8Array - mimeType: string - path: string - } - | { - ok: false - reason: Exclude - } - -export type NotificationSoundPathResult = - | { ok: true; path: string } - | { ok: false; reason: 'missing-path' | 'invalid-path' | 'unsupported-type' } - -export type OnboardingOutcome = 'completed' | 'dismissed' - -export type OnboardingChecklistState = { - addedRepo: boolean - choseAgent: boolean - ranFirstAgent: boolean - ranSecondAgentOnSameTask: boolean - triedCmdJ: boolean - shapedSidebar: boolean - reviewedDiff: boolean - openedPr: boolean - addedFolder: boolean - openedFile: boolean - ranAgentOnFile: boolean - // Why: UI state flag (panel visibility), not an activation event; telemetry checklist enum omits it. - dismissed: boolean -} - -export type OnboardingState = { - // Why: step meanings change when pages are removed; version marker prevents migration re-running on new progress. - flowVersion: number - closedAt: number | null - outcome: OnboardingOutcome | null - // Sentinel -1 = not started; 1..5 = highest finished wizard step. number (not union) because callers clamp via Math.max/min. - lastCompletedStep: number - checklist: OnboardingChecklistState -} - -export type NotificationPermissionStatusResult = { - supported: boolean - platform: NodeJS.Platform - requested: boolean -} - -/** macOS notification permission outcome: authoritative native UNUserNotificationCenter readout, else a weaker - * delivery-probe fallback; 'awaiting-decision' = permission dialog unanswered. */ -export type NotificationDeliveryProbeResult = { - state: 'delivered' | 'blocked' | 'awaiting-decision' | 'unsupported' - /** True when the state comes from the native authorization readout (vs. the delivery-probe fallback). */ - authoritative: boolean -} - -export type WorktreeCardProperty = - | 'status' - | 'unread' - // Legacy persisted preference. CI status is now represented by linked PR metadata. - | 'ci' - // Migration-only: legacy detailed cards showed branch identity as a visible row. - | 'branch' - // Task metadata on workspace cards; provider-specific persisted values kept for older profiles. - | 'issue' - | 'linear-issue' - | 'jira-issue' - | 'pr' - | 'automation' - // Badge marking workspaces created through `orca worktree create`. - | 'cli' - | 'comment' - | 'ports' - // Inline agent-activity list rendered in each workspace card; on by default (see DEFAULT_WORKTREE_CARD_PROPERTIES in shared/constants.ts). - | 'inline-agents' - -export type WorktreeCardMode = 'Default' | 'Compact' - -export type AgentActivityDisplayMode = 'compact' | 'full' - -export type StatusBarItem = - | 'claude' - | 'codex' - | 'gemini' - | 'antigravity' - | 'opencode-go' - | 'kimi' - | 'minimax' - | 'grok' - | 'ssh' - | 'resource-usage' - | 'ports' -export type FloatingTerminalTriggerLocation = 'floating-button' | 'status-bar' - -export type TaskResumeState = { - githubMode?: 'items' | 'project' - githubItemsPreset?: TaskViewPresetId | null - githubItemsQuery?: string - githubProjectHiddenFieldIdsByView?: Record - linearMode?: 'issues' | 'projects' | 'views' | 'in-orca' - linearPreset?: 'assigned' | 'created' | 'all' | 'completed' - linearQuery?: string - linearContext?: { - kind: 'project' | 'view' - id: string - workspaceId: LinearConcreteWorkspaceId - model?: LinearCustomViewModel - } - jiraPreset?: 'assigned' | 'reported' | 'all' | 'done' - jiraQuery?: string -} - -export type RightSidebarTab = - | 'explorer' - | 'search' - | 'vault' - | 'workspaces' - | 'pr-checks' - | 'source-control' - | 'checks' - | 'ports' - // Plugin-contributed panels are keyed `plugin:/` so the - // static union stays closed while plugin tabs remain type-representable. - | `plugin:${string}` -export type ActiveRightSidebarTab = Exclude -export type RightSidebarExplorerView = 'files' | 'search' - -export type ProjectOrderBy = 'manual' | 'recent' -export type WorkspaceHostScope = 'all' | 'local' | `ssh:${string}` | `runtime:${string}` -export type VisibleWorkspaceHostIds = Exclude[] | null -export type WorkspaceHostOrder = Exclude[] -export type ManualRepoOrderEntry = { - hostId: WorkspaceHostOrder[number] - repoId: string -} - -/** The active top-level section shown in the main content area. */ -export type TopLevelView = - | 'terminal' - | 'settings' - | 'tasks' - | 'activity' - | 'automations' - | 'space' - | 'artifacts' - | 'mobile' - -export type PersistedUIState = { - lastActiveRepoId: string | null - lastActiveWorktreeId: string | null - /** Active top-level view at save time, restored on relaunch; sanitized to 'terminal' if unknown or now-gated. */ - activeView: TopLevelView - sidebarWidth: number - rightSidebarOpen: boolean - rightSidebarTab: RightSidebarTab - rightSidebarExplorerView: RightSidebarExplorerView - rightSidebarWidth: number - markdownTocPanelWidth?: number - combinedDiffFileTreeWidth?: number - groupBy: 'none' | 'workspace-status' | 'repo' | 'pr-status' - sortBy: 'name' | 'smart' | 'recent' | 'repo' | 'manual' - /** Project header ordering in `groupBy: 'repo'`, independent of `sortBy`: 'manual' uses persisted order + header drag, 'recent' by latest visible activity. */ - projectOrderBy: ProjectOrderBy - /** Deprecated; the Active only filter is retired and ignored on hydration. */ - showActiveOnly: boolean - /** Hide sleeping/inactive workspaces from workspace navigation. Off by default. */ - hideSleepingWorkspaces?: boolean - /** Which execution hosts the sidebar shows; `all` = mixed view, specific IDs focus without tearing down other hosts' sessions. */ - workspaceHostScope?: WorkspaceHostScope - /** Which execution hosts the sidebar shows; `null` = sticky all-hosts so new hosts appear automatically. */ - visibleWorkspaceHostIds?: VisibleWorkspaceHostIds - /** User-defined sidebar order for host sections; missing/new hosts append in discovered order. */ - workspaceHostOrder?: WorkspaceHostOrder - /** Desktop-owned all-host repo order; host-qualified identities keep a manual cross-host interleaving while each host owns its local permutation. */ - manualRepoOrder?: ManualRepoOrderEntry[] - /** Deprecated legacy positive-form setting. Ignored on hydration. */ - showSleepingWorkspaces?: boolean - /** Deprecated legacy name used by a short-lived build. Ignored on hydration. */ - showInactiveWorkspaces?: boolean - /** Hide the repo's checked-out branch from workspace nav (sidebar, Cmd+J); folder-mode repos are unaffected (empty-branch worktrees excluded). */ - hideDefaultBranchWorkspace: boolean - /** Hide workspaces created by automation new-per-run dispatches. */ - hideAutomationGeneratedWorkspaces?: boolean - /** Hide workspaces created through `orca worktree create`. */ - hideCliCreatedWorkspaces?: boolean - /** Hide workspaces sitting on a detached HEAD; folder workspaces (no head at all) are unaffected. */ - hideDetachedHeadWorkspaces?: boolean - /** Hide workspaces with known provenance from another paired device or the host UI. */ - hideWorkspacesFromOtherDevices?: boolean - /** Keep each project's main workspace out of the "Hide sleeping" sweep. Absent means on (#8873). */ - alwaysShowDefaultBranchWorkspace?: boolean - /** Per-worktree Explorer dotfile visibility. Missing entries inherit the default: show. */ - showDotfilesByWorktree?: Record - filterRepoIds: string[] - collapsedGroups: string[] - uiZoomLevel: number - editorFontZoomLevel: number - worktreeCardProperties: WorktreeCardProperty[] - /** One-shot migration flag for deriving card properties from the two worktree card modes. */ - _worktreeCardModeDefaulted?: boolean - agentActivityDisplayMode?: AgentActivityDisplayMode - workspaceStatuses?: WorkspaceStatusDefinition[] - workspaceBoardOpacity?: number - workspaceBoardColumnWidth?: number - syncTaskStatusFromWorkspaceBoard?: boolean - /** One-shot migration flag for a short-lived build that persisted default statuses in reverse order; once stamped, ordering is never re-inferred from IDs/labels. */ - _workspaceStatusesDefaultOrderMigrated?: boolean - /** One-shot repair flag for the exact default payload a short-lived build persisted in reverse workflow order. */ - _workspaceStatusesReorderedDefaultRepaired?: boolean - /** One-shot migration flag for default status workflow labels/visuals; only exact legacy defaults migrate, customized statuses preserved. */ - _workspaceStatusesDefaultWorkflowMigrated?: boolean - /** One-shot migration flag for the old default status visuals; once stamped, user-authored colors/icons are preserved. */ - _workspaceStatusesDefaultVisualsMigrated?: boolean - /** One-shot migration flag for adding the default-on Ports status item. */ - _portsStatusBarDefaultAdded?: boolean - /** One-shot migration flag for adding the default-on Kimi status item. */ - _kimiStatusBarDefaultAdded?: boolean - /** One-shot migration flag for adding the default-on MiniMax status item. */ - _minimaxStatusBarDefaultAdded?: boolean - /** One-shot migration flag for adding the default-on Antigravity status item. */ - _antigravityStatusBarDefaultAdded?: boolean - /** One-shot migration flag for adding the default-on Grok status item. */ - _grokStatusBarDefaultAdded?: boolean - statusBarItems: StatusBarItem[] - statusBarVisible: boolean - /** Why: this is client-side presentation, not a provider/account or execution-host setting. */ - usagePercentageDisplay?: UsagePercentageDisplay - /** Client-side footer presentation; verbose preserves the pre-roster all-window default. */ - statusBarUsageMode?: StatusBarUsageMode - dismissedUpdateVersion: string | null - lastUpdateCheckAt: number | null - /** Dev-only update channel override; absent means the build's own channel. */ - releaseChannelOverride?: ReleaseChannel | null - pendingUpdateNudgeId?: string | null - dismissedUpdateNudgeId?: string | null - /** Whether Orca already tried triggering the macOS notification permission dialog; prevents re-firing every launch. */ - notificationPermissionRequested?: boolean - /** Once the "your sessions won't be interrupted" reassurance card is seen, never show it again. */ - updateReassuranceSeen?: boolean - /** Per-paneKey "row visited" timestamps that mute seen inline-agent rows; persisted because rows survive restart, else acked rows return bold. Renderer-owned via ui:set. */ - acknowledgedAgentsByPaneKey?: Record - /** User-hidden setup-guide sidebar entry; a reversible declutter pref (Help menu stays available), not completion. */ - setupGuideSidebarDismissed?: boolean - /** One-shot marker for the browser setup-guide milestone; profiles missing it are evaluated once in the renderer (completion needs runtime probes). */ - setupGuideBrowserMilestoneMigrated?: boolean - /** Existing users who completed/dismissed the pre-browser checklist stay complete after the browser milestone is added. */ - setupGuideBrowserMilestoneLegacyComplete?: boolean - /** User-dismissed browser import toolbar hint; import stays available from Settings > Browser and the overflow menu. */ - browserImportHintHidden?: boolean - /** Why: Windows-only. Set once on first hide to tray so the "Orca is still running" notice shows only once. */ - trayMinimizeNoticeShown?: boolean - /** Set by the OSC 52 default-on migration when it overrode a persisted `false`; the renderer shows one notice and clears it. */ - osc52ClipboardDefaultOnNoticePending?: boolean - /** User dismissed the first-run Mobile Emulator intro; reversible only by re-enabling the feature in Settings. */ - mobileEmulatorTabIntroDismissed?: boolean - /** User deferred the in-pane Mobile Emulator CLI + skill setup guide. */ - mobileEmulatorAgentSetupDismissed?: boolean - /** One-shot rollout notice for manual project ordering default; absent or true keeps the sidebar callout hidden. */ - projectOrderManualDefaultNoticeDismissed?: boolean - /** One-shot notice that usage meters show percent used, not remaining; absent resolves on load (new profiles dismissed, upgraded see it once). */ - usagePercentageDisplayChangeNoticeDismissed?: boolean - /** User-hidden empty-state usage CTA; permanently hides the "Connect AI accounts" prompt even if providers are later disconnected. */ - usageEmptyStateDismissed?: boolean - /** URL for new browser tabs; null = blank tab. */ - browserDefaultUrl?: string | null - browserDefaultSearchEngine?: 'google' | 'duckduckgo' | 'bing' | 'kagi' | null - /** Electron browser zoom level applied when a new local browser tab is created. */ - browserDefaultZoomLevel?: number - /** Optional Kagi private-session link used only when Kagi is the search engine. */ - browserKagiSessionLink?: string | null - /** Saved window bounds so the app restores last position/size instead of maximizing each launch. */ - windowBounds?: { x: number; y: number; width: number; height: number } | null - /** Whether the window was maximized when it was last closed. */ - windowMaximized?: boolean - /** Saved bounds for the pop-out dashboard window so it restores to its last - * position/size. Independent of the main window's bounds. */ - dashboardPopoutBounds?: { x: number; y: number; width: number; height: number } | null - /** One-shot flag: 'recent' once meant the smart sort (v1→v2 rename), migrated to 'smart' once so the new last-activity 'recent' isn't re-clobbered. */ - _sortBySmartMigrated?: boolean - /** LEGACY inline-agents flag, stamped unconditionally every load so it can't gate migration; kept only for rollback forward-compat (real gate: _inlineAgentsDefaultedForAllUsers). */ - _inlineAgentsDefaultedForExperiment?: boolean - /** One-shot flag for the inline-agents default-on rollout; distinct from _inlineAgentsDefaultedForExperiment, which was stamped every load and is permanently dirty. */ - _inlineAgentsDefaultedForAllUsers?: boolean - /** One-shot migration flag for split-out card properties, set once so later deliberate unchecks of Linear issue/Ports stick across restarts. */ - _expandedWorktreeCardPropertiesDefaulted?: boolean - /** One-shot backfill flag for 'jira-issue', which joined the defaults after the expansion migration had already stamped upgraded profiles. */ - _jiraIssueWorktreeCardPropertyDefaulted?: boolean - /** totalAgentsSpawned snapshot at first sighting of the current app version, so the nag counts agents since last update (not from zero). */ - starNagBaselineAgents?: number | null - /** App version that set the current baseline; a version change re-captures the baseline on next spawn, restarting the nag countdown. */ - starNagAppVersion?: string | null - /** Next agents-since-baseline threshold that fires the star-nag; starts at 35, doubles per dismissal without starring. */ - starNagNextThreshold?: number - /** Once the user has starred Orca (any entry point), permanently suppress the nag. */ - starNagCompleted?: boolean - /** Timestamp until which nonterminal dismissals suppress threshold prompts (force-show bypasses for dev/testing). */ - starNagDeferredUntil?: number | null - /** App version that consumed the first value-moment ask; main-owned so remote/web clients can't spoof the once-per-version cap. */ - starNagAgentValueMomentAppVersion?: string | null - trustedOrcaHooks?: PersistedTrustedOrcaHooks - setupScriptPromptDismissedRepoIds?: string[] - /** Pet overlay visibility, separate from the experimentalPet settings flag so "Hide pet" is a reversible dismiss; absent = true. */ - petVisible?: boolean - /** Active pet id (bundled id or custom UUID); unknown ids fall back to the default on read so a removed custom pet doesn't blank the overlay. */ - petId?: string - /** Metadata index for user-uploaded pet images; bytes live under legacy userData/sidekicks/custom/. */ - customPets?: CustomPet[] - /** Pet overlay size in CSS pixels (square); clamped to [PET_SIZE_MIN, PET_SIZE_MAX] on read. */ - petSize?: number - /** Legacy keys from before the sidekick -> pet rename; read only during migration, new writes use pet* above. */ - sidekickVisible?: boolean - sidekickId?: string - customSidekicks?: CustomPet[] - sidekickSize?: number - /** Page-position state for Tasks: only transient tabs/searches (source/repo/team/project selections use their own settings paths). */ - taskResumeState?: TaskResumeState - workspaceCleanup?: WorkspaceCleanupUIState - /** Feature tips already surfaced; startup opens the tips modal only when a current tip id is missing here. */ - featureTipsSeenIds?: FeatureTipId[] - /** Feature ids the user has actually used; education surfaces skip teaching already-discovered features. */ - featureInteractions?: FeatureInteractionState - /** Contextual tours already surfaced; unknown ids ignored on hydration for downgrade/upgrade forward-compat. */ - contextualToursSeenIds?: ContextualTourId[] - /** Whether this profile may receive automatic contextual tours; missing = renderer hasn't classified the profile yet. */ - contextualToursAutoEligible?: boolean -} - -export const PET_SIZE_MIN = 60 -export const PET_SIZE_MAX = 360 -export const PET_SIZE_DEFAULT = 180 - -/** User-uploaded pet image metadata; renderer fetches bytes from main via pet:read (id, fileName), never learning the on-disk path. */ -export type CustomPet = { - id: string - label: string - fileName: string - /** MIME type for the renderer's Blob Content-Type — esp. image/svg+xml, which browsers won't render from a misdeclared blob URL. */ - mimeType: string - /** Storage layout: `image` = legacy flat file `custom/.`; `bundle` = `.codex-pet` expanded into `custom//`; absent = legacy `image`. */ - kind?: 'image' | 'bundle' - /** Sprite-sheet metadata; present iff from a `.codex-pet` bundle with a manifest frame layout. Dims derived in main so the renderer needn't probe the image. */ - sprite?: { - frameWidth: number - frameHeight: number - columns: number - rows: number - sheetWidth: number - sheetHeight: number - fps: number - defaultAnimation?: string - animations?: Record - } - /** Manifest-declared fps kept even when frames are auto-detected, so playback honors the bundle's speed instead of a hardcoded 8 fps. */ - spriteFps?: number -} - -/** One animation strip in a sprite sheet: `row` = 0-based y-index, `frames` = consecutive cells played left-to-right. */ -export type SpriteAnimation = { - row: number - frames: number - /** Per-frame holds in ms (length === frames). Absent means uniform sheet fps. */ - frameDurationsMs?: number[] -} - -export type PersistedTrustedOrcaHookEntry = { - contentHash: string - approvedAt: number -} - -export type PersistedTrustedOrcaHookRepo = { - all?: { - approvedAt: number - } - setup?: PersistedTrustedOrcaHookEntry - archive?: PersistedTrustedOrcaHookEntry - issueCommand?: PersistedTrustedOrcaHookEntry - vmRecipe?: PersistedTrustedOrcaHookEntry -} - -export type PersistedTrustedOrcaHooks = Record - -export type LegacyPaneKeyAliasEntry = { - ptyId: string - /** Physical pane key retained by the live process; name is persisted for compatibility (UUID keys after detach). */ - legacyPaneKey: string - /** Current logical owner pane key. May belong to another tab after detach. */ - stablePaneKey: string - updatedAt: number -} - -/** Last tab selection a paired client made in a worktree; restores phone navigation across host restarts. */ -export type PersistedMobileClientTabSelection = { - activeTabId: string | null - activeGroupId: string | null - activeTabIdByGroupId: Readonly> -} - -/** deviceId → worktreeId → selection. */ -export type PersistedMobileClientTabSelections = Record< - string, - Record -> - -// ─── Persistence shape ────────────────────────────────────────────── -export type PersistedState = { - schemaVersion: number - repos: Repo[] - projects: Project[] - projectHostSetups: ProjectHostSetup[] - projectGroups: ProjectGroup[] - folderWorkspaces: FolderWorkspace[] - /** Folder-workspace review notes, keyed by FolderWorkspace.id. Top-level, NOT nested in - * folderWorkspaces[]: normalizeFolderWorkspaces rebuilds each record field-by-field, so an - * older build drops nested fields, while unknown top-level keys round-trip untouched. - * - * WRITE-ONLY PROJECTION. FolderWorkspace.diffComments is the single in-memory home; load() - * hydrates from this key and then deletes it from Store state, and buildStateToSave() is the - * only producer of it. Never read Store.state.folderWorkspaceDiffComments outside load(). */ - folderWorkspaceDiffComments?: Record - /** Sparse-checkout presets keyed by repoId. */ - sparsePresetsByRepo: Record - /** Per paired device last tab selection by worktree; keeps mobile navigation across host restarts. */ - mobileClientTabSelectionsByDeviceId?: PersistedMobileClientTabSelections - worktreeMeta: Record - worktreeLineageById: Record - workspaceLineageByChildKey: Record - settings: GlobalSettings - ui: PersistedUIState - githubCache: { - pr: Record - issue: Record - } - /** Legacy single-blob session, kept as the canonical 'local' host partition so an app downgrade still reads its workspace. */ - workspaceSession: WorkspaceSessionState - /** Per-execution-host session partitions for non-'local' hosts (ssh:/runtime:); 'local' stays in workspaceSession so pre-partition builds keep working. */ - workspaceSessionsByHostId?: Partial> - sshTargets: SshTarget[] - /** SSH config aliases the user deleted; suppresses re-import from ~/.ssh/config so a deleted host doesn't reappear. */ - deletedSshConfigAliases: string[] - /** Identity records for removed SSH targets so a re-added host can re-adopt workspaces orphaned on the old target id. */ - removedSshTargetTombstones?: RemovedSshTargetTombstone[] - sshRemotePtyLeases: SshRemotePtyLease[] - /** Main-owned authenticated relay recovery records; never expose through renderer settings APIs. */ - sshPtyConsumerRecoveries?: SshPtyConsumerRecovery[] - /** Live local Claude daemon session ids; seeds the live-PTY gate so early OAuth refresh can't rotate the single-use refresh token out from under a running daemon. */ - claudeLivePtySessionIds?: string[] - migrationUnsupportedPtyEntries: MigrationUnsupportedPtyEntry[] - legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] - automations: Automation[] - automationRuns: AutomationRun[] - onboarding: OnboardingState - /** Main-owned telemetry de-dupe marker; never exposed through PersistedUIState. */ - featureInteractionTelemetryBuckets?: FeatureInteractionTelemetryBucketState - /** Main-owned reset mutation journal. Never expose this through renderer settings APIs. */ - codexResetCreditAttemptLedger?: CodexResetCreditAttemptLedger -} - -// ─── Filesystem ───────────────────────────────────────────── -export type FilesystemPathFlavor = 'posix' | 'win32' - -export type DirEntry = { - name: string - isDirectory: boolean - isSymlink: boolean -} - -export type MarkdownDocument = { - filePath: string - relativePath: string - basename: string - name: string -} - -// ─── Filesystem watcher ───────────────────────────────────── -export type FsChangeEvent = { - kind: 'create' | 'update' | 'delete' | 'rename' | 'overflow' - absolutePath: string - oldAbsolutePath?: string - isDirectory?: boolean -} - -export type FsChangedPayload = { - worktreePath: string - events: FsChangeEvent[] -} - -// ─── Git Status ───────────────────────────────────────────── -// Re-exported from git-status-types.ts so mobile shares the wire contract without this desktop aggregate. - -export type GitBranchChangeEntry = { - path: string - status: GitBranchChangeStatus - oldPath?: string - added?: number - removed?: number -} - -export type GitBranchCompareSummary = { - baseRef: string - baseOid: string | null - compareRef: string - headOid: string | null - mergeBase: string | null - changedFiles: number - commitsAhead?: number - status: 'ready' | 'invalid-base' | 'unborn-head' | 'no-merge-base' | 'loading' | 'error' - errorMessage?: string -} - -export type GitBranchCompareResult = { - summary: GitBranchCompareSummary - entries: GitBranchChangeEntry[] -} - -export type GitCommitCompareSummary = { - commitOid: string - parentOid: string | null - compareRef: string - baseRef: string - changedFiles: number - status: 'ready' | 'invalid-commit' | 'error' - errorMessage?: string -} - -export type GitCommitCompareResult = { - summary: GitCommitCompareSummary - entries: GitBranchChangeEntry[] -} - -export type GitDiffTextResult = { - kind: 'text' - originalContent: string - modifiedContent: string - originalIsBinary: false - modifiedIsBinary: false - largeDiffRenderLimit?: LargeDiffRenderLimit -} - -export type GitDiffBinaryResult = { - kind: 'binary' - originalContent: string - modifiedContent: string - /** Legacy flag used by the renderer for any binary format it can preview, including PDFs. */ - isImage?: boolean - /** MIME type for binary preview rendering, e.g. "image/png" or "application/pdf" */ - mimeType?: string - /** True only for a proven deletion — distinct from an empty modified side caused by a read failure or size cap. */ - modifiedDeleted?: boolean -} & ( - | { originalIsBinary: true; modifiedIsBinary: boolean } - | { originalIsBinary: boolean; modifiedIsBinary: true } -) - -export type GitDiffResult = GitDiffTextResult | GitDiffBinaryResult - -// ─── Search ───────────────────────────────────────────── -export type SearchMatch = { - line: number - column: number - matchLength: number - lineContent: string - displayColumn?: number - displayMatchLength?: number -} - -export type SearchFileResult = { - filePath: string - relativePath: string - matches: SearchMatch[] - matchCount?: number -} - -export type SearchResult = { - files: SearchFileResult[] - totalMatches: number - truncated: boolean -} - -export type SearchOptions = { - query: string - rootPath: string - caseSensitive?: boolean - wholeWord?: boolean - useRegex?: boolean - includePattern?: string - excludePattern?: string - maxResults?: number -} - -// ─── Stats ────────────────────────────────────────────────────────── - -export type StatsSummary = { - totalAgentsSpawned: number - totalPRsCreated: number - totalAgentTimeMs: number - // Sourced from aggregates, not the event log, so it survives event trimming. - firstEventAt: number | null // timestamp of first-ever event, for "tracking since..." -} - -// ─── Memory dashboard ────────────────────────────────────────────── - -/** cpu is percent of a single core — can exceed 100 on multi-core. memory is in bytes. */ -export type UsageValues = { - cpu: number - memory: number -} - -export type ProcessMemoryMetric = 'rss' | 'working-set' - -export type HostAvailableMemorySource = 'memory-pressure' | 'proc-meminfo' | 'free-memory' - -/** The top-level cpu/memory are the sum of main + renderer + other. */ -export type AppMemory = UsageValues & { - main: UsageValues - renderer: UsageValues - other: UsageValues - /** Oldest-first memory samples (bytes) for the whole Orca app; empty before the first snapshot. */ - history: number[] -} - -export type SessionMemory = UsageValues & { - sessionId: string - paneKey: string | null - pid: number -} - -/** The top-level cpu/memory are the sum of sessions. */ -export type WorktreeMemory = UsageValues & { - worktreeId: string - worktreeName: string - repoId: string - repoName: string - sessions: SessionMemory[] - /** Oldest-first memory samples (bytes) for this worktree's tracked subtrees. */ - history: number[] -} - -export type HostMemory = { - totalMemory: number - /** Immediately free memory reported by Node's host API. */ - freeMemory: number - /** Memory available without material pressure, or freeMemory when unavailable. */ - availableMemory: number - availableMemorySource: HostAvailableMemorySource - /** totalMemory - availableMemory. */ - usedMemory: number - memoryUsagePercent: number - cpuCoreCount: number - loadAverage1m: number -} - -export type MemorySnapshot = { - app: AppMemory - worktrees: WorktreeMemory[] - host: HostMemory - /** Per-process byte metric used by app, session, worktree, history, and totalMemory values. */ - processMemoryMetric: ProcessMemoryMetric - /** Sum of app + all tracked worktree sessions. Percent of a single core, so may exceed 100 on multi-core machines. */ - totalCpu: number - /** Sum of per-process samples. Shared pages may repeat, so this can exceed host.totalMemory. */ - totalMemory: number - collectedAt: number -} +export type { ClassifiedError } from './classified-error' diff --git a/src/shared/ui-chrome-types.ts b/src/shared/ui-chrome-types.ts new file mode 100644 index 00000000000..80c78d55afa --- /dev/null +++ b/src/shared/ui-chrome-types.ts @@ -0,0 +1,117 @@ +import type { LinearConcreteWorkspaceId } from './linear/workspace-types' +import type { LinearCustomViewModel } from './linear/project-types' + +export type TaskViewPresetId = 'all' | 'issues' | 'review' | 'my-issues' | 'my-prs' | 'prs' + +export type OpenInApplication = { + id: string + label: string + command: string +} + +export type SourceControlViewMode = 'list' | 'tree' +export type SourceControlGroupOrder = 'changes-first' | 'staged-first' | 'untracked-first' + +export type LeftSidebarAppearanceMode = 'default' | 'match-terminal' | 'tinted' + +/** Strategy for the prefix prepended to worktree branch names. */ +export type BranchPrefixStrategy = 'git-username' | 'custom' | 'none' + +export type FloatingTerminalCwdRequest = { + path?: string + requireTrusted?: boolean +} + +/** Presentation mode for the experimental Agent Dashboard. */ +export type AgentDashboardMode = 'in-window' | 'popout' + +export type WorktreeCardProperty = + | 'status' + | 'unread' + // Legacy persisted preference. CI status is now represented by linked PR metadata. + | 'ci' + // Migration-only: legacy detailed cards showed branch identity as a visible row. + | 'branch' + // Task metadata on workspace cards; provider-specific persisted values kept for older profiles. + | 'issue' + | 'linear-issue' + | 'jira-issue' + | 'pr' + | 'automation' + // Badge marking workspaces created through `orca worktree create`. + | 'cli' + | 'comment' + | 'ports' + // Inline agent-activity list rendered in each workspace card; on by default (see DEFAULT_WORKTREE_CARD_PROPERTIES in shared/constants.ts). + | 'inline-agents' + +export type WorktreeCardMode = 'Default' | 'Compact' + +export type AgentActivityDisplayMode = 'compact' | 'full' + +export type StatusBarItem = + | 'claude' + | 'codex' + | 'gemini' + | 'antigravity' + | 'opencode-go' + | 'kimi' + | 'minimax' + | 'grok' + | 'ssh' + | 'resource-usage' + | 'ports' +export type FloatingTerminalTriggerLocation = 'floating-button' | 'status-bar' + +export type TaskResumeState = { + githubMode?: 'items' | 'project' + githubItemsPreset?: TaskViewPresetId | null + githubItemsQuery?: string + githubProjectHiddenFieldIdsByView?: Record + linearMode?: 'issues' | 'projects' | 'views' | 'in-orca' + linearPreset?: 'assigned' | 'created' | 'all' | 'completed' + linearQuery?: string + linearContext?: { + kind: 'project' | 'view' + id: string + workspaceId: LinearConcreteWorkspaceId + model?: LinearCustomViewModel + } + jiraPreset?: 'assigned' | 'reported' | 'all' | 'done' + jiraQuery?: string +} + +export type RightSidebarTab = + | 'explorer' + | 'search' + | 'vault' + | 'workspaces' + | 'pr-checks' + | 'source-control' + | 'checks' + | 'ports' + // Plugin-contributed panels are keyed `plugin:/` so the + // static union stays closed while plugin tabs remain type-representable. + | `plugin:${string}` +export type ActiveRightSidebarTab = Exclude +export type RightSidebarExplorerView = 'files' | 'search' + +export type ProjectOrderBy = 'manual' | 'recent' +export type WorkspaceHostScope = 'all' | 'local' | `ssh:${string}` | `runtime:${string}` +export type VisibleWorkspaceHostIds = Exclude[] | null +export type WorkspaceHostOrder = Exclude[] +export type ManualRepoOrderEntry = { + hostId: WorkspaceHostOrder[number] + repoId: string +} + +/** The active top-level section shown in the main content area. */ +export type TopLevelView = + | 'terminal' + | 'settings' + | 'tasks' + | 'activity' + | 'automations' + | 'space' + | 'artifacts' + | 'mobile' diff --git a/src/shared/update-status-types.ts b/src/shared/update-status-types.ts new file mode 100644 index 00000000000..8d54837c9c5 --- /dev/null +++ b/src/shared/update-status-types.ts @@ -0,0 +1,88 @@ +import type { DedicatedRepoChannel, ReleaseBuild, ReleaseChannel } from './release-channel' + +// ─── Updater ───────────────────────────────────────────────────────── + +// Why: the release object sent to the renderer omits `version` (redundant +// with the top-level UpdateStatus.version) to keep one source of truth. +export type ChangelogRelease = { + title: string + description: string + mediaUrl?: string + releaseNotesUrl: string +} + +export type ChangelogData = { + release: ChangelogRelease + releasesBehind: number | null +} + +export type UpdateCheckOptions = { + includePrerelease?: boolean + includePerfPrerelease?: boolean + localBuild?: boolean + /** Dev channel switching; `targetTag` pins an exact build, including older ones. */ + channel?: ReleaseChannel + targetTag?: string +} + +/** Non-release origins for an update. Derived from the dev-channel list so a new + * channel with its own repo cannot be reported as an ordinary release. */ +export type UpdateSource = 'local' | DedicatedRepoChannel + +/** Root-package Linux install formats whose update installs need privilege escalation. */ +export type LinuxRootPackageType = 'deb' | 'rpm' + +export type LinuxPackageInstallFailureReason = + | 'authentication-agent-unavailable' + | 'authentication-denied' + | 'package-install-failed' + +// Why: the renderer must not infer "no polkit agent" from copy alone — main classifies and the card branches on this discriminant. +export type LinuxPackageInstallRecovery = { + kind: 'linux-package-install' + packageType: LinuxRootPackageType + reason: LinuxPackageInstallFailureReason + version: string +} + +/** Why: only these two mean no safe command exists here; every other failure clears recovery entirely. */ +export type LinuxPackageCommandUnavailableReason = 'no-sudo' | 'no-package-manager' + +export type LinuxPackageInstallInstructions = + | { ok: true; command: string; packageFileName: string } + | { ok: false; reason: LinuxPackageCommandUnavailableReason; message: string } + +export type UpdateStatus = ( + | { state: 'idle' } + | { state: 'checking'; userInitiated?: boolean } + | { + state: 'available' + version: string + activeNudgeId?: string + // Why: releaseUrl is not currently populated by the update-available handler + // (it always sends undefined). Kept on the type for the Settings page's + // release-notes link fallback and for potential future use if the main + // process starts extracting release URLs from electron-updater metadata. + releaseUrl?: string + // Why: changelog is always explicitly set by the main process — null means + // the fetch failed or the version wasn't in the JSON (simple mode), and a + // populated object means rich mode. Using `| null` (not `?`) avoids a + // three-state ambiguity (undefined vs null vs present) and makes exhaustive + // checks straightforward. + changelog: ChangelogData | null + } + | { state: 'not-available'; userInitiated?: boolean } + | { state: 'downloading'; percent: number; version: string; activeNudgeId?: string } + | { state: 'downloaded'; version: string; releaseUrl?: string; activeNudgeId?: string } + | { + state: 'error' + message: string + userInitiated?: boolean + activeNudgeId?: string + recovery?: LinuxPackageInstallRecovery + } +) & { source?: UpdateSource } + +export type ReleaseBuildListResult = + | { ok: true; channel: ReleaseChannel; builds: ReleaseBuild[] } + | { ok: false; channel: ReleaseChannel; message: string } diff --git a/src/shared/workspace-session-state-types.ts b/src/shared/workspace-session-state-types.ts new file mode 100644 index 00000000000..bc20b7c5459 --- /dev/null +++ b/src/shared/workspace-session-state-types.ts @@ -0,0 +1,113 @@ +import type { ExecutionHostId } from './execution-host' +import type { SleepingAgentSessionRecord } from './agent-session-resume' +import type { WorkspaceKey } from './folder-workspace-types' +import type { Tab, TabGroup, TabGroupLayoutNode, WorkspaceVisibleTabType } from './tab-types' +import type { TerminalLayoutSnapshot, TerminalTab } from './terminal-tab-types' +import type { BrowserHistoryEntry, BrowserPage, BrowserWorkspace } from './browser-workspace-types' + +/** Minimal subset of OpenFile persisted across restarts. + * Only edit-mode files are saved — diffs, conflict reviews, and other + * transient views are reconstructed on demand from git state. */ +export type PersistedOpenFile = { + filePath: string + relativePath: string + worktreeId: string + language: string + isPreview?: boolean + runtimeEnvironmentId?: string | null + /** SSH target that owns an absolute path outside the worktree. */ + externalSshTargetId?: string + /** Unsaved editor buffer captured for hot exit; presence restores the tab dirty. */ + dirtyDraftContent?: string + /** Signature of the disk content the dirty draft is based on; lets restore + * re-derive a changed-on-disk conflict from ground truth. */ + lastKnownDiskSignature?: string + /** Why: a read-only tab (AI Vault View Log) must survive restart still + * read-only; persisted only when true so old sessions stay writable. */ + readOnly?: boolean + /** Opt-in streaming append for a read-only local log tab. */ + liveTail?: boolean +} + +export type WorkspaceSessionState = { + activeRepoId: string | null + /** Scope-aware active owner for folder workspaces. Legacy worktree UI still reads activeWorktreeId. */ + activeWorkspaceKey?: WorkspaceKey | null + activeWorkspaceExecutionHostId?: ExecutionHostId | null + activeWorktreeId: string | null + activeTabId: string | null + /** Keys may be legacy raw worktree IDs or canonical WorkspaceKey values. */ + tabsByWorktree: Record + terminalLayoutsByTabId: Record + /** Worktree IDs that had at least one tab with a live PTY at shutdown. + * Used on startup to eagerly re-spawn PTY processes so the Active filter + * works immediately after restart. */ + activeWorktreeIdsOnShutdown?: string[] + /** Editor files that were open at shutdown, keyed by worktree ID. + * Only edit-mode files are persisted — diffs and conflict views are + * transient and not restored. */ + openFilesByWorktree?: Record + /** Per-worktree active editor file ID (filePath) at shutdown. */ + activeFileIdByWorktree?: Record + /** Per-file markdown preview front-matter visibility. Absent entry means hidden. */ + markdownFrontmatterVisible?: Record + /** Persisted browser workspaces, keyed by worktree ID. */ + browserTabsByWorktree?: Record + /** Persisted browser pages, keyed by workspace ID. */ + browserPagesByWorkspace?: Record + /** Per-worktree active browser workspace ID at shutdown. */ + activeBrowserTabIdByWorktree?: Record + /** Per-worktree active tab type (terminal vs editor vs browser) at shutdown. */ + activeTabTypeByWorktree?: Record + /** Global browser URL history for address bar autocomplete. */ + browserUrlHistory?: BrowserHistoryEntry[] + /** Per-worktree last-active terminal tab ID at shutdown. */ + activeTabIdByWorktree?: Record + /** Unified tab model — present when saved by a build that includes TabsSlice. + * Read-path checks for this first; falls back to legacy fields if absent. */ + unifiedTabs?: Record + /** Tab group model — present alongside unifiedTabs. */ + tabGroups?: Record + /** Persisted split layout tree per worktree. */ + tabGroupLayouts?: Record + /** Per-worktree focused group at shutdown. */ + activeGroupIdByWorktree?: Record + /** SSH target IDs that were connected at shutdown. Used on startup to + * auto-reconnect before attempting remote PTY reattach. */ + activeConnectionIdsAtShutdown?: string[] + /** Maps tab IDs to their remote relay PTY session IDs. Populated at + * shutdown from renderer state so remote PTYs can be reattached via + * the relay's pty.attach RPC on startup. */ + remoteSessionIdsByTabId?: Record + /** Per-worktree focus-recency timestamps used by the Cmd+J empty-query + * ordering. Separate from worktree.lastActivityAt (background signal) + * and worktreeNavHistory (Back/Forward stack). See + * docs/cmd-j-empty-query-ordering.md. Absent in sessions written by + * older builds — hydration tolerates missing/partial maps and the + * active worktree is seeded on first restore. */ + lastVisitedAtByWorktreeId?: Record + /** Worktrees whose repo-defined default terminal tabs have already been + * considered. Persisted so closing all tabs and re-opening the workspace + * does not recreate the template. */ + defaultTerminalTabsAppliedByWorktreeId?: Record + /** Provider-session resume records captured when workspaces sleep. */ + sleepingAgentSessionsByPaneKey?: Record + /** Host-issued process incarnation for each durable terminal surface. */ + terminalPtyIncarnationsByPaneKey?: Record + /** Monotonic host authority watermark for terminal membership in each repo. */ + terminalTopologyRevisionByRepoId?: Record + /** Legacy per-surface fences migrated into terminalTopologyRevisionByRepoId on load. */ + terminalSurfaceTombstonesByPaneKey?: Record< + string, + { + worktreeId: string + parentTabId: string + leafId: string + ptyId: string + incarnationId: string + retiredAt: number + } + > +} + +export type WorkspaceSessionPatch = Partial diff --git a/src/shared/workspace-session-terminal-buffers.ts b/src/shared/workspace-session-terminal-buffers.ts index 81ab8315c72..b2ec9df45dc 100644 --- a/src/shared/workspace-session-terminal-buffers.ts +++ b/src/shared/workspace-session-terminal-buffers.ts @@ -1,6 +1,6 @@ import type { Repo, WorkspaceSessionState } from './types' import { FLOATING_TERMINAL_WORKTREE_ID } from './constants' -import { getRepoIdFromWorktreeId } from './worktree-id' +import { getRepoIdFromWorktreeId } from './worktree/id' import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from './terminal-scrollback-limits' import { clampUtf8TextTail, measureUtf8ByteLength } from './utf8-byte-limits' import { parseExecutionHostId } from './execution-host' diff --git a/src/shared/worktree-visibility-resolution.ts b/src/shared/worktree-visibility-resolution.ts index deb904cf32c..574a3a367f3 100644 --- a/src/shared/worktree-visibility-resolution.ts +++ b/src/shared/worktree-visibility-resolution.ts @@ -6,7 +6,7 @@ import { import { effectiveWorktreeSourceVisibility, type WorktreeVisibilitySourceMatcher -} from './worktree-visibility-sources' +} from './worktree/visibility-sources' import type { GlobalSettings, Repo, Worktree, WorktreeOwnership } from './types' export function shouldShowWorktree(args: { diff --git a/src/shared/worktree/base-ref-drift-types.ts b/src/shared/worktree/base-ref-drift-types.ts new file mode 100644 index 00000000000..416e8a96812 --- /dev/null +++ b/src/shared/worktree/base-ref-drift-types.ts @@ -0,0 +1,33 @@ +export type LocalBaseRefRefreshResult = { + status: 'updated' | 'skipped_dirty_worktree' | 'skipped_not_fast_forward' | 'skipped_error' + baseRef: string + localBranch: string + ownerWorktreePath?: string +} + +export type LocalBaseRefUpdateSuggestion = { + baseRef: string + localBranch: string + behind: number +} + +export type WorktreeBaseStatusKind = 'checking' | 'current' | 'drift' | 'base_changed' | 'unknown' + +export type WorktreeBaseStatusEvent = { + repoId: string + worktreeId: string + status: WorktreeBaseStatusKind + base: string + /** Configured remote name parsed from `base` (longest-prefix match). Absent + * when classification skipped optimistic reconcile (e.g. legacy fallback). */ + remote?: string + behind?: number + recentSubjects?: string[] +} + +export type WorktreeRemoteBranchConflictEvent = { + repoId: string + worktreeId: string + remote: string + branchName: string +} diff --git a/src/shared/worktree-base-ref.test.ts b/src/shared/worktree/base-ref.test.ts similarity index 97% rename from src/shared/worktree-base-ref.test.ts rename to src/shared/worktree/base-ref.test.ts index 9776be541f5..062d840b1cd 100644 --- a/src/shared/worktree-base-ref.test.ts +++ b/src/shared/worktree/base-ref.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { resolveWorktreeAddBaseRef } from './worktree-base-ref' +import { resolveWorktreeAddBaseRef } from './base-ref' describe('resolveWorktreeAddBaseRef', () => { it('leaves fully qualified refs unchanged', async () => { diff --git a/src/shared/worktree-base-ref.ts b/src/shared/worktree/base-ref.ts similarity index 100% rename from src/shared/worktree-base-ref.ts rename to src/shared/worktree/base-ref.ts diff --git a/src/shared/worktree-card-properties.test.ts b/src/shared/worktree/card-properties.test.ts similarity index 98% rename from src/shared/worktree-card-properties.test.ts rename to src/shared/worktree/card-properties.test.ts index be9973265f3..45bc50e24a9 100644 --- a/src/shared/worktree-card-properties.test.ts +++ b/src/shared/worktree/card-properties.test.ts @@ -7,7 +7,7 @@ import { getWorktreeCardModeUpdates, isDefaultedCompactWorktreeCardProperties, normalizeWorktreeCardProperties -} from './worktree-card-properties' +} from './card-properties' describe('worktree card properties', () => { it('defines Default with inline agents and without branch', () => { diff --git a/src/shared/worktree-card-properties.ts b/src/shared/worktree/card-properties.ts similarity index 99% rename from src/shared/worktree-card-properties.ts rename to src/shared/worktree/card-properties.ts index a11bbdc3c03..240b3ba341e 100644 --- a/src/shared/worktree-card-properties.ts +++ b/src/shared/worktree/card-properties.ts @@ -3,7 +3,7 @@ import type { PersistedUIState, WorktreeCardMode, WorktreeCardProperty -} from './types' +} from '../types' const FIXED_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = ['status', 'unread'] diff --git a/src/shared/worktree/create-types.ts b/src/shared/worktree/create-types.ts new file mode 100644 index 00000000000..80e865d2c85 --- /dev/null +++ b/src/shared/worktree/create-types.ts @@ -0,0 +1,175 @@ +import type { ExecutionHostId } from '../execution-host' +import type { WorkspaceSource } from '../workspace-source' +import type { TaskSourceContext } from '../task-source-context' +import type { WorkspaceKey } from '../folder-workspace-types' +import type { TuiAgent } from '../tui-agent' +import type { + AutomationWorkspaceProvenanceRequest, + GitPushTarget, + GitWorktreeInfo, + WorkspaceLinkedItem, + WorkspaceStatus, + Worktree +} from './types' +import type { WorkspaceLineage, WorktreeLineage, WorktreeLineageWarning } from './lineage-types' +import type { + WorktreeDefaultTabsLaunch, + WorktreeSetupLaunch, + WorktreeStartupLaunch +} from './launch-types' +import type { + LocalBaseRefRefreshResult, + LocalBaseRefUpdateSuggestion, + WorktreeBaseStatusEvent +} from './base-ref-drift-types' + +export type SetupDecision = 'inherit' | 'run' | 'skip' + +export type WorktreeCreateTimingPhase = { + phase: string + startedAtMs: number + durationMs: number +} + +export type WorktreeCreateTiming = { + totalDurationMs: number + phases: WorktreeCreateTimingPhase[] +} + +export type CreateSparseCheckoutRequest = { + directories: string[] + /** Set when the directories came from a saved preset and the user did not + * modify them — recorded on WorktreeMeta so the worktree can show "from + * preset X" later. Cleared if the user edited the textarea. */ + presetId?: string +} + +/** A reusable per-repo sparse directory list. Saved by the user from the + * composer; surfaced again the next time they create a worktree in the same + * repo. The MVP scope (no preset) is `presetId === undefined`. */ +export type SparsePreset = { + id: string + repoId: string + name: string + directories: string[] + createdAt: number + updatedAt: number +} + +export type CreateWorktreeArgs = { + repoId: string + name: string + /** Optional user-facing label to persist separately from the git-safe + * branch/path seed. Used when a workspace is created from a GitHub or + * Linear artifact whose title should remain readable in the sidebar. */ + displayName?: string + baseBranch?: string + /** Source Control compare target when it differs from the checkout start point. */ + compareBaseRef?: string + /** Optional git branch to create, separate from the filesystem-safe worktree + * name. Used when creating from an existing branch whose local branch name + * legitimately contains `/` while the worktree directory must not. */ + branchNameOverride?: string + setupDecision?: SetupDecision + sparseCheckout?: CreateSparseCheckoutRequest + linkedIssue?: number + linkedPR?: number + linkedLinearIssue?: string + linkedLinearIssueWorkspaceId?: string | null + linkedLinearIssueOrganizationUrlKey?: string | null + linkedGitLabIssue?: number + linkedGitLabMR?: number + linkedBitbucketPR?: number | null + linkedAzureDevOpsPR?: number | null + linkedGiteaPR?: number | null + linkedWorkItem?: WorkspaceLinkedItem | null + linkedTaskSourceContext?: TaskSourceContext | null + pushTarget?: GitPushTarget + workspaceStatus?: WorkspaceStatus + manualOrder?: number + /** Parent workspace for in-app creates launched from a folder workspace. */ + parentWorkspace?: WorkspaceKey + /** Agent selected in the create surface. Omitted for blank-shell creates. */ + createdWithAgent?: TuiAgent + /** Set when the renderer knows this auto-generated branch should be renamed + * from the first agent message. */ + pendingFirstAgentMessageRename?: boolean + /** Telemetry-only: which UI surface initiated this create. Threaded from + * the renderer entry point so main can emit `workspace_created` with the + * correct `source`. `unknown` is a valid wire value — an unrecognized + * surface emits `source: 'unknown'` rather than dropping the event, so + * dashboards surface enum-coverage gaps as a slice rather than as + * missing data. Optional on the type so older renderer code paths that + * pre-date this prop default to `unknown` at the IPC boundary instead + * of failing typecheck. */ + telemetrySource?: WorkspaceSource + /** Optional startup command for callers that want the backend to spawn the + * first terminal as soon as the worktree is registered. */ + startup?: WorktreeStartupLaunch + /** Correlates `createWorktree:progress` events back to a specific pending + * creation in the renderer, so concurrent background creates each drive + * their own status surface. Omitted by synchronous callers. */ + creationId?: string + /** Authorizes the host to mint system-owned automation provenance. */ + automationProvenanceRequest?: AutomationWorkspaceProvenanceRequest +} + +export type AdoptProvisionedRootArgs = CreateWorktreeArgs & { + runtimeId: string + executionHostId: ExecutionHostId + expectedPath: string +} + +export type CreateWorktreeResult = { + worktree: Worktree & { + parentWorktreeId?: string | null + childWorktreeIds?: string[] + lineage?: WorktreeLineage | null + workspaceLineage?: WorkspaceLineage | null + git?: GitWorktreeInfo + } + lineage?: WorktreeLineage | null + workspaceLineage?: WorkspaceLineage | null + warnings?: WorktreeLineageWarning[] + setup?: WorktreeSetupLaunch + setupReceipt?: { + requested: 'run' | 'skip' | 'inherit' + hookFound: boolean + startupPolicy: 'start-immediately' | 'wait-for-setup' + state: 'running' | 'skipped' | 'not_configured' | 'spawn_failed' + terminalHandle?: string + } + defaultTabs?: WorktreeDefaultTabsLaunch + warning?: string + baseFallback?: WorktreeCreateBaseFallback + initialBaseStatus?: WorktreeBaseStatusEvent + localBaseRefRefresh?: LocalBaseRefRefreshResult + localBaseRefUpdateSuggestion?: LocalBaseRefUpdateSuggestion + startupTerminal?: { + spawned: boolean + handle?: string + tabId?: string + paneKey?: string | null + ptyId?: string | null + surface?: 'visible' | 'background' + } + timing?: WorktreeCreateTiming +} + +export type WorktreeCreateBaseFallback = { + requestedRef: string + localRef: string +} + +export type PreservedWorktreeBranch = { + branchName: string + head?: string +} + +export type RemoveWorktreeResult = { + preservedBranch?: PreservedWorktreeBranch +} + +export type ForceDeleteWorktreeBranchResult = { + deleted: true +} diff --git a/src/shared/worktree-id.test.ts b/src/shared/worktree/id.test.ts similarity index 99% rename from src/shared/worktree-id.test.ts rename to src/shared/worktree/id.test.ts index 99e6b4e49e7..e64d27784f5 100644 --- a/src/shared/worktree-id.test.ts +++ b/src/shared/worktree/id.test.ts @@ -5,7 +5,7 @@ import { getWorktreePathBasenameFromId, splitWorktreeId, splitWorktreeIdForFilesystem -} from './worktree-id' +} from './id' describe('WORKTREE_ID_SEPARATOR', () => { it('is the literal "::" separator', () => { diff --git a/src/shared/worktree-id.ts b/src/shared/worktree/id.ts similarity index 93% rename from src/shared/worktree-id.ts rename to src/shared/worktree/id.ts index 69f01f4b378..d71cbac66eb 100644 --- a/src/shared/worktree-id.ts +++ b/src/shared/worktree/id.ts @@ -1,6 +1,6 @@ -import { WORKTREE_ID_SEPARATOR } from './pty-session-id-format' +import { WORKTREE_ID_SEPARATOR } from '../pty-session-id-format' -export { WORKTREE_ID_SEPARATOR } from './pty-session-id-format' +export { WORKTREE_ID_SEPARATOR } from '../pty-session-id-format' export type ParsedWorktreeId = { repoId: string diff --git a/src/shared/worktree/launch-types.ts b/src/shared/worktree/launch-types.ts new file mode 100644 index 00000000000..5fc9f18d397 --- /dev/null +++ b/src/shared/worktree/launch-types.ts @@ -0,0 +1,39 @@ +import type { StartupCommandDelivery } from '../codex-startup-delivery' +import type { AgentKind, LaunchSource, RequestKind } from '../telemetry-events' +import type { SleepingAgentLaunchConfig } from '../agent-session-resume' +import type { SetupRunnerShell } from '../setup-runner-command' +import type { OrcaDefaultTabTemplate } from '../orca-yaml-hook-types' +import type { TuiAgent } from '../tui-agent' + +export type WorktreeSetupLaunch = { + runnerScriptPath: string + envVars: Record + shell?: SetupRunnerShell + command?: string + waitForAgentStartup?: boolean +} + +export type WorktreeStartupLaunch = { + command: string + env?: Record + launchConfig?: SleepingAgentLaunchConfig + launchToken?: string + launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' + startupCommandDelivery?: StartupCommandDelivery + telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } +} + +export type WorktreeDefaultTabsLaunch = { + tabs: OrcaDefaultTabTemplate[] + runCommands: boolean +} + +/** Where the repo setup script runs when a worktree is created. + * - 'new-tab': open a background tab titled "Setup" and leave focus on the first tab (default). + * - 'split-vertical': split the initial terminal pane with a vertical divider. + * - 'split-horizontal': split the initial terminal pane with a horizontal divider. */ +export type SetupScriptLaunchMode = 'split-vertical' | 'split-horizontal' | 'new-tab' + +/** Direction used when the setup script launch mode is a split. */ +export type SetupSplitDirection = 'vertical' | 'horizontal' diff --git a/src/shared/worktree/lineage-types.ts b/src/shared/worktree/lineage-types.ts new file mode 100644 index 00000000000..c7f0e3d791e --- /dev/null +++ b/src/shared/worktree/lineage-types.ts @@ -0,0 +1,56 @@ +import type { WorkspaceKey } from '../folder-workspace-types' + +export type WorktreeLineageOrigin = 'orchestration' | 'cli' | 'manual' +export type WorktreeLineageCaptureConfidence = 'explicit' | 'inferred' +export type WorktreeLineageCaptureSource = + | 'explicit-cli-flag' + | 'env-workspace' + | 'cwd-context' + | 'terminal-context' + | 'orchestration-context' + | 'active-workspace' + | 'manual-action' + +export type WorktreeLineageCapture = { + source: WorktreeLineageCaptureSource + confidence: WorktreeLineageCaptureConfidence +} + +export type WorktreeLineage = { + worktreeId: string + worktreeInstanceId: string + parentWorktreeId: string + parentWorktreeInstanceId: string + origin: WorktreeLineageOrigin + capture: WorktreeLineageCapture + orchestrationRunId?: string + taskId?: string + coordinatorHandle?: string + createdByTerminalHandle?: string + createdAt: number +} + +export type WorkspaceLineage = { + childWorkspaceKey: WorkspaceKey + childInstanceId?: string | null + parentWorkspaceKey: WorkspaceKey + parentInstanceId?: string | null + origin: WorktreeLineageOrigin + capture: WorktreeLineageCapture + taskId?: string + orchestrationRunId?: string + coordinatorHandle?: string + createdByTerminalHandle?: string + createdAt: number +} + +export type WorktreeLineageWarningCode = + | 'LINEAGE_PARENT_CONTEXT_MISSING' + | 'LINEAGE_PARENT_CONTEXT_CONFLICT' + | 'LINEAGE_PARENT_INSTANCE_STALE' + +export type WorktreeLineageWarning = { + code: WorktreeLineageWarningCode + message: string + details?: Record +} diff --git a/src/shared/worktree/meta-types.ts b/src/shared/worktree/meta-types.ts new file mode 100644 index 00000000000..54bc96c890d --- /dev/null +++ b/src/shared/worktree/meta-types.ts @@ -0,0 +1,91 @@ +import type { ExecutionHostId } from '../execution-host' +import type { TaskSourceContext } from '../task-source-context' +import type { EphemeralVmCheckoutMode } from '../orca-yaml-hook-types' +import type { + AutomationWorkspaceProvenance, + CliWorkspaceProvenance, + GitPushTarget, + WorkspaceCreatorProvenance, + WorkspaceLinkedItem, + WorkspaceStatus +} from './types' +import type { TuiAgent } from '../tui-agent' +import type { OrcaWorkspaceLayout } from '../global-settings-types' +import type { DiffComment, MobileDiffReviewState } from '../diff-comment-types' + +// ─── Worktree metadata (persisted user-authored fields only) ───────── +export type WorktreeMeta = { + /** Immutable per-workspace-instance ID used to reject stale lineage after path reuse. */ + instanceId?: string + /** See Worktree.projectId. Persisted for project-first workspace ownership. */ + projectId?: string + /** See Worktree.hostId. Persisted for project-first workspace ownership. */ + hostId?: ExecutionHostId + /** See Worktree.projectHostSetupId. Persisted for project-first workspace ownership. */ + projectHostSetupId?: string + /** See Worktree.ephemeralVmCheckoutMode. */ + ephemeralVmCheckoutMode?: EphemeralVmCheckoutMode + /** See Worktree.creatorProvenance. */ + creatorProvenance?: WorkspaceCreatorProvenance + displayName: string + comment: string + linkedIssue: number | null + linkedPR: number | null + linkedLinearIssue: string | null + linkedLinearIssueWorkspaceId?: string | null + linkedLinearIssueOrganizationUrlKey?: string | null + /** Optional for backward compatibility — see Worktree.linkedGitLabMR. */ + linkedGitLabMR?: number | null + /** Optional for backward compatibility — see Worktree.linkedGitLabIssue. */ + linkedGitLabIssue?: number | null + /** Optional for backward compatibility — see Worktree.linkedBitbucketPR. */ + linkedBitbucketPR?: number | null + /** Optional for backward compatibility — see Worktree.linkedAzureDevOpsPR. */ + linkedAzureDevOpsPR?: number | null + /** Optional for backward compatibility — see Worktree.linkedGiteaPR. */ + linkedGiteaPR?: number | null + linkedWorkItem?: WorkspaceLinkedItem | null + linkedTaskSourceContext?: TaskSourceContext | null + isArchived: boolean + isUnread: boolean + isPinned: boolean + sortOrder: number + /** User-authored sidebar ordering. Higher values render earlier in Manual sort. */ + manualOrder?: number + lastActivityAt: number + /** See {@link Worktree.createdAt}. Persisted to orca-data.json. */ + createdAt?: number + /** See {@link Worktree.createdWithAgent}. Persisted to orca-data.json. */ + createdWithAgent?: TuiAgent + /** See {@link Worktree.pendingFirstAgentMessageRename}. */ + pendingFirstAgentMessageRename?: boolean + /** See {@link Worktree.firstAgentMessageRenameError}. */ + firstAgentMessageRenameError?: string | null + sparseDirectories?: string[] + sparseBaseRef?: string + sparsePresetId?: string + /** Intended create base for stale-base probes. Persisted metadata, not UI drift state. */ + baseRef?: string + /** True when Orca checked out a pre-existing local branch that delete must not prune. */ + preserveBranchOnDelete?: boolean + /** See {@link Worktree.pushTarget}. Persisted so refreshed worktree lists keep the target. */ + pushTarget?: GitPushTarget + /** Explicit marker stamped when Orca creates the worktree. */ + orcaCreatedAt?: number + orcaCreationSource?: 'desktop' | 'runtime' | 'cli' | 'ssh' + /** Workspace layout active when Orca created the worktree. */ + orcaCreationWorkspaceLayout?: OrcaWorkspaceLayout + /** User-assigned workspace board status for manual sidebar organization. */ + workspaceStatus?: WorkspaceStatus + diffComments?: DiffComment[] + /** Path-derived worktree ids this worktree had before its folder was renamed + * on disk (the id embeds the path). Lets the daemon's session GC and registry + * hydration recognize sessions minted under an old id instead of reaping + * them. Self-prunes when the worktree is deleted. */ + priorWorktreeIds?: string[] + mobileDiffReview?: MobileDiffReviewState + /** System-owned provenance for workspaces created by automation new-per-run dispatches. */ + automationProvenance?: AutomationWorkspaceProvenance + /** System-owned provenance for workspaces created via `orca worktree create`. */ + cliProvenance?: CliWorkspaceProvenance +} diff --git a/src/shared/worktree-ownership-worktree-base-path.test.ts b/src/shared/worktree/ownership-worktree-base-path.test.ts similarity index 96% rename from src/shared/worktree-ownership-worktree-base-path.test.ts rename to src/shared/worktree/ownership-worktree-base-path.test.ts index d6af4dab77d..aff3c951285 100644 --- a/src/shared/worktree-ownership-worktree-base-path.test.ts +++ b/src/shared/worktree/ownership-worktree-base-path.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { GlobalSettings, Repo, Worktree } from './types' -import { buildKnownOrcaWorkspaceLayouts, classifyWorktreeOwnership } from './worktree-ownership' +import type { GlobalSettings, Repo, Worktree } from '../types' +import { buildKnownOrcaWorkspaceLayouts, classifyWorktreeOwnership } from './ownership' function makeRepo(overrides: Partial = {}): Repo { return { diff --git a/src/shared/worktree-ownership.test.ts b/src/shared/worktree/ownership.test.ts similarity index 99% rename from src/shared/worktree-ownership.test.ts rename to src/shared/worktree/ownership.test.ts index 5cc0a9dfee0..e880ee8f1bc 100644 --- a/src/shared/worktree-ownership.test.ts +++ b/src/shared/worktree/ownership.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' -import type { GlobalSettings, Repo, Worktree, WorktreeMeta } from './types' -import { createAgentScratchWorktreePathMatcher } from './agent-scratch-worktrees' -import { migrateExternalWorktreeVisibilityDefaults } from './external-worktree-visibility' +import type { GlobalSettings, Repo, Worktree, WorktreeMeta } from '../types' +import { createAgentScratchWorktreePathMatcher } from '../agent-scratch-worktrees' +import { migrateExternalWorktreeVisibilityDefaults } from '../external-worktree-visibility' import { applyMetadataFallbackVisibility, buildKnownOrcaWorkspaceLayouts, @@ -11,7 +11,7 @@ import { shouldShowWorktree, toDetectedWorktree, EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT -} from './worktree-ownership' +} from './ownership' const LARGE_WORKSPACE_HISTORY_COUNT = 150_000 diff --git a/src/shared/worktree-ownership.ts b/src/shared/worktree/ownership.ts similarity index 95% rename from src/shared/worktree-ownership.ts rename to src/shared/worktree/ownership.ts index ad0d3cadb27..1f376a3956b 100644 --- a/src/shared/worktree-ownership.ts +++ b/src/shared/worktree/ownership.ts @@ -5,19 +5,19 @@ import { normalizeRuntimePathSeparators, relativePathInsideRoot, resolveRuntimePath -} from './cross-platform-path' -import { parseWslUncPath } from './wsl-paths' +} from '../cross-platform-path' +import { parseWslUncPath } from '../wsl-paths' import { isAgentScratchWorktreePath, type AgentScratchWorktreePathMatcher -} from './agent-scratch-worktrees' +} from '../agent-scratch-worktrees' import { createWorktreeVisibilitySourceMatcher, resolveCustomWorktreeVisibilitySources, type WorktreeVisibilitySourceMatcher -} from './worktree-visibility-sources' -import { isLegacyRepoForExternalWorktreeVisibility } from './external-worktree-visibility' -import { shouldShowWorktree } from './worktree-visibility-resolution' +} from './visibility-sources' +import { isLegacyRepoForExternalWorktreeVisibility } from '../external-worktree-visibility' +import { shouldShowWorktree } from '../worktree-visibility-resolution' import type { DetectedWorktree, GlobalSettings, @@ -26,15 +26,15 @@ import type { Worktree, WorktreeMeta, WorktreeOwnership -} from './types' +} from '../types' export { effectiveAgentWorktreeVisibility, effectiveExternalWorktreeVisibility, EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT, isLegacyRepoForExternalWorktreeVisibility -} from './external-worktree-visibility' -export { shouldShowWorktree } from './worktree-visibility-resolution' +} from '../external-worktree-visibility' +export { shouldShowWorktree } from '../worktree-visibility-resolution' export function buildKnownOrcaWorkspaceLayouts( settings: Pick, diff --git a/src/shared/worktree-removal-fence-error.test.ts b/src/shared/worktree/removal-fence-error.test.ts similarity index 96% rename from src/shared/worktree-removal-fence-error.test.ts rename to src/shared/worktree/removal-fence-error.test.ts index ce9e3d76bb7..407f1baf1f5 100644 --- a/src/shared/worktree-removal-fence-error.test.ts +++ b/src/shared/worktree/removal-fence-error.test.ts @@ -3,7 +3,7 @@ import { TERMINAL_REMOVAL_IN_PROGRESS_MESSAGE, WATCHER_REMOVAL_IN_PROGRESS_MESSAGE, isWorktreeRemovalFenceError -} from './worktree-removal-fence-error' +} from './removal-fence-error' describe('isWorktreeRemovalFenceError', () => { it('recognizes the raw terminal and watcher fence messages', () => { diff --git a/src/shared/worktree-removal-fence-error.ts b/src/shared/worktree/removal-fence-error.ts similarity index 100% rename from src/shared/worktree-removal-fence-error.ts rename to src/shared/worktree/removal-fence-error.ts diff --git a/src/shared/worktree-removal-force-classification.test.ts b/src/shared/worktree/removal-force-classification.test.ts similarity index 99% rename from src/shared/worktree-removal-force-classification.test.ts rename to src/shared/worktree/removal-force-classification.test.ts index b4d95a5727d..73ac6ea77a6 100644 --- a/src/shared/worktree-removal-force-classification.test.ts +++ b/src/shared/worktree/removal-force-classification.test.ts @@ -5,7 +5,7 @@ import { UNSTOPPED_PTY_REMOVAL_PREFIX, WORKTREE_TEARDOWN_FORCE_HINT, WORKTREE_TEARDOWN_TIMEOUT_PREFIX -} from './worktree-removal' +} from './removal' const liveError = `${UNSTOPPED_PTY_REMOVAL_PREFIX} repo-1::/w — still live: term_a. ${WORKTREE_TEARDOWN_FORCE_HINT}` const unverifiableError = `${UNSTOPPED_PTY_REMOVAL_PREFIX} repo-1::/w — could not verify these exited: term_a (daemon socket closed). ${WORKTREE_TEARDOWN_FORCE_HINT}` diff --git a/src/shared/worktree-removal.ts b/src/shared/worktree/removal.ts similarity index 99% rename from src/shared/worktree-removal.ts rename to src/shared/worktree/removal.ts index 8788171ebe0..68d1072b247 100644 --- a/src/shared/worktree-removal.ts +++ b/src/shared/worktree/removal.ts @@ -1,4 +1,4 @@ -import type { GitWorktreeInfo } from './types' +import type { GitWorktreeInfo } from '../types' export const LOCKED_WORKTREE_REMOVAL_PREFIX = 'Worktree is locked by Git.' diff --git a/src/shared/worktree-sort-order-update.test.ts b/src/shared/worktree/sort-order-update.test.ts similarity index 95% rename from src/shared/worktree-sort-order-update.test.ts rename to src/shared/worktree/sort-order-update.test.ts index 4c53ddff56d..6981e851eef 100644 --- a/src/shared/worktree-sort-order-update.test.ts +++ b/src/shared/worktree/sort-order-update.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { planWorktreeSortOrderUpdates } from './worktree-sort-order-update' +import { planWorktreeSortOrderUpdates } from './sort-order-update' describe('planWorktreeSortOrderUpdates', () => { it('skips an order that is already represented by descending ranks', () => { diff --git a/src/shared/worktree-sort-order-update.ts b/src/shared/worktree/sort-order-update.ts similarity index 100% rename from src/shared/worktree-sort-order-update.ts rename to src/shared/worktree/sort-order-update.ts diff --git a/src/shared/worktree-submodule-removal.test.ts b/src/shared/worktree/submodule-removal.test.ts similarity index 93% rename from src/shared/worktree-submodule-removal.test.ts rename to src/shared/worktree/submodule-removal.test.ts index 0c8564b79d3..464fc9e7766 100644 --- a/src/shared/worktree-submodule-removal.test.ts +++ b/src/shared/worktree/submodule-removal.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { isSubmoduleWorktreeRemovalRefusal } from './worktree-submodule-removal' +import { isSubmoduleWorktreeRemovalRefusal } from './submodule-removal' describe('isSubmoduleWorktreeRemovalRefusal', () => { it('matches the English git fatal on stderr', () => { diff --git a/src/shared/worktree-submodule-removal.ts b/src/shared/worktree/submodule-removal.ts similarity index 100% rename from src/shared/worktree-submodule-removal.ts rename to src/shared/worktree/submodule-removal.ts diff --git a/src/shared/worktree/types.ts b/src/shared/worktree/types.ts new file mode 100644 index 00000000000..af6252f5c3a --- /dev/null +++ b/src/shared/worktree/types.ts @@ -0,0 +1,217 @@ +import type { ExecutionHostId } from '../execution-host' +import type { AutomationExecutionTargetType } from '../automations-types' +import type { TaskSourceContext } from '../task-source-context' +import type { TuiAgent } from '../tui-agent' +import type { DiffComment, MobileDiffReviewState } from '../diff-comment-types' +import type { EphemeralVmCheckoutMode } from '../orca-yaml-hook-types' +import type { BuiltInWorktreeVisibilitySourceId } from '../repo-types' + +export type WorkspaceLinkedItem = { + provider: 'github' | 'gitlab' | 'linear' | 'jira' + type: 'issue' | 'pr' | 'mr' + number: number + title: string + url: string + linearIdentifier?: string + jiraIdentifier?: string + repoId?: string +} + +// ─── Worktree (git-level) ──────────────────────────────────────────── +export type GitWorktreeInfo = { + path: string + head: string + branch: string + isBare: boolean + isSparse?: boolean + locked?: boolean + lockReason?: string + /** True when Git reports the worktree as prunable (its directory is gone but + * the registration remains). Detected via the `prunable` porcelain field + * (Git ≥ 2.36) or a path-existence probe on older Git. */ + prunable?: boolean + prunableReason?: string + /** True for the repo's main working tree (the first entry from `git worktree list`). + * Linked worktrees created via `git worktree add` have this set to false. */ + isMainWorktree: boolean +} + +/** Head/branch snapshot read from Git metadata files without spawning Git. + * Carries background-worktree freshness when status-only churn includes a + * real head move (external commit/amend/reset) that must not re-enter the + * structural `worktrees:changed` fanout. */ +export type WorktreeHeadIdentity = { + worktreePath: string + head: string + /** Full ref (e.g. `refs/heads/main`), or null for a detached HEAD. */ + branch: string | null +} + +// ─── Worktree (app-level, enriched) ────────────────────────────────── +export type WorkspaceStatus = string + +export type WorkspaceStatusDefinition = { + id: WorkspaceStatus + label: string + color?: string + icon?: string +} + +export type Worktree = { + id: string // `${repoId}::${path}` + instanceId?: string + repoId: string + /** Durable project identity. Optional while legacy repo-only workspaces migrate. */ + projectId?: string + /** Execution host that owns the workspace. Optional for pre-project-host metadata. */ + hostId?: ExecutionHostId + /** Renderer projection of the paired runtime that transports operations to `hostId`. */ + runtimeOwnerEnvironmentId?: string + /** Authenticated client that created this workspace. Missing means unknown legacy origin. */ + creatorProvenance?: WorkspaceCreatorProvenance + /** Host-specific setup used to create/run this workspace. */ + projectHostSetupId?: string + /** Checkout ownership for a recipe-provisioned main workspace. */ + ephemeralVmCheckoutMode?: EphemeralVmCheckoutMode + displayName: string + comment: string + linkedIssue: number | null + linkedPR: number | null + linkedLinearIssue: string | null + linkedLinearIssueWorkspaceId?: string | null + linkedLinearIssueOrganizationUrlKey?: string | null + // Why: parallel slots for non-GitHub work-item references. Kept as separate + // fields (rather than reusing linkedIssue / linkedPR with a provider + // discriminator) so the persistence layer is unambiguous when a user + // has remotes from several providers on the same repo, and so the + // existing GitHub renderer code keeps reading linkedPR / linkedIssue + // unchanged. Optional on the type so existing test fixtures and + // persisted older worktrees that never carried these fields continue + // to typecheck and load without migration. + linkedGitLabMR?: number | null + linkedGitLabIssue?: number | null + linkedBitbucketPR?: number | null + linkedAzureDevOpsPR?: number | null + linkedGiteaPR?: number | null + linkedWorkItem?: WorkspaceLinkedItem | null + linkedTaskSourceContext?: TaskSourceContext | null + isArchived: boolean + isUnread: boolean + isPinned: boolean + sortOrder: number + /** User-authored sidebar ordering. Higher values render earlier in Manual sort. */ + manualOrder?: number + lastActivityAt: number + /** Set once when Orca creates the worktree. Absent for worktrees discovered + * on disk or persisted before this field existed. Used by the sidebar to + * grant newly-created worktrees a short grace window at the top of Recent, + * immune to ambient PTY-bump reordering in other worktrees. */ + createdAt?: number + /** Agent selected when Orca originally created the worktree. Used only to + * seed a replacement terminal if the user later reopens the worktree after + * closing every visible surface. */ + createdWithAgent?: TuiAgent + /** True while an auto-named workspace is waiting for the first agent message + * to drive the branch/title rename. */ + pendingFirstAgentMessageRename?: boolean + /** Holds the last auto-rename generation failure message so the sidebar can + * show a "rename failed" badge. null/undefined when there is no failure + * (never attempted, succeeded, or only a benign skip). */ + firstAgentMessageRenameError?: string | null + sparseDirectories?: string[] + sparseBaseRef?: string + /** ID of the saved preset this worktree was created from, if any. Cleared + * when the worktree is no longer sparse on refresh. */ + sparsePresetId?: string + /** Intended create base for stale-base probes. Persisted metadata, not UI drift state. */ + baseRef?: string + /** Remote/branch Orca should publish review commits to when it created this worktree. */ + pushTarget?: GitPushTarget + /** Path-derived worktree ids this worktree had before folder renames. */ + priorWorktreeIds?: string[] + workspaceStatus?: WorkspaceStatus + diffComments?: DiffComment[] + mobileDiffReview?: MobileDiffReviewState + automationProvenance?: AutomationWorkspaceProvenance + cliProvenance?: CliWorkspaceProvenance +} & GitWorktreeInfo + +/** Provenance for workspaces created through `orca worktree create`. Absent on + * workspaces created before this field existed and on every non-CLI create, so + * consumers must read "missing" as "not CLI-created". */ +export type CliWorkspaceProvenance = { + kind: 'created-by-cli' + createdAt: number + /** Orca terminal the CLI ran inside, when the caller had one — distinguishes + * an agent-issued create from one hand-typed in an external shell. */ + callerTerminalHandle?: string + /** Agent requested via `--agent`, when one was passed. */ + startupAgent?: TuiAgent +} + +export type WorkspaceCreatorProvenance = + | { kind: 'host' } + | { kind: 'paired-device'; deviceId: string } + +export type AutomationWorkspaceProvenance = { + kind: 'created-by-automation' + automationId: string + automationNameSnapshot: string + automationRunId: string + automationRunTitleSnapshot: string + createdAt: number + executionTargetType: AutomationExecutionTargetType + executionTargetId: string + projectId: string + repoId?: string + hostId?: ExecutionHostId +} + +export type AutomationWorkspaceProvenanceRequest = { + automationId: string + automationRunId: string + dispatchToken: string + createRequestId: string +} + +export type GitPushTarget = { + remoteName: string + branchName: string + remoteUrl?: string + /** True when Orca added this remote while preparing a fork-PR worktree. */ + remoteCreated?: boolean +} + +export type GitHubPrStartPoint = { + baseBranch: string + /** Review target branch to use for Source Control compare after creating from a PR head SHA. */ + compareBaseRef?: string + pushTarget?: GitPushTarget + /** Verified PR head commit. Present when checkout can be tied to a stable SHA. */ + headSha?: string + /** Exact local branch name to create/reuse when the PR head is a safe same-repo branch. */ + branchNameOverride?: string + /** Fork PRs: false when "Allow edits from maintainers" is off; a push to the fork may be rejected. */ + maintainerCanModify?: boolean +} + +export type WorktreeOwnership = 'orca-managed' | 'external' | 'unknown-legacy' | 'agent-scratch' + +export type DetectedWorktreeListSource = 'git' | 'metadata-fallback' | 'session-fallback' + +export type DetectedWorktree = Worktree & { + ownership: WorktreeOwnership + selectedCheckout: boolean + visible: boolean + /** Optional additive source identity; older hosts omit it. */ + visibilitySource?: + | { kind: 'built-in'; id: BuiltInWorktreeVisibilitySourceId } + | { kind: 'custom'; id: string } +} + +export type DetectedWorktreeListResult = { + repoId: string + authoritative: boolean + source: DetectedWorktreeListSource + worktrees: DetectedWorktree[] +} diff --git a/src/shared/worktree-visibility-source-preferences.ts b/src/shared/worktree/visibility-source-preferences.ts similarity index 98% rename from src/shared/worktree-visibility-source-preferences.ts rename to src/shared/worktree/visibility-source-preferences.ts index 4a79997b2e9..17bda7e2718 100644 --- a/src/shared/worktree-visibility-source-preferences.ts +++ b/src/shared/worktree/visibility-source-preferences.ts @@ -3,11 +3,11 @@ import type { Repo, WorktreeVisibilityDefaults, WorktreeVisibilitySourcePreferences -} from './types' +} from '../types' import { normalizeWorktreeVisibilitySourcePreferences, type WorktreeVisibilitySourceMatch -} from './worktree-visibility-sources' +} from './visibility-sources' function legacyBuiltInPreferences( repo: Pick diff --git a/src/shared/worktree-visibility-sources.test.ts b/src/shared/worktree/visibility-sources.test.ts similarity index 98% rename from src/shared/worktree-visibility-sources.test.ts rename to src/shared/worktree/visibility-sources.test.ts index 3144b9ce64d..cf16a476361 100644 --- a/src/shared/worktree-visibility-sources.test.ts +++ b/src/shared/worktree/visibility-sources.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, vi } from 'vitest' -import type { Repo } from './types' +import type { Repo } from '../types' import { buildDefaultWorktreeSourcePreferenceUpdate, buildWorktreeSourcePreferenceUpdate -} from './worktree-visibility-source-preferences' +} from './visibility-source-preferences' import { createWorktreeVisibilitySourceMatcher, effectiveBuiltInWorktreeSourceVisibility, @@ -11,7 +11,7 @@ import { normalizeCustomWorktreeVisibilitySources, normalizeWorktreeVisibilitySourcePreferences, resolveCustomWorktreeVisibilitySources -} from './worktree-visibility-sources' +} from './visibility-sources' function repo(overrides: Partial = {}): Repo { return { diff --git a/src/shared/worktree-visibility-sources.ts b/src/shared/worktree/visibility-sources.ts similarity index 99% rename from src/shared/worktree-visibility-sources.ts rename to src/shared/worktree/visibility-sources.ts index e4240776eba..91ed38a7a18 100644 --- a/src/shared/worktree-visibility-sources.ts +++ b/src/shared/worktree/visibility-sources.ts @@ -2,7 +2,7 @@ import { createNormalizedPathInsideOrEqualMatcher, isRuntimePathAbsolute, normalizeRuntimePathForComparison -} from './cross-platform-path' +} from '../cross-platform-path' import type { BuiltInWorktreeVisibilitySourceId, CustomWorktreeVisibilitySource, @@ -10,7 +10,7 @@ import type { Repo, WorktreeVisibilityDefaults, WorktreeVisibilitySourcePreferences -} from './types' +} from '../types' export const MAX_CUSTOM_WORKTREE_VISIBILITY_SOURCES = 32 const MAX_SOURCE_ID_LENGTH = 128 diff --git a/tests/e2e/completed-worker-retirement-resume.spec.ts b/tests/e2e/completed-worker-retirement-resume.spec.ts index 5540c4d6896..7d505c69587 100644 --- a/tests/e2e/completed-worker-retirement-resume.spec.ts +++ b/tests/e2e/completed-worker-retirement-resume.spec.ts @@ -19,7 +19,7 @@ import { } from './helpers/completed-worker-retirement-fixture' import { RuntimeClient } from '../../src/cli/runtime-client' import type { RuntimeTerminalRead, RuntimeTerminalSummary } from '../../src/shared/runtime-types' -import { splitWorktreeIdForFilesystem } from '../../src/shared/worktree-id' +import { splitWorktreeIdForFilesystem } from '../../src/shared/worktree/id' const PROVIDER_SESSION_ID = '019feb51-2269-71c2-89c6-faa8dc65c8dc'