From b1973657ea658aeb9bf76529e76cd05044bfbf09 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 21 May 2026 23:26:07 -0400 Subject: [PATCH] Add mobile Tasks parity (#2452) Co-authored-by: Orca --- mobile/app/h/[hostId]/index.tsx | 121 +- .../app/h/[hostId]/session/[worktreeId].tsx | 52 +- mobile/app/h/[hostId]/tasks.tsx | 14577 ++++++++++++++++ mobile/app/h/_layout.tsx | 1 + mobile/app/index.tsx | 308 +- mobile/mock-homepage.html | 205 +- mobile/mock-tasks.html | 1492 ++ mobile/pnpm-lock.yaml | 26 - mobile/src/components/BottomDrawer.tsx | 19 +- mobile/src/components/MobileAgentIcon.tsx | 89 + mobile/src/components/MobileMarkdown.test.ts | 24 + mobile/src/components/MobileMarkdown.tsx | 354 + mobile/src/components/NewWorktreeModal.tsx | 835 +- mobile/src/components/PickerModal.tsx | 42 +- mobile/src/components/TaskProviderLogo.tsx | 55 + .../src/components/mobile-markdown-parser.ts | 133 + mobile/src/tasks/github-check-summary.test.ts | 45 + mobile/src/tasks/github-check-summary.ts | 45 + mobile/src/tasks/github-pr-file-diff.test.ts | 53 + mobile/src/tasks/github-pr-file-diff.ts | 168 + .../tasks/github-project-repo-match.test.ts | 78 + mobile/src/tasks/github-project-repo-match.ts | 76 + .../github-work-item-source-errors.test.ts | 95 + .../tasks/github-work-item-source-errors.ts | 62 + .../tasks/hosted-review-start-point.test.ts | 28 + mobile/src/tasks/hosted-review-start-point.ts | 11 + mobile/src/tasks/mobile-agent-catalog.test.ts | 41 + mobile/src/tasks/mobile-agent-catalog.ts | 22 + .../tasks/mobile-composer-branch-selection.ts | 32 + .../tasks/mobile-github-project-group-sort.ts | 261 + mobile/src/tasks/mobile-task-providers.ts | 57 + mobile/src/tasks/mobile-tui-agents.ts | 143 + mobile/src/tasks/mobile-work-items.ts | 23 + mobile/src/tasks/mobile-workspace-name.ts | 32 + mobile/src/tasks/setup-hook-trust.test.ts | 89 + mobile/src/tasks/setup-hook-trust.ts | 46 + mobile/src/tasks/sparse-preset-draft.test.ts | 49 + mobile/src/tasks/sparse-preset-draft.ts | 67 + .../tasks/workspace-agent-selection.test.ts | 39 + mobile/src/tasks/workspace-agent-selection.ts | 37 + .../src/tasks/workspace-create-params.test.ts | 153 + mobile/src/tasks/workspace-create-params.ts | 137 + mobile/src/tasks/workspace-create-timeout.ts | 3 + mobile/src/tasks/workspace-ssh-gate.test.ts | 91 + mobile/src/tasks/workspace-ssh-gate.ts | 39 + src/main/github/client-create-pr.test.ts | 5 + src/main/github/client.ts | 76 +- src/main/github/project-view/mutations.ts | 2 +- src/main/gitlab/client-mr.test.ts | 154 +- src/main/gitlab/client-work-items.test.ts | 81 +- src/main/gitlab/client.ts | 393 +- src/main/gitlab/gl-utils.test.ts | 57 +- src/main/gitlab/gl-utils.ts | 62 +- src/main/gitlab/issues.test.ts | 93 +- src/main/gitlab/issues.ts | 150 +- src/main/gitlab/mappers.ts | 15 +- src/main/gitlab/work-item-details.ts | 131 +- src/main/ipc/gitlab.ts | 108 +- src/main/ipc/hosted-review.ts | 46 +- src/main/ipc/ssh.ts | 32 +- src/main/ipc/worktree-remote.ts | 56 +- src/main/ipc/worktrees.test.ts | 130 +- src/main/ipc/worktrees.ts | 106 +- src/main/linear/mappers.test.ts | 48 + src/main/linear/mappers.ts | 16 +- src/main/providers/ssh-git-provider.test.ts | 8 +- src/main/providers/ssh-git-provider.ts | 2 +- src/main/providers/types.ts | 2 +- src/main/rate-limits/claude-fetcher.test.ts | 11 +- src/main/runtime/mobile-rpc-allowlist.test.ts | 91 + src/main/runtime/orca-runtime.test.ts | 1243 +- src/main/runtime/orca-runtime.ts | 1295 +- .../runtime/rpc/methods/client-ui.test.ts | 87 +- src/main/runtime/rpc/methods/client-ui.ts | 50 + src/main/runtime/rpc/methods/github.test.ts | 57 + src/main/runtime/rpc/methods/github.ts | 20 + src/main/runtime/rpc/methods/gitlab.test.ts | 158 + src/main/runtime/rpc/methods/gitlab.ts | 151 + src/main/runtime/rpc/methods/index.ts | 4 + src/main/runtime/rpc/methods/repo.test.ts | 102 + src/main/runtime/rpc/methods/repo.ts | 26 +- src/main/runtime/rpc/methods/ssh.test.ts | 65 + src/main/runtime/rpc/methods/ssh.ts | 20 + src/main/runtime/rpc/methods/terminal.ts | 6 +- .../runtime/rpc/methods/worktree-schemas.ts | 173 + src/main/runtime/rpc/methods/worktree.test.ts | 80 + src/main/runtime/rpc/methods/worktree.ts | 180 +- src/main/runtime/runtime-rpc.test.ts | 724 +- src/main/runtime/runtime-rpc.ts | 80 + .../hosted-review-creation.test.ts | 119 +- .../source-control/hosted-review-creation.ts | 172 +- src/main/source-control/hosted-review.test.ts | 8 +- src/main/source-control/hosted-review.ts | 10 +- src/preload/api-types.ts | 2 +- src/preload/index.ts | 2 +- src/relay/git-handler-worktree-ops.ts | 4 + src/relay/git-handler.test.ts | 27 + .../src/components/GitLabItemDialog.tsx | 2 +- src/renderer/src/components/TaskPage.tsx | 10 +- .../github-project/ProjectGroupHeader.tsx | 2 +- .../github-project/ProjectViewList.tsx | 2 +- .../github-project/group-sort.test.ts | 14 +- .../src/hooks/composer-branch-selection.ts | 41 +- src/renderer/src/hooks/useComposerState.ts | 11 +- src/renderer/src/hooks/useIpcEvents.test.ts | 37 + src/renderer/src/hooks/useIpcEvents.ts | 12 +- .../src/lib/launch-work-item-direct.ts | 24 +- src/renderer/src/lib/new-workspace.ts | 39 +- src/renderer/src/lib/tui-agent-startup.ts | 179 +- src/renderer/src/web/web-preload-api.ts | 18 +- src/shared/composer-branch-selection.ts | 37 + .../github-project-group-sort.ts} | 18 +- src/shared/gitlab-types.ts | 8 +- src/shared/protocol-version.ts | 3 +- src/shared/telemetry-events.ts | 12 +- src/shared/tui-agent-selection.test.ts | 17 + src/shared/tui-agent-selection.ts | 52 + src/shared/tui-agent-startup.test.ts | 59 + src/shared/tui-agent-startup.ts | 161 + src/shared/types.ts | 7 +- src/shared/workspace-name.test.ts | 47 + src/shared/workspace-name.ts | 36 + src/shared/workspace-source.ts | 10 + 123 files changed, 26492 insertions(+), 1461 deletions(-) create mode 100644 mobile/app/h/[hostId]/tasks.tsx create mode 100644 mobile/mock-tasks.html create mode 100644 mobile/src/components/MobileAgentIcon.tsx create mode 100644 mobile/src/components/MobileMarkdown.test.ts create mode 100644 mobile/src/components/MobileMarkdown.tsx create mode 100644 mobile/src/components/TaskProviderLogo.tsx create mode 100644 mobile/src/components/mobile-markdown-parser.ts create mode 100644 mobile/src/tasks/github-check-summary.test.ts create mode 100644 mobile/src/tasks/github-check-summary.ts create mode 100644 mobile/src/tasks/github-pr-file-diff.test.ts create mode 100644 mobile/src/tasks/github-pr-file-diff.ts create mode 100644 mobile/src/tasks/github-project-repo-match.test.ts create mode 100644 mobile/src/tasks/github-project-repo-match.ts create mode 100644 mobile/src/tasks/github-work-item-source-errors.test.ts create mode 100644 mobile/src/tasks/github-work-item-source-errors.ts create mode 100644 mobile/src/tasks/hosted-review-start-point.test.ts create mode 100644 mobile/src/tasks/hosted-review-start-point.ts create mode 100644 mobile/src/tasks/mobile-agent-catalog.test.ts create mode 100644 mobile/src/tasks/mobile-agent-catalog.ts create mode 100644 mobile/src/tasks/mobile-composer-branch-selection.ts create mode 100644 mobile/src/tasks/mobile-github-project-group-sort.ts create mode 100644 mobile/src/tasks/mobile-task-providers.ts create mode 100644 mobile/src/tasks/mobile-tui-agents.ts create mode 100644 mobile/src/tasks/mobile-work-items.ts create mode 100644 mobile/src/tasks/mobile-workspace-name.ts create mode 100644 mobile/src/tasks/setup-hook-trust.test.ts create mode 100644 mobile/src/tasks/setup-hook-trust.ts create mode 100644 mobile/src/tasks/sparse-preset-draft.test.ts create mode 100644 mobile/src/tasks/sparse-preset-draft.ts create mode 100644 mobile/src/tasks/workspace-agent-selection.test.ts create mode 100644 mobile/src/tasks/workspace-agent-selection.ts create mode 100644 mobile/src/tasks/workspace-create-params.test.ts create mode 100644 mobile/src/tasks/workspace-create-params.ts create mode 100644 mobile/src/tasks/workspace-create-timeout.ts create mode 100644 mobile/src/tasks/workspace-ssh-gate.test.ts create mode 100644 mobile/src/tasks/workspace-ssh-gate.ts create mode 100644 src/main/linear/mappers.test.ts create mode 100644 src/main/runtime/mobile-rpc-allowlist.test.ts create mode 100644 src/main/runtime/rpc/methods/gitlab.test.ts create mode 100644 src/main/runtime/rpc/methods/gitlab.ts create mode 100644 src/main/runtime/rpc/methods/ssh.test.ts create mode 100644 src/main/runtime/rpc/methods/ssh.ts create mode 100644 src/main/runtime/rpc/methods/worktree-schemas.ts create mode 100644 src/shared/composer-branch-selection.ts rename src/{renderer/src/components/github-project/group-sort.ts => shared/github-project-group-sort.ts} (90%) create mode 100644 src/shared/tui-agent-selection.test.ts create mode 100644 src/shared/tui-agent-selection.ts create mode 100644 src/shared/tui-agent-startup.test.ts create mode 100644 src/shared/tui-agent-startup.ts create mode 100644 src/shared/workspace-name.test.ts create mode 100644 src/shared/workspace-name.ts create mode 100644 src/shared/workspace-source.ts diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index ed6b9509a9b..35b57078b63 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -17,6 +17,7 @@ import { Bell, GitBranch, GitPullRequest, + List, SlidersHorizontal, Layers, ChevronDown, @@ -90,9 +91,14 @@ type Worktree = { status?: 'working' | 'active' | 'permission' | 'done' | 'inactive' } -type SortMode = 'smart' | 'name' | 'recent' +type RepoSummary = { + displayName: string + badgeColor?: string +} + +type SortMode = 'smart' | 'name' | 'recent' | 'repo' type _FilterMode = 'all' | 'active' -type GroupMode = 'none' | 'repo' | 'prStatus' +type GroupMode = 'none' | 'workspaceStatus' | 'repo' | 'prStatus' type FilterState = { activeOnly: boolean @@ -106,11 +112,13 @@ function isErrorVerdict(v: ConnectionVerdict): boolean { const SORT_OPTIONS: PickerOption[] = [ { value: 'smart', label: 'Smart', subtitle: 'Unread and active first' }, { value: 'name', label: 'Name', subtitle: 'Alphabetical by name' }, - { value: 'recent', label: 'Recent', subtitle: 'Most recent output first' } + { value: 'recent', label: 'Recent', subtitle: 'Most recent output first' }, + { value: 'repo', label: 'Repo', subtitle: 'Repository, then workspace name' } ] const GROUP_OPTIONS: PickerOption[] = [ { value: 'none', label: 'No Grouping' }, + { value: 'workspaceStatus', label: 'Status' }, { value: 'repo', label: 'Repository' }, { value: 'prStatus', label: 'PR Status' } ] @@ -131,10 +139,30 @@ function isWorktreeActive(w: Worktree): boolean { return false } +const WORKSPACE_STATUS_LABELS: Record, string> = { + permission: 'Needs Permission', + working: 'Working', + done: 'Done', + active: 'Active', + inactive: 'Inactive' +} + +const WORKSPACE_STATUS_ORDER: ReturnType[] = [ + 'permission', + 'working', + 'done', + 'active', + 'inactive' +] + function sortWorktrees(worktrees: Worktree[], mode: SortMode): Worktree[] { return [...worktrees].sort((a, b) => { if (mode === 'name') return (a.displayName || a.repo).localeCompare(b.displayName || b.repo) if (mode === 'recent') return (b.lastOutputAt ?? 0) - (a.lastOutputAt ?? 0) + if (mode === 'repo') { + const repoComparison = a.repo.localeCompare(b.repo, undefined, { sensitivity: 'base' }) + return repoComparison || (a.displayName || a.repo).localeCompare(b.displayName || b.repo) + } // 'smart' — attention-first if (a.unread !== b.unread) return a.unread ? -1 : 1 const aStatus = getWorktreeStatus(a) @@ -237,6 +265,20 @@ function buildSections( for (const [repo, items] of byRepo) { sections.push({ title: repo, data: items }) } + } else if (groupMode === 'workspaceStatus') { + const byStatus = new Map, Worktree[]>() + for (const w of unpinned) { + const key = getWorktreeStatus(w) + const list = byStatus.get(key) + if (list) list.push(w) + else byStatus.set(key, [w]) + } + for (const status of WORKSPACE_STATUS_ORDER) { + const items = byStatus.get(status) + if (items && items.length > 0) { + sections.push({ title: WORKSPACE_STATUS_LABELS[status], data: items }) + } + } } else if (groupMode === 'prStatus') { const byGroup = new Map() for (const w of unpinned) { @@ -273,6 +315,7 @@ export default function HostScreen() { const forceReconnectHost = useForceReconnect() const [worktrees, setWorktrees] = useState(initialCache ?? []) const [worktreesLoaded, setWorktreesLoaded] = useState(initialCache != null) + const [repoColorsByName, setRepoColorsByName] = useState>(new Map()) const [hostName, setHostName] = useState('') const [error, setError] = useState('') const [compatVerdict, setCompatVerdict] = useState({ kind: 'ok' }) @@ -338,6 +381,7 @@ export default function HostScreen() { setHostName('') setError('') setCompatVerdict({ kind: 'ok' }) + setRepoColorsByName(new Map()) // Why: re-seed from the current host's cache on every hostId change. // The useState initializer only runs on first mount, so if Expo Router // reuses this screen with a different hostId, we must reset here. @@ -382,6 +426,23 @@ export default function HostScreen() { setLastKnownWorktrees(result.worktrees) setWorktreesLoaded(true) + void requestClient + .sendRequest('repo.list') + .then((repoResponse) => { + if (clientRef.current !== requestClient || hostId !== requestHostId) return + if (!repoResponse.ok) return + const repoResult = (repoResponse as RpcSuccess).result as { repos: RepoSummary[] } + setRepoColorsByName( + new Map( + repoResult.repos.map((repo) => [ + repo.displayName, + repo.badgeColor || repoColor(repo.displayName) + ]) + ) + ) + }) + .catch(() => null) + // Clear optimistic sleep overrides once the server confirms the // worktree is actually inactive (liveTerminalCount dropped to 0). setSleptIds((prev) => { @@ -638,10 +699,15 @@ export default function HostScreen() { const uniqueRepos = useMemo(() => { const repos = new Map() for (const w of displayWorktrees) { - if (!repos.has(w.repo)) repos.set(w.repo, repoColor(w.repo)) + if (!repos.has(w.repo)) repos.set(w.repo, repoColorsByName.get(w.repo) ?? repoColor(w.repo)) } return [...repos.entries()].map(([name, color]) => ({ name, color })) - }, [displayWorktrees]) + }, [displayWorktrees, repoColorsByName]) + + const uniqueRepoColors = useMemo( + () => new Map(uniqueRepos.map((repo) => [repo.name, repo.color])), + [uniqueRepos] + ) const toggleCollapsed = useCallback( (title: string) => { @@ -752,14 +818,26 @@ export default function HostScreen() { setShowSortPicker(true)}> - {sortMode === 'smart' ? 'Smart' : sortMode === 'name' ? 'Name' : 'Recent'} + {sortMode === 'smart' + ? 'Smart' + : sortMode === 'name' + ? 'Name' + : sortMode === 'repo' + ? 'Repo' + : 'Recent'} setShowGroupPicker(true)}> - {groupMode === 'none' ? 'Group' : groupMode === 'repo' ? 'Repo' : 'PR'} + {groupMode === 'none' + ? 'Group' + : groupMode === 'workspaceStatus' + ? 'Status' + : groupMode === 'repo' + ? 'Repo' + : 'PR'} @@ -776,6 +854,17 @@ export default function HostScreen() { /> + router.push(`/h/${hostId}/tasks`)} + disabled={connState !== 'connected'} + > + + + setShowNewWorktree(true)} @@ -873,6 +962,8 @@ export default function HostScreen() { const isCollapsed = collapsedGroups.has(section.title) const rawSection = rawSections.find((s) => s.title === section.title) const count = rawSection?.data.length ?? 0 + const repoSectionColor = + groupMode === 'repo' ? uniqueRepoColors.get(section.title) : null return ( )} + {repoSectionColor ? ( + + ) : null} {section.title} {count} @@ -933,7 +1027,12 @@ export default function HostScreen() { )} - + {item.repo} @@ -1330,6 +1429,12 @@ const styles = StyleSheet.create({ sectionIcon: { marginRight: spacing.xs }, + sectionRepoDot: { + width: 8, + height: 8, + borderRadius: 4, + marginRight: spacing.xs + }, sectionTitle: { fontSize: 11, fontWeight: '600', diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 34ee93a6b12..5d92367ed2a 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -18,6 +18,7 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import { useLocalSearchParams, useRouter } from 'expo-router' import AsyncStorage from '@react-native-async-storage/async-storage' import { + AlertTriangle, ArrowUp, ChevronLeft, ChevronRight, @@ -32,7 +33,8 @@ import { Plus, RefreshCw, Smartphone, - SquareTerminal + SquareTerminal, + X } from 'lucide-react-native' import type { RpcClient } from '../../../../src/transport/rpc-client' import { loadHosts } from '../../../../src/transport/host-store' @@ -548,18 +550,21 @@ export default function SessionScreen() { hostId, worktreeId, name: worktreeName, - created + created, + warning: createdWarning } = useLocalSearchParams<{ hostId: string worktreeId: string name?: string created?: string + warning?: string }>() const router = useRouter() const insets = useSafeAreaInsets() // Why: shared client per host owned by RpcClientProvider. See // docs/mobile-shared-client-per-host.md. const { client, state: connState } = useHostClient(hostId) + const initialCreateWarning = typeof createdWarning === 'string' ? createdWarning.trim() : '' const [terminals, setTerminals] = useState([]) const terminalsRef = useRef([]) const [sessionTabs, setSessionTabs] = useState([]) @@ -575,6 +580,7 @@ export default function SessionScreen() { const [creating, setCreating] = useState(false) const [creatingBrowser, setCreatingBrowser] = useState(false) const [createError, setCreateError] = useState('') + const [createWarning, setCreateWarning] = useState(initialCreateWarning) const [showCreateTabDrawer, setShowCreateTabDrawer] = useState(false) const [showCreateBrowserModal, setShowCreateBrowserModal] = useState(false) const [actionTarget, setActionTarget] = useState(null) @@ -667,6 +673,10 @@ export default function SessionScreen() { activeSessionTab?.type !== 'browser' const [browserScreencastSupported, setBrowserScreencastSupported] = useState(null) + useEffect(() => { + setCreateWarning(initialCreateWarning) + }, [initialCreateWarning]) + const showToast = useCallback((message: string, durationMs = 1200) => { setToastMessage(message) Animated.timing(toastOpacityRef.current, { @@ -2762,6 +2772,21 @@ export default function SessionScreen() { )} + {createWarning ? ( + + + {createWarning} + setCreateWarning('')} + accessibilityLabel="Dismiss workspace creation warning" + hitSlop={8} + > + + + + ) : null} + {showLoadingState ? ( @@ -3690,6 +3715,29 @@ const styles = StyleSheet.create({ borderRadius: radii.button, overflow: 'hidden' }, + createWarningBanner: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.sm, + backgroundColor: colors.bgPanel, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + }, + createWarningText: { + flex: 1, + color: colors.textPrimary, + fontSize: 12, + lineHeight: 16 + }, + createWarningDismiss: { + width: 24, + height: 24, + alignItems: 'center', + justifyContent: 'center', + marginTop: -4 + }, emptyState: { flex: 1, alignItems: 'center', diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx new file mode 100644 index 00000000000..c35800c29b4 --- /dev/null +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -0,0 +1,14577 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { ReactNode } from 'react' +import { + ActivityIndicator, + FlatList, + Linking, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View +} from 'react-native' +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' +import * as Clipboard from 'expo-clipboard' +import { useLocalSearchParams, useRouter } from 'expo-router' +import { + AlertTriangle, + Check, + ChevronDown, + ChevronLeft, + ChevronRight, + ChevronUp, + Copy, + ExternalLink, + GitBranch, + Lock, + Pencil, + Plus, + RefreshCw, + Search, + Send, + X +} from 'lucide-react-native' +import type { RpcClient } from '../../../src/transport/rpc-client' +import type { RpcSuccess } from '../../../src/transport/types' +import { + useHostClient, + useLastConnectedAt, + useReconnectAttempt +} from '../../../src/transport/client-context' +import { classifyConnection } from '../../../src/transport/connection-health' +import { StatusDot } from '../../../src/components/StatusDot' +import { ActionSheetModal } from '../../../src/components/ActionSheetModal' +import { BottomDrawer } from '../../../src/components/BottomDrawer' +import { ConfirmModal } from '../../../src/components/ConfirmModal' +import { MobileMarkdown } from '../../../src/components/MobileMarkdown' +import { MobileAgentIcon } from '../../../src/components/MobileAgentIcon' +import { PickerModal, type PickerOption } from '../../../src/components/PickerModal' +import { TaskProviderLogo } from '../../../src/components/TaskProviderLogo' +import { + buildGitHubPrFileDiffLines, + type GitHubPrFileDiffLine +} from '../../../src/tasks/github-pr-file-diff' +import { buildGitHubCheckSummary } from '../../../src/tasks/github-check-summary' +import { buildTaskWorkspaceCreateParams } from '../../../src/tasks/workspace-create-params' +import { + pickWorkspaceAgent, + workspaceAgentLabel, + type WorkspaceAgentChoice +} from '../../../src/tasks/workspace-agent-selection' +import { shouldResolveHostedReviewStartPoint } from '../../../src/tasks/hosted-review-start-point' +import { getLinkedWorkItemSuggestedName } from '../../../src/tasks/mobile-workspace-name' +import { + filterGitHubProjectRowsForRepos, + findRepoForGitHubProjectRepository, + type GitHubRepoSlugCacheEntry +} from '../../../src/tasks/github-project-repo-match' +import { + extractGitHubIssueSourceFallback, + extractGitHubIssueSourceError, + type GitHubIssueSourceFallback, + type GitHubIssueSourceError +} from '../../../src/tasks/github-work-item-source-errors' +import { parseSparsePresetDirectories } from '../../../src/tasks/sparse-preset-draft' +import { + deriveWorkspaceSshGate, + workspaceSshStatusLabel +} from '../../../src/tasks/workspace-ssh-gate' +import { WORKTREE_CREATE_TIMEOUT_MS } from '../../../src/tasks/workspace-create-timeout' +import { + isSetupHookTrusted, + normalizeSetupHookTrust, + trustedOrcaHooksWithSetupApproval, + wasSetupHookPreviouslyApproved +} from '../../../src/tasks/setup-hook-trust' +import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme' +import { triggerMediumImpact } from '../../../src/platform/haptics' +import type { + GitHubProjectSortDirection, + GitHubProjectTable as SharedGitHubProjectTable +} from '../../../src/tasks/mobile-github-project-group-sort' +import { + groupRows, + isIterationCurrent, + sortRows, + type ProjectGroup +} from '../../../src/tasks/mobile-github-project-group-sort' +import { + CROSS_REPO_DISPLAY_LIMIT, + isGitHubWorkItemsSshRemoteRequiredError, + PER_REPO_FETCH_LIMIT +} from '../../../src/tasks/mobile-work-items' +import { + filterAvailableTaskProviders, + normalizeVisibleTaskProviders, + resolveVisibleTaskProvider, + type TaskProvider +} from '../../../src/tasks/mobile-task-providers' +import { MOBILE_TUI_AGENT_AUTO_PICK_ORDER } from '../../../src/tasks/mobile-tui-agents' +import { resolveComposerBranchSelection } from '../../../src/tasks/mobile-composer-branch-selection' +import type { + BaseRefSearchResult, + PersistedTrustedOrcaHooks, + SparsePreset, + TuiAgent +} from '../../../../src/shared/types' +import type { SshConnectionState } from '../../../../src/shared/ssh-types' + +type RepoSummary = { + id: string + displayName: string + path: string + badgeColor?: string + kind?: 'git' | 'folder' + connectionId?: string | null + issueSourcePreference?: IssueSourcePreference +} + +type IssueSourcePreference = 'upstream' | 'origin' | 'auto' + +type GitHubOwnerRepo = { + owner: string + repo: string +} + +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 + branchName?: string + baseRefName?: string + isCrossRepository?: boolean + additions?: number + deletions?: number + changedFiles?: number + repoId: string + repoName: string + reviewDecision?: string | null + reviewRequests?: GitHubAssignableUser[] + latestReviews?: GitHubPRReviewSummary[] + checksSummary?: GitHubPRCheckSummary + mergeable?: GitHubPRMergeableState + mergeStateStatus?: string | null +} + +type GitHubAssignableUser = { + login: string + name?: string | null + avatarUrl?: string | null +} + +type GitHubPRReviewSummary = { + login: string + state?: string | null + avatarUrl?: string | null +} + +type GitHubPRCheckSummary = { + state: 'success' | 'failure' | 'pending' | 'none' + total: number + passed: number + failed: number + pending: number +} + +type GitHubPRMergeableState = 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN' + +type GitHubPRReviewerRow = { + login: string + name?: string | null + avatarUrl?: string | null + stateLabel: string +} + +type GitHubRepoSources = { + issues: GitHubOwnerRepo | null + prs: GitHubOwnerRepo | null + upstreamCandidate: GitHubOwnerRepo | null +} + +type TaskRuntimeStatus = { + capabilities?: string[] +} + +type TasksSupportState = + | { kind: 'unknown'; client: RpcClient | null } + | { kind: 'supported'; client: RpcClient } + | { kind: 'unsupported'; client: RpcClient } + +type GitLabWorkItem = { + id: string + type: 'issue' | 'mr' + number: number + title: string + state: 'opened' | 'closed' | 'merged' | 'locked' | 'draft' + url: string + labels: string[] + updatedAt: string + author: string | null + branchName?: string + baseRefName?: string + isCrossRepository?: boolean + projectRef?: { host: string; path: string } + repoId: string + repoName: string +} + +type GitLabTodo = { + id: number + actionName: string + targetType: string + targetIid: number | null + targetTitle: string + targetUrl: string + projectPath: string + authorUsername: string + updatedAt: string + state: 'pending' | 'done' +} + +type GitPushTarget = { + remoteName: string + branchName: string + remoteUrl?: string +} + +type SetupDecision = 'inherit' | 'run' | 'skip' +type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default' + +type RepoHooksResponse = { + hooks: { scripts?: { setup?: string } } | null + source: string | null + setupRunPolicy?: SetupRunPolicy + setupTrust?: { + contentHash: string + scriptContent: string + } +} + +type LinearProject = { + id: string + name: string + url?: string + color?: string +} + +type LinearIssueChild = { + id: string + identifier: string + title: string + url: string +} + +type LinearIssue = { + id: string + workspaceId?: string + workspaceName?: string + identifier: string + title: string + description?: string + url: string + state: { name: string; type: string; color: string } + team: { id: string; name: string; key: string } + project?: LinearProject + subIssues?: LinearIssueChild[] + labels: string[] + labelIds?: string[] + assignee?: { id?: string; displayName: string } + estimate?: number | null + priority: number + updatedAt: string +} + +type LinearState = { + id: string + name: string + type: string + color?: string +} + +type LinearTeam = { + id: string + workspaceId?: string + workspaceName?: string + name: string + key: string +} + +type DetailComment = { + id: string | number + author?: string + authorAvatarUrl?: string + user?: { displayName?: string } + isBot?: boolean + body: string + createdAt?: string + url?: string + reactions?: Array<{ + content: + | 'thumbs_up' + | 'thumbs_down' + | 'laugh' + | 'confused' + | 'heart' + | 'hooray' + | 'rocket' + | 'eyes' + count: number + }> + path?: string + line?: number + startLine?: number + threadId?: string + isResolved?: boolean +} + +type GitHubDetailFile = { + path: string + oldPath?: string + status?: 'added' | 'modified' | 'removed' | 'renamed' | 'copied' | 'changed' | 'unchanged' + additions?: number + deletions?: number + isBinary?: boolean + viewerViewedState?: 'DISMISSED' | 'VIEWED' | 'UNVIEWED' +} + +type GitHubDetailCheck = { + name: string + status: string + conclusion?: string | null + url?: string | null +} + +type GitHubPRFileContents = { + original: string + modified: string + originalIsBinary: boolean + modifiedIsBinary: boolean +} + +type DetailPayload = + | { + provider: 'github' + body: string + comments: DetailComment[] + labels: string[] + assignees: string[] + reviewDecision?: string | null + reviewRequests: GitHubAssignableUser[] + latestReviews: GitHubPRReviewSummary[] + headSha?: string + baseSha?: string + pullRequestId?: string + checks: GitHubDetailCheck[] + files: GitHubDetailFile[] + } + | { + provider: 'gitlab' + body: string + comments: DetailComment[] + labels: string[] + assignees: string[] + pipelineJobs: Array<{ + id?: number + name: string + stage: string + status: string + webUrl?: string | null + duration?: number | null + }> + } + | { + provider: 'linear' + description: string + comments: DetailComment[] + labels: string[] + assignee?: string + project?: LinearProject + children: LinearIssueChild[] + } + +type GitHubTaskKind = 'issues' | 'prs' +type GitHubMode = GitHubTaskKind | 'project' +type GitHubPreset = 'issues' | 'my-issues' | 'prs' | 'my-prs' | 'review' +type GitLabView = 'project' | 'todos' +type GitLabFilter = 'opened' | 'merged' | 'closed' | 'all' +type LinearFilter = 'assigned' | 'created' | 'all' | 'completed' +type LinearViewMode = 'list' | 'board' +type LinearGroupBy = 'none' | 'status' | 'assignee' | 'priority' | 'team' +type LinearOrderBy = 'priority' | 'updated' | 'identifier' +type LinearDisplayProperty = 'state' | 'priority' | 'assignee' | 'team' | 'labels' | 'updated' +type TaskSort = 'updated' | 'repository' +type DetailCommentGroup = + | { kind: 'standalone'; comment: DetailComment } + | { kind: 'thread'; threadId: string; root: DetailComment; replies: DetailComment[] } +type TaskResumeState = { + githubMode?: 'items' | 'project' + githubItemsPreset?: GitHubPreset | 'all' | null + githubItemsQuery?: string + githubProjectHiddenFieldIdsByView?: Record + linearPreset?: LinearFilter + linearQuery?: string +} +type RuntimeTaskSettings = { + defaultTuiAgent?: TuiAgent | 'blank' | null + agentCmdOverrides?: Record + defaultTaskSource?: TaskProvider + defaultTaskViewPreset?: GitHubPreset | 'all' + visibleTaskProviders?: TaskProvider[] + defaultRepoSelection?: string[] | null + defaultLinearTeamSelection?: string[] | null + githubProjects?: GitHubProjectSettings +} + +type LinearWorkspace = { + id: string + organizationName?: string + displayName?: string +} + +type LinearStatusResponse = { + connected?: boolean + workspaces?: LinearWorkspace[] + selectedWorkspaceId?: string | 'all' | null + activeWorkspaceId?: string | null +} + +type GitHubProjectOwnerType = 'organization' | 'user' +type GitHubProjectRef = { + owner: string + ownerType: GitHubProjectOwnerType + number: number +} +type GitHubProjectSettings = { + pinned: GitHubProjectRef[] + recent: Array + lastViewByProject: Record + activeProject: GitHubProjectRef | null +} +type GitHubProjectSummary = GitHubProjectRef & { + id: string + title: string + url: string + source: string +} +type GitHubProjectPartialFailure = { + owner: string + message: string +} +type GitHubProjectViewSummary = { + id: string + number: number + name: string + layout: 'TABLE_LAYOUT' | 'BOARD_LAYOUT' | 'ROADMAP_LAYOUT' +} +type GitHubIssueType = { + id: string + name: string + color: string | null + description: string | null +} +type GitHubProjectField = + | { + kind: 'field' + id: string + name: string + dataType: string + } + | { + kind: 'single-select' + id: string + name: string + dataType: 'SINGLE_SELECT' + options: Array<{ id: string; name: string; color: string }> + } + | { + kind: 'iteration' + id: string + name: string + dataType: 'ITERATION' + iterations: Array<{ + id: string + title: string + startDate: string + duration: number + completed?: boolean + }> + } +type GitHubProjectSort = { + direction: GitHubProjectSortDirection + field: GitHubProjectField +} +type GitHubProjectFieldValue = + | { kind: 'single-select'; fieldId: string; optionId: string; name: string; color: string } + | { + kind: 'iteration' + fieldId: string + iterationId: string + title: string + startDate: string + duration: number + } + | { kind: 'text'; fieldId: string; text: string } + | { kind: 'number'; fieldId: string; number: number } + | { kind: 'date'; fieldId: string; date: string } + | { kind: 'labels'; fieldId: string; labels: Array<{ name: string; color: string }> } + | { kind: 'users'; fieldId: string; users: Array<{ login: string; name: string | null }> } +type GitHubProjectFieldMutationValue = + | { kind: 'text'; text: string } + | { kind: 'number'; number: number } + | { kind: 'date'; date: string } + | { kind: 'single-select'; optionId: string } + | { kind: 'iteration'; iterationId: string } +type GitHubProjectRow = { + id: string + itemType: 'ISSUE' | 'PULL_REQUEST' | 'DRAFT_ISSUE' | 'REDACTED' + content: { + number: number | null + title: string + body: string | null + url: string | null + state: string | null + stateReason?: string | null + isDraft: boolean | null + repository: string | null + issueType?: GitHubIssueType | null + labels: Array<{ name: string; color: string }> + assignees: Array<{ login: string; name: string | null }> + parentIssue?: { number: number; title: string; url: string } | null + } + fieldValuesByFieldId?: Record + updatedAt: string + position?: number +} +type GitHubProjectTable = { + project: GitHubProjectRef & { + id: string + title: string + url: string + } + selectedView: { + id: string + number: number + name: string + filter: string + layout: 'TABLE_LAYOUT' | 'BOARD_LAYOUT' | 'ROADMAP_LAYOUT' + fields?: GitHubProjectField[] + groupByFields?: GitHubProjectField[] + sortByFields?: GitHubProjectSort[] + } + rows: GitHubProjectRow[] + totalCount: number + parentFieldDropped?: boolean +} + +type TaskItem = + | { + key: string + provider: 'github' + title: string + subtitle: string + status: string + updatedAt: string + source: GitHubWorkItem + } + | { + key: string + provider: 'gitlab' + title: string + subtitle: string + status: string + updatedAt: string + source: GitLabWorkItem + } + | { + key: string + provider: 'gitlabTodo' + title: string + subtitle: string + status: string + updatedAt: string + source: GitLabTodo + } + | { + key: string + provider: 'linear' + title: string + subtitle: string + status: string + updatedAt: string + source: LinearIssue + } + +type ActionableTaskItem = Exclude +type HostedReviewMergeMethod = 'merge' | 'squash' | 'rebase' +type HostedReviewItem = + | Extract + | Extract +type PendingHostedMerge = { + item: HostedReviewItem + method: HostedReviewMergeMethod +} +type PendingProjectGitHubMerge = { + row: GitHubProjectRow + method: HostedReviewMergeMethod +} +type PendingHostedStateChange = + | { + source: 'task' + item: Extract | Extract + nextState: 'open' | 'opened' | 'closed' + } + | { + source: 'project' + row: GitHubProjectRow + nextState: 'open' | 'closed' + } + +type SetupPrompt = { + item: ActionableTaskItem + repoIdOverride?: string + agentOverride?: WorkspaceAgentChoice + workspaceNameOverride?: string + noteOverride?: string + baseBranchOverride?: string + branchNameOverride?: string + sparseCheckoutOverride?: { directories: string[]; presetId?: string } + repoName: string + command: string + source: string | null +} + +type WorkspaceCreateArgs = { + item: ActionableTaskItem + repoIdOverride?: string + setupOverride?: Exclude + agentOverride?: WorkspaceAgentChoice + workspaceNameOverride?: string + noteOverride?: string + baseBranchOverride?: string + branchNameOverride?: string + sparseCheckoutOverride?: { directories: string[]; presetId?: string } +} + +type OrcaYamlTrustPrompt = WorkspaceCreateArgs & { + repoId: string + repoName: string + scriptContent: string + contentHash: string + previouslyApproved: boolean +} + +type WorkspaceCreateDraft = { + item: ActionableTaskItem + repoIdOverride?: string +} + +type WorkspaceSparseDraft = { + mode: 'new' | 'edit' + presetId?: string + name: string + directoriesText: string +} + +function sortSparsePresetsByName(presets: SparsePreset[]): SparsePreset[] { + return [...presets].sort((left, right) => left.name.localeCompare(right.name)) +} + +function workspaceAgentIconId(agent: WorkspaceAgentChoice): string { + return agent === 'blank' ? '__blank__' : agent +} + +type ProjectRepoNotInOrcaPrompt = { + owner: string + repo: string + url: string | null +} + +type TaskListEntry = + | { type: 'section'; key: string; label: string; color: string } + | { type: 'item'; key: string; item: TaskItem } + +const PROVIDER_OPTIONS: PickerOption[] = [ + { + value: 'github', + label: 'GitHub', + subtitle: 'Issues and pull requests', + renderIcon: (selected) => ( + + ) + }, + { + value: 'gitlab', + label: 'GitLab', + subtitle: 'Issues and merge requests', + renderIcon: (selected) => ( + + ) + }, + { + value: 'linear', + label: 'Linear', + subtitle: 'Assigned and team issues', + renderIcon: (selected) => ( + + ) + } +] + +const GITLAB_FILTER_OPTIONS: PickerOption[] = [ + { value: 'opened', label: 'Open', subtitle: 'Open issues and merge requests' }, + { value: 'merged', label: 'Merged', subtitle: 'Merged merge requests' }, + { value: 'closed', label: 'Closed', subtitle: 'Closed issues and merge requests' }, + { value: 'all', label: 'All', subtitle: 'Any GitLab state' } +] + +const LINEAR_FILTER_OPTIONS: PickerOption[] = [ + { value: 'all', label: 'All', subtitle: 'Open issues across connected workspaces' }, + { value: 'assigned', label: 'My Issues', subtitle: 'Issues assigned to you' }, + { value: 'created', label: 'Created', subtitle: 'Issues created by you' }, + { value: 'completed', label: 'Completed', subtitle: 'Recently completed issues' } +] + +const LINEAR_VIEW_OPTIONS: PickerOption[] = [ + { value: 'list', label: 'List', subtitle: 'Compact issue rows' }, + { value: 'board', label: 'Board', subtitle: 'Grouped columns' } +] + +function taskWorkspaceFallback(item: ActionableTaskItem): string { + if (item.provider === 'github' || item.provider === 'gitlab') { + return `${item.source.type}-${item.source.number}` + } + return item.source.identifier.toLowerCase() +} + +function taskWorkspaceSuggestedName(item: ActionableTaskItem): string { + return getLinkedWorkItemSuggestedName(item) || taskWorkspaceFallback(item) +} + +const COMMENT_REACTION_EMOJI: Record< + NonNullable[number]['content'], + string +> = { + thumbs_up: '+1', + thumbs_down: '-1', + laugh: 'laugh', + confused: 'confused', + heart: 'heart', + hooray: 'hooray', + rocket: 'rocket', + eyes: 'eyes' +} + +const LINEAR_GROUP_OPTIONS: PickerOption[] = [ + { value: 'none', label: 'No grouping' }, + { value: 'status', label: 'Status' }, + { value: 'assignee', label: 'Assignee' }, + { value: 'priority', label: 'Priority' }, + { value: 'team', label: 'Team' } +] + +const LINEAR_ORDER_OPTIONS: PickerOption[] = [ + { value: 'priority', label: 'Priority' }, + { value: 'updated', label: 'Updated' }, + { value: 'identifier', label: 'Identifier' } +] + +const LINEAR_DISPLAY_OPTIONS: PickerOption[] = [ + { value: 'state', label: 'Status' }, + { value: 'priority', label: 'Priority' }, + { value: 'assignee', label: 'Assignee' }, + { value: 'team', label: 'Team' }, + { value: 'labels', label: 'Labels' }, + { value: 'updated', label: 'Updated' } +] + +const DEFAULT_LINEAR_DISPLAY_PROPERTIES: LinearDisplayProperty[] = [ + 'state', + 'priority', + 'assignee', + 'team', + 'labels', + 'updated' +] + +const GITHUB_KIND_OPTIONS: PickerOption[] = [ + { value: 'issues', label: 'Issues', subtitle: 'GitHub issues' }, + { value: 'prs', label: 'PRs', subtitle: 'GitHub pull requests' }, + { value: 'project', label: 'Projects', subtitle: 'GitHub Projects views' } +] + +const ISSUE_PRESETS: PickerOption[] = [ + { value: 'issues', label: 'Open', subtitle: 'Open GitHub issues' }, + { value: 'my-issues', label: 'Assigned to me', subtitle: 'Open issues assigned to you' } +] + +const PR_PRESETS: PickerOption[] = [ + { value: 'prs', label: 'Open', subtitle: 'Open pull requests' }, + { value: 'my-prs', label: 'Mine', subtitle: 'Pull requests authored by you' }, + { value: 'review', label: 'Needs review', subtitle: 'Review requests assigned to you' } +] + +const GITLAB_VIEW_OPTIONS: PickerOption[] = [ + { value: 'project', label: 'Project MRs', subtitle: 'Merge requests and issues by repository' }, + { value: 'todos', label: 'My Todos', subtitle: 'Pending GitLab todos' } +] + +const SORT_OPTIONS: PickerOption[] = [ + { value: 'updated', label: 'Updated', subtitle: 'Newest activity first' }, + { + value: 'repository', + label: 'Repository', + subtitle: 'Group by repository, then newest activity' + } +] + +type ProjectSortOverride = { fieldId: string; direction: GitHubProjectSortDirection } +type ProjectListEntry = + | { type: 'group'; group: ProjectGroup; collapsed: boolean } + | { type: 'row'; row: GitHubProjectRow } + +const PROJECT_VIEW_DEFAULT_SORT = '__view_default__' +const GITHUB_REPO_CONCURRENCY = 3 +const MAX_RENDERED_PR_DIFF_LINES = 400 +const GITLAB_PER_PAGE = 50 +const LINEAR_LIMIT = 50 +const MOBILE_TASKS_CAPABILITY = 'mobile.tasks.v1' +// Why: task detail drawers can launch child sheets; children must layer above +// the still-mounted parent while its dismissal animation/state remains alive. +const TASK_SECONDARY_DRAWER_Z_INDEX = 1100 +// Why: the mobile detail drawer should support quick triage and core actions. +// Desktop keeps the broad metadata editing surface for dense issue/PR work. +const SHOW_MOBILE_DETAIL_LABEL_CHIPS = false +const SHOW_MOBILE_DETAIL_METADATA_EDITORS = false +const SHOW_MOBILE_DETAIL_REVIEW_PANELS = false +const SHOW_MOBILE_LINEAR_DETAIL_TOOLS = false +const SHOW_MOBILE_COMMENT_THREAD_TOOLS = false +const SHOW_MOBILE_PROJECT_METADATA_EDITORS = false +const SHOW_MOBILE_PROJECT_REVIEW_PANELS = false +const EMPTY_GITHUB_PROJECT_SETTINGS: GitHubProjectSettings = { + pinned: [], + recent: [], + lastViewByProject: {}, + activeProject: null +} + +function isSuccess(response: unknown): response is RpcSuccess { + return Boolean(response && typeof response === 'object' && (response as RpcSuccess).ok) +} + +function taskTime(value: string): number { + const time = Date.parse(value) + return Number.isFinite(time) ? time : 0 +} + +function formatUpdatedAt(value: string): string { + const time = taskTime(value) + if (!time) return '' + const minutes = Math.max(0, Math.floor((Date.now() - time) / 60_000)) + if (minutes < 60) return `${minutes}m` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h` + return `${Math.floor(hours / 24)}d` +} + +function getTaskPresetQuery(preset: GitHubPreset): string { + switch (preset) { + case 'my-issues': + return 'assignee:@me is:issue is:open' + case 'prs': + return 'is:pr is:open' + case 'my-prs': + return 'author:@me is:pr is:open' + case 'review': + return 'review-requested:@me is:pr is:open' + case 'issues': + default: + return 'is:issue is:open' + } +} + +function isTaskProvider(value: unknown): value is TaskProvider { + return value === 'github' || value === 'gitlab' || value === 'linear' +} + +function normalizeGitHubPreset(value: unknown): GitHubPreset { + return value === 'my-issues' || + value === 'prs' || + value === 'my-prs' || + value === 'review' || + value === 'issues' + ? value + : 'issues' +} + +function normalizeLinearFilter(value: unknown): LinearFilter { + return value === 'assigned' || value === 'created' || value === 'completed' || value === 'all' + ? value + : 'all' +} + +function githubKindFromQuery(query: string, fallbackPreset: GitHubPreset): GitHubTaskKind { + if (/\bis:pr\b/i.test(query)) return 'prs' + if (/\bis:issue\b/i.test(query)) return 'issues' + return fallbackPreset === 'prs' || fallbackPreset === 'my-prs' || fallbackPreset === 'review' + ? 'prs' + : 'issues' +} + +function githubProjectKey(project: GitHubProjectRef): string { + return `${project.ownerType}:${project.owner}:${project.number}` +} + +function parseProjectInput( + input: string +): { owner: string; number: number; viewNumber?: number } | null { + const trimmed = input.trim() + if (!trimmed) return null + const short = /^([A-Za-z0-9][A-Za-z0-9-]*)\/(\d+)$/.exec(trimmed) + if (short) { + return { owner: short[1]!, number: Number(short[2]) } + } + try { + const url = new URL(trimmed) + if (url.hostname !== 'github.com') return null + const parts = url.pathname.split('/').filter(Boolean) + if ((parts[0] === 'orgs' || parts[0] === 'users') && parts[2] === 'projects' && parts[3]) { + const number = Number(parts[3]) + if (!Number.isInteger(number) || number < 1) return null + const viewNumber = + parts[4] === 'views' && parts[5] && Number.isInteger(Number(parts[5])) + ? Number(parts[5]) + : undefined + return { + owner: parts[1]!, + number, + ...(viewNumber && viewNumber > 0 ? { viewNumber } : {}) + } + } + } catch { + return null + } + return null +} + +function projectRowType(row: GitHubProjectRow): 'issue' | 'pr' | null { + if (row.itemType === 'ISSUE') return 'issue' + if (row.itemType === 'PULL_REQUEST') return 'pr' + return null +} + +function canCreateWorkspaceFromProjectRow(row: GitHubProjectRow): boolean { + // Why: desktop only exposes Project "Start work" for backed issue/PR rows + // with enough GitHub identity to build the linked work item. + return projectRowType(row) !== null && row.content.number != null && Boolean(row.content.url) +} + +function splitRepositorySlug(slug: string | null): { owner: string; repo: string } | null { + const [owner, repo] = slug?.split('/') ?? [] + return owner && repo ? { owner, repo } : null +} + +const GITHUB_PROJECT_OPTION_COLORS: Record = { + GRAY: '#8b949e', + RED: '#f85149', + ORANGE: '#db6d28', + YELLOW: '#d29922', + GREEN: '#3fb950', + BLUE: '#58a6ff', + PURPLE: '#bc8cff', + PINK: '#db61a2' +} + +function githubProjectOptionColor(color: string | null | undefined): string { + if (!color) return colors.textMuted + const upper = color.toUpperCase() + const mapped = GITHUB_PROJECT_OPTION_COLORS[upper] + if (mapped) return mapped + const hex = color.startsWith('#') ? color : `#${color}` + return /^#[0-9a-fA-F]{6}$/.test(hex) ? hex : colors.textMuted +} + +function projectRowStatusLabel(row: GitHubProjectRow): string { + if (row.itemType === 'DRAFT_ISSUE') return 'Draft' + if (row.itemType === 'REDACTED') return 'Redacted' + if (row.content.isDraft) return 'Draft' + if (row.content.state === 'MERGED') return 'Merged' + if (row.content.state === 'CLOSED') return 'Closed' + return 'Open' +} + +function scopeGitHubTaskSearch(query: string, kind: GitHubTaskKind): string { + const trimmed = query.trim() + if (!trimmed) return getTaskPresetQuery(kind === 'prs' ? 'prs' : 'issues') + if (/\bis:(?:issue|pr)\b/i.test(trimmed)) return trimmed + return `${kind === 'prs' ? 'is:pr' : 'is:issue'} ${trimmed}` +} + +function gitHubStatusLabel(item: GitHubWorkItem): string { + if (item.state === 'merged') return 'Merged' + if (item.state === 'draft') return 'Draft' + return item.state === 'closed' ? 'Closed' : 'Open' +} + +function gitHubTaskSubtitle(item: GitHubWorkItem): string { + return `${item.repoName} ${item.type === 'pr' ? '#' : '#'}${item.number}` +} + +function createGitHubTask(repo: RepoSummary, item: Omit) { + const source: GitHubWorkItem = { ...item, repoId: repo.id, repoName: repo.displayName } + return { + key: `github:${repo.id}:${item.type}:${item.number}`, + provider: 'github' as const, + title: item.title, + subtitle: gitHubTaskSubtitle(source), + status: gitHubStatusLabel(source), + updatedAt: item.updatedAt, + source + } +} + +function gitLabStatusLabel(item: GitLabWorkItem): string { + if (item.state === 'opened') return 'Open' + if (item.state === 'merged') return 'Merged' + if (item.state === 'draft') return 'Draft' + return item.state === 'closed' ? 'Closed' : 'Locked' +} + +function createGitLabTask(repo: RepoSummary, item: Omit) { + const source: GitLabWorkItem = { ...item, repoId: repo.id, repoName: repo.displayName } + return { + key: `gitlab:${repo.id}:${item.type}:${item.number}`, + provider: 'gitlab' as const, + title: item.title, + subtitle: `${repo.displayName} ${item.type === 'mr' ? '!' : '#'}${item.number}`, + status: gitLabStatusLabel(source), + updatedAt: item.updatedAt, + source + } +} + +function gitLabTodoTargetLabel(todo: Pick): string { + if (todo.targetType === 'MergeRequest') return 'Merge request' + if (todo.targetType === 'Issue') return 'Issue' + return 'GitLab todo' +} + +function gitLabTodoTargetRef(todo: Pick): string { + if (!todo.targetIid) return '' + if (todo.targetType === 'MergeRequest') return `!${todo.targetIid}` + if (todo.targetType === 'Issue') return `#${todo.targetIid}` + return String(todo.targetIid) +} + +function createGitLabTodoTask(todo: GitLabTodo): TaskItem { + const targetRef = gitLabTodoTargetRef(todo) + return { + key: `gitlab-todo:${todo.id}`, + provider: 'gitlabTodo', + title: todo.targetTitle || todo.targetUrl, + subtitle: `${todo.projectPath}${targetRef ? ` ${targetRef}` : ''}`, + status: todo.actionName.replace(/_/g, ' ') || 'Todo', + updatedAt: todo.updatedAt, + source: todo + } +} + +async function mapWithConcurrency( + items: T[], + limit: number, + worker: (item: T) => Promise +): Promise { + const results: R[] = [] + let nextIndex = 0 + async function run(): Promise { + while (nextIndex < items.length) { + const index = nextIndex + nextIndex += 1 + results[index] = await worker(items[index]!) + } + } + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => run())) + return results +} + +function isHostedTaskRepo(repo: RepoSummary): boolean { + return repo.kind !== 'folder' +} + +function reconcileRepoSelection( + repos: RepoSummary[], + persisted: string[] | null | undefined +): Set { + if (!persisted || persisted.length === 0) return new Set() + const availableIds = new Set(repos.filter(isHostedTaskRepo).map((repo) => repo.id)) + const selected = persisted.filter((id) => availableIds.has(id)) + return selected.length === 0 ? new Set() : new Set(selected) +} + +function createLinearTask(issue: LinearIssue): TaskItem { + return { + key: `linear:${issue.workspaceId ?? 'workspace'}:${issue.id}`, + provider: 'linear', + title: issue.title, + subtitle: `${issue.identifier} · ${issue.team.name}`, + status: issue.state.name, + updatedAt: issue.updatedAt, + source: issue + } +} + +const LINEAR_PRIORITY_LABELS: Record = { + 0: 'None', + 1: 'Urgent', + 2: 'High', + 3: 'Medium', + 4: 'Low' +} + +function getLinearPriorityLabel(priority: number): string { + return LINEAR_PRIORITY_LABELS[priority] ?? `P${priority}` +} + +function getLinearPriorityRank(priority: number): number { + return priority === 0 ? 5 : priority +} + +function formatGitHubReviewState(state: string | null | undefined): string { + switch (state) { + case 'APPROVED': + return 'Approved' + case 'CHANGES_REQUESTED': + return 'Changes requested' + case 'COMMENTED': + return 'Commented' + case 'DISMISSED': + return 'Dismissed' + case 'PENDING': + return 'Pending' + default: + return 'Reviewed' + } +} + +function getGitHubReviewerRows(item: { + reviewRequests?: GitHubAssignableUser[] + latestReviews?: GitHubPRReviewSummary[] +}): GitHubPRReviewerRow[] { + const byLogin = new Map() + for (const user of item.reviewRequests ?? []) { + const login = user.login.trim() + if (!login) continue + byLogin.set(login.toLowerCase(), { + login, + name: user.name, + avatarUrl: user.avatarUrl, + stateLabel: 'Requested' + }) + } + for (const review of item.latestReviews ?? []) { + const login = review.login.trim() + const key = login.toLowerCase() + if (!login || byLogin.has(key)) continue + byLogin.set(key, { + login, + name: null, + avatarUrl: review.avatarUrl, + stateLabel: formatGitHubReviewState(review.state) + }) + } + return Array.from(byLogin.values()) +} + +function getGitHubReviewSummary(item: { + reviewDecision?: string | null + reviewRequests?: GitHubAssignableUser[] + latestReviews?: GitHubPRReviewSummary[] +}): string { + if (item.reviewDecision === 'APPROVED') return 'Approved' + if (item.reviewDecision === 'CHANGES_REQUESTED') return 'Changes requested' + const rows = getGitHubReviewerRows(item) + if (rows.length === 0) return 'No reviewers' + if (rows.length === 1) return `${rows[0]!.login} - ${rows[0]!.stateLabel}` + return `${rows[0]!.login} +${rows.length - 1}` +} + +function formatGitHubPRDelta(item: GitHubWorkItem): string | null { + const parts: string[] = [] + if (typeof item.additions === 'number') { + parts.push(`+${item.additions}`) + } + if (typeof item.deletions === 'number') { + parts.push(`-${item.deletions}`) + } + if (typeof item.changedFiles === 'number') { + parts.push(`${item.changedFiles} ${item.changedFiles === 1 ? 'file' : 'files'}`) + } + return parts.length > 0 ? parts.join(' ') : null +} + +function hostedBranchSummary(item: TaskItem): { head: string; base: string } | null { + if (item.provider === 'github' && item.source.type === 'pr') { + return { + head: item.source.branchName?.trim() || 'unknown head', + base: item.source.baseRefName?.trim() || 'base' + } + } + if (item.provider === 'gitlab' && item.source.type === 'mr') { + return { + head: item.source.branchName?.trim() || 'unknown head', + base: item.source.baseRefName?.trim() || 'base' + } + } + return null +} + +function getGitHubChecksLabel(item: GitHubWorkItem): string { + const summary = item.checksSummary + if (!summary) { + return 'Checks' + } + if (summary.total === 0) { + return 'No checks' + } + if (summary.failed > 0) { + return `${summary.failed} failing` + } + if (summary.pending > 0) { + return `${summary.pending} pending` + } + return `${summary.passed}/${summary.total} passed` +} + +function getGitHubMergeLabel(item: GitHubWorkItem): string { + if (item.mergeable === undefined && item.mergeStateStatus === undefined) { + return 'Merge' + } + if (item.state === 'merged') { + return 'Merged' + } + if (item.state === 'closed') { + return 'Closed' + } + if (item.mergeable === 'CONFLICTING') { + return 'Conflicts' + } + if (item.mergeStateStatus === 'BEHIND') { + return 'Behind' + } + if (item.mergeStateStatus === 'BLOCKED') { + return 'Blocked' + } + if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') { + return 'Able to merge' + } + return 'Unknown' +} + +function getHostedReviewMergeMethodLabel(method: HostedReviewMergeMethod): string { + if (method === 'squash') return 'Squash and merge' + if (method === 'rebase') return 'Rebase and merge' + return 'Create merge commit' +} + +function hostedReviewMergeTargetLabel(item: HostedReviewItem): string { + return item.provider === 'gitlab' ? 'merge request' : 'PR' +} + +function getHostedMergeConfirmMessage(pending: PendingHostedMerge): string { + const target = hostedReviewMergeTargetLabel(pending.item) + if (pending.method === 'squash') { + return `Squash and merge ${target} #${pending.item.source.number}?` + } + const action = pending.method === 'rebase' ? 'Rebase and merge' : 'Merge' + return `${action} ${target} #${pending.item.source.number}?` +} + +function getProjectGitHubMergeConfirmMessage(pending: PendingProjectGitHubMerge): string { + const number = pending.row.content.number + if (pending.method === 'squash') { + return `Squash and merge PR #${number}?` + } + const action = pending.method === 'rebase' ? 'Rebase and merge' : 'Merge' + return `${action} PR #${number}?` +} + +function hostedStateChangeAction(nextState: PendingHostedStateChange['nextState']): string { + return nextState === 'closed' ? 'Close' : 'Reopen' +} + +function hostedStateChangeTarget(pending: PendingHostedStateChange): { + titleTarget: string + labelTarget: string + number: number | null +} { + if (pending.source === 'project') { + const type = projectRowType(pending.row) + return { + titleTarget: type === 'pr' ? 'Pull Request' : 'Issue', + labelTarget: type === 'pr' ? 'PR' : 'Issue', + number: pending.row.content.number + } + } + if (pending.item.provider === 'gitlab') { + return { + titleTarget: pending.item.source.type === 'mr' ? 'Merge Request' : 'Issue', + labelTarget: pending.item.source.type === 'mr' ? 'MR' : 'Issue', + number: pending.item.source.number + } + } + return { + titleTarget: pending.item.source.type === 'pr' ? 'Pull Request' : 'Issue', + labelTarget: pending.item.source.type === 'pr' ? 'PR' : 'Issue', + number: pending.item.source.number + } +} + +function getHostedStateConfirmTitle(pending: PendingHostedStateChange): string { + const target = hostedStateChangeTarget(pending) + return `${hostedStateChangeAction(pending.nextState)} ${target.titleTarget}` +} + +function getHostedStateConfirmMessage(pending: PendingHostedStateChange): string { + const target = hostedStateChangeTarget(pending) + return `${hostedStateChangeAction(pending.nextState)} ${target.labelTarget} #${target.number}?` +} + +function getHostedStateConfirmLabel(pending: PendingHostedStateChange): string { + const target = hostedStateChangeTarget(pending) + return `${hostedStateChangeAction(pending.nextState)} ${target.labelTarget}` +} + +function getGitHubPRSignalTone( + item: GitHubWorkItem, + signal: 'review' | 'checks' | 'merge' +): 'neutral' | 'success' | 'warning' | 'danger' { + if (signal === 'review') { + if (item.reviewDecision === 'APPROVED') return 'success' + if (item.reviewDecision === 'CHANGES_REQUESTED') return 'danger' + if (item.reviewRequests && item.reviewRequests.length > 0) return 'warning' + return 'neutral' + } + if (signal === 'checks') { + if (item.checksSummary?.state === 'success') return 'success' + if (item.checksSummary?.state === 'failure') return 'danger' + if (item.checksSummary?.state === 'pending') return 'warning' + return 'neutral' + } + if (item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'BLOCKED') return 'danger' + if (item.mergeStateStatus === 'BEHIND' || item.checksSummary?.state === 'pending') { + return 'warning' + } + if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') return 'success' + return 'neutral' +} + +function mergeGitHubAssignableUsers( + users: GitHubAssignableUser[], + seeds: GitHubAssignableUser[] +): GitHubAssignableUser[] { + const byLogin = new Map() + for (const user of [...users, ...seeds]) { + const login = user.login.trim() + if (!login || byLogin.has(login.toLowerCase())) continue + byLogin.set(login.toLowerCase(), { ...user, login }) + } + return [...byLogin.values()] +} + +function getGitHubReviewerSeedUsers(item: { + reviewRequests?: GitHubAssignableUser[] + latestReviews?: GitHubPRReviewSummary[] + author?: string | null +}): GitHubAssignableUser[] { + const byLogin = new Map() + const add = (user: GitHubAssignableUser): void => { + const login = user.login.trim() + if (!login || byLogin.has(login.toLowerCase())) return + byLogin.set(login.toLowerCase(), { ...user, login }) + } + for (const user of item.reviewRequests ?? []) { + add(user) + } + for (const review of item.latestReviews ?? []) { + add({ + login: review.login, + name: null, + avatarUrl: review.avatarUrl ?? null + }) + } + if (item.author) { + add({ login: item.author, name: null, avatarUrl: null }) + } + return [...byLogin.values()] +} + +function sameGitHubOwnerRepo( + a: GitHubOwnerRepo | null | undefined, + b: GitHubOwnerRepo | null | undefined +): boolean { + return ( + !!a && + !!b && + a.owner.toLowerCase() === b.owner.toLowerCase() && + a.repo.toLowerCase() === b.repo.toLowerCase() + ) +} + +function hasGitHubIssueSourceChoice(sources: GitHubRepoSources | undefined): boolean { + return Boolean( + sources?.prs && + sources.upstreamCandidate && + !sameGitHubOwnerRepo(sources.prs, sources.upstreamCandidate) + ) +} + +function issueSourceSlug(source: GitHubOwnerRepo | null | undefined): string { + return source ? `${source.owner}/${source.repo}` : 'Unknown' +} + +function compareLinearIssues(a: LinearIssue, b: LinearIssue, orderBy: LinearOrderBy): number { + if (orderBy === 'updated') { + return taskTime(b.updatedAt) - taskTime(a.updatedAt) + } + if (orderBy === 'identifier') { + return a.identifier.localeCompare(b.identifier, undefined, { numeric: true }) + } + const priorityDelta = getLinearPriorityRank(a.priority) - getLinearPriorityRank(b.priority) + return priorityDelta || taskTime(b.updatedAt) - taskTime(a.updatedAt) +} + +function getLinearIssueGroup( + issue: LinearIssue, + groupBy: LinearGroupBy +): { + key: string + label: string + color: string +} { + if (groupBy === 'status') { + return { key: `status:${issue.state.name}`, label: issue.state.name, color: issue.state.color } + } + if (groupBy === 'assignee') { + return { + key: `assignee:${issue.assignee?.id ?? issue.assignee?.displayName ?? 'unassigned'}`, + label: issue.assignee?.displayName ?? 'Unassigned', + color: colors.accentBlue + } + } + if (groupBy === 'priority') { + return { + key: `priority:${issue.priority}`, + label: getLinearPriorityLabel(issue.priority), + color: issue.priority === 1 ? colors.statusRed : colors.accentBlue + } + } + if (groupBy === 'team') { + return { key: `team:${issue.team.id}`, label: issue.team.name, color: issue.state.color } + } + return { key: 'all', label: 'Issues', color: colors.accentBlue } +} + +function groupLinearIssues( + issues: LinearIssue[], + groupBy: LinearGroupBy, + orderBy: LinearOrderBy +): Array<{ key: string; label: string; color: string; issues: LinearIssue[] }> { + const sorted = [...issues].sort((a, b) => compareLinearIssues(a, b, orderBy)) + if (groupBy === 'none') { + return [{ key: 'all', label: 'Issues', color: colors.accentBlue, issues: sorted }] + } + const sections = new Map< + string, + { key: string; label: string; color: string; issues: LinearIssue[] } + >() + for (const issue of sorted) { + const group = getLinearIssueGroup(issue, groupBy) + const section = sections.get(group.key) + if (section) { + section.issues.push(issue) + } else { + sections.set(group.key, { ...group, issues: [issue] }) + } + } + return [...sections.values()] +} + +function linearIssueSecondaryParts( + issue: LinearIssue, + displayProperties: ReadonlySet +): string[] { + const parts = [issue.identifier] + if (displayProperties.has('priority')) parts.push(getLinearPriorityLabel(issue.priority)) + if (displayProperties.has('assignee') && issue.assignee?.displayName) { + parts.push(issue.assignee.displayName) + } + if (displayProperties.has('team')) parts.push(issue.team.name) + if (displayProperties.has('labels') && issue.labels.length > 0) { + parts.push(issue.labels.slice(0, 2).join(', ')) + } + if (displayProperties.has('updated')) parts.push(formatUpdatedAt(issue.updatedAt)) + return parts +} + +function reconcileTeamSelection( + teams: LinearTeam[], + saved: string[] | null | undefined +): Set { + if (!saved) { + return new Set(teams.map((team) => team.id)) + } + const available = new Set(teams.map((team) => team.id)) + const next = new Set(saved.filter((id) => available.has(id))) + return next.size === 0 ? new Set(teams.map((team) => team.id)) : next +} + +function splitCommaList(value: string): string[] { + return value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) +} + +function splitReviewerList(value: string): string[] { + return value + .split(/[\s,]+/) + .map((entry) => entry.trim()) + .filter(Boolean) +} + +function editableProjectFields(table: GitHubProjectTable | null): GitHubProjectField[] { + return ( + table?.selectedView.fields?.filter((field) => + ['TEXT', 'NUMBER', 'DATE', 'SINGLE_SELECT', 'ITERATION'].includes(field.dataType) + ) ?? [] + ) +} + +function projectFieldValueLabel(row: GitHubProjectRow, field: GitHubProjectField): string { + const value = row.fieldValuesByFieldId?.[field.id] + if (!value) return 'Empty' + if (value.kind === 'single-select') return value.name + if (value.kind === 'iteration') return value.title + if (value.kind === 'text') return value.text || 'Empty' + if (value.kind === 'number') return String(value.number) + if (value.kind === 'date') return value.date + if (value.kind === 'labels') return value.labels.map((label) => label.name).join(', ') || 'Empty' + if (value.kind === 'users') return value.users.map((user) => user.login).join(', ') || 'Empty' + return 'Empty' +} + +function projectFieldDisplayLabel(row: GitHubProjectRow, field: GitHubProjectField): string { + if (field.dataType === 'ASSIGNEES') { + return row.content.assignees.map((user) => user.login).join(', ') || 'Empty' + } + if (field.dataType === 'LABELS') { + return row.content.labels.map((label) => label.name).join(', ') || 'Empty' + } + if (field.dataType === 'REPOSITORY') { + return row.content.repository ?? 'Empty' + } + if (field.dataType === 'PARENT_ISSUE') { + return row.content.parentIssue ? `#${row.content.parentIssue.number}` : 'Empty' + } + if (field.dataType === 'ISSUE_TYPE') { + return row.content.issueType?.name ?? 'Empty' + } + if (field.dataType === 'TITLE') { + return row.content.title + } + return projectFieldValueLabel(row, field) +} + +function projectSummaryFields(table: GitHubProjectTable | null): GitHubProjectField[] { + return ( + table?.selectedView.fields?.filter( + (field) => field.dataType !== 'TITLE' && field.dataType !== 'REPOSITORY' + ) ?? [] + ) +} + +function projectFieldVisibilityKey(table: GitHubProjectTable | null): string | null { + if (!table) return null + // Why: desktop scopes column visibility to project + view; matching that + // avoids hiding fields across unrelated Project views with colliding IDs. + return `${table.project.id}:${table.selectedView.id}` +} + +function projectFieldDraftValue(row: GitHubProjectRow, field: GitHubProjectField): string { + const value = row.fieldValuesByFieldId?.[field.id] + if (!value) return '' + if (value.kind === 'text') return value.text + if (value.kind === 'number') return String(value.number) + if (value.kind === 'date') return value.date + return '' +} + +function normalizeProjectTableForMobileSort( + table: GitHubProjectTable, + rows: GitHubProjectRow[], + sortOverride: ProjectSortOverride | null +): SharedGitHubProjectTable { + const fields = table.selectedView.fields ?? [] + const overrideField = sortOverride + ? fields.find((field) => field.id === sortOverride.fieldId) + : undefined + const normalizedRows = rows.map((row, index) => ({ + ...row, + content: { + ...row.content, + stateReason: row.content.stateReason ?? null, + parentIssue: row.content.parentIssue ?? null, + issueType: row.content.issueType ?? null + }, + fieldValuesByFieldId: row.fieldValuesByFieldId ?? {}, + position: row.position ?? index + })) + + return { + ...table, + selectedView: { + ...table.selectedView, + fields, + groupByFields: table.selectedView.groupByFields ?? [], + sortByFields: + sortOverride && overrideField + ? [{ field: overrideField, direction: sortOverride.direction }] + : (table.selectedView.sortByFields ?? []) + }, + rows: normalizedRows, + parentFieldDropped: table.parentFieldDropped === true + } as unknown as SharedGitHubProjectTable +} + +function projectGroupMeta(group: ProjectGroup): string { + const parts = [`${group.rows.length}`] + if (group.iteration) { + const endDate = new Date(`${group.iteration.startDate}T00:00:00Z`) + if (!Number.isNaN(endDate.getTime())) { + endDate.setUTCDate(endDate.getUTCDate() + group.iteration.duration - 1) + parts.push(`${group.iteration.startDate} - ${endDate.toISOString().slice(0, 10)}`) + } + if (isIterationCurrent(group.iteration)) { + parts.push('Current') + } + } + return parts.join(' · ') +} + +function optimisticProjectFieldValue( + field: GitHubProjectField, + value: GitHubProjectFieldMutationValue +): GitHubProjectFieldValue { + if (value.kind === 'single-select' && field.kind === 'single-select') { + const option = field.options.find((entry) => entry.id === value.optionId) + return { + kind: 'single-select', + fieldId: field.id, + optionId: value.optionId, + name: option?.name ?? 'Selected', + color: option?.color ?? 'GRAY' + } + } + if (value.kind === 'iteration' && field.kind === 'iteration') { + const iteration = field.iterations.find((entry) => entry.id === value.iterationId) + return { + kind: 'iteration', + fieldId: field.id, + iterationId: value.iterationId, + title: iteration?.title ?? 'Iteration', + startDate: iteration?.startDate ?? '', + duration: iteration?.duration ?? 0 + } + } + if (value.kind === 'number') return { kind: 'number', fieldId: field.id, number: value.number } + if (value.kind === 'date') return { kind: 'date', fieldId: field.id, date: value.date } + return { kind: 'text', fieldId: field.id, text: value.kind === 'text' ? value.text : '' } +} + +function taskKindLabel(item: TaskItem): string { + if (item.provider === 'github') return item.source.type === 'pr' ? 'Pull request' : 'Issue' + if (item.provider === 'gitlab') return item.source.type === 'mr' ? 'Merge request' : 'Issue' + if (item.provider === 'gitlabTodo') { + return `${gitLabTodoTargetLabel(item.source)} todo` + } + return 'Linear ticket' +} + +function taskExternalOpenLabel(item: TaskItem): string { + if (item.provider === 'github') return 'Open in GitHub' + if (item.provider === 'gitlab' || item.provider === 'gitlabTodo') return 'Open in GitLab' + return 'Open in Linear' +} + +function taskStatusActionLabel(item: TaskItem): string { + const verb = + item.provider === 'github' || item.provider === 'gitlab' + ? item.source.state === 'closed' + ? 'Reopen' + : 'Close' + : '' + return verb ? `${verb} ${taskKindLabel(item).toLowerCase()}` : '' +} + +function isGitHubPrMergeBlocked(item: Extract): boolean { + return item.source.type === 'pr' && item.source.mergeable === 'CONFLICTING' +} + +function commentAuthor(comment: DetailComment): string { + return comment.author ?? comment.user?.displayName ?? 'unknown' +} + +function commentDate(value: string | undefined): string { + if (!value) return '' + const time = Date.parse(value) + return Number.isFinite(time) ? new Date(time).toLocaleDateString() : '' +} + +function formatDurationSeconds(value: number | null | undefined): string { + if (typeof value !== 'number' || !Number.isFinite(value)) return '' + const seconds = Math.max(0, Math.floor(value)) + if (seconds >= 60) return `${Math.floor(seconds / 60)}m ${seconds % 60}s` + return `${seconds}s` +} + +function commentSourceLabel(comment: DetailComment): string { + if (comment.path) { + const line = + typeof comment.line === 'number' + ? typeof comment.startLine === 'number' && comment.startLine !== comment.line + ? `${comment.startLine}-${comment.line}` + : String(comment.line) + : '' + const location = line ? `${comment.path}:${line}` : comment.path + return `${comment.isResolved ? 'Resolved review' : 'Review'} · ${location}` + } + if (comment.threadId) { + return comment.isResolved ? 'Resolved review thread' : 'Review thread' + } + return 'Top-level comment' +} + +function groupDetailComments(comments: DetailComment[]): DetailCommentGroup[] { + const threads = new Map() + const groups: DetailCommentGroup[] = [] + const emittedThreads = new Set() + + for (const comment of comments) { + if (!comment.threadId) continue + const existing = threads.get(comment.threadId) + if (existing) { + existing.replies.push(comment) + } else { + threads.set(comment.threadId, { root: comment, replies: [] }) + } + } + + for (const comment of comments) { + if (!comment.threadId) { + groups.push({ kind: 'standalone', comment }) + continue + } + if (emittedThreads.has(comment.threadId)) continue + emittedThreads.add(comment.threadId) + const thread = threads.get(comment.threadId) + if (thread) groups.push({ kind: 'thread', threadId: comment.threadId, ...thread }) + } + + return groups +} + +function detailCommentGroupId(group: DetailCommentGroup): string { + return group.kind === 'thread' ? `thread:${group.threadId}` : `comment:${group.comment.id}` +} + +function detailCommentGroupRoot(group: DetailCommentGroup): DetailComment { + return group.kind === 'thread' ? group.root : group.comment +} + +function detailCommentGroupCount(group: DetailCommentGroup): number { + return group.kind === 'thread' ? 1 + group.replies.length : 1 +} + +function isResolvedDetailCommentGroup(group: DetailCommentGroup): boolean { + return detailCommentGroupRoot(group).isResolved === true +} + +function discussionSummary(count: number): string { + if (count === 0) return 'No comments yet' + return `${count} ${count === 1 ? 'comment' : 'comments'}` +} + +function renderCommentReactions(comment: DetailComment): ReactNode { + const reactions = (comment.reactions ?? []).filter((reaction) => reaction.count > 0) + if (reactions.length === 0) return null + return ( + + {reactions.map((reaction) => ( + + + {COMMENT_REACTION_EMOJI[reaction.content]} {reaction.count} + + + ))} + + ) +} + +function formatDiffLineNumber(value: number | undefined): string { + return value === undefined ? ' ' : value.toString().padStart(4, ' ') +} + +function diffLinePrefix(kind: GitHubPrFileDiffLine['kind']): string { + if (kind === 'added') return '+' + if (kind === 'removed') return '-' + return ' ' +} + +function GitHubPrFileDiff({ + filePath, + contents, + commentDrafts, + disabled, + onCommentDraftChange, + onSubmitComment +}: { + filePath: string + contents: GitHubPRFileContents + commentDrafts: Record + disabled: boolean + onCommentDraftChange: (key: string, value: string) => void + onSubmitComment: (line: number) => void +}): ReactNode { + const diffLines = useMemo( + () => buildGitHubPrFileDiffLines(contents.original, contents.modified), + [contents.modified, contents.original] + ) + const visibleDiffLines = diffLines.slice(0, MAX_RENDERED_PR_DIFF_LINES) + const hiddenDiffLineCount = Math.max(0, diffLines.length - visibleDiffLines.length) + + if (diffLines.length === 0) { + return No text changes found. + } + + return ( + + {hiddenDiffLineCount > 0 ? ( + + Showing first {MAX_RENDERED_PR_DIFF_LINES} of {diffLines.length} diff lines. + + ) : null} + {visibleDiffLines.map((line) => { + const commentLine = line.kind === 'removed' ? undefined : line.newLineNumber + const draftKey = commentLine === undefined ? '' : `${filePath}:${commentLine}` + return ( + + + + {formatDiffLineNumber(line.oldLineNumber)}{' '} + {formatDiffLineNumber(line.newLineNumber)} + + + {diffLinePrefix(line.kind)} {line.text || ' '} + + + {commentLine !== undefined ? ( + <> + onCommentDraftChange(draftKey, next)} + placeholder="Add review comment" + placeholderTextColor={colors.textMuted} + multiline + textAlignVertical="top" + /> + onSubmitComment(commentLine)} + > + Comment on line {commentLine} + + + ) : null} + + ) + })} + + ) +} + +function isFailedGitHubCheck(check: { conclusion?: string | null }): boolean { + return ['failure', 'cancelled', 'timed_out'].includes(check.conclusion ?? '') +} + +function repositoryCount(count: number): string { + return `${count} ${count === 1 ? 'repository' : 'repositories'}` +} + +function buildPartialRepositoryNotice(failedCount: number, totalCount: number): string { + return `${failedCount} of ${repositoryCount(totalCount)} failed to load.` +} + +function repoColor(name: string): string { + const palette = ['#f97316', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f59e0b', '#6366f1'] + let hash = 0 + for (let i = 0; i < name.length; i += 1) hash = (hash * 31 + name.charCodeAt(i)) | 0 + return palette[Math.abs(hash) % palette.length]! +} + +function getRepoBadgeColor(repo: RepoSummary | undefined, fallbackName: string): string { + return repo?.badgeColor || repoColor(repo?.displayName ?? fallbackName) +} + +function setupSourceLabel(source: string | null): string { + if (source === 'orca.yaml') return 'orca.yaml' + if (source === 'legacy') return 'local hooks' + return 'repository hooks' +} + +function taskRepositoryMeta( + item: TaskItem, + reposById: Map +): { key: string; label: string; color: string } { + if (item.provider === 'github' || item.provider === 'gitlab') { + const repo = reposById.get(item.source.repoId) + return { + key: item.source.repoId, + label: repo?.displayName ?? item.source.repoName, + color: getRepoBadgeColor(repo, item.source.repoName) + } + } + if (item.provider === 'gitlabTodo') { + return { + key: item.source.projectPath, + label: item.source.projectPath, + color: repoColor(item.source.projectPath) + } + } + return { + key: item.source.team.id, + label: item.source.team.name, + color: item.source.state.color || colors.accentBlue + } +} + +function compareTasksByUpdated(a: TaskItem, b: TaskItem): number { + return taskTime(b.updatedAt) - taskTime(a.updatedAt) +} + +function compareTasksByRepository( + a: TaskItem, + b: TaskItem, + reposById: Map +): number { + const aRepo = taskRepositoryMeta(a, reposById) + const bRepo = taskRepositoryMeta(b, reposById) + const repoComparison = aRepo.label.localeCompare(bRepo.label, undefined, { sensitivity: 'base' }) + return repoComparison || compareTasksByUpdated(a, b) +} + +export default function MobileTasksScreen() { + const { hostId, taskSource } = useLocalSearchParams<{ hostId: string; taskSource?: string }>() + const router = useRouter() + const insets = useSafeAreaInsets() + const { client, state: connState } = useHostClient(hostId) + const reconnectAttempts = useReconnectAttempt(hostId) + const lastConnectedAt = useLastConnectedAt(hostId) + const clientRef = useRef(null) + const reposRef = useRef([]) + const loadGenerationRef = useRef(0) + const taskResumeRef = useRef({}) + const [repos, setRepos] = useState([]) + const [provider, setProvider] = useState('github') + const [visibleProviders, setVisibleProviders] = useState(() => + normalizeVisibleTaskProviders(undefined) + ) + const [linearConnected, setLinearConnected] = useState(false) + const [githubMode, setGithubMode] = useState<'items' | 'project'>('items') + const [githubKind, setGithubKind] = useState('issues') + const [githubPreset, setGithubPreset] = useState('issues') + const [defaultGitHubPreset, setDefaultGitHubPreset] = useState('issues') + const [gitlabView, setGitlabView] = useState('project') + const [gitlabFilter, setGitlabFilter] = useState('opened') + const [linearFilter, setLinearFilter] = useState('all') + const [linearViewMode, setLinearViewMode] = useState('list') + const [linearGroupBy, setLinearGroupBy] = useState('none') + const [linearOrderBy, setLinearOrderBy] = useState('priority') + const [linearDisplayProperties, setLinearDisplayProperties] = useState< + ReadonlySet + >(() => new Set(DEFAULT_LINEAR_DISPLAY_PROPERTIES)) + const [linearTeamPropertyTouched, setLinearTeamPropertyTouched] = useState(false) + const [linearWorkspaces, setLinearWorkspaces] = useState([]) + const [selectedLinearWorkspaceId, setSelectedLinearWorkspaceId] = useState( + null + ) + const [selectedLinearTeamIds, setSelectedLinearTeamIds] = useState>(new Set()) + const defaultRepoSelectionRef = useRef(null) + const repoSelectionHydratedRef = useRef(false) + const defaultLinearTeamSelectionRef = useRef(null) + const [showLinearWorkspacePicker, setShowLinearWorkspacePicker] = useState(false) + const [showLinearTeamPicker, setShowLinearTeamPicker] = useState(false) + const [showLinearViewPicker, setShowLinearViewPicker] = useState(false) + const [showLinearGroupPicker, setShowLinearGroupPicker] = useState(false) + const [showLinearOrderPicker, setShowLinearOrderPicker] = useState(false) + const [showLinearDisplayPicker, setShowLinearDisplayPicker] = useState(false) + const [showLinearConnect, setShowLinearConnect] = useState(false) + const [linearApiKeyDraft, setLinearApiKeyDraft] = useState('') + const [linearConnectState, setLinearConnectState] = useState<'idle' | 'connecting' | 'error'>( + 'idle' + ) + const [linearConnectError, setLinearConnectError] = useState('') + const [taskSort, setTaskSort] = useState('updated') + const [selectedRepoIds, setSelectedRepoIds] = useState>(new Set()) + const [items, setItems] = useState([]) + const [githubPages, setGithubPages] = useState< + Array[]> + >([]) + const [githubCurrentPage, setGithubCurrentPage] = useState(0) + const [githubTotalCount, setGithubTotalCount] = useState(null) + const [githubPaginationLoading, setGithubPaginationLoading] = useState(false) + const [githubLoadingTargetPage, setGithubLoadingTargetPage] = useState(null) + const [githubRepoSources, setGithubRepoSources] = useState>({}) + const [githubSourceErrors, setGithubSourceErrors] = useState([]) + const [githubSourceFallbacks, setGithubSourceFallbacks] = useState( + [] + ) + const [retryingGithubSourceRepoPaths, setRetryingGithubSourceRepoPaths] = useState>( + new Set() + ) + const [githubRepoSlugCache, setGithubRepoSlugCache] = useState< + Record + >({}) + const [query, setQuery] = useState(getTaskPresetQuery('issues')) + const [appliedQuery, setAppliedQuery] = useState(getTaskPresetQuery('issues')) + const [showProviderPicker, setShowProviderPicker] = useState(false) + const [showGitHubKindPicker, setShowGitHubKindPicker] = useState(false) + const [showGitHubPresetPicker, setShowGitHubPresetPicker] = useState(false) + const [showGitLabViewPicker, setShowGitLabViewPicker] = useState(false) + const [showGitLabFilterPicker, setShowGitLabFilterPicker] = useState(false) + const [showLinearFilterPicker, setShowLinearFilterPicker] = useState(false) + const [showSortPicker, setShowSortPicker] = useState(false) + const [showRepoPicker, setShowRepoPicker] = useState(false) + const [showGitHubIssueSourcePicker, setShowGitHubIssueSourcePicker] = useState(false) + const [showGitHubPagePicker, setShowGitHubPagePicker] = useState(false) + const [showCreateTask, setShowCreateTask] = useState(false) + const [showCreateTargetPicker, setShowCreateTargetPicker] = useState(false) + const [createTitle, setCreateTitle] = useState('') + const [createBody, setCreateBody] = useState('') + const [createRepoId, setCreateRepoId] = useState(null) + const [createTeamId, setCreateTeamId] = useState(null) + const [linearTeams, setLinearTeams] = useState([]) + const [creatingTask, setCreatingTask] = useState(false) + const [loading, setLoading] = useState(false) + const [refreshing, setRefreshing] = useState(false) + const [tasksSupportState, setTasksSupportState] = useState({ + kind: 'unknown', + client: null + }) + const [error, setError] = useState('') + const [actionItem, setActionItem] = useState(null) + const [mergeMethodTaskItem, setMergeMethodTaskItem] = useState< + Extract | Extract | null + >(null) + const [mergeMethodProjectRow, setMergeMethodProjectRow] = useState(null) + const [pendingHostedMerge, setPendingHostedMerge] = useState(null) + const [pendingProjectGitHubMerge, setPendingProjectGitHubMerge] = + useState(null) + const [pendingHostedStateChange, setPendingHostedStateChange] = + useState(null) + const [detailPayload, setDetailPayload] = useState(null) + const [detailLoading, setDetailLoading] = useState(false) + const [detailError, setDetailError] = useState('') + const [detailRefreshSeq, setDetailRefreshSeq] = useState(0) + const [itemTitleDraft, setItemTitleDraft] = useState('') + const [itemBodyDraft, setItemBodyDraft] = useState('') + const [itemCommentDraft, setItemCommentDraft] = useState('') + const [itemAddLabelsDraft, setItemAddLabelsDraft] = useState('') + const [itemRemoveLabelsDraft, setItemRemoveLabelsDraft] = useState('') + const [itemAddAssigneesDraft, setItemAddAssigneesDraft] = useState('') + const [itemRemoveAssigneesDraft, setItemRemoveAssigneesDraft] = useState('') + const [itemAvailableLabels, setItemAvailableLabels] = useState([]) + const [itemLabelsLoading, setItemLabelsLoading] = useState(false) + const [itemLabelsError, setItemLabelsError] = useState('') + const [itemAssignableUsers, setItemAssignableUsers] = useState([]) + const [itemAssignableUsersLoading, setItemAssignableUsersLoading] = useState(false) + const [itemAssignableUsersError, setItemAssignableUsersError] = useState('') + const [itemReviewersDraft, setItemReviewersDraft] = useState('') + const [itemReplyDrafts, setItemReplyDrafts] = useState>({}) + const [expandedPrFilePath, setExpandedPrFilePath] = useState(null) + const [prFileContents, setPrFileContents] = useState>({}) + const [prFileLoadingPath, setPrFileLoadingPath] = useState(null) + const [prFileCommentDrafts, setPrFileCommentDrafts] = useState>({}) + const [copiedLinkKey, setCopiedLinkKey] = useState(null) + const [expandedResolvedCommentGroups, setExpandedResolvedCommentGroups] = useState>( + () => new Set() + ) + const detailCommentGroups = useMemo( + () => groupDetailComments(detailPayload?.comments ?? []), + [detailPayload?.comments] + ) + const [workspaceRepoPickerItem, setWorkspaceRepoPickerItem] = useState | null>(null) + const [workspaceCreateDraft, setWorkspaceCreateDraft] = useState( + null + ) + const [workspaceNameDraft, setWorkspaceNameDraft] = useState('') + const [workspaceLastAutoName, setWorkspaceLastAutoName] = useState('') + const [workspaceBranchAutoName, setWorkspaceBranchAutoName] = useState('') + const [workspaceBranchNameOverride, setWorkspaceBranchNameOverride] = useState< + string | undefined + >(undefined) + const [workspaceBaseBranch, setWorkspaceBaseBranch] = useState(null) + const [workspaceBaseBranchQuery, setWorkspaceBaseBranchQuery] = useState('') + const [workspaceBaseBranchResults, setWorkspaceBaseBranchResults] = useState< + BaseRefSearchResult[] + >([]) + const [workspaceBaseBranchLoading, setWorkspaceBaseBranchLoading] = useState(false) + const [workspaceBaseBranchError, setWorkspaceBaseBranchError] = useState('') + const [workspaceSparsePresets, setWorkspaceSparsePresets] = useState([]) + const [workspaceSparsePresetsLoading, setWorkspaceSparsePresetsLoading] = useState(false) + const [workspaceSparsePresetsLoaded, setWorkspaceSparsePresetsLoaded] = useState(false) + const [_workspaceSparsePresetsError, setWorkspaceSparsePresetsError] = useState('') + const [workspaceSparseReloadKey, setWorkspaceSparseReloadKey] = useState(0) + const [workspaceSparsePresetId, setWorkspaceSparsePresetId] = useState(null) + const [workspaceSparseDraft, setWorkspaceSparseDraft] = useState( + null + ) + const [workspaceSparseSaving, setWorkspaceSparseSaving] = useState(false) + const [workspaceAgent, setWorkspaceAgent] = useState(null) + const [workspaceAgentOverridden, setWorkspaceAgentOverridden] = useState(false) + const [workspaceDetectedAgentIds, setWorkspaceDetectedAgentIds] = useState | null>( + null + ) + const [workspaceSshState, setWorkspaceSshState] = useState(null) + const [workspaceSshConnecting, setWorkspaceSshConnecting] = useState(false) + const [showWorkspaceAgentPicker, setShowWorkspaceAgentPicker] = useState(false) + const [showWorkspaceCreateRepoPicker, setShowWorkspaceCreateRepoPicker] = useState(false) + const [showWorkspaceAdvanced, setShowWorkspaceAdvanced] = useState(false) + const [showWorkspaceBaseBranchPicker, setShowWorkspaceBaseBranchPicker] = useState(false) + const [showWorkspaceSparsePicker, setShowWorkspaceSparsePicker] = useState(false) + const [linearStatusPickerItem, setLinearStatusPickerItem] = useState | null>(null) + const [setupPrompt, setSetupPrompt] = useState(null) + const [creatingKey, setCreatingKey] = useState(null) + const [mutatingStatus, setMutatingStatus] = useState(false) + const [linearStates, setLinearStates] = useState([]) + const [linearStatesLoading, setLinearStatesLoading] = useState(false) + const [linearCommentDraft, setLinearCommentDraft] = useState('') + const [linearSubIssueTitle, setLinearSubIssueTitle] = useState('') + const [taskStateHydrated, setTaskStateHydrated] = useState(false) + const [runtimeTaskSettings, setRuntimeTaskSettings] = useState({}) + const [trustedOrcaHooks, setTrustedOrcaHooks] = useState({}) + const [orcaYamlTrustPrompt, setOrcaYamlTrustPrompt] = useState(null) + const [githubProjectSettings, setGithubProjectSettings] = useState( + EMPTY_GITHUB_PROJECT_SETTINGS + ) + const [githubProjects, setGithubProjects] = useState([]) + const [githubProjectViews, setGithubProjectViews] = useState([]) + const [githubProjectTable, setGithubProjectTable] = useState(null) + const [githubProjectLoading, setGithubProjectLoading] = useState(false) + const [githubProjectError, setGithubProjectError] = useState('') + const [githubProjectPartialFailures, setGithubProjectPartialFailures] = useState< + GitHubProjectPartialFailure[] + >([]) + const [githubProjectSearch, setGithubProjectSearch] = useState('') + const [githubProjectPickerSearch, setGithubProjectPickerSearch] = useState('') + const [githubProjectPasteInput, setGithubProjectPasteInput] = useState('') + const [githubProjectPasteError, setGithubProjectPasteError] = useState('') + const [githubProjectPasteBusy, setGithubProjectPasteBusy] = useState(false) + const [appliedGithubProjectSearch, setAppliedGithubProjectSearch] = useState( + undefined + ) + const [githubProjectSortOverride, setGithubProjectSortOverride] = + useState(null) + const [githubProjectHiddenFieldIdsByView, setGithubProjectHiddenFieldIdsByView] = useState< + Record + >({}) + const [collapsedGitHubProjectGroups, setCollapsedGitHubProjectGroups] = useState>( + () => new Set() + ) + const [showGitHubProjectPicker, setShowGitHubProjectPicker] = useState(false) + const [showGitHubProjectViewPicker, setShowGitHubProjectViewPicker] = useState(false) + const [showGitHubProjectSortPicker, setShowGitHubProjectSortPicker] = useState(false) + const [showGitHubProjectFieldsPicker, setShowGitHubProjectFieldsPicker] = useState(false) + const [pendingGitHubProjectViewSelection, setPendingGitHubProjectViewSelection] = + useState(null) + const [projectRowItem, setProjectRowItem] = useState(null) + const [projectRowDetail, setProjectRowDetail] = useState(null) + const [projectRowDetailLoading, setProjectRowDetailLoading] = useState(false) + const [projectRowDetailError, setProjectRowDetailError] = useState('') + const [projectRowDetailRefreshSeq, setProjectRowDetailRefreshSeq] = useState(0) + const [projectTitleDraft, setProjectTitleDraft] = useState('') + const [projectBodyDraft, setProjectBodyDraft] = useState('') + const [projectCommentDraft, setProjectCommentDraft] = useState('') + const [projectEditingCommentId, setProjectEditingCommentId] = useState(null) + const [projectEditingCommentDraft, setProjectEditingCommentDraft] = useState('') + const [projectReviewersDraft, setProjectReviewersDraft] = useState('') + const [projectFieldDrafts, setProjectFieldDrafts] = useState>({}) + const [projectAvailableLabels, setProjectAvailableLabels] = useState([]) + const [projectLabelsLoading, setProjectLabelsLoading] = useState(false) + const [projectLabelsError, setProjectLabelsError] = useState('') + const [projectAssignableUsers, setProjectAssignableUsers] = useState([]) + const [projectAssignableUsersLoading, setProjectAssignableUsersLoading] = useState(false) + const [projectAssignableUsersError, setProjectAssignableUsersError] = useState('') + const [projectIssueTypes, setProjectIssueTypes] = useState([]) + const [projectIssueTypesLoading, setProjectIssueTypesLoading] = useState(false) + const [projectIssueTypesError, setProjectIssueTypesError] = useState('') + const [projectMutating, setProjectMutating] = useState(false) + const [projectRepoNotInOrca, setProjectRepoNotInOrca] = + useState(null) + const requestedTaskSource = useMemo( + () => (isTaskProvider(taskSource) ? taskSource : undefined), + [taskSource] + ) + const linearMetadataItem = actionItem?.provider === 'linear' ? actionItem : linearStatusPickerItem + const tasksSupported = + connState === 'connected' && + client != null && + tasksSupportState.kind === 'supported' && + tasksSupportState.client === client + const tasksUnsupported = + connState === 'connected' && + client != null && + tasksSupportState.kind === 'unsupported' && + tasksSupportState.client === client + const taskUiReady = tasksSupported && taskStateHydrated + const hostedRepos = useMemo(() => repos.filter(isHostedTaskRepo), [repos]) + const workspaceRepos = useMemo(() => repos.filter((repo) => repo.kind !== 'folder'), [repos]) + const reposById = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos]) + const selectedHostedRepos = useMemo( + () => + selectedRepoIds.size === 0 + ? hostedRepos + : hostedRepos.filter((repo) => selectedRepoIds.has(repo.id)), + [hostedRepos, selectedRepoIds] + ) + const findProjectRowRepo = useCallback( + (row: GitHubProjectRow): RepoSummary | null => + findRepoForGitHubProjectRepository( + row.content.repository, + hostedRepos, + githubRepoSlugCache + ) as RepoSummary | null, + [githubRepoSlugCache, hostedRepos] + ) + const githubProjectRepoSlugReady = useMemo( + () => + hostedRepos.every((repo) => { + const cached = githubRepoSlugCache[repo.id] + return cached !== undefined && cached.path === repo.path + }), + [githubRepoSlugCache, hostedRepos] + ) + const visibleGitHubProjectRows = useMemo( + () => + githubProjectTable + ? (filterGitHubProjectRowsForRepos( + githubProjectTable.rows, + hostedRepos, + githubRepoSlugCache + ) as GitHubProjectRow[]) + : [], + [githubProjectTable, githubRepoSlugCache, hostedRepos] + ) + const visibleGitHubProjectGroups = useMemo(() => { + if (!githubProjectTable) return [] + const normalizedTable = normalizeProjectTableForMobileSort( + githubProjectTable, + visibleGitHubProjectRows, + githubProjectSortOverride + ) + const sorted = sortRows(normalizedTable, normalizedTable.rows) + return groupRows(normalizedTable, sorted) + }, [githubProjectSortOverride, githubProjectTable, visibleGitHubProjectRows]) + const githubProjectListEntries = useMemo(() => { + const grouped = githubProjectTable?.selectedView.groupByFields?.[0] != null + if (!grouped) { + return visibleGitHubProjectGroups.flatMap((group) => + group.rows.map((row) => ({ + type: 'row' as const, + row: row as unknown as GitHubProjectRow + })) + ) + } + return visibleGitHubProjectGroups.flatMap((group) => { + const collapsed = collapsedGitHubProjectGroups.has(group.key) + const header: ProjectListEntry = { type: 'group', group, collapsed } + if (collapsed) return [header] + return [ + header, + ...group.rows.map((row) => ({ + type: 'row' as const, + row: row as unknown as GitHubProjectRow + })) + ] + }) + }, [collapsedGitHubProjectGroups, githubProjectTable, visibleGitHubProjectGroups]) + const githubProjectAvailableSummaryFields = useMemo( + () => projectSummaryFields(githubProjectTable), + [githubProjectTable] + ) + const githubProjectFieldVisibilityScope = projectFieldVisibilityKey(githubProjectTable) + const githubProjectHiddenFieldIds = useMemo( + () => + new Set( + githubProjectFieldVisibilityScope + ? (githubProjectHiddenFieldIdsByView[githubProjectFieldVisibilityScope] ?? []) + : [] + ), + [githubProjectFieldVisibilityScope, githubProjectHiddenFieldIdsByView] + ) + const githubProjectSummaryFields = useMemo( + () => + githubProjectAvailableSummaryFields.filter( + (field) => !githubProjectHiddenFieldIds.has(field.id) + ), + [githubProjectAvailableSummaryFields, githubProjectHiddenFieldIds] + ) + + useEffect(() => { + if ( + !client || + connState !== 'connected' || + !tasksSupported || + !taskStateHydrated || + provider !== 'github' || + githubMode !== 'project' + ) { + return + } + const missing = hostedRepos.filter((repo) => { + const cached = githubRepoSlugCache[repo.id] + return !cached || cached.path !== repo.path + }) + if (missing.length === 0) { + return + } + + let cancelled = false + void mapWithConcurrency(missing, GITHUB_REPO_CONCURRENCY, async (repo) => { + try { + const response = await client.sendRequest( + 'github.repoSlug', + { repo: `id:${repo.id}` }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as GitHubOwnerRepo | null + return { + repoId: repo.id, + path: repo.path, + slug: result ? `${result.owner}/${result.repo}` : null + } + } catch { + return { repoId: repo.id, path: repo.path, slug: null } + } + }).then((entries) => { + if (cancelled) { + return + } + setGithubRepoSlugCache((current) => { + const next = { ...current } + for (const entry of entries) { + next[entry.repoId] = { path: entry.path, slug: entry.slug } + } + return next + }) + }) + + return () => { + cancelled = true + } + }, [ + client, + connState, + githubMode, + githubRepoSlugCache, + hostedRepos, + provider, + taskStateHydrated, + tasksSupported + ]) + const activeGitHubProject = githubProjectSettings.activeProject + const activeGitHubProjectKey = activeGitHubProject ? githubProjectKey(activeGitHubProject) : null + const activeGitHubProjectViewId = activeGitHubProjectKey + ? githubProjectSettings.lastViewByProject[activeGitHubProjectKey]?.viewId + : undefined + const activeGitHubProjectView = + githubProjectViews.find((view) => view.id === activeGitHubProjectViewId) ?? + (githubProjectTable + ? { + id: githubProjectTable.selectedView.id, + number: githubProjectTable.selectedView.number, + name: githubProjectTable.selectedView.name, + layout: githubProjectTable.selectedView.layout + } + : null) + const projectIssueTypeRepository = + projectRowItem?.itemType === 'ISSUE' ? projectRowItem.content.repository : null + const projectMetadataRepository = + projectRowItem && projectRowType(projectRowItem) ? projectRowItem.content.repository : null + const projectRowHostedRepo = useMemo( + () => (projectRowItem ? findProjectRowRepo(projectRowItem) : null), + [findProjectRowRepo, projectRowItem] + ) + const itemReviewerCandidates = useMemo(() => { + if (!actionItem || actionItem.provider !== 'github' || actionItem.source.type !== 'pr') { + return [] + } + const reviewerSeedUsers = getGitHubReviewerSeedUsers({ + reviewRequests: + detailPayload?.provider === 'github' + ? detailPayload.reviewRequests + : actionItem.source.reviewRequests, + latestReviews: + detailPayload?.provider === 'github' + ? detailPayload.latestReviews + : actionItem.source.latestReviews, + author: actionItem.source.author + }) + const authorLogin = actionItem.source.author?.trim().toLowerCase() ?? null + return mergeGitHubAssignableUsers(itemAssignableUsers, reviewerSeedUsers).filter( + (user) => user.login.trim().toLowerCase() !== authorLogin + ) + }, [actionItem, detailPayload, itemAssignableUsers]) + const itemSelectedReviewerLogins = useMemo(() => { + if (!actionItem || actionItem.provider !== 'github' || actionItem.source.type !== 'pr') { + return new Set() + } + const reviewRequests = + detailPayload?.provider === 'github' + ? detailPayload.reviewRequests + : (actionItem.source.reviewRequests ?? []) + return new Set( + reviewRequests.map((reviewer) => reviewer.login.trim().toLowerCase()).filter(Boolean) + ) + }, [actionItem, detailPayload]) + const projectReviewerCandidates = useMemo(() => { + if ( + !projectRowItem || + projectRowItem.itemType !== 'PULL_REQUEST' || + projectRowDetail?.provider !== 'github' + ) { + return [] + } + const reviewerSeedUsers = getGitHubReviewerSeedUsers({ + reviewRequests: projectRowDetail.reviewRequests, + latestReviews: projectRowDetail.latestReviews + }) + return mergeGitHubAssignableUsers(projectAssignableUsers, reviewerSeedUsers) + }, [projectAssignableUsers, projectRowDetail, projectRowItem]) + const projectSelectedReviewerLogins = useMemo(() => { + if (projectRowDetail?.provider !== 'github') { + return new Set() + } + return new Set( + projectRowDetail.reviewRequests + .map((reviewer) => reviewer.login.trim().toLowerCase()) + .filter(Boolean) + ) + }, [projectRowDetail]) + const projectMetadataSeedLogins = useMemo(() => { + const logins = new Set() + for (const assignee of projectRowItem?.content.assignees ?? []) { + const login = assignee.login.trim() + if (login) logins.add(login) + } + for (const reviewer of projectRowDetail?.provider === 'github' + ? getGitHubReviewerSeedUsers(projectRowDetail) + : []) { + const login = reviewer.login.trim() + if (login) logins.add(login) + } + return [...logins].sort().join(',') + }, [projectRowDetail, projectRowItem?.content.assignees]) + + useEffect(() => { + clientRef.current = client + }, [client]) + + const persistTaskResumeState = useCallback( + (updates: Partial) => { + if (!client || !taskUiReady) return + const next = { ...taskResumeRef.current, ...updates } + taskResumeRef.current = next + void client.sendRequest('ui.set', { taskResumeState: next }).catch(() => { + // Best-effort: desktop treats task resume as a convenience preference. + }) + }, + [client, taskUiReady] + ) + + const toggleGitHubProjectFieldVisibility = useCallback( + (fieldId: string) => { + if (!githubProjectFieldVisibilityScope) return + setGithubProjectHiddenFieldIdsByView((current) => { + const hidden = new Set(current[githubProjectFieldVisibilityScope] ?? []) + if (hidden.has(fieldId)) { + hidden.delete(fieldId) + } else { + hidden.add(fieldId) + } + const next = { ...current } + if (hidden.size === 0) { + delete next[githubProjectFieldVisibilityScope] + } else { + next[githubProjectFieldVisibilityScope] = [...hidden] + } + persistTaskResumeState({ githubProjectHiddenFieldIdsByView: next }) + return next + }) + }, + [githubProjectFieldVisibilityScope, persistTaskResumeState] + ) + + const persistTaskSource = useCallback( + (nextProvider: TaskProvider) => { + if (!client || !taskUiReady) return + void client.sendRequest('settings.update', { defaultTaskSource: nextProvider }).catch(() => { + // Best-effort: a failed settings write should not block switching views. + }) + }, + [client, taskUiReady] + ) + + const persistRepoSelection = useCallback( + (selection: Set, allRepos: RepoSummary[]) => { + if (!client || !taskUiReady) return + const nextSelection = + selection.size === 0 || selection.size === allRepos.length ? null : [...selection] + defaultRepoSelectionRef.current = nextSelection + void client + .sendRequest('settings.update', { defaultRepoSelection: nextSelection }) + .catch(() => { + // Best-effort: the in-memory repo picker already reflects the change. + }) + }, + [client, taskUiReady] + ) + + const persistDefaultGitHubPreset = useCallback( + (preset: GitHubPreset) => { + setDefaultGitHubPreset(preset) + if (!client || !taskUiReady) return + void client.sendRequest('settings.update', { defaultTaskViewPreset: preset }).catch(() => { + // Best-effort: the current session still uses the selected preset. + }) + }, + [client, taskUiReady] + ) + + const persistGitHubProjectSettings = useCallback( + (nextSettings: GitHubProjectSettings) => { + setGithubProjectSettings(nextSettings) + if (!client || !taskUiReady) return + void client.sendRequest('settings.update', { githubProjects: nextSettings }).catch(() => { + // Best-effort: project selection can still work for the current session. + }) + }, + [client, taskUiReady] + ) + + const persistSetupHookTrust = useCallback( + async (repoId: string, contentHash: string, alwaysTrust: boolean): Promise => { + if (!client) return + const next = trustedOrcaHooksWithSetupApproval({ + trust: trustedOrcaHooks, + repoId, + contentHash, + alwaysTrust + }) + const response = await client.sendRequest('ui.set', { trustedOrcaHooks: next }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + setTrustedOrcaHooks(next) + }, + [client, trustedOrcaHooks] + ) + + const resetWorkspaceCreateState = useCallback((): void => { + setWorkspaceRepoPickerItem(null) + setWorkspaceCreateDraft(null) + setWorkspaceNameDraft('') + setWorkspaceLastAutoName('') + setWorkspaceBranchAutoName('') + setWorkspaceBranchNameOverride(undefined) + setWorkspaceBaseBranch(null) + setWorkspaceBaseBranchQuery('') + setWorkspaceBaseBranchResults([]) + setWorkspaceBaseBranchLoading(false) + setWorkspaceBaseBranchError('') + setWorkspaceSparsePresets([]) + setWorkspaceSparsePresetsLoading(false) + setWorkspaceSparsePresetsLoaded(false) + setWorkspaceSparsePresetsError('') + setWorkspaceSparseReloadKey(0) + setWorkspaceSparsePresetId(null) + setWorkspaceSparseDraft(null) + setWorkspaceSparseSaving(false) + setWorkspaceAgent(null) + setWorkspaceAgentOverridden(false) + setWorkspaceDetectedAgentIds(null) + setWorkspaceSshState(null) + setWorkspaceSshConnecting(false) + setShowWorkspaceAgentPicker(false) + setShowWorkspaceCreateRepoPicker(false) + setShowWorkspaceAdvanced(false) + setShowWorkspaceBaseBranchPicker(false) + setShowWorkspaceSparsePicker(false) + setSetupPrompt(null) + setOrcaYamlTrustPrompt(null) + }, []) + + useEffect(() => { + if (!client || connState !== 'connected') { + taskResumeRef.current = {} + defaultRepoSelectionRef.current = null + repoSelectionHydratedRef.current = false + setRuntimeTaskSettings({}) + setTrustedOrcaHooks({}) + setOrcaYamlTrustPrompt(null) + setGithubProjectHiddenFieldIdsByView({}) + setTaskStateHydrated(false) + setTasksSupportState({ kind: 'unknown', client: null }) + setShowLinearWorkspacePicker(false) + setShowLinearTeamPicker(false) + setShowLinearViewPicker(false) + setShowLinearGroupPicker(false) + setShowLinearOrderPicker(false) + setShowLinearDisplayPicker(false) + setShowLinearConnect(false) + setShowProviderPicker(false) + setShowGitHubKindPicker(false) + setShowGitHubPresetPicker(false) + setShowGitLabViewPicker(false) + setShowGitLabFilterPicker(false) + setShowLinearFilterPicker(false) + setShowSortPicker(false) + setShowRepoPicker(false) + setShowGitHubIssueSourcePicker(false) + setShowGitHubPagePicker(false) + setShowGitHubProjectPicker(false) + setShowGitHubProjectViewPicker(false) + setShowGitHubProjectSortPicker(false) + setShowGitHubProjectFieldsPicker(false) + setPendingGitHubProjectViewSelection(null) + setActionItem(null) + setProjectRowItem(null) + setProjectRepoNotInOrca(null) + setDetailPayload(null) + setProjectRowDetail(null) + setShowCreateTask(false) + setShowCreateTargetPicker(false) + setLinearStatusPickerItem(null) + setPendingHostedMerge(null) + setPendingProjectGitHubMerge(null) + setPendingHostedStateChange(null) + setMergeMethodTaskItem(null) + setMergeMethodProjectRow(null) + resetWorkspaceCreateState() + return + } + + let stale = false + setTaskStateHydrated(false) + setTasksSupportState({ kind: 'unknown', client }) + setShowLinearWorkspacePicker(false) + setShowLinearTeamPicker(false) + setShowLinearViewPicker(false) + setShowLinearGroupPicker(false) + setShowLinearOrderPicker(false) + setShowLinearDisplayPicker(false) + setShowLinearConnect(false) + setShowProviderPicker(false) + setShowGitHubKindPicker(false) + setShowGitHubPresetPicker(false) + setShowGitLabViewPicker(false) + setShowGitLabFilterPicker(false) + setShowLinearFilterPicker(false) + setShowSortPicker(false) + setShowRepoPicker(false) + setShowGitHubIssueSourcePicker(false) + setShowGitHubPagePicker(false) + setShowGitHubProjectPicker(false) + setShowGitHubProjectViewPicker(false) + setShowGitHubProjectSortPicker(false) + setShowGitHubProjectFieldsPicker(false) + setPendingGitHubProjectViewSelection(null) + setActionItem(null) + setProjectRowItem(null) + setProjectRepoNotInOrca(null) + setDetailPayload(null) + setProjectRowDetail(null) + setShowCreateTask(false) + setShowCreateTargetPicker(false) + setLinearStatusPickerItem(null) + setPendingHostedMerge(null) + setPendingProjectGitHubMerge(null) + setPendingHostedStateChange(null) + setMergeMethodTaskItem(null) + setMergeMethodProjectRow(null) + resetWorkspaceCreateState() + + const hydrateTaskState = async (): Promise => { + const statusResponse = await client.sendRequest('status.get') + if (stale) return + if (!isSuccess(statusResponse)) { + throw new Error(statusResponse.error.message) + } + const status = statusResponse.result as TaskRuntimeStatus + if (!status.capabilities?.includes(MOBILE_TASKS_CAPABILITY)) { + // Why: Tasks is additive RPC surface, so old desktop builds can still + // pair but must not receive the newer task-specific method calls. + setTasksSupportState({ kind: 'unsupported', client }) + setItems([]) + setGithubPages([]) + setGithubProjectTable(null) + setShowLinearWorkspacePicker(false) + setShowLinearTeamPicker(false) + setShowLinearViewPicker(false) + setShowLinearGroupPicker(false) + setShowLinearOrderPicker(false) + setShowLinearDisplayPicker(false) + setShowLinearConnect(false) + setShowProviderPicker(false) + setShowGitHubKindPicker(false) + setShowGitHubPresetPicker(false) + setShowGitLabViewPicker(false) + setShowGitLabFilterPicker(false) + setShowLinearFilterPicker(false) + setShowSortPicker(false) + setShowRepoPicker(false) + setShowGitHubIssueSourcePicker(false) + setShowGitHubPagePicker(false) + setShowGitHubProjectPicker(false) + setShowGitHubProjectViewPicker(false) + setShowGitHubProjectSortPicker(false) + setShowGitHubProjectFieldsPicker(false) + setPendingGitHubProjectViewSelection(null) + setActionItem(null) + setProjectRowItem(null) + setProjectRepoNotInOrca(null) + setDetailPayload(null) + setProjectRowDetail(null) + setShowCreateTask(false) + setShowCreateTargetPicker(false) + setLinearStatusPickerItem(null) + setPendingHostedMerge(null) + setPendingProjectGitHubMerge(null) + setPendingHostedStateChange(null) + setMergeMethodTaskItem(null) + setMergeMethodProjectRow(null) + resetWorkspaceCreateState() + setError('Update Orca desktop to use Tasks on mobile.') + setTaskStateHydrated(false) + return + } + setTasksSupportState({ kind: 'supported', client }) + setError('') + const [settingsResponse, uiResponse, preflightResponse, linearStatusResponse] = + await Promise.all([ + client.sendRequest('settings.get'), + client.sendRequest('ui.get'), + client.sendRequest('preflight.check'), + client.sendRequest('linear.status') + ]) + if (stale) return + + const settings = isSuccess(settingsResponse) + ? (((settingsResponse.result as { settings?: RuntimeTaskSettings }).settings ?? + {}) as RuntimeTaskSettings) + : {} + setRuntimeTaskSettings(settings) + const uiState = isSuccess(uiResponse) + ? ( + uiResponse.result as { + ui?: { + taskResumeState?: TaskResumeState + trustedOrcaHooks?: PersistedTrustedOrcaHooks + } + } + ).ui + : null + setTrustedOrcaHooks(uiState?.trustedOrcaHooks ?? {}) + const resume = uiState?.taskResumeState ?? {} + taskResumeRef.current = resume + setGithubProjectHiddenFieldIdsByView(resume.githubProjectHiddenFieldIdsByView ?? {}) + + const preflight = isSuccess(preflightResponse) + ? (preflightResponse.result as { glab?: { installed?: boolean } }) + : null + const linearStatus = isSuccess(linearStatusResponse) + ? (linearStatusResponse.result as LinearStatusResponse) + : null + const preferredProviders = normalizeVisibleTaskProviders(settings.visibleTaskProviders) + const linearIsConnected = linearStatus?.connected === true + const availableProviders = filterAvailableTaskProviders(preferredProviders, { + gitlabInstalled: preflight?.glab?.installed === true, + linearConnected: linearIsConnected + }) + const nextVisibleProviders = + preferredProviders.includes('linear') && !availableProviders.includes('linear') + ? [...availableProviders, 'linear' as const] + : availableProviders + setLinearConnected(linearIsConnected) + if (!linearIsConnected) { + setLinearWorkspaces([]) + setLinearTeams([]) + setSelectedLinearTeamIds(new Set()) + setSelectedLinearWorkspaceId(null) + } + const nextProvider = + requestedTaskSource && nextVisibleProviders.includes(requestedTaskSource) + ? requestedTaskSource + : resolveVisibleTaskProvider( + isTaskProvider(settings.defaultTaskSource) ? settings.defaultTaskSource : undefined, + nextVisibleProviders + ) + const preset = + resume.githubItemsPreset === null + ? normalizeGitHubPreset(settings.defaultTaskViewPreset) + : normalizeGitHubPreset(resume.githubItemsPreset ?? settings.defaultTaskViewPreset) + const defaultPreset = normalizeGitHubPreset(settings.defaultTaskViewPreset) + const githubQuery = + resume.githubItemsPreset === null + ? (resume.githubItemsQuery ?? '') + : getTaskPresetQuery(preset) + const nextLinearFilter = normalizeLinearFilter(resume.linearPreset) + const nextLinearQuery = resume.linearQuery ?? '' + defaultRepoSelectionRef.current = settings.defaultRepoSelection ?? null + defaultLinearTeamSelectionRef.current = settings.defaultLinearTeamSelection ?? null + const nextQuery = + nextProvider === 'github' ? githubQuery : nextProvider === 'linear' ? nextLinearQuery : '' + const nextAppliedQuery = + nextProvider === 'github' + ? scopeGitHubTaskSearch(githubQuery, githubKindFromQuery(githubQuery, preset)) + : nextQuery + + setVisibleProviders(nextVisibleProviders) + setProvider(nextProvider) + setGithubMode(resume.githubMode === 'project' ? 'project' : 'items') + setDefaultGitHubPreset(defaultPreset) + setGithubPreset(preset) + setGithubKind(githubKindFromQuery(githubQuery, preset)) + setLinearFilter(nextLinearFilter) + setGithubProjectSettings(settings.githubProjects ?? EMPTY_GITHUB_PROJECT_SETTINGS) + setQuery(nextQuery) + setAppliedQuery(nextAppliedQuery) + setTaskStateHydrated(true) + } + + void hydrateTaskState().catch((err) => { + if (stale) return + setError(err instanceof Error ? err.message : 'Failed to load Tasks settings') + setTaskStateHydrated(false) + }) + + return () => { + stale = true + } + }, [client, connState, requestedTaskSource, resetWorkspaceCreateState]) + + useEffect(() => { + if (visibleProviders.includes(provider)) return + setProvider(resolveVisibleTaskProvider(provider, visibleProviders)) + }, [provider, visibleProviders]) + + const loadRepos = useCallback(async (): Promise => { + if (!client || connState !== 'connected') return [] + const response = await client.sendRequest('repo.list') + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { repos: RepoSummary[] } + reposRef.current = result.repos + setRepos(result.repos) + if (!repoSelectionHydratedRef.current) { + repoSelectionHydratedRef.current = true + setSelectedRepoIds(reconcileRepoSelection(result.repos, defaultRepoSelectionRef.current)) + } else { + setSelectedRepoIds((current) => { + if (current.size === 0) return current + const availableIds = new Set(result.repos.filter(isHostedTaskRepo).map((repo) => repo.id)) + const next = new Set([...current].filter((id) => availableIds.has(id))) + return next.size === current.size ? current : next + }) + } + return result.repos + }, [client, connState]) + + const loadLinearContext = useCallback(async (): Promise => { + if (!client || connState !== 'connected' || !tasksSupported) return + const statusResponse = await client.sendRequest('linear.status') + if (!isSuccess(statusResponse)) { + throw new Error(statusResponse.error.message) + } + const status = statusResponse.result as LinearStatusResponse + setLinearConnected(status.connected === true) + if (status.connected !== true) { + setLinearWorkspaces([]) + setLinearTeams([]) + setSelectedLinearTeamIds(new Set()) + setSelectedLinearWorkspaceId(null) + return + } + const workspaces = status.workspaces ?? [] + const workspaceId = + status.selectedWorkspaceId ?? status.activeWorkspaceId ?? workspaces[0]?.id ?? null + setLinearWorkspaces(workspaces) + setSelectedLinearWorkspaceId(workspaceId) + + const teamsResponse = await client.sendRequest('linear.listTeams', { + workspaceId: workspaceId ?? undefined + }) + if (!isSuccess(teamsResponse)) { + throw new Error(teamsResponse.error.message) + } + const teams = teamsResponse.result as LinearTeam[] + setLinearTeams(teams) + setSelectedLinearTeamIds(reconcileTeamSelection(teams, defaultLinearTeamSelectionRef.current)) + }, [client, connState, tasksSupported]) + + const persistLinearTeamSelection = useCallback( + (teamIds: Set, allTeams: LinearTeam[]) => { + if (!client || !taskUiReady) return + const selection = teamIds.size === allTeams.length ? null : [...teamIds] + defaultLinearTeamSelectionRef.current = selection + void client + .sendRequest('settings.update', { defaultLinearTeamSelection: selection }) + .catch(() => { + // Best-effort preference persistence; the local picker state already changed. + }) + }, + [client, taskUiReady] + ) + + const fetchGitHubItemsPage = useCallback( + async ( + requestClient: RpcClient, + queriedRepos: RepoSummary[], + before?: string + ): Promise<{ + items: Array> + failedCount: number + sourcesByRepoId: Record + sourceErrors: GitHubIssueSourceError[] + sourceFallbacks: GitHubIssueSourceFallback[] + }> => { + const results = await mapWithConcurrency( + queriedRepos, + GITHUB_REPO_CONCURRENCY, + async (repo) => { + try { + const response = await requestClient.sendRequest('github.listWorkItems', { + repo: `id:${repo.id}`, + limit: PER_REPO_FETCH_LIMIT, + query: scopeGitHubTaskSearch(appliedQuery, githubKind), + before + }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const envelope = response.result as { + items: Array> + sources?: GitHubRepoSources + errors?: { issues?: { message: string } } + issueSourceFellBack?: true + } + return { + items: envelope.items.map((item) => createGitHubTask(repo, item)), + sources: envelope.sources, + sourceError: extractGitHubIssueSourceError(repo, envelope), + sourceFallback: extractGitHubIssueSourceFallback(repo, envelope), + repoId: repo.id + } + } catch (err) { + const isExpectedSshSkip = isGitHubWorkItemsSshRemoteRequiredError(err) + const logWorkItemFetchFailure = isExpectedSshSkip ? console.log : console.warn + logWorkItemFetchFailure( + '[mobile tasks] failed to fetch github work items', + repo.id, + isExpectedSshSkip && err instanceof Error ? err.message : err + ) + return { + items: [] as Array>, + repoId: repo.id, + error: err instanceof Error ? err.message : 'Failed to load GitHub tasks' + } + } + } + ) + + const sourcesByRepoId: Record = {} + const sourceErrors: GitHubIssueSourceError[] = [] + const sourceFallbacks: GitHubIssueSourceFallback[] = [] + for (const result of results) { + if (result.sources) { + sourcesByRepoId[result.repoId] = result.sources + } + if (result.sourceError) { + sourceErrors.push(result.sourceError) + } + if (result.sourceFallback) { + sourceFallbacks.push(result.sourceFallback) + } + } + + return { + items: results + .flatMap((result) => result.items) + .sort((a, b) => taskTime(b.updatedAt) - taskTime(a.updatedAt)) + .slice(0, CROSS_REPO_DISPLAY_LIMIT), + failedCount: results.filter((result) => result.error).length, + sourcesByRepoId, + sourceErrors, + sourceFallbacks + } + }, + [appliedQuery, githubKind] + ) + + const countGitHubItems = useCallback( + async (requestClient: RpcClient, queriedRepos: RepoSummary[]): Promise => { + const counts = await mapWithConcurrency( + queriedRepos, + GITHUB_REPO_CONCURRENCY, + async (repo) => { + try { + const response = await requestClient.sendRequest( + 'github.countWorkItems', + { + repo: `id:${repo.id}`, + query: scopeGitHubTaskSearch(appliedQuery, githubKind) + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + return typeof response.result === 'number' ? response.result : 0 + } catch (err) { + const isExpectedSshSkip = isGitHubWorkItemsSshRemoteRequiredError(err) + const logWorkItemCountFailure = isExpectedSshSkip ? console.log : console.warn + logWorkItemCountFailure( + '[mobile tasks] failed to count github work items', + repo.id, + isExpectedSshSkip && err instanceof Error ? err.message : err + ) + return 0 + } + } + ) + return counts.reduce((sum, count) => sum + count, 0) + }, + [appliedQuery, githubKind] + ) + + const loadTasks = useCallback( + async (options: { silent?: boolean } = {}): Promise => { + if (!client || connState !== 'connected' || !tasksSupported || !taskStateHydrated) return + const generation = loadGenerationRef.current + 1 + loadGenerationRef.current = generation + const requestClient = client + const isCurrent = () => + loadGenerationRef.current === generation && clientRef.current === requestClient + setError('') + if (options.silent) setRefreshing(true) + else setLoading(true) + try { + if (provider !== 'github' || githubMode !== 'items') { + setGithubPages([]) + setGithubCurrentPage(0) + setGithubTotalCount(null) + setGithubSourceErrors([]) + setGithubSourceFallbacks([]) + } + if (provider === 'github' && githubMode === 'project') { + setItems([]) + return + } + if (provider === 'linear' && !linearConnected) { + setItems([]) + return + } + const currentRepos = reposRef.current.length > 0 ? reposRef.current : await loadRepos() + if (!isCurrent()) return + if (provider === 'github' || provider === 'gitlab') { + const supportedRepos = currentRepos.filter(isHostedTaskRepo) + const queriedRepos = + selectedRepoIds.size === 0 + ? supportedRepos + : supportedRepos.filter((repo) => selectedRepoIds.has(repo.id)) + if (queriedRepos.length === 0) { + if (!isCurrent()) return + setItems([]) + setGithubPages([]) + setGithubCurrentPage(0) + setGithubTotalCount(null) + setGithubSourceErrors([]) + setGithubSourceFallbacks([]) + return + } + if (provider === 'github') { + const page = await fetchGitHubItemsPage(requestClient, queriedRepos) + if (!isCurrent()) return + setGithubRepoSources((current) => ({ ...current, ...page.sourcesByRepoId })) + setGithubSourceErrors(page.sourceErrors) + setGithubSourceFallbacks(page.sourceFallbacks) + if (page.failedCount === queriedRepos.length) { + throw new Error('Failed to load GitHub tasks') + } + setGithubPages([page.items]) + setGithubCurrentPage(0) + setItems(page.items) + if (selectedRepoIds.size > 0) { + void countGitHubItems(requestClient, queriedRepos).then((count) => { + if (isCurrent()) { + setGithubTotalCount(count) + } + }) + } else { + // Why: the default all-repos view must not spend GitHub Search + // quota on totals before the user narrows the repository scope. + setGithubTotalCount(null) + } + if (page.failedCount > 0) { + setError(buildPartialRepositoryNotice(page.failedCount, queriedRepos.length)) + } else { + setError('') + } + return + } + if (provider === 'gitlab' && gitlabView === 'todos') { + setGithubPages([]) + setGithubCurrentPage(0) + setGithubTotalCount(null) + setGithubSourceErrors([]) + setGithubSourceFallbacks([]) + const response = await requestClient.sendRequest('gitlab.todos', { + repo: `id:${queriedRepos[0]!.id}` + }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + if (!isCurrent()) return + setItems( + ((response.result as GitLabTodo[]) ?? []) + .map(createGitLabTodoTask) + .sort((a, b) => taskTime(b.updatedAt) - taskTime(a.updatedAt)) + ) + return + } + setGithubPages([]) + setGithubCurrentPage(0) + setGithubTotalCount(null) + setGithubSourceErrors([]) + setGithubSourceFallbacks([]) + const results = await mapWithConcurrency( + queriedRepos, + GITHUB_REPO_CONCURRENCY, + async (repo) => { + try { + const response = await requestClient.sendRequest('gitlab.listWorkItems', { + repo: `id:${repo.id}`, + state: gitlabFilter, + page: 1, + perPage: GITLAB_PER_PAGE, + query: appliedQuery.trim() || undefined + }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const envelope = response.result as { + items: Array> + error?: { type?: string; message: string } + } + if (envelope.error?.type && envelope.error.type !== 'not_found') { + return { items: [], error: envelope.error.message } + } + return { items: envelope.items.map((item) => createGitLabTask(repo, item)) } + } catch (err) { + console.warn(`[mobile tasks] failed to fetch ${provider} work items`, repo.id, err) + return { + items: [] as TaskItem[], + error: err instanceof Error ? err.message : 'Failed to load GitLab tasks' + } + } + } + ) + if (!isCurrent()) return + const failedCount = results.filter((result) => result.error).length + if (failedCount === queriedRepos.length) { + throw new Error( + results.find((result) => result.error)?.error ?? 'Failed to load GitLab tasks' + ) + } + setItems( + results + .flatMap((result) => result.items) + .sort((a, b) => taskTime(b.updatedAt) - taskTime(a.updatedAt)) + ) + if (failedCount > 0) { + setError(buildPartialRepositoryNotice(failedCount, queriedRepos.length)) + } else { + setError('') + } + } else { + const normalizedQuery = appliedQuery.trim() + const response = normalizedQuery + ? await requestClient.sendRequest('linear.searchIssues', { + query: normalizedQuery, + limit: LINEAR_LIMIT, + workspaceId: selectedLinearWorkspaceId ?? undefined + }) + : await requestClient.sendRequest('linear.listIssues', { + filter: linearFilter, + limit: LINEAR_LIMIT, + workspaceId: selectedLinearWorkspaceId ?? undefined + }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const issues = response.result as LinearIssue[] + const filtered = + selectedLinearTeamIds.size > 0 + ? issues.filter((issue) => selectedLinearTeamIds.has(issue.team.id)) + : issues + const sorted = [...filtered].sort((a, b) => compareLinearIssues(a, b, linearOrderBy)) + if (!isCurrent()) return + setItems(sorted.map(createLinearTask)) + } + } catch (err) { + if (!isCurrent()) return + setItems([]) + setGithubSourceErrors([]) + setGithubSourceFallbacks([]) + setError(err instanceof Error ? err.message : 'Failed to load tasks') + } finally { + if (isCurrent()) { + setLoading(false) + setRefreshing(false) + } + } + }, + [ + appliedQuery, + client, + connState, + countGitHubItems, + fetchGitHubItemsPage, + gitlabFilter, + gitlabView, + githubMode, + linearConnected, + linearFilter, + linearOrderBy, + loadRepos, + provider, + selectedLinearTeamIds, + selectedLinearWorkspaceId, + selectedRepoIds, + taskStateHydrated, + tasksSupported + ] + ) + + const connectLinearAccount = useCallback(async (): Promise => { + if (!client || connState !== 'connected' || !taskUiReady) return + const apiKey = linearApiKeyDraft.trim() + if (!apiKey || linearConnectState === 'connecting') return + setLinearConnectState('connecting') + setLinearConnectError('') + try { + const response = await client.sendRequest('linear.connect', { apiKey }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: string } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to connect Linear') + } + setLinearApiKeyDraft('') + setLinearConnectState('idle') + setShowLinearConnect(false) + setLinearConnected(true) + setVisibleProviders((current) => + current.includes('linear') ? current : [...current, 'linear'] + ) + setProvider('linear') + await loadLinearContext() + } catch (err) { + setLinearConnectState('error') + setLinearConnectError(err instanceof Error ? err.message : 'Connection failed') + } + }, [client, connState, linearApiKeyDraft, linearConnectState, loadLinearContext, taskUiReady]) + + const retryGitHubIssueSourceFetch = useCallback( + async (repoPath: string): Promise => { + setRetryingGithubSourceRepoPaths((current) => { + const next = new Set(current) + next.add(repoPath) + return next + }) + try { + // Why: desktop retries through the shared list refresh path so source + // errors, rows, counts, and pagination reset from one authoritative fetch. + await loadTasks({ silent: true }) + } finally { + setRetryingGithubSourceRepoPaths((current) => { + const next = new Set(current) + next.delete(repoPath) + return next + }) + } + }, + [loadTasks] + ) + + const githubTotalPages = useMemo(() => { + const selectedRepoCount = Math.max(1, selectedHostedRepos.length) + const pageCapacity = Math.max( + 1, + Math.min(CROSS_REPO_DISPLAY_LIMIT, selectedRepoCount * PER_REPO_FETCH_LIMIT) + ) + if (githubTotalCount !== null) { + return Math.max(githubPages.length, Math.ceil(githubTotalCount / pageCapacity)) + } + return githubPages.length + }, [githubPages.length, githubTotalCount, selectedHostedRepos.length]) + const githubPageCapacity = useMemo(() => { + const selectedRepoCount = Math.max(1, selectedHostedRepos.length) + return Math.max(1, Math.min(CROSS_REPO_DISPLAY_LIMIT, selectedRepoCount * PER_REPO_FETCH_LIMIT)) + }, [selectedHostedRepos.length]) + const githubCanLoadUncountedNextPage = + githubTotalCount === null && (githubPages.at(-1)?.length ?? 0) >= githubPageCapacity + const githubCanShowPagination = + githubTotalPages > 1 || (githubPages.length > 0 && githubCanLoadUncountedNextPage) + const githubPagePickerPages = useMemo(() => { + const visible = new Set() + const availablePages = Math.min( + githubTotalPages + (githubCanLoadUncountedNextPage ? 1 : 0), + githubPages.length + (githubCanLoadUncountedNextPage ? 1 : 0) + ) + for (let index = 0; index < availablePages; index += 1) { + visible.add(index) + } + for ( + let index = Math.max(0, githubCurrentPage - 2); + index <= Math.min(availablePages - 1, githubCurrentPage + 2); + index += 1 + ) { + visible.add(index) + } + return [...visible].sort((a, b) => a - b) + }, [githubCanLoadUncountedNextPage, githubCurrentPage, githubTotalPages]) + + const handleGitHubPageChange = useCallback( + async (targetPage: number): Promise => { + if ( + !client || + !tasksSupported || + targetPage < 0 || + githubPaginationLoading || + selectedHostedRepos.length === 0 + ) { + return + } + if (targetPage > githubPages.length) { + return + } + if (targetPage < githubPages.length) { + setGithubCurrentPage(targetPage) + setItems(githubPages[targetPage] ?? []) + return + } + const lastPage = githubPages[githubPages.length - 1] + const oldestItem = lastPage?.[lastPage.length - 1] + if (!oldestItem?.updatedAt) { + return + } + + setGithubPaginationLoading(true) + setGithubLoadingTargetPage(targetPage) + try { + let cursor = oldestItem.updatedAt + let loadedPages = githubPages.length + const nextPages: Array[]> = [] + while (loadedPages <= targetPage) { + const page = await fetchGitHubItemsPage(client, selectedHostedRepos, cursor) + if (page.items.length === 0) { + break + } + if (page.failedCount > 0) { + setError(buildPartialRepositoryNotice(page.failedCount, selectedHostedRepos.length)) + } + nextPages.push(page.items) + cursor = page.items[page.items.length - 1]!.updatedAt + loadedPages += 1 + } + if (nextPages.length === 0) { + return + } + const allPages = [...githubPages, ...nextPages] + const nextPage = targetPage < loadedPages ? targetPage : loadedPages - 1 + setGithubPages(allPages) + setGithubCurrentPage(nextPage) + setItems(allPages[nextPage] ?? []) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load more GitHub tasks') + } finally { + setGithubPaginationLoading(false) + setGithubLoadingTargetPage(null) + } + }, + [ + client, + fetchGitHubItemsPage, + githubPages, + githubPaginationLoading, + selectedHostedRepos, + tasksSupported + ] + ) + + const loadGitHubProjects = useCallback(async (): Promise => { + if (!client || connState !== 'connected' || !tasksSupported) return + setGithubProjectError('') + setGithubProjectPartialFailures([]) + const response = await client.sendRequest('github.project.listAccessible', {}) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as + | { + ok: true + projects: GitHubProjectSummary[] + partialFailures?: GitHubProjectPartialFailure[] + } + | { ok: false; error: { message: string } } + if (!result.ok) { + throw new Error(result.error.message) + } + setGithubProjects(result.projects) + setGithubProjectPartialFailures(result.partialFailures ?? []) + }, [client, connState, tasksSupported]) + + const loadGitHubProjectViews = useCallback( + async (project: GitHubProjectRef): Promise => { + if (!client || connState !== 'connected' || !tasksSupported || !taskStateHydrated) return [] + const response = await client.sendRequest('github.project.listViews', { + owner: project.owner, + ownerType: project.ownerType, + projectNumber: project.number + }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as + | { ok: true; views: GitHubProjectViewSummary[] } + | { ok: false; error: { message: string } } + if (!result.ok) { + throw new Error(result.error.message) + } + setGithubProjectViews(result.views) + return result.views + }, + [client, connState, taskStateHydrated, tasksSupported] + ) + + const loadGitHubProjectTable = useCallback( + async (options: { force?: boolean; queryOverride?: string } = {}): Promise => { + if ( + !client || + connState !== 'connected' || + !tasksSupported || + !activeGitHubProject || + !activeGitHubProjectViewId + ) { + setGithubProjectTable(null) + return + } + setGithubProjectLoading(true) + setGithubProjectError('') + try { + const response = await client.sendRequest( + 'github.project.viewTable', + { + owner: activeGitHubProject.owner, + ownerType: activeGitHubProject.ownerType, + projectNumber: activeGitHubProject.number, + viewId: activeGitHubProjectViewId, + ...(options.queryOverride !== undefined ? { queryOverride: options.queryOverride } : {}) + }, + { timeoutMs: 60_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as + | { ok: true; data: GitHubProjectTable } + | { ok: false; error: { message: string }; totalCount?: number } + if (!result.ok) { + throw new Error(result.error.message) + } + setGithubProjectTable(result.data) + setGithubProjectSearch(options.queryOverride ?? result.data.selectedView.filter ?? '') + setGithubProjectViews((current) => + current.some((view) => view.id === result.data.selectedView.id) + ? current + : [ + ...current, + { + id: result.data.selectedView.id, + number: result.data.selectedView.number, + name: result.data.selectedView.name, + layout: result.data.selectedView.layout + } + ] + ) + } catch (err) { + setGithubProjectTable(null) + setGithubProjectError(err instanceof Error ? err.message : 'Failed to load project view') + } finally { + setGithubProjectLoading(false) + } + }, + [activeGitHubProject, activeGitHubProjectViewId, client, connState, tasksSupported] + ) + + const commitGitHubProjectView = useCallback( + (project: GitHubProjectRef, viewId: string): void => { + const projectKey = githubProjectKey(project) + const nextSettings: GitHubProjectSettings = { + ...githubProjectSettings, + recent: [ + { ...project, lastOpenedAt: new Date().toISOString() }, + ...githubProjectSettings.recent.filter((entry) => githubProjectKey(entry) !== projectKey) + ].slice(0, 10), + lastViewByProject: { + ...githubProjectSettings.lastViewByProject, + [projectKey]: { viewId } + }, + activeProject: project + } + persistGitHubProjectSettings(nextSettings) + setAppliedGithubProjectSearch(undefined) + setGithubProjectSearch('') + setGithubProjectTable(null) + }, + [githubProjectSettings, persistGitHubProjectSettings] + ) + + const selectGitHubProject = useCallback( + async (project: GitHubProjectRef, options: { viewNumber?: number } = {}): Promise => { + if (!tasksSupported || !taskStateHydrated) return + setGithubProjectLoading(true) + setGithubProjectError('') + try { + const views = await loadGitHubProjectViews(project) + const projectKey = githubProjectKey(project) + const rememberedView = githubProjectSettings.lastViewByProject[projectKey]?.viewId + const explicitView = + typeof options.viewNumber === 'number' + ? views.find((view) => view.number === options.viewNumber) + : undefined + if (options.viewNumber !== undefined && !explicitView) { + // Why: desktop treats stale /views/{n} URLs as a prompt to choose a + // replacement view, not as a failed project selection. + const supportedViews = views.filter((view) => view.layout === 'TABLE_LAYOUT') + if (supportedViews.length === 0) { + throw new Error('This project has no supported views.') + } + setPendingGitHubProjectViewSelection(project) + setShowGitHubProjectViewPicker(true) + return + } + if (explicitView && explicitView.layout !== 'TABLE_LAYOUT') { + throw new Error("Orca doesn't support this GitHub Project layout yet.") + } + if (!explicitView && !rememberedView) { + // Why: desktop asks which Project view to open the first time a project + // is selected. Mobile should not silently choose the first table view. + const supportedViews = views.filter((view) => view.layout === 'TABLE_LAYOUT') + if (supportedViews.length === 0) { + throw new Error('This project has no supported views.') + } + setPendingGitHubProjectViewSelection(project) + setShowGitHubProjectViewPicker(true) + return + } + const selectedView = + explicitView ?? + views.find((view) => view.id === rememberedView && view.layout === 'TABLE_LAYOUT') ?? + undefined + if (!selectedView) { + throw new Error('This project has no supported views.') + } + commitGitHubProjectView(project, selectedView.id) + } catch (err) { + setGithubProjectError(err instanceof Error ? err.message : 'Failed to select project') + } finally { + setGithubProjectLoading(false) + } + }, + [ + commitGitHubProjectView, + githubProjectSettings, + loadGitHubProjectViews, + taskStateHydrated, + tasksSupported + ] + ) + + const resolveGitHubProjectFromInput = useCallback(async (): Promise => { + if (!client || connState !== 'connected' || !tasksSupported || !taskStateHydrated) return + const input = githubProjectPasteInput.trim() + if (!parseProjectInput(input)) { + setGithubProjectPasteError('Expected a project URL or owner/number.') + return + } + setGithubProjectPasteBusy(true) + setGithubProjectPasteError('') + setGithubProjectError('') + try { + const response = await client.sendRequest('github.project.resolveRef', { input }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as + | { + ok: true + owner: string + ownerType: GitHubProjectOwnerType + number: number + title: string + viewNumber?: number + } + | { ok: false; error: { message: string } } + if (!result.ok) { + setGithubProjectPasteError(result.error.message) + return + } + setGithubProjectPasteInput('') + setShowGitHubProjectPicker(false) + await selectGitHubProject( + { + owner: result.owner, + ownerType: result.ownerType, + number: result.number + }, + { viewNumber: result.viewNumber } + ) + } catch (err) { + setGithubProjectPasteError(err instanceof Error ? err.message : 'Failed to add project.') + } finally { + setGithubProjectPasteBusy(false) + } + }, [ + client, + connState, + githubProjectPasteInput, + selectGitHubProject, + taskStateHydrated, + tasksSupported + ]) + + useEffect(() => { + if (!taskStateHydrated) return + const timer = setTimeout(() => { + setAppliedQuery( + provider === 'github' ? scopeGitHubTaskSearch(query, githubKind) : query.trim() + ) + }, 300) + return () => clearTimeout(timer) + }, [githubKind, provider, query, taskStateHydrated]) + + useEffect(() => { + if (!taskUiReady || provider !== 'github' || githubMode !== 'items') return + const trimmed = appliedQuery.trim() + persistTaskResumeState({ + githubMode: 'items', + githubItemsPreset: trimmed === getTaskPresetQuery(githubPreset) ? githubPreset : null, + githubItemsQuery: trimmed + }) + }, [appliedQuery, githubMode, githubPreset, persistTaskResumeState, provider, taskUiReady]) + + useEffect(() => { + if (!taskUiReady || provider !== 'linear') return + persistTaskResumeState({ + linearPreset: linearFilter, + linearQuery: appliedQuery.trim() + }) + }, [appliedQuery, linearFilter, persistTaskResumeState, provider, taskUiReady]) + + useEffect(() => { + if (connState !== 'connected' || !taskStateHydrated) return + void loadTasks() + }, [connState, loadTasks, taskStateHydrated]) + + useEffect(() => { + if (!taskStateHydrated || provider !== 'linear' || !linearConnected) return + void loadLinearContext().catch((err) => { + setError(err instanceof Error ? err.message : 'Failed to load Linear context') + }) + }, [linearConnected, loadLinearContext, provider, taskStateHydrated]) + + useEffect(() => { + if (!taskUiReady || provider !== 'github' || githubMode !== 'project') return + persistTaskResumeState({ githubMode: 'project' }) + if (activeGitHubProject && activeGitHubProjectViewId) { + void loadGitHubProjectTable({ queryOverride: appliedGithubProjectSearch }) + } else if (activeGitHubProject) { + void selectGitHubProject(activeGitHubProject) + } else { + void loadGitHubProjects().catch((err) => { + setGithubProjectError(err instanceof Error ? err.message : 'Failed to load projects') + }) + } + }, [ + activeGitHubProject, + activeGitHubProjectViewId, + appliedGithubProjectSearch, + githubMode, + loadGitHubProjectTable, + loadGitHubProjects, + persistTaskResumeState, + provider, + selectGitHubProject, + taskUiReady + ]) + + useEffect(() => { + if (!taskUiReady || !showGitHubProjectPicker) return + void loadGitHubProjects().catch((err) => { + setGithubProjectError(err instanceof Error ? err.message : 'Failed to load projects') + }) + }, [loadGitHubProjects, showGitHubProjectPicker, taskUiReady]) + + useEffect(() => { + if (!tasksSupported || !taskStateHydrated || !showCreateTask) return + setCreatingTask(false) + if (provider === 'github' || provider === 'gitlab') { + setCreateRepoId((current) => + current && hostedRepos.some((repo) => repo.id === current) + ? current + : (hostedRepos[0]?.id ?? null) + ) + return + } + if (!client) return + let stale = false + setCreateTeamId(null) + void client + .sendRequest('linear.listTeams') + .then((response) => { + if (stale) return + if (isSuccess(response)) { + const teams = response.result as LinearTeam[] + setLinearTeams(teams) + setCreateTeamId((current) => current ?? teams[0]?.id ?? null) + } else { + setLinearTeams([]) + setCreateTeamId(null) + } + }) + .catch(() => { + if (!stale) { + setLinearTeams([]) + setCreateTeamId(null) + } + }) + return () => { + stale = true + } + }, [client, hostedRepos, provider, showCreateTask, taskStateHydrated, tasksSupported]) + + useEffect(() => { + if (!tasksSupported || !linearMetadataItem || !client) { + setLinearStates([]) + setLinearCommentDraft('') + setLinearSubIssueTitle('') + return + } + let stale = false + setLinearStatesLoading(true) + setLinearCommentDraft('') + setLinearSubIssueTitle('') + const baseParams = { + teamId: linearMetadataItem.source.team.id, + workspaceId: linearMetadataItem.source.workspaceId + } + void client + .sendRequest('linear.teamStates', baseParams) + .then((statesResponse) => { + if (stale) return + if (isSuccess(statesResponse)) { + setLinearStates(statesResponse.result as LinearState[]) + } else { + setLinearStates([]) + } + }) + .catch(() => { + if (!stale) { + setLinearStates([]) + } + }) + .finally(() => { + if (!stale) setLinearStatesLoading(false) + }) + return () => { + stale = true + } + }, [client, linearMetadataItem, tasksSupported]) + + useEffect(() => { + if (!actionItem) { + setItemTitleDraft('') + setItemBodyDraft('') + setItemCommentDraft('') + setItemAddLabelsDraft('') + setItemRemoveLabelsDraft('') + setItemAddAssigneesDraft('') + setItemRemoveAssigneesDraft('') + setItemReviewersDraft('') + setItemReplyDrafts({}) + setExpandedPrFilePath(null) + setPrFileContents({}) + setPrFileLoadingPath(null) + setPrFileCommentDrafts({}) + setExpandedResolvedCommentGroups(new Set()) + return + } + setItemTitleDraft(actionItem.title) + setItemBodyDraft('') + setItemCommentDraft('') + setItemAddLabelsDraft('') + setItemRemoveLabelsDraft('') + setItemAddAssigneesDraft('') + setItemRemoveAssigneesDraft('') + setItemReviewersDraft('') + setItemReplyDrafts({}) + setExpandedPrFilePath(null) + setPrFileContents({}) + setPrFileLoadingPath(null) + setPrFileCommentDrafts({}) + setExpandedResolvedCommentGroups(new Set()) + }, [actionItem]) + + useEffect(() => { + if (!detailPayload) { + setItemBodyDraft('') + return + } + setItemBodyDraft( + detailPayload.provider === 'linear' ? detailPayload.description : detailPayload.body + ) + }, [detailPayload]) + + useEffect(() => { + if (!tasksSupported || !client || actionItem?.provider !== 'github') { + setItemAvailableLabels([]) + setItemLabelsLoading(false) + setItemLabelsError('') + setItemAssignableUsers([]) + setItemAssignableUsersLoading(false) + setItemAssignableUsersError('') + return + } + + let stale = false + if (actionItem.source.type === 'issue' || actionItem.source.type === 'pr') { + setItemAvailableLabels([]) + setItemLabelsError('') + setItemLabelsLoading(true) + void client + .sendRequest( + 'github.listLabels', + { repo: `id:${actionItem.source.repoId}` }, + { timeoutMs: 30_000 } + ) + .then((response) => { + if (stale) return + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + setItemAvailableLabels(response.result as string[]) + }) + .catch((err) => { + if (!stale) { + setItemLabelsError(err instanceof Error ? err.message : 'Failed to load labels') + } + }) + .finally(() => { + if (!stale) setItemLabelsLoading(false) + }) + } else { + setItemAvailableLabels([]) + setItemLabelsLoading(false) + setItemLabelsError('') + } + + setItemAssignableUsers([]) + setItemAssignableUsersError('') + setItemAssignableUsersLoading(true) + void client + .sendRequest( + 'github.listAssignableUsers', + { repo: `id:${actionItem.source.repoId}` }, + { timeoutMs: 30_000 } + ) + .then((response) => { + if (stale) return + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + setItemAssignableUsers(response.result as GitHubAssignableUser[]) + }) + .catch((err) => { + if (!stale) { + setItemAssignableUsersError( + err instanceof Error ? err.message : 'Failed to load assignees' + ) + } + }) + .finally(() => { + if (!stale) setItemAssignableUsersLoading(false) + }) + + return () => { + stale = true + } + }, [actionItem, client, tasksSupported]) + + useEffect(() => { + if (!tasksSupported || !actionItem || !client) { + setDetailPayload(null) + setDetailLoading(false) + setDetailError('') + return + } + + let stale = false + setDetailPayload(null) + setDetailError('') + setDetailLoading(true) + + const loadDetails = async (): Promise => { + if (actionItem.provider === 'github') { + const response = await client.sendRequest( + 'github.workItemDetails', + { + repo: `id:${actionItem.source.repoId}`, + number: actionItem.source.number, + type: actionItem.source.type + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const details = response.result as { + body?: string + comments?: DetailComment[] + item?: { + labels?: string[] + reviewDecision?: string | null + reviewRequests?: GitHubAssignableUser[] + latestReviews?: GitHubPRReviewSummary[] + } + assignees?: string[] + headSha?: string + baseSha?: string + pullRequestId?: string + checks?: GitHubDetailCheck[] + files?: Array<{ + path: string + oldPath?: string + status?: GitHubDetailFile['status'] + additions?: number + deletions?: number + isBinary?: boolean + viewerViewedState?: 'DISMISSED' | 'VIEWED' | 'UNVIEWED' + }> + } | null + if (!details) throw new Error('Details not found') + if (!stale) { + setDetailPayload({ + provider: 'github', + body: details.body ?? '', + comments: details.comments ?? [], + labels: details.item?.labels ?? actionItem.source.labels, + assignees: details.assignees ?? [], + reviewDecision: details.item?.reviewDecision ?? actionItem.source.reviewDecision, + reviewRequests: details.item?.reviewRequests ?? actionItem.source.reviewRequests ?? [], + latestReviews: details.item?.latestReviews ?? actionItem.source.latestReviews ?? [], + headSha: details.headSha, + baseSha: details.baseSha, + pullRequestId: details.pullRequestId, + checks: details.checks ?? [], + files: details.files ?? [] + }) + } + return + } + + if (actionItem.provider === 'gitlab') { + const response = await client.sendRequest( + 'gitlab.workItemDetails', + { + repo: `id:${actionItem.source.repoId}`, + iid: actionItem.source.number, + type: actionItem.source.type, + projectRef: actionItem.source.projectRef + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const details = response.result as { + body?: string + comments?: DetailComment[] + item?: { labels?: string[] } + assignees?: string[] + pipelineJobs?: Array<{ + id?: number + name: string + stage: string + status: string + webUrl?: string | null + duration?: number | null + }> + } | null + if (!details) throw new Error('Details not found') + if (!stale) { + setDetailPayload({ + provider: 'gitlab', + body: details.body ?? '', + comments: details.comments ?? [], + labels: details.item?.labels ?? actionItem.source.labels, + assignees: details.assignees ?? [], + pipelineJobs: details.pipelineJobs ?? [] + }) + } + return + } + + const [issueResponse, commentsResponse] = await Promise.all([ + client.sendRequest( + 'linear.getIssue', + { + id: actionItem.source.id, + workspaceId: actionItem.source.workspaceId + }, + { timeoutMs: 30_000 } + ), + client.sendRequest( + 'linear.issueComments', + { + issueId: actionItem.source.id, + workspaceId: actionItem.source.workspaceId + }, + { timeoutMs: 30_000 } + ) + ]) + if (!isSuccess(issueResponse)) { + throw new Error(issueResponse.error.message) + } + const issue = issueResponse.result as LinearIssue | null + const comments = isSuccess(commentsResponse) + ? ((commentsResponse.result as DetailComment[]) ?? []) + : [] + if (!issue) throw new Error('Details not found') + if (!stale) { + setDetailPayload({ + provider: 'linear', + description: issue.description ?? '', + comments, + labels: issue.labels ?? [], + assignee: issue.assignee?.displayName, + project: issue.project, + children: issue.subIssues ?? [] + }) + setActionItem((current) => { + if (current?.provider !== 'linear' || current.source.id !== issue.id) { + return current + } + const currentChildren = current.source.subIssues ?? [] + const nextChildren = issue.subIssues ?? [] + const alreadyHydrated = + current.source.project?.id === issue.project?.id && + currentChildren.length === nextChildren.length && + currentChildren.every((child, index) => child.id === nextChildren[index]?.id) + return alreadyHydrated + ? current + : (createLinearTask(issue) as Extract) + }) + } + } + + void loadDetails() + .catch((err) => { + if (!stale) { + setDetailError(err instanceof Error ? err.message : 'Failed to load details') + } + }) + .finally(() => { + if (!stale) setDetailLoading(false) + }) + + return () => { + stale = true + } + }, [actionItem, client, detailRefreshSeq, tasksSupported]) + + useEffect(() => { + if (!projectRowItem) { + setProjectRowDetail(null) + setProjectRowDetailLoading(false) + setProjectRowDetailError('') + setProjectTitleDraft('') + setProjectBodyDraft('') + setProjectCommentDraft('') + setProjectEditingCommentId(null) + setProjectEditingCommentDraft('') + setProjectReviewersDraft('') + setExpandedPrFilePath(null) + setPrFileContents({}) + setPrFileLoadingPath(null) + setPrFileCommentDrafts({}) + setProjectFieldDrafts({}) + return + } + + const type = projectRowType(projectRowItem) + const slug = splitRepositorySlug(projectRowItem.content.repository) + setProjectTitleDraft(projectRowItem.content.title) + setProjectBodyDraft(projectRowItem.content.body ?? '') + setProjectCommentDraft('') + setProjectEditingCommentId(null) + setProjectEditingCommentDraft('') + setProjectReviewersDraft('') + setExpandedPrFilePath(null) + setPrFileContents({}) + setPrFileLoadingPath(null) + setPrFileCommentDrafts({}) + setProjectFieldDrafts( + Object.fromEntries( + editableProjectFields(githubProjectTable).map((field) => [ + field.id, + projectFieldDraftValue(projectRowItem, field) + ]) + ) + ) + setProjectRowDetail(null) + setProjectRowDetailError('') + + if (!tasksSupported || !client || !type || !slug || !projectRowItem.content.number) { + setProjectRowDetailLoading(false) + return + } + + let stale = false + setProjectRowDetailLoading(true) + + void client + .sendRequest( + 'github.project.workItemDetailsBySlug', + { + owner: slug.owner, + repo: slug.repo, + number: projectRowItem.content.number, + type + }, + { timeoutMs: 30_000 } + ) + .then((response) => { + if (stale) return + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as + | { + ok: true + details: { + body?: string + comments?: DetailComment[] + item?: { + labels?: string[] + reviewDecision?: string | null + reviewRequests?: GitHubAssignableUser[] + latestReviews?: GitHubPRReviewSummary[] + } + assignees?: string[] + headSha?: string + baseSha?: string + pullRequestId?: string + checks?: GitHubDetailCheck[] + files?: Array<{ + path: string + oldPath?: string + status?: GitHubDetailFile['status'] + additions?: number + deletions?: number + isBinary?: boolean + viewerViewedState?: 'DISMISSED' | 'VIEWED' | 'UNVIEWED' + }> + } + } + | { ok: false; error: { message: string } } + if (!result.ok) { + throw new Error(result.error.message) + } + setProjectRowDetail({ + provider: 'github', + body: result.details.body ?? '', + comments: result.details.comments ?? [], + labels: result.details.item?.labels ?? projectRowItem.content.labels.map((l) => l.name), + assignees: result.details.assignees ?? [], + reviewDecision: result.details.item?.reviewDecision, + reviewRequests: result.details.item?.reviewRequests ?? [], + latestReviews: result.details.item?.latestReviews ?? [], + headSha: result.details.headSha, + baseSha: result.details.baseSha, + pullRequestId: result.details.pullRequestId, + checks: result.details.checks ?? [], + files: result.details.files ?? [] + }) + }) + .catch((err) => { + if (!stale) { + setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to load details') + } + }) + .finally(() => { + if (!stale) setProjectRowDetailLoading(false) + }) + + return () => { + stale = true + } + }, [client, githubProjectTable, projectRowDetailRefreshSeq, projectRowItem, tasksSupported]) + + useEffect(() => { + const slug = splitRepositorySlug(projectMetadataRepository) + if (!tasksSupported || !client || !slug) { + setProjectAvailableLabels([]) + setProjectLabelsLoading(false) + setProjectLabelsError('') + return + } + + let stale = false + setProjectAvailableLabels([]) + setProjectLabelsError('') + setProjectLabelsLoading(true) + void client + .sendRequest( + 'github.project.listLabelsBySlug', + { owner: slug.owner, repo: slug.repo }, + { timeoutMs: 30_000 } + ) + .then((response) => { + if (stale) return + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as + | { ok: true; labels?: string[] } + | { ok: false; error?: { message?: string } } + if (!result.ok) { + throw new Error(result.error?.message ?? 'Failed to load labels') + } + setProjectAvailableLabels(result.labels ?? []) + }) + .catch((err) => { + if (!stale) { + setProjectLabelsError(err instanceof Error ? err.message : 'Failed to load labels') + } + }) + .finally(() => { + if (!stale) setProjectLabelsLoading(false) + }) + + return () => { + stale = true + } + }, [client, projectMetadataRepository, tasksSupported]) + + useEffect(() => { + const slug = splitRepositorySlug(projectMetadataRepository) + if (!tasksSupported || !client || !slug) { + setProjectAssignableUsers([]) + setProjectAssignableUsersLoading(false) + setProjectAssignableUsersError('') + return + } + + let stale = false + setProjectAssignableUsers([]) + setProjectAssignableUsersError('') + setProjectAssignableUsersLoading(true) + void client + .sendRequest( + 'github.project.listAssignableUsersBySlug', + { + owner: slug.owner, + repo: slug.repo, + ...(projectMetadataSeedLogins ? { seedLogins: projectMetadataSeedLogins.split(',') } : {}) + }, + { timeoutMs: 30_000 } + ) + .then((response) => { + if (stale) return + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as + | { ok: true; users?: GitHubAssignableUser[] } + | { ok: false; error?: { message?: string } } + if (!result.ok) { + throw new Error(result.error?.message ?? 'Failed to load assignees') + } + setProjectAssignableUsers(result.users ?? []) + }) + .catch((err) => { + if (!stale) { + setProjectAssignableUsersError( + err instanceof Error ? err.message : 'Failed to load assignees' + ) + } + }) + .finally(() => { + if (!stale) setProjectAssignableUsersLoading(false) + }) + + return () => { + stale = true + } + }, [client, projectMetadataRepository, projectMetadataSeedLogins, tasksSupported]) + + useEffect(() => { + const slug = splitRepositorySlug(projectIssueTypeRepository) + if (!tasksSupported || !client || !slug) { + setProjectIssueTypes([]) + setProjectIssueTypesLoading(false) + setProjectIssueTypesError('') + return + } + + let stale = false + setProjectIssueTypes([]) + setProjectIssueTypesError('') + setProjectIssueTypesLoading(true) + void client + .sendRequest( + 'github.project.listIssueTypesBySlug', + { owner: slug.owner, repo: slug.repo }, + { timeoutMs: 30_000 } + ) + .then((response) => { + if (stale) return + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as + | { ok: true; types?: GitHubIssueType[] } + | { ok: false; error?: { message?: string } } + if (!result.ok) { + throw new Error(result.error?.message ?? 'Failed to load issue types') + } + setProjectIssueTypes(result.types ?? []) + }) + .catch((err) => { + if (!stale) { + setProjectIssueTypesError( + err instanceof Error ? err.message : 'Failed to load issue types' + ) + } + }) + .finally(() => { + if (!stale) setProjectIssueTypesLoading(false) + }) + + return () => { + stale = true + } + }, [client, projectIssueTypeRepository, tasksSupported]) + + const getWorkspaceTargetRepo = useCallback( + (item: ActionableTaskItem, repoIdOverride?: string): RepoSummary | null => { + if (item.provider === 'github' || item.provider === 'gitlab') { + return repos.find((entry) => entry.id === item.source.repoId) ?? null + } + if (repoIdOverride) { + return workspaceRepos.find((entry) => entry.id === repoIdOverride) ?? null + } + return workspaceRepos[0] ?? null + }, + [repos, workspaceRepos] + ) + + const workspaceCreateTargetRepo = useMemo( + () => + workspaceCreateDraft + ? getWorkspaceTargetRepo(workspaceCreateDraft.item, workspaceCreateDraft.repoIdOverride) + : null, + [getWorkspaceTargetRepo, workspaceCreateDraft] + ) + const workspaceCreateTargetConnectionId = workspaceCreateTargetRepo?.connectionId ?? null + const workspaceCreateSshGate = deriveWorkspaceSshGate({ + connectionId: workspaceCreateTargetConnectionId, + state: workspaceSshState, + connecting: workspaceSshConnecting + }) + const workspaceCreateSshStatus = workspaceCreateSshGate.status + const workspaceCreateRequiresSshConnection = workspaceCreateSshGate.requiresConnection + const workspaceCreateSshConnectInProgress = workspaceCreateSshGate.connectInProgress + const workspaceCreateSshError = workspaceCreateSshGate.error + const workspaceCreateCanPickRepo = + workspaceCreateDraft?.item.provider === 'linear' && workspaceRepos.length > 1 + const workspaceSparseCheckoutAvailable = + workspaceCreateTargetRepo != null && !workspaceCreateTargetRepo.connectionId + const workspaceSparseDraftParsed = useMemo( + () => + workspaceSparseDraft + ? parseSparsePresetDirectories(workspaceSparseDraft.directoriesText) + : null, + [workspaceSparseDraft] + ) + const workspaceSparseDraftName = workspaceSparseDraft?.name.trim() ?? '' + const workspaceSparseDraftNameCollision = + workspaceSparseDraft && workspaceSparseDraftName + ? (workspaceSparsePresets.find( + (preset) => + preset.id !== workspaceSparseDraft.presetId && + preset.name.toLowerCase() === workspaceSparseDraftName.toLowerCase() + ) ?? null) + : null + const workspaceSparseDraftError = + workspaceSparseDraft && workspaceSparseDraftName.length === 0 + ? 'Name is required.' + : workspaceSparseDraftName.length > 80 + ? 'Name must be 80 characters or fewer.' + : workspaceSparseDraftNameCollision + ? `"${workspaceSparseDraftNameCollision.name}" already exists.` + : (workspaceSparseDraftParsed?.error ?? null) + const canSaveWorkspaceSparseDraft = + workspaceSparseDraft !== null && + workspaceCreateTargetRepo !== null && + workspaceSparseCheckoutAvailable && + workspaceSparsePresetsLoaded && + !workspaceSparsePresetsLoading && + !workspaceSparseSaving && + !workspaceSparseDraftError && + workspaceSparseDraftParsed !== null + + const workspaceAgentOptions = useMemo[]>(() => { + const availableAgents = + workspaceDetectedAgentIds === null + ? new Set(MOBILE_TUI_AGENT_AUTO_PICK_ORDER) + : new Set( + MOBILE_TUI_AGENT_AUTO_PICK_ORDER.filter((agent) => workspaceDetectedAgentIds.has(agent)) + ) + if ( + workspaceAgent && + workspaceAgent !== 'blank' && + (workspaceDetectedAgentIds === null || workspaceDetectedAgentIds.has(workspaceAgent)) + ) { + availableAgents.add(workspaceAgent) + } + const agents = MOBILE_TUI_AGENT_AUTO_PICK_ORDER.filter((agent) => availableAgents.has(agent)) + return [ + ...agents.map((agent) => ({ + value: agent, + label: workspaceAgentLabel(agent), + subtitle: agent, + renderIcon: () => + })), + { + value: 'blank' as const, + label: workspaceAgentLabel('blank'), + subtitle: 'Open a shell', + renderIcon: () => + } + ] + }, [workspaceAgent, workspaceDetectedAgentIds]) + + const openWorkspaceCreate = useCallback((item: ActionableTaskItem, repoIdOverride?: string) => { + const suggestedName = taskWorkspaceSuggestedName(item) + setWorkspaceCreateDraft({ item, ...(repoIdOverride ? { repoIdOverride } : {}) }) + setWorkspaceNameDraft(suggestedName) + setWorkspaceLastAutoName(suggestedName) + setWorkspaceBranchAutoName('') + setWorkspaceBranchNameOverride(undefined) + setWorkspaceBaseBranch(null) + setWorkspaceBaseBranchQuery('') + setWorkspaceBaseBranchResults([]) + setWorkspaceBaseBranchLoading(false) + setWorkspaceBaseBranchError('') + setWorkspaceSparsePresetId(null) + setWorkspaceSparsePresets([]) + setWorkspaceSparsePresetsLoaded(false) + setWorkspaceSparsePresetsError('') + setWorkspaceSparseReloadKey(0) + setWorkspaceSparseDraft(null) + setWorkspaceSparseSaving(false) + setWorkspaceAgentOverridden(false) + setWorkspaceAgent(null) + setOrcaYamlTrustPrompt(null) + setShowWorkspaceAgentPicker(false) + setShowWorkspaceCreateRepoPicker(false) + setShowWorkspaceAdvanced(false) + setShowWorkspaceBaseBranchPicker(false) + setShowWorkspaceSparsePicker(false) + setError('') + }, []) + + const handleWorkspaceNameDraftChange = useCallback( + (nextName: string): void => { + if (!nextName.trim()) { + setWorkspaceLastAutoName('') + } else if (workspaceNameDraft !== workspaceLastAutoName) { + setWorkspaceLastAutoName('') + } + if (workspaceBranchNameOverride && nextName !== workspaceBranchAutoName) { + setWorkspaceBranchNameOverride(undefined) + setWorkspaceBranchAutoName('') + } + setWorkspaceNameDraft(nextName) + }, + [ + workspaceBranchAutoName, + workspaceBranchNameOverride, + workspaceLastAutoName, + workspaceNameDraft + ] + ) + + const selectWorkspaceBaseBranch = useCallback( + (branch: BaseRefSearchResult): void => { + const selection = resolveComposerBranchSelection({ + refName: branch.refName, + localBranchName: branch.localBranchName, + currentName: workspaceNameDraft, + lastAutoName: workspaceLastAutoName + }) + setWorkspaceBaseBranch(branch) + setWorkspaceBranchAutoName(selection.branchAutoName) + setWorkspaceBranchNameOverride(selection.branchNameOverride) + if (selection.name !== undefined && selection.lastAutoName !== undefined) { + setWorkspaceNameDraft(selection.name) + setWorkspaceLastAutoName(selection.lastAutoName) + } + setShowWorkspaceBaseBranchPicker(false) + }, + [workspaceLastAutoName, workspaceNameDraft] + ) + + const clearWorkspaceBaseBranch = useCallback((): void => { + setWorkspaceBaseBranch(null) + setWorkspaceBranchAutoName('') + setWorkspaceBranchNameOverride(undefined) + setShowWorkspaceBaseBranchPicker(false) + }, []) + + useEffect(() => { + if (!tasksSupported || !client || !workspaceCreateDraft || !workspaceCreateTargetRepo) { + setWorkspaceSparsePresets([]) + setWorkspaceSparsePresetsLoading(false) + setWorkspaceSparsePresetsLoaded(false) + setWorkspaceSparsePresetsError('') + setWorkspaceSparsePresetId(null) + setWorkspaceSparseDraft(null) + return + } + if (workspaceCreateTargetRepo.connectionId) { + setWorkspaceSparsePresets([]) + setWorkspaceSparsePresetsLoading(false) + setWorkspaceSparsePresetsLoaded(false) + setWorkspaceSparsePresetsError('') + setWorkspaceSparsePresetId(null) + setWorkspaceSparseDraft(null) + return + } + + let stale = false + setWorkspaceSparsePresetsLoading(true) + setWorkspaceSparsePresetsLoaded(false) + setWorkspaceSparsePresetsError('') + void client + .sendRequest('repo.sparsePresets', { repo: `id:${workspaceCreateTargetRepo.id}` }) + .then((response) => { + if (stale) return + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const presets = (response.result as { presets?: SparsePreset[] }).presets ?? [] + setWorkspaceSparsePresets(presets) + setWorkspaceSparsePresetsLoaded(true) + setWorkspaceSparsePresetId((current) => + current && presets.some((preset) => preset.id === current) ? current : null + ) + }) + .catch((err) => { + if (!stale) { + setWorkspaceSparsePresets([]) + setWorkspaceSparsePresetsLoaded(false) + setWorkspaceSparsePresetId(null) + setWorkspaceSparsePresetsError( + err instanceof Error ? err.message : 'Failed to load sparse presets.' + ) + } + }) + .finally(() => { + if (!stale) setWorkspaceSparsePresetsLoading(false) + }) + + return () => { + stale = true + } + }, [ + client, + tasksSupported, + workspaceCreateDraft, + workspaceCreateTargetRepo, + workspaceSparseReloadKey + ]) + + useEffect(() => { + if ( + !client || + !tasksSupported || + !workspaceCreateDraft || + !workspaceCreateTargetRepo || + !showWorkspaceBaseBranchPicker + ) { + setWorkspaceBaseBranchResults([]) + setWorkspaceBaseBranchLoading(false) + setWorkspaceBaseBranchError('') + return + } + const query = workspaceBaseBranchQuery.trim() + if (!query) { + setWorkspaceBaseBranchResults([]) + setWorkspaceBaseBranchLoading(false) + setWorkspaceBaseBranchError('') + return + } + + let stale = false + setWorkspaceBaseBranchLoading(true) + setWorkspaceBaseBranchError('') + void client + .sendRequest( + 'repo.searchRefs', + { repo: `id:${workspaceCreateTargetRepo.id}`, query, limit: 20 }, + { timeoutMs: 30_000 } + ) + .then((response) => { + if (stale) return + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { + refDetails?: BaseRefSearchResult[] + refs?: string[] + } + setWorkspaceBaseBranchResults( + result.refDetails ?? + (result.refs ?? []).map((refName) => ({ refName, localBranchName: refName })) + ) + }) + .catch((err) => { + if (!stale) { + setWorkspaceBaseBranchResults([]) + setWorkspaceBaseBranchError( + err instanceof Error ? err.message : 'Failed to search branches.' + ) + } + }) + .finally(() => { + if (!stale) setWorkspaceBaseBranchLoading(false) + }) + + return () => { + stale = true + } + }, [ + client, + tasksSupported, + showWorkspaceBaseBranchPicker, + workspaceBaseBranchQuery, + workspaceCreateDraft, + workspaceCreateTargetRepo + ]) + + const startNewWorkspaceSparsePreset = useCallback(() => { + if ( + !workspaceSparseCheckoutAvailable || + !workspaceSparsePresetsLoaded || + workspaceSparsePresetsLoading + ) { + return + } + setWorkspaceSparseDraft({ mode: 'new', name: '', directoriesText: '' }) + setShowWorkspaceSparsePicker(false) + }, [ + workspaceSparseCheckoutAvailable, + workspaceSparsePresetsLoaded, + workspaceSparsePresetsLoading + ]) + + const startEditWorkspaceSparsePreset = useCallback( + (preset: SparsePreset) => { + if ( + !workspaceSparseCheckoutAvailable || + !workspaceSparsePresetsLoaded || + workspaceSparsePresetsLoading + ) { + return + } + setWorkspaceSparseDraft({ + mode: 'edit', + presetId: preset.id, + name: preset.name, + directoriesText: preset.directories.join('\n') + }) + setShowWorkspaceSparsePicker(false) + }, + [workspaceSparseCheckoutAvailable, workspaceSparsePresetsLoaded, workspaceSparsePresetsLoading] + ) + + const saveWorkspaceSparsePreset = useCallback(async (): Promise => { + if ( + !client || + !tasksSupported || + !workspaceCreateTargetRepo || + !workspaceSparseDraft || + !workspaceSparseDraftParsed || + !canSaveWorkspaceSparseDraft + ) { + return + } + setWorkspaceSparseSaving(true) + setWorkspaceSparsePresetsError('') + try { + const response = await client.sendRequest('repo.saveSparsePreset', { + repo: `id:${workspaceCreateTargetRepo.id}`, + ...(workspaceSparseDraft.presetId ? { id: workspaceSparseDraft.presetId } : {}), + name: workspaceSparseDraftName, + directories: workspaceSparseDraftParsed.directories + }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const saved = (response.result as { preset?: SparsePreset }).preset + if (!saved) { + throw new Error('Failed to save sparse preset.') + } + setWorkspaceSparsePresets((current) => { + const withoutSaved = current.filter((preset) => preset.id !== saved.id) + return sortSparsePresetsByName([...withoutSaved, saved]) + }) + setWorkspaceSparsePresetsLoaded(true) + if (workspaceSparseDraft.mode === 'new' || workspaceSparsePresetId === saved.id) { + setWorkspaceSparsePresetId(saved.id) + } + setWorkspaceSparseDraft(null) + } catch (err) { + setWorkspaceSparsePresetsError( + err instanceof Error ? err.message : 'Failed to save sparse preset.' + ) + } finally { + setWorkspaceSparseSaving(false) + } + }, [ + canSaveWorkspaceSparseDraft, + client, + tasksSupported, + workspaceCreateTargetRepo, + workspaceSparseDraft, + workspaceSparseDraftName, + workspaceSparseDraftParsed, + workspaceSparsePresetId + ]) + + useEffect(() => { + if (!tasksSupported || !client || !workspaceCreateDraft || !workspaceCreateTargetConnectionId) { + setWorkspaceSshState(null) + setWorkspaceSshConnecting(false) + return + } + + let stale = false + void client + .sendRequest('ssh.getState', { targetId: workspaceCreateTargetConnectionId }) + .then((response) => { + if (stale) return + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const state = (response.result as { state?: SshConnectionState | null }).state ?? null + setWorkspaceSshState( + state ?? { + targetId: workspaceCreateTargetConnectionId, + status: 'disconnected', + error: null, + reconnectAttempt: 0 + } + ) + }) + .catch((err) => { + if (!stale) { + setWorkspaceSshState({ + targetId: workspaceCreateTargetConnectionId, + status: 'error', + error: err instanceof Error ? err.message : 'Failed to read SSH connection state.', + reconnectAttempt: 0 + }) + } + }) + + return () => { + stale = true + } + }, [client, tasksSupported, workspaceCreateDraft, workspaceCreateTargetConnectionId]) + + const connectWorkspaceSshRepo = useCallback(async (): Promise => { + if (!client || !tasksSupported || !workspaceCreateTargetConnectionId) { + return + } + setWorkspaceSshConnecting(true) + setWorkspaceSshState({ + targetId: workspaceCreateTargetConnectionId, + status: 'connecting', + error: null, + reconnectAttempt: 0 + }) + try { + const response = await client.sendRequest( + 'ssh.connect', + { targetId: workspaceCreateTargetConnectionId }, + { timeoutMs: 120_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const state = (response.result as { state?: SshConnectionState | null }).state + setWorkspaceSshState( + state ?? { + targetId: workspaceCreateTargetConnectionId, + status: 'connected', + error: null, + reconnectAttempt: 0 + } + ) + } catch (err) { + setWorkspaceSshState({ + targetId: workspaceCreateTargetConnectionId, + status: 'error', + error: err instanceof Error ? err.message : 'Failed to connect to SSH repository.', + reconnectAttempt: 0 + }) + } finally { + setWorkspaceSshConnecting(false) + } + }, [client, tasksSupported, workspaceCreateTargetConnectionId]) + + const ensureWorkspaceSshReady = useCallback( + async (repo: RepoSummary): Promise => { + if (!repo.connectionId || !client || !tasksSupported) { + return + } + if ( + workspaceSshState?.targetId === repo.connectionId && + workspaceSshState.status === 'connected' + ) { + return + } + const response = await client.sendRequest('ssh.getState', { targetId: repo.connectionId }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const state = (response.result as { state?: SshConnectionState | null }).state ?? null + if (state) { + setWorkspaceSshState(state) + } + if (state?.status !== 'connected') { + throw new Error(`Connect ${repo.displayName} before creating a workspace.`) + } + }, + [client, tasksSupported, workspaceSshState] + ) + + useEffect(() => { + if (!tasksSupported || !workspaceCreateDraft || !client || !workspaceCreateTargetRepo) { + setWorkspaceDetectedAgentIds(null) + return + } + if (workspaceCreateTargetRepo.connectionId && workspaceCreateSshStatus !== 'connected') { + // Why: remote agent detection runs on the SSH host through the relay; a + // disconnected repo would fail and cache an empty agent list. + setWorkspaceDetectedAgentIds(null) + return + } + let stale = false + setWorkspaceDetectedAgentIds(null) + const request = workspaceCreateTargetRepo.connectionId + ? client.sendRequest('preflight.detectRemoteAgents', { + connectionId: workspaceCreateTargetRepo.connectionId + }) + : client.sendRequest('preflight.detectAgents') + void request + .then((response) => { + if (stale) return + setWorkspaceDetectedAgentIds( + isSuccess(response) ? new Set(response.result as string[]) : new Set() + ) + }) + .catch(() => { + if (!stale) setWorkspaceDetectedAgentIds(new Set()) + }) + return () => { + stale = true + } + }, [ + client, + tasksSupported, + workspaceCreateDraft, + workspaceCreateSshStatus, + workspaceCreateTargetRepo + ]) + + useEffect(() => { + if (!tasksSupported || !workspaceCreateDraft || workspaceAgentOverridden) return + setWorkspaceAgent(pickWorkspaceAgent(runtimeTaskSettings, workspaceDetectedAgentIds)) + }, [ + runtimeTaskSettings, + tasksSupported, + workspaceAgentOverridden, + workspaceCreateDraft, + workspaceDetectedAgentIds + ]) + + useEffect(() => { + if ( + !workspaceCreateDraft || + !tasksSupported || + workspaceDetectedAgentIds === null || + !workspaceAgent || + workspaceAgent === 'blank' || + workspaceDetectedAgentIds.has(workspaceAgent) + ) { + return + } + // Why: the drawer can open before SSH/local detection settles. If the user + // picked an agent that is not actually available on that host, fall back to + // the same detected-agent rule desktop uses instead of launching a bad CLI. + setWorkspaceAgent(pickWorkspaceAgent(runtimeTaskSettings, workspaceDetectedAgentIds)) + setWorkspaceAgentOverridden(false) + }, [ + runtimeTaskSettings, + tasksSupported, + workspaceAgent, + workspaceCreateDraft, + workspaceDetectedAgentIds + ]) + + const resolvedWorkspaceAgent = useMemo( + () => workspaceAgent ?? pickWorkspaceAgent(runtimeTaskSettings, workspaceDetectedAgentIds), + [runtimeTaskSettings, workspaceAgent, workspaceDetectedAgentIds] + ) + const workspaceAgentDetectionPending = + workspaceCreateDraft != null && + workspaceCreateTargetRepo != null && + !workspaceCreateRequiresSshConnection && + workspaceDetectedAgentIds === null + + const resolveCreateSetupDecision = useCallback( + async ( + repo: RepoSummary, + override?: Exclude + ): Promise< + | { kind: 'decision'; decision: SetupDecision; setupTrust?: RepoHooksResponse['setupTrust'] } + | { + kind: 'prompt' + command: string + source: string | null + setupTrust?: RepoHooksResponse['setupTrust'] + } + > => { + if (!client || !tasksSupported) return { kind: 'decision', decision: override ?? 'inherit' } + const response = await client.sendRequest('repo.hooks', { repo: `id:${repo.id}` }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as RepoHooksResponse + const setupCommand = result.hooks?.scripts?.setup?.trim() + const setupTrust = normalizeSetupHookTrust(result.setupTrust) ?? undefined + if (!setupCommand) { + return { kind: 'decision', decision: 'inherit' } + } + if (override) { + return { kind: 'decision', decision: override, setupTrust } + } + const setupRunPolicy = result.setupRunPolicy ?? 'run-by-default' + if (setupRunPolicy === 'ask') { + return { kind: 'prompt', command: setupCommand, source: result.source, setupTrust } + } + return { + kind: 'decision', + decision: setupRunPolicy === 'run-by-default' ? 'run' : 'skip', + setupTrust + } + }, + [client, tasksSupported] + ) + + const createWorkspace = useCallback( + async ( + item: ActionableTaskItem, + repoIdOverride?: string, + setupOverride?: Exclude, + agentOverride?: WorkspaceAgentChoice, + workspaceNameOverride?: string, + noteOverride?: string, + baseBranchOverride?: string, + branchNameOverride?: string, + sparseCheckoutOverride?: { directories: string[]; presetId?: string }, + approvedSetupContentHash?: string + ): Promise => { + if (!client || !tasksSupported || !taskStateHydrated) return + setCreatingKey(item.key) + setError('') + try { + const targetRepo = getWorkspaceTargetRepo(item, repoIdOverride) + if (!targetRepo) { + throw new Error( + item.provider === 'linear' + ? 'Add a Git repository before creating a Linear workspace.' + : 'Repository not found.' + ) + } + await ensureWorkspaceSshReady(targetRepo) + const setupResolution = await resolveCreateSetupDecision(targetRepo, setupOverride) + const comment = noteOverride?.trim() + if (setupResolution.kind === 'prompt') { + // Why: desktop does not silently create when a repo policy says setup + // requires a per-workspace decision. Mobile must ask before create too. + setSetupPrompt({ + item, + ...(repoIdOverride ? { repoIdOverride } : {}), + ...(agentOverride ? { agentOverride } : {}), + ...(workspaceNameOverride ? { workspaceNameOverride } : {}), + ...(comment ? { noteOverride: comment } : {}), + ...(baseBranchOverride ? { baseBranchOverride } : {}), + ...(branchNameOverride ? { branchNameOverride } : {}), + ...(sparseCheckoutOverride ? { sparseCheckoutOverride } : {}), + repoName: targetRepo.displayName, + command: setupResolution.command, + source: setupResolution.source + }) + return + } + const setupDecision = setupResolution.decision + if ( + setupDecision === 'run' && + setupResolution.setupTrust && + setupResolution.setupTrust.contentHash !== approvedSetupContentHash && + !isSetupHookTrusted( + trustedOrcaHooks, + targetRepo.id, + setupResolution.setupTrust.contentHash + ) + ) { + // Why: desktop prompts before running repo-owned orca.yaml hooks. Mobile + // stores the same trust hash in persisted UI state so either surface can + // approve the script version for future workspace creates. + setSetupPrompt(null) + setOrcaYamlTrustPrompt({ + item, + ...(repoIdOverride ? { repoIdOverride } : {}), + setupOverride: 'run', + ...(agentOverride ? { agentOverride } : {}), + ...(workspaceNameOverride ? { workspaceNameOverride } : {}), + ...(comment ? { noteOverride: comment } : {}), + ...(baseBranchOverride ? { baseBranchOverride } : {}), + ...(branchNameOverride ? { branchNameOverride } : {}), + ...(sparseCheckoutOverride ? { sparseCheckoutOverride } : {}), + repoId: targetRepo.id, + repoName: targetRepo.displayName, + scriptContent: setupResolution.setupTrust.scriptContent, + contentHash: setupResolution.setupTrust.contentHash, + previouslyApproved: wasSetupHookPreviouslyApproved(trustedOrcaHooks, targetRepo.id) + }) + return + } + const selectedAgent = agentOverride + let params: Record + if (item.provider === 'github') { + const source = item.source + let prStartPoint: { baseBranch: string; pushTarget?: GitPushTarget } | undefined + if ( + shouldResolveHostedReviewStartPoint({ + type: source.type, + baseBranchOverride + }) + ) { + const response = await client.sendRequest( + 'worktree.resolvePrBase', + { + repo: `id:${source.repoId}`, + prNumber: source.number, + ...(source.branchName ? { headRefName: source.branchName } : {}), + ...(source.isCrossRepository !== undefined + ? { isCrossRepository: source.isCrossRepository } + : {}) + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as + | { baseBranch: string; pushTarget?: GitPushTarget } + | { error: string } + if ('error' in result) { + throw new Error(result.error) + } + prStartPoint = result + } + params = buildTaskWorkspaceCreateParams({ + item, + targetRepoId: targetRepo.id, + setupDecision, + agent: selectedAgent, + workspaceName: workspaceNameOverride, + note: comment, + baseBranch: baseBranchOverride, + branchNameOverride, + sparseCheckout: sparseCheckoutOverride, + hostedStartPoint: prStartPoint + }) + } else if (item.provider === 'gitlab') { + const source = item.source + let mrStartPoint: { baseBranch: string; pushTarget?: GitPushTarget } | undefined + if ( + shouldResolveHostedReviewStartPoint({ + type: source.type, + baseBranchOverride + }) + ) { + const response = await client.sendRequest( + 'worktree.resolveMrBase', + { + repo: `id:${source.repoId}`, + mrIid: source.number, + ...(source.branchName ? { sourceBranch: source.branchName } : {}), + ...(source.isCrossRepository !== undefined + ? { isCrossRepository: source.isCrossRepository } + : {}) + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as + | { baseBranch: string; pushTarget?: GitPushTarget } + | { error: string } + if ('error' in result) { + throw new Error(result.error) + } + mrStartPoint = result + } + params = buildTaskWorkspaceCreateParams({ + item, + targetRepoId: targetRepo.id, + setupDecision, + agent: selectedAgent, + workspaceName: workspaceNameOverride, + note: comment, + baseBranch: baseBranchOverride, + branchNameOverride, + sparseCheckout: sparseCheckoutOverride, + hostedStartPoint: mrStartPoint + }) + } else { + params = buildTaskWorkspaceCreateParams({ + item, + targetRepoId: targetRepo.id, + setupDecision, + agent: selectedAgent, + workspaceName: workspaceNameOverride, + note: comment, + baseBranch: baseBranchOverride, + branchNameOverride, + sparseCheckout: sparseCheckoutOverride + }) + } + const response = await client.sendRequest('worktree.create', params, { + timeoutMs: WORKTREE_CREATE_TIMEOUT_MS + }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { + worktree: { id: string; displayName?: string } + warning?: string + } + setActionItem(null) + setWorkspaceCreateDraft(null) + setSetupPrompt(null) + const name = result.worktree.displayName ?? item.title + const queryParams = new URLSearchParams({ name, created: '1' }) + if (result.warning) { + queryParams.set('warning', result.warning) + } + router.push( + `/h/${hostId}/session/${encodeURIComponent(result.worktree.id)}?${queryParams.toString()}` + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create workspace') + } finally { + setCreatingKey(null) + } + }, + [ + client, + ensureWorkspaceSshReady, + getWorkspaceTargetRepo, + hostId, + resolveCreateSetupDecision, + router, + taskStateHydrated, + tasksSupported, + trustedOrcaHooks + ] + ) + + const createWorkspaceFromProjectRow = useCallback( + async (row: GitHubProjectRow): Promise => { + if (!tasksSupported) return + const kind = projectRowType(row) + const repo = findProjectRowRepo(row) + if (!kind || !row.content.number || !row.content.url) { + setError('Add the project item repository to Orca before creating a workspace.') + return + } + if (!repo) { + const slug = splitRepositorySlug(row.content.repository) + setProjectRepoNotInOrca({ + owner: slug?.owner ?? 'Unknown', + repo: slug?.repo ?? row.content.repository ?? 'repository', + url: row.content.url ?? null + }) + return + } + const state: GitHubWorkItem['state'] = + row.content.state === 'MERGED' + ? 'merged' + : row.content.state === 'CLOSED' + ? 'closed' + : row.content.isDraft + ? 'draft' + : 'open' + const source: GitHubWorkItem = { + id: row.id, + type: kind, + number: row.content.number, + title: row.content.title, + state, + url: row.content.url, + labels: row.content.labels.map((label) => label.name), + updatedAt: row.updatedAt, + author: null, + repoId: repo.id, + repoName: repo.displayName + } + openWorkspaceCreate({ + key: `github-project:${row.id}`, + provider: 'github', + title: row.content.title, + subtitle: `${repo.displayName} #${row.content.number}`, + status: projectRowStatusLabel(row), + updatedAt: row.updatedAt, + source + }) + }, + [findProjectRowRepo, openWorkspaceCreate, tasksSupported] + ) + + const mutateProjectRowIssueOrPr = useCallback( + async ( + row: GitHubProjectRow, + updates: { title?: string; body?: string; state?: 'open' | 'closed' } + ): Promise => { + if (!client || projectMutating) return + const type = projectRowType(row) + const slug = splitRepositorySlug(row.content.repository) + if (!type || !slug || !row.content.number) { + setProjectRowDetailError('This project item cannot be edited from mobile.') + return + } + setProjectMutating(true) + try { + const response = await client.sendRequest( + type === 'issue' + ? 'github.project.updateIssueBySlug' + : 'github.project.updatePullRequestBySlug', + { + owner: slug.owner, + repo: slug.repo, + number: row.content.number, + updates + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: { message?: string } } + if (result.ok === false) { + throw new Error(result.error?.message ?? 'Failed to update GitHub item') + } + setProjectRowItem((current) => { + if (!current || current.id !== row.id) return current + return { + ...current, + content: { + ...current.content, + ...(updates.title !== undefined ? { title: updates.title } : {}), + ...(updates.body !== undefined ? { body: updates.body } : {}), + ...(updates.state !== undefined + ? { state: updates.state === 'closed' ? 'CLOSED' : 'OPEN' } + : {}) + } + } + }) + setGithubProjectTable((table) => + table + ? { + ...table, + rows: table.rows.map((candidate) => + candidate.id === row.id + ? { + ...candidate, + content: { + ...candidate.content, + ...(updates.title !== undefined ? { title: updates.title } : {}), + ...(updates.body !== undefined ? { body: updates.body } : {}), + ...(updates.state !== undefined + ? { state: updates.state === 'closed' ? 'CLOSED' : 'OPEN' } + : {}) + } + } + : candidate + ) + } + : table + ) + if (updates.body !== undefined) { + setProjectRowDetail((current) => + current?.provider === 'github' ? { ...current, body: updates.body ?? '' } : current + ) + } + } catch (err) { + setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to update item') + } finally { + setProjectMutating(false) + } + }, + [client, projectMutating] + ) + + const addProjectRowComment = useCallback( + async (row: GitHubProjectRow): Promise => { + if (!client || projectMutating) return + const slug = splitRepositorySlug(row.content.repository) + const body = projectCommentDraft.trim() + if (!slug || !row.content.number || !body) return + setProjectMutating(true) + try { + const response = await client.sendRequest( + 'github.project.addIssueCommentBySlug', + { + owner: slug.owner, + repo: slug.repo, + number: row.content.number, + body + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as + | { ok: true; comment?: DetailComment } + | { ok: false; error?: { message?: string } } + if (!result.ok) { + throw new Error(result.error?.message ?? 'Failed to add comment') + } + setProjectCommentDraft('') + if (result.comment) { + setProjectRowDetail((current) => + current?.provider === 'github' + ? { ...current, comments: [...current.comments, result.comment as DetailComment] } + : current + ) + } + } catch (err) { + setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to add comment') + } finally { + setProjectMutating(false) + } + }, + [client, projectCommentDraft, projectMutating] + ) + + const updateProjectRowComment = useCallback( + async (row: GitHubProjectRow, comment: DetailComment): Promise => { + if (!client || projectMutating) return + const slug = splitRepositorySlug(row.content.repository) + const commentId = Number(comment.id) + const body = projectEditingCommentDraft.trim() + if (!slug || !Number.isInteger(commentId) || commentId <= 0 || !body) { + setProjectRowDetailError('This project comment cannot be edited from mobile.') + return + } + setProjectMutating(true) + setProjectRowDetailError('') + try { + const response = await client.sendRequest( + 'github.project.updateIssueCommentBySlug', + { + owner: slug.owner, + repo: slug.repo, + commentId, + body + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { + ok?: boolean + error?: string | { message?: string } + } + if (result.ok === false) { + throw new Error( + typeof result.error === 'string' + ? result.error + : (result.error?.message ?? 'Failed to edit comment') + ) + } + setProjectRowDetail((current) => + current?.provider === 'github' + ? { + ...current, + comments: current.comments.map((candidate) => + Number(candidate.id) === commentId ? { ...candidate, body } : candidate + ) + } + : current + ) + setProjectEditingCommentId(null) + setProjectEditingCommentDraft('') + } catch (err) { + setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to edit comment') + } finally { + setProjectMutating(false) + } + }, + [client, projectEditingCommentDraft, projectMutating] + ) + + const deleteProjectRowComment = useCallback( + async (row: GitHubProjectRow, comment: DetailComment): Promise => { + if (!client || projectMutating) return + const slug = splitRepositorySlug(row.content.repository) + const commentId = Number(comment.id) + if (!slug || !Number.isInteger(commentId) || commentId <= 0) { + setProjectRowDetailError('This project comment cannot be deleted from mobile.') + return + } + setProjectMutating(true) + setProjectRowDetailError('') + try { + const response = await client.sendRequest( + 'github.project.deleteIssueCommentBySlug', + { + owner: slug.owner, + repo: slug.repo, + commentId + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { + ok?: boolean + error?: string | { message?: string } + } + if (result.ok === false) { + throw new Error( + typeof result.error === 'string' + ? result.error + : (result.error?.message ?? 'Failed to delete comment') + ) + } + setProjectRowDetail((current) => + current?.provider === 'github' + ? { + ...current, + comments: current.comments.filter((candidate) => Number(candidate.id) !== commentId) + } + : current + ) + if (projectEditingCommentId === String(comment.id)) { + setProjectEditingCommentId(null) + setProjectEditingCommentDraft('') + } + } catch (err) { + setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to delete comment') + } finally { + setProjectMutating(false) + } + }, + [client, projectEditingCommentId, projectMutating] + ) + + const toggleProjectGitHubReviewThread = useCallback( + async (row: GitHubProjectRow, comment: DetailComment): Promise => { + const repo = findProjectRowRepo(row) + if ( + !client || + projectMutating || + row.itemType !== 'PULL_REQUEST' || + !repo || + !comment.threadId + ) { + return + } + const resolve = !comment.isResolved + setProjectMutating(true) + setProjectRowDetailError('') + try { + const response = await client.sendRequest( + 'github.resolveReviewThread', + { + repo: `id:${repo.id}`, + threadId: comment.threadId, + resolve + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + if (response.result !== true) { + throw new Error(resolve ? 'Failed to resolve thread' : 'Failed to reopen thread') + } + setProjectRowDetail((current) => + current?.provider === 'github' + ? { + ...current, + comments: current.comments.map((candidate) => + candidate.threadId === comment.threadId + ? { ...candidate, isResolved: resolve } + : candidate + ) + } + : current + ) + } catch (err) { + setProjectRowDetailError( + err instanceof Error ? err.message : 'Failed to update review thread' + ) + } finally { + setProjectMutating(false) + } + }, + [client, findProjectRowRepo, projectMutating] + ) + + const replyToProjectGitHubComment = useCallback( + async (row: GitHubProjectRow, comment: DetailComment): Promise => { + const repo = findProjectRowRepo(row) + if (!client || projectMutating || !repo || !row.content.number) return + const key = String(comment.id) + const body = (itemReplyDrafts[key] ?? '').trim() + if (!body) return + setProjectMutating(true) + setProjectRowDetailError('') + try { + const canUseReviewReply = + row.itemType === 'PULL_REQUEST' && + comment.path && + typeof comment.line === 'number' && + typeof comment.id === 'number' + const response = canUseReviewReply + ? await client.sendRequest( + 'github.addPRReviewCommentReply', + { + repo: `id:${repo.id}`, + prNumber: row.content.number, + commentId: comment.id, + body, + threadId: comment.threadId, + path: comment.path, + line: comment.line + }, + { timeoutMs: 30_000 } + ) + : await client.sendRequest( + 'github.addIssueComment', + { + repo: `id:${repo.id}`, + number: row.content.number, + body: `@${commentAuthor(comment)} ${body}`, + type: projectRowType(row) ?? 'issue' + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { + ok?: boolean + error?: string + comment?: DetailComment + } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to reply') + } + const reply: DetailComment = result.comment ?? { + id: `local-${Date.now()}`, + body, + createdAt: new Date().toISOString(), + author: 'You', + path: comment.path, + line: comment.line, + threadId: comment.threadId + } + setItemReplyDrafts((current) => { + const next = { ...current } + delete next[key] + return next + }) + setProjectRowDetail((current) => + current?.provider === 'github' + ? { ...current, comments: [...current.comments, reply] } + : current + ) + } catch (err) { + setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to reply') + } finally { + setProjectMutating(false) + } + }, + [client, findProjectRowRepo, itemReplyDrafts, projectMutating] + ) + + const mutateProjectRowMetadata = useCallback( + async ( + row: GitHubProjectRow, + updates: { + addLabels?: string[] + removeLabels?: string[] + addAssignees?: string[] + removeAssignees?: string[] + } + ): Promise => { + if (!client || projectMutating) return + const slug = splitRepositorySlug(row.content.repository) + if (!slug || !row.content.number) { + setProjectRowDetailError('This project item cannot be edited from mobile.') + return + } + setProjectMutating(true) + try { + const response = await client.sendRequest( + 'github.project.updateIssueBySlug', + { + owner: slug.owner, + repo: slug.repo, + number: row.content.number, + updates + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: { message?: string } } + if (result.ok === false) { + throw new Error(result.error?.message ?? 'Failed to update GitHub item') + } + const applyContentUpdate = (candidate: GitHubProjectRow): GitHubProjectRow => { + const labels = new Map(candidate.content.labels.map((label) => [label.name, label])) + for (const label of updates.addLabels ?? []) { + if (!labels.has(label)) labels.set(label, { name: label, color: '808080' }) + } + for (const label of updates.removeLabels ?? []) labels.delete(label) + const assignees = new Map( + candidate.content.assignees.map((assignee) => [assignee.login, assignee]) + ) + for (const login of updates.addAssignees ?? []) { + if (!assignees.has(login)) assignees.set(login, { login, name: null }) + } + for (const login of updates.removeAssignees ?? []) assignees.delete(login) + return { + ...candidate, + content: { + ...candidate.content, + labels: [...labels.values()], + assignees: [...assignees.values()] + } + } + } + setProjectRowItem((current) => + current && current.id === row.id ? applyContentUpdate(current) : current + ) + setGithubProjectTable((table) => + table + ? { + ...table, + rows: table.rows.map((candidate) => + candidate.id === row.id ? applyContentUpdate(candidate) : candidate + ) + } + : table + ) + setProjectRowDetail((current) => + current?.provider === 'github' + ? { + ...current, + labels: [ + ...new Set([ + ...current.labels.filter( + (label) => !(updates.removeLabels ?? []).includes(label) + ), + ...(updates.addLabels ?? []) + ]) + ], + assignees: [ + ...new Set([ + ...current.assignees.filter( + (login) => !(updates.removeAssignees ?? []).includes(login) + ), + ...(updates.addAssignees ?? []) + ]) + ] + } + : current + ) + } catch (err) { + setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to update item') + } finally { + setProjectMutating(false) + } + }, + [client, projectMutating] + ) + + const mutateProjectRowField = useCallback( + async ( + row: GitHubProjectRow, + field: GitHubProjectField, + value: GitHubProjectFieldMutationValue | null + ): Promise => { + if (!client || !githubProjectTable || projectMutating) return + setProjectMutating(true) + try { + const response = await client.sendRequest( + value === null ? 'github.project.clearItemField' : 'github.project.updateItemField', + value === null + ? { + projectId: githubProjectTable.project.id, + itemId: row.id, + fieldId: field.id + } + : { + projectId: githubProjectTable.project.id, + itemId: row.id, + fieldId: field.id, + value + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: { message?: string } } + if (result.ok === false) { + throw new Error(result.error?.message ?? 'Failed to update project field') + } + const patchRow = (candidate: GitHubProjectRow): GitHubProjectRow => { + const fieldValuesByFieldId = { ...candidate.fieldValuesByFieldId } + if (value === null) { + delete fieldValuesByFieldId[field.id] + } else { + fieldValuesByFieldId[field.id] = optimisticProjectFieldValue(field, value) + } + return { ...candidate, fieldValuesByFieldId } + } + setProjectRowItem((current) => + current && current.id === row.id ? patchRow(current) : current + ) + setGithubProjectTable((table) => + table + ? { + ...table, + rows: table.rows.map((candidate) => + candidate.id === row.id ? patchRow(candidate) : candidate + ) + } + : table + ) + if (value === null) { + setProjectFieldDrafts((current) => ({ ...current, [field.id]: '' })) + } + } catch (err) { + setProjectRowDetailError( + err instanceof Error ? err.message : 'Failed to update project field' + ) + } finally { + setProjectMutating(false) + } + }, + [client, githubProjectTable, projectMutating] + ) + + const mutateProjectRowIssueType = useCallback( + async (row: GitHubProjectRow, issueType: GitHubIssueType | null): Promise => { + if (!client || projectMutating) return + const slug = splitRepositorySlug(row.content.repository) + if (row.itemType !== 'ISSUE' || !slug || !row.content.number) { + setProjectRowDetailError('This project issue type cannot be edited from mobile.') + return + } + setProjectMutating(true) + try { + const response = await client.sendRequest( + 'github.project.updateIssueTypeBySlug', + { + owner: slug.owner, + repo: slug.repo, + number: row.content.number, + issueTypeId: issueType?.id ?? null + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: { message?: string } } + if (result.ok === false) { + throw new Error(result.error?.message ?? 'Failed to update issue type') + } + const patchRow = (candidate: GitHubProjectRow): GitHubProjectRow => ({ + ...candidate, + content: { ...candidate.content, issueType } + }) + setProjectRowItem((current) => + current && current.id === row.id ? patchRow(current) : current + ) + setGithubProjectTable((table) => + table + ? { + ...table, + rows: table.rows.map((candidate) => + candidate.id === row.id ? patchRow(candidate) : candidate + ) + } + : table + ) + } catch (err) { + setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to update issue type') + } finally { + setProjectMutating(false) + } + }, + [client, projectMutating] + ) + + const requestProjectGitHubReviewers = useCallback( + async (row: GitHubProjectRow, logins?: string[]): Promise => { + const repo = findProjectRowRepo(row) + if (!client || projectMutating || row.itemType !== 'PULL_REQUEST' || !repo) return + const reviewers = logins ?? splitReviewerList(projectReviewersDraft) + if (reviewers.length === 0 || !row.content.number) return + setProjectMutating(true) + setProjectRowDetailError('') + try { + const response = await client.sendRequest( + 'github.requestPRReviewers', + { + repo: `id:${repo.id}`, + prNumber: row.content.number, + reviewers + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: string } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to request reviewers') + } + const nextReviewRequests = (() => { + const byLogin = new Map() + for (const reviewer of projectRowDetail?.provider === 'github' + ? projectRowDetail.reviewRequests + : []) { + const login = reviewer.login.trim() + if (login) byLogin.set(login.toLowerCase(), reviewer) + } + for (const login of reviewers) { + const normalized = login.trim().replace(/^@/, '') + if (normalized && !byLogin.has(normalized.toLowerCase())) { + byLogin.set(normalized.toLowerCase(), { + login: normalized, + name: null, + avatarUrl: null + }) + } + } + return Array.from(byLogin.values()) + })() + setProjectRowDetail((current) => + current?.provider === 'github' + ? { ...current, reviewRequests: nextReviewRequests } + : current + ) + if (!logins) { + setProjectReviewersDraft('') + } + } catch (err) { + setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to request reviewers') + } finally { + setProjectMutating(false) + } + }, + [client, findProjectRowRepo, projectMutating, projectReviewersDraft, projectRowDetail] + ) + + const refreshProjectGitHubChecks = useCallback( + async (row: GitHubProjectRow): Promise => { + const repo = findProjectRowRepo(row) + if ( + !client || + projectMutating || + row.itemType !== 'PULL_REQUEST' || + !repo || + !row.content.number + ) { + return + } + setProjectMutating(true) + setProjectRowDetailError('') + try { + const response = await client.sendRequest( + 'github.prChecks', + { + repo: `id:${repo.id}`, + prNumber: row.content.number, + headSha: projectRowDetail?.provider === 'github' ? projectRowDetail.headSha : undefined, + noCache: true + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + if (!Array.isArray(response.result)) { + throw new Error('Invalid checks response') + } + const checks = response.result as GitHubDetailCheck[] + setProjectRowDetail((current) => + current?.provider === 'github' ? { ...current, checks } : current + ) + } catch (err) { + setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to refresh checks') + } finally { + setProjectMutating(false) + } + }, + [client, findProjectRowRepo, projectMutating, projectRowDetail] + ) + + const rerunProjectGitHubChecks = useCallback( + async (row: GitHubProjectRow, failedOnly: boolean): Promise => { + const repo = findProjectRowRepo(row) + if ( + !client || + projectMutating || + row.itemType !== 'PULL_REQUEST' || + !repo || + !row.content.number + ) { + return + } + setProjectMutating(true) + setProjectRowDetailError('') + try { + const response = await client.sendRequest( + 'github.rerunPRChecks', + { + repo: `id:${repo.id}`, + prNumber: row.content.number, + headSha: projectRowDetail?.provider === 'github' ? projectRowDetail.headSha : undefined, + failedOnly + }, + { timeoutMs: 60_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: string } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to rerun checks') + } + setProjectRowDetailRefreshSeq((current) => current + 1) + } catch (err) { + setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to rerun checks') + } finally { + setProjectMutating(false) + } + }, + [client, findProjectRowRepo, projectMutating, projectRowDetail] + ) + + const toggleProjectGitHubFileViewed = useCallback( + async (row: GitHubProjectRow, file: GitHubDetailFile): Promise => { + const repo = findProjectRowRepo(row) + if (!client || projectMutating || row.itemType !== 'PULL_REQUEST' || !repo) return + if (projectRowDetail?.provider !== 'github' || !projectRowDetail.pullRequestId) { + setProjectRowDetailError('Unable to sync viewed state for this pull request.') + return + } + const viewed = file.viewerViewedState !== 'VIEWED' + setProjectMutating(true) + setProjectRowDetailError('') + try { + const response = await client.sendRequest( + 'github.setPRFileViewed', + { + repo: `id:${repo.id}`, + pullRequestId: projectRowDetail.pullRequestId, + path: file.path, + viewed + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + if (response.result !== true) { + throw new Error('Failed to sync viewed state with GitHub.') + } + setProjectRowDetail((current) => + current?.provider === 'github' + ? { + ...current, + files: current.files.map((candidate) => + candidate.path === file.path + ? { ...candidate, viewerViewedState: viewed ? 'VIEWED' : 'UNVIEWED' } + : candidate + ) + } + : current + ) + } catch (err) { + setProjectRowDetailError( + err instanceof Error ? err.message : 'Failed to update viewed state' + ) + } finally { + setProjectMutating(false) + } + }, + [client, findProjectRowRepo, projectMutating, projectRowDetail] + ) + + const toggleProjectGitHubFileExpansion = useCallback( + async (row: GitHubProjectRow, file: GitHubDetailFile): Promise => { + if (expandedPrFilePath === file.path) { + setExpandedPrFilePath(null) + return + } + setExpandedPrFilePath(file.path) + if (prFileContents[file.path]) { + return + } + const repo = findProjectRowRepo(row) + if ( + !client || + row.itemType !== 'PULL_REQUEST' || + !repo || + !row.content.number || + projectRowDetail?.provider !== 'github' || + !projectRowDetail.headSha || + !projectRowDetail.baseSha + ) { + setProjectRowDetailError('Unable to load file contents for this pull request.') + return + } + setPrFileLoadingPath(file.path) + setProjectRowDetailError('') + try { + const response = await client.sendRequest( + 'github.prFileContents', + { + repo: `id:${repo.id}`, + prNumber: row.content.number, + path: file.path, + oldPath: file.oldPath, + status: file.status ?? 'modified', + headSha: projectRowDetail.headSha, + baseSha: projectRowDetail.baseSha + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + setPrFileContents((current) => ({ + ...current, + [file.path]: response.result as GitHubPRFileContents + })) + } catch (err) { + setProjectRowDetailError( + err instanceof Error ? err.message : 'Failed to load file contents' + ) + } finally { + setPrFileLoadingPath(null) + } + }, + [client, expandedPrFilePath, findProjectRowRepo, prFileContents, projectRowDetail] + ) + + const addProjectGitHubFileReviewComment = useCallback( + async (row: GitHubProjectRow, file: GitHubDetailFile, line: number): Promise => { + const repo = findProjectRowRepo(row) + if ( + !client || + projectMutating || + row.itemType !== 'PULL_REQUEST' || + !repo || + !row.content.number + ) { + return + } + if (projectRowDetail?.provider !== 'github' || !projectRowDetail.headSha) { + setProjectRowDetailError('Unable to comment without the PR head SHA.') + return + } + const draftKey = `${file.path}:${line}` + const body = (prFileCommentDrafts[draftKey] ?? '').trim() + if (!body) return + setProjectMutating(true) + setProjectRowDetailError('') + try { + const response = await client.sendRequest( + 'github.addPRReviewComment', + { + repo: `id:${repo.id}`, + prNumber: row.content.number, + commitId: projectRowDetail.headSha, + path: file.path, + line, + body + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { + ok?: boolean + error?: string + comment?: DetailComment + } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to add review comment') + } + const comment: DetailComment = result.comment ?? { + id: `local-${Date.now()}`, + author: 'You', + body, + createdAt: new Date().toISOString(), + path: file.path, + line + } + setPrFileCommentDrafts((current) => { + const next = { ...current } + delete next[draftKey] + return next + }) + setProjectRowDetail((current) => + current?.provider === 'github' + ? { ...current, comments: [...current.comments, comment] } + : current + ) + } catch (err) { + setProjectRowDetailError( + err instanceof Error ? err.message : 'Failed to add review comment' + ) + } finally { + setProjectMutating(false) + } + }, + [client, findProjectRowRepo, prFileCommentDrafts, projectMutating, projectRowDetail] + ) + + const mergeProjectGitHubPullRequest = useCallback( + async (row: GitHubProjectRow, method: HostedReviewMergeMethod): Promise => { + const repo = findProjectRowRepo(row) + if ( + !client || + projectMutating || + row.itemType !== 'PULL_REQUEST' || + !repo || + !row.content.number + ) { + return + } + if (row.content.state === 'CLOSED' || row.content.state === 'MERGED') { + return + } + setProjectMutating(true) + setProjectRowDetailError('') + try { + const response = await client.sendRequest( + 'github.mergePR', + { + repo: `id:${repo.id}`, + prNumber: row.content.number, + method + }, + { timeoutMs: 60_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: string } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to merge pull request') + } + setProjectRowItem((current) => + current?.id === row.id + ? { ...current, content: { ...current.content, state: 'MERGED' } } + : current + ) + setGithubProjectTable((table) => + table + ? { + ...table, + rows: table.rows.map((candidate) => + candidate.id === row.id + ? { ...candidate, content: { ...candidate.content, state: 'MERGED' } } + : candidate + ) + } + : table + ) + } catch (err) { + setProjectRowDetailError( + err instanceof Error ? err.message : 'Failed to merge pull request' + ) + } finally { + setProjectMutating(false) + } + }, + [client, findProjectRowRepo, projectMutating] + ) + + const toggleGitHubStatus = useCallback( + async (item: Extract): Promise => { + if (!client || mutatingStatus || item.source.state === 'merged') return + setMutatingStatus(true) + setError('') + const nextState = item.source.state === 'closed' ? 'open' : 'closed' + try { + const method = item.source.type === 'issue' ? 'github.updateIssue' : 'github.updatePRState' + const params = + item.source.type === 'issue' + ? { + repo: `id:${item.source.repoId}`, + number: item.source.number, + updates: { state: nextState } + } + : { + repo: `id:${item.source.repoId}`, + prNumber: item.source.number, + updates: { state: nextState } + } + const response = await client.sendRequest(method, params) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: string } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to update GitHub status') + } + setActionItem(null) + await loadTasks({ silent: true }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update status') + } finally { + setMutatingStatus(false) + } + }, + [client, loadTasks, mutatingStatus] + ) + + const toggleGitLabStatus = useCallback( + async (item: Extract): Promise => { + if (!client || mutatingStatus || item.source.state === 'merged') return + setMutatingStatus(true) + setError('') + const nextState = item.source.state === 'closed' ? 'opened' : 'closed' + try { + const response = + item.source.type === 'issue' + ? await client.sendRequest('gitlab.updateIssue', { + repo: `id:${item.source.repoId}`, + number: item.source.number, + updates: { state: nextState }, + projectRef: item.source.projectRef + }) + : await client.sendRequest('gitlab.updateMRState', { + repo: `id:${item.source.repoId}`, + iid: item.source.number, + state: nextState, + projectRef: item.source.projectRef + }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: string } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to update GitLab item') + } + setActionItem(null) + await loadTasks({ silent: true }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update GitLab item') + } finally { + setMutatingStatus(false) + } + }, + [client, loadTasks, mutatingStatus] + ) + + const updateGitHubIssueMetadata = useCallback( + async ( + item: Extract, + updates: { + title?: string + body?: string + addLabels?: string[] + removeLabels?: string[] + addAssignees?: string[] + removeAssignees?: string[] + } + ): Promise => { + if (!client || mutatingStatus) return + setMutatingStatus(true) + setError('') + try { + const response = await client.sendRequest( + 'github.updateIssue', + { + repo: `id:${item.source.repoId}`, + number: item.source.number, + updates + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: string } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to update GitHub issue') + } + + const nextLabels = [ + ...new Set([ + ...(detailPayload?.provider === 'github' + ? detailPayload.labels.filter( + (label) => !(updates.removeLabels ?? []).includes(label) + ) + : item.source.labels.filter( + (label) => !(updates.removeLabels ?? []).includes(label) + )), + ...(updates.addLabels ?? []) + ]) + ] + const nextAssignees = + detailPayload?.provider === 'github' + ? [ + ...new Set([ + ...detailPayload.assignees.filter( + (login) => !(updates.removeAssignees ?? []).includes(login) + ), + ...(updates.addAssignees ?? []) + ]) + ] + : undefined + const nextTitle = updates.title?.trim() + setActionItem((current) => + current?.provider === 'github' && current.source.id === item.source.id + ? { + ...current, + ...(nextTitle ? { title: nextTitle } : {}), + source: { + ...current.source, + ...(nextTitle ? { title: nextTitle } : {}), + labels: nextLabels + } + } + : current + ) + setItems((current) => + current.map((candidate) => + candidate.provider === 'github' && candidate.source.id === item.source.id + ? { + ...candidate, + ...(nextTitle ? { title: nextTitle } : {}), + source: { + ...candidate.source, + ...(nextTitle ? { title: nextTitle } : {}), + labels: nextLabels + } + } + : candidate + ) + ) + setDetailPayload((current) => + current?.provider === 'github' + ? { + ...current, + labels: nextLabels, + ...(updates.body !== undefined ? { body: updates.body } : {}), + ...(nextAssignees ? { assignees: nextAssignees } : {}) + } + : current + ) + setItemAddLabelsDraft('') + setItemRemoveLabelsDraft('') + setItemAddAssigneesDraft('') + setItemRemoveAssigneesDraft('') + await loadTasks({ silent: true }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update GitHub issue') + } finally { + setMutatingStatus(false) + } + }, + [client, detailPayload, loadTasks, mutatingStatus] + ) + + const updateGitHubPullRequestMetadata = useCallback( + async ( + item: Extract, + updates: { title?: string; body?: string } + ): Promise => { + if (!client || mutatingStatus || item.source.type !== 'pr') return + const nextTitle = updates.title?.trim() + if (updates.title !== undefined && !nextTitle) return + setMutatingStatus(true) + setError('') + try { + const response = await client.sendRequest( + 'github.updatePR', + { + repo: `id:${item.source.repoId}`, + prNumber: item.source.number, + updates: { + ...(nextTitle !== undefined ? { title: nextTitle } : {}), + ...(updates.body !== undefined ? { body: updates.body } : {}) + } + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: string } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to update GitHub pull request') + } + if (nextTitle !== undefined) { + setActionItem((current) => + current?.provider === 'github' && current.source.id === item.source.id + ? { + ...current, + title: nextTitle, + source: { ...current.source, title: nextTitle } + } + : current + ) + setItems((current) => + current.map((candidate) => + candidate.provider === 'github' && candidate.source.id === item.source.id + ? { + ...candidate, + title: nextTitle, + source: { ...candidate.source, title: nextTitle } + } + : candidate + ) + ) + } + if (updates.body !== undefined) { + setDetailPayload((current) => + current?.provider === 'github' ? { ...current, body: updates.body ?? '' } : current + ) + } + await loadTasks({ silent: true }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update GitHub pull request') + } finally { + setMutatingStatus(false) + } + }, + [client, loadTasks, mutatingStatus] + ) + + const updateGitLabIssueMetadata = useCallback( + async ( + item: Extract, + updates: { + title?: string + body?: string + addLabels?: string[] + removeLabels?: string[] + addAssignees?: string[] + removeAssignees?: string[] + } + ): Promise => { + if (!client || mutatingStatus) return + setMutatingStatus(true) + setError('') + try { + const method = item.source.type === 'issue' ? 'gitlab.updateIssue' : 'gitlab.updateMR' + const params = + item.source.type === 'issue' + ? { + repo: `id:${item.source.repoId}`, + number: item.source.number, + updates, + projectRef: item.source.projectRef + } + : { + repo: `id:${item.source.repoId}`, + iid: item.source.number, + projectRef: item.source.projectRef, + updates: { + title: updates.title, + body: updates.body, + addLabels: updates.addLabels, + removeLabels: updates.removeLabels + } + } + const response = await client.sendRequest(method, params, { timeoutMs: 30_000 }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: string } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to update GitLab item') + } + const nextLabels = [ + ...new Set([ + ...(detailPayload?.provider === 'gitlab' + ? detailPayload.labels.filter( + (label) => !(updates.removeLabels ?? []).includes(label) + ) + : item.source.labels.filter( + (label) => !(updates.removeLabels ?? []).includes(label) + )), + ...(updates.addLabels ?? []) + ]) + ] + const nextAssignees = + detailPayload?.provider === 'gitlab' + ? [ + ...new Set([ + ...detailPayload.assignees.filter( + (login) => !(updates.removeAssignees ?? []).includes(login) + ), + ...(updates.addAssignees ?? []) + ]) + ] + : undefined + const nextTitle = updates.title?.trim() + setActionItem((current) => + current?.provider === 'gitlab' && current.source.id === item.source.id + ? { + ...current, + ...(nextTitle ? { title: nextTitle } : {}), + source: { + ...current.source, + ...(nextTitle ? { title: nextTitle } : {}), + labels: nextLabels + } + } + : current + ) + setItems((current) => + current.map((candidate) => + candidate.provider === 'gitlab' && candidate.source.id === item.source.id + ? { + ...candidate, + ...(nextTitle ? { title: nextTitle } : {}), + source: { + ...candidate.source, + ...(nextTitle ? { title: nextTitle } : {}), + labels: nextLabels + } + } + : candidate + ) + ) + setDetailPayload((current) => + current?.provider === 'gitlab' + ? { + ...current, + labels: nextLabels, + ...(updates.body !== undefined ? { body: updates.body } : {}), + ...(nextAssignees ? { assignees: nextAssignees } : {}) + } + : current + ) + setItemAddLabelsDraft('') + setItemRemoveLabelsDraft('') + if (item.source.type === 'issue') { + setItemAddAssigneesDraft('') + setItemRemoveAssigneesDraft('') + } + await loadTasks({ silent: true }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update GitLab item') + } finally { + setMutatingStatus(false) + } + }, + [client, detailPayload, loadTasks, mutatingStatus] + ) + + const addHostedItemComment = useCallback( + async ( + item: Extract | Extract + ): Promise => { + if (!client || mutatingStatus) return + const body = itemCommentDraft.trim() + if (!body) return + setMutatingStatus(true) + setError('') + try { + const response = + item.provider === 'github' + ? await client.sendRequest( + 'github.addIssueComment', + { + repo: `id:${item.source.repoId}`, + number: item.source.number, + body, + type: item.source.type + }, + { timeoutMs: 30_000 } + ) + : await client.sendRequest( + item.source.type === 'mr' ? 'gitlab.addMRComment' : 'gitlab.addIssueComment', + item.source.type === 'mr' + ? { + repo: `id:${item.source.repoId}`, + iid: item.source.number, + body, + projectRef: item.source.projectRef + } + : { + repo: `id:${item.source.repoId}`, + number: item.source.number, + body, + projectRef: item.source.projectRef + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { + ok?: boolean + error?: string + comment?: DetailComment + } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to add comment') + } + const comment: DetailComment = result.comment ?? { + id: `local-${Date.now()}`, + body, + createdAt: new Date().toISOString(), + author: 'You' + } + setItemCommentDraft('') + setDetailPayload((current) => + current && + ((item.provider === 'github' && current.provider === 'github') || + (item.provider === 'gitlab' && current.provider === 'gitlab')) + ? { ...current, comments: [...current.comments, comment] } + : current + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to add comment') + } finally { + setMutatingStatus(false) + } + }, + [client, itemCommentDraft, mutatingStatus] + ) + + const copyTaskLink = useCallback(async (key: string, url: string): Promise => { + try { + await Clipboard.setStringAsync(url) + setCopiedLinkKey(key) + setTimeout(() => { + setCopiedLinkKey((current) => (current === key ? null : current)) + }, 1500) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to copy link') + } + }, []) + + const copyTextToClipboard = useCallback(async (key: string, value: string): Promise => { + try { + await Clipboard.setStringAsync(value) + setCopiedLinkKey(key) + setTimeout(() => { + setCopiedLinkKey((current) => (current === key ? null : current)) + }, 1500) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to copy text') + } + }, []) + + const requestGitHubReviewers = useCallback( + async (item: Extract, logins?: string[]): Promise => { + if (!client || mutatingStatus || item.source.type !== 'pr') return + const reviewers = logins ?? splitReviewerList(itemReviewersDraft) + if (reviewers.length === 0) return + setMutatingStatus(true) + setError('') + try { + const response = await client.sendRequest( + 'github.requestPRReviewers', + { + repo: `id:${item.source.repoId}`, + prNumber: item.source.number, + reviewers + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: string } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to request reviewers') + } + const nextReviewRequests = (() => { + const byLogin = new Map() + for (const reviewer of detailPayload?.provider === 'github' + ? detailPayload.reviewRequests + : (item.source.reviewRequests ?? [])) { + const login = reviewer.login.trim() + if (login) byLogin.set(login.toLowerCase(), reviewer) + } + for (const login of reviewers) { + const normalized = login.trim().replace(/^@/, '') + if (normalized && !byLogin.has(normalized.toLowerCase())) { + byLogin.set(normalized.toLowerCase(), { + login: normalized, + name: null, + avatarUrl: null + }) + } + } + return Array.from(byLogin.values()) + })() + setActionItem((current) => + current?.provider === 'github' && current.source.id === item.source.id + ? { + ...current, + source: { ...current.source, reviewRequests: nextReviewRequests } + } + : current + ) + setItems((current) => + current.map((candidate) => + candidate.provider === 'github' && candidate.source.id === item.source.id + ? { + ...candidate, + source: { ...candidate.source, reviewRequests: nextReviewRequests } + } + : candidate + ) + ) + setDetailPayload((current) => + current?.provider === 'github' + ? { ...current, reviewRequests: nextReviewRequests } + : current + ) + if (!logins) { + setItemReviewersDraft('') + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to request reviewers') + } finally { + setMutatingStatus(false) + } + }, + [client, detailPayload, itemReviewersDraft, mutatingStatus] + ) + + const refreshGitHubChecks = useCallback( + async (item: Extract): Promise => { + if (!client || mutatingStatus || item.source.type !== 'pr') return + setMutatingStatus(true) + setError('') + try { + const response = await client.sendRequest( + 'github.prChecks', + { + repo: `id:${item.source.repoId}`, + prNumber: item.source.number, + headSha: detailPayload?.provider === 'github' ? detailPayload.headSha : undefined, + noCache: true + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + if (!Array.isArray(response.result)) { + throw new Error('Invalid checks response') + } + const checks = response.result as GitHubDetailCheck[] + const checksSummary = buildGitHubCheckSummary(checks) + setDetailPayload((current) => + current?.provider === 'github' ? { ...current, checks } : current + ) + setActionItem((current) => + current?.provider === 'github' && current.source.id === item.source.id + ? { + ...current, + source: { ...current.source, checksSummary } + } + : current + ) + setItems((current) => + current.map((candidate) => + candidate.provider === 'github' && candidate.source.id === item.source.id + ? { + ...candidate, + source: { ...candidate.source, checksSummary } + } + : candidate + ) + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to refresh checks') + } finally { + setMutatingStatus(false) + } + }, + [client, detailPayload, mutatingStatus] + ) + + const rerunGitHubChecks = useCallback( + async (item: Extract, failedOnly: boolean): Promise => { + if (!client || mutatingStatus || item.source.type !== 'pr') return + setMutatingStatus(true) + setError('') + try { + const response = await client.sendRequest( + 'github.rerunPRChecks', + { + repo: `id:${item.source.repoId}`, + prNumber: item.source.number, + headSha: detailPayload?.provider === 'github' ? detailPayload.headSha : undefined, + failedOnly + }, + { timeoutMs: 60_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: string } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to rerun checks') + } + setDetailRefreshSeq((current) => current + 1) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to rerun checks') + } finally { + setMutatingStatus(false) + } + }, + [client, detailPayload, mutatingStatus] + ) + + const toggleGitHubFileViewed = useCallback( + async ( + item: Extract, + file: NonNullable['files'][number]> + ): Promise => { + if (!client || mutatingStatus || item.source.type !== 'pr') return + if (detailPayload?.provider !== 'github' || !detailPayload.pullRequestId) { + setError('Unable to sync viewed state for this pull request.') + return + } + const viewed = file.viewerViewedState !== 'VIEWED' + setMutatingStatus(true) + setError('') + try { + const response = await client.sendRequest( + 'github.setPRFileViewed', + { + repo: `id:${item.source.repoId}`, + pullRequestId: detailPayload.pullRequestId, + path: file.path, + viewed + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + if (response.result !== true) { + throw new Error('Failed to sync viewed state with GitHub.') + } + setDetailPayload((current) => + current?.provider === 'github' + ? { + ...current, + files: current.files.map((candidate) => + candidate.path === file.path + ? { ...candidate, viewerViewedState: viewed ? 'VIEWED' : 'UNVIEWED' } + : candidate + ) + } + : current + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update viewed state') + } finally { + setMutatingStatus(false) + } + }, + [client, detailPayload, mutatingStatus] + ) + + const toggleGitHubReviewThread = useCallback( + async ( + item: Extract, + comment: DetailComment + ): Promise => { + if (!client || mutatingStatus || item.source.type !== 'pr' || !comment.threadId) return + const resolve = !comment.isResolved + setMutatingStatus(true) + setError('') + try { + const response = await client.sendRequest( + 'github.resolveReviewThread', + { + repo: `id:${item.source.repoId}`, + threadId: comment.threadId, + resolve + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + if (response.result !== true) { + throw new Error(resolve ? 'Failed to resolve thread' : 'Failed to reopen thread') + } + setDetailPayload((current) => + current?.provider === 'github' + ? { + ...current, + comments: current.comments.map((candidate) => + candidate.threadId === comment.threadId + ? { ...candidate, isResolved: resolve } + : candidate + ) + } + : current + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update review thread') + } finally { + setMutatingStatus(false) + } + }, + [client, mutatingStatus] + ) + + const toggleGitHubFileExpansion = useCallback( + async ( + item: Extract, + file: GitHubDetailFile + ): Promise => { + if (expandedPrFilePath === file.path) { + setExpandedPrFilePath(null) + return + } + setExpandedPrFilePath(file.path) + if (prFileContents[file.path]) { + return + } + if ( + !client || + item.source.type !== 'pr' || + detailPayload?.provider !== 'github' || + !detailPayload.headSha || + !detailPayload.baseSha + ) { + setError('Unable to load file contents for this pull request.') + return + } + setPrFileLoadingPath(file.path) + setError('') + try { + const response = await client.sendRequest( + 'github.prFileContents', + { + repo: `id:${item.source.repoId}`, + prNumber: item.source.number, + path: file.path, + oldPath: file.oldPath, + status: file.status ?? 'modified', + headSha: detailPayload.headSha, + baseSha: detailPayload.baseSha + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + setPrFileContents((current) => ({ + ...current, + [file.path]: response.result as GitHubPRFileContents + })) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load file contents') + } finally { + setPrFileLoadingPath(null) + } + }, + [client, detailPayload, expandedPrFilePath, prFileContents] + ) + + const addGitHubFileReviewComment = useCallback( + async ( + item: Extract, + file: GitHubDetailFile, + line: number + ): Promise => { + if (!client || mutatingStatus || item.source.type !== 'pr') return + if (detailPayload?.provider !== 'github' || !detailPayload.headSha) { + setError('Unable to comment without the PR head SHA.') + return + } + const draftKey = `${file.path}:${line}` + const body = (prFileCommentDrafts[draftKey] ?? '').trim() + if (!body) return + setMutatingStatus(true) + setError('') + try { + const response = await client.sendRequest( + 'github.addPRReviewComment', + { + repo: `id:${item.source.repoId}`, + prNumber: item.source.number, + commitId: detailPayload.headSha, + path: file.path, + line, + body + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { + ok?: boolean + error?: string + comment?: DetailComment + } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to add review comment') + } + const comment: DetailComment = result.comment ?? { + id: `local-${Date.now()}`, + author: 'You', + body, + createdAt: new Date().toISOString(), + path: file.path, + line + } + setPrFileCommentDrafts((current) => { + const next = { ...current } + delete next[draftKey] + return next + }) + setDetailPayload((current) => + current?.provider === 'github' + ? { ...current, comments: [...current.comments, comment] } + : current + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to add review comment') + } finally { + setMutatingStatus(false) + } + }, + [client, detailPayload, mutatingStatus, prFileCommentDrafts] + ) + + const replyToGitHubComment = useCallback( + async ( + item: Extract, + comment: DetailComment + ): Promise => { + if (!client || mutatingStatus) return + const key = String(comment.id) + const body = (itemReplyDrafts[key] ?? '').trim() + if (!body) return + setMutatingStatus(true) + setError('') + try { + const canUseReviewReply = + item.source.type === 'pr' && + comment.path && + typeof comment.line === 'number' && + typeof comment.id === 'number' + const response = canUseReviewReply + ? await client.sendRequest( + 'github.addPRReviewCommentReply', + { + repo: `id:${item.source.repoId}`, + prNumber: item.source.number, + commentId: comment.id, + body, + threadId: comment.threadId, + path: comment.path, + line: comment.line + }, + { timeoutMs: 30_000 } + ) + : await client.sendRequest( + 'github.addIssueComment', + { + repo: `id:${item.source.repoId}`, + number: item.source.number, + body: `@${commentAuthor(comment)} ${body}`, + type: item.source.type + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { + ok?: boolean + error?: string + comment?: DetailComment + } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to reply') + } + const reply: DetailComment = result.comment ?? { + id: `local-${Date.now()}`, + body, + createdAt: new Date().toISOString(), + author: 'You', + path: comment.path, + line: comment.line, + threadId: comment.threadId + } + setItemReplyDrafts((current) => { + const next = { ...current } + delete next[key] + return next + }) + setDetailPayload((current) => + current?.provider === 'github' + ? { ...current, comments: [...current.comments, reply] } + : current + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to reply') + } finally { + setMutatingStatus(false) + } + }, + [client, itemReplyDrafts, mutatingStatus] + ) + + const mergeHostedReview = useCallback( + async ( + item: Extract | Extract, + method: HostedReviewMergeMethod + ): Promise => { + if (!client || mutatingStatus) return + if (item.provider === 'github' && item.source.type !== 'pr') return + if (item.provider === 'gitlab' && item.source.type !== 'mr') return + if (item.provider === 'github' && isGitHubPrMergeBlocked(item)) { + setError('GitHub reports merge conflicts. Open in GitHub to continue.') + return + } + setMutatingStatus(true) + setError('') + try { + const response = + item.provider === 'github' + ? await client.sendRequest( + 'github.mergePR', + { + repo: `id:${item.source.repoId}`, + prNumber: item.source.number, + method + }, + { timeoutMs: 60_000 } + ) + : await client.sendRequest( + 'gitlab.mergeMR', + { + repo: `id:${item.source.repoId}`, + iid: item.source.number, + method, + projectRef: item.source.projectRef + }, + { timeoutMs: 60_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: string } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to merge') + } + setActionItem(null) + await loadTasks({ silent: true }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to merge') + } finally { + setMutatingStatus(false) + } + }, + [client, loadTasks, mutatingStatus] + ) + + const setLinearStatus = useCallback( + async ( + item: Extract, + state: LinearState, + options: { closeDetail?: boolean } = {} + ): Promise => { + if (!client || !taskUiReady || mutatingStatus) return + setMutatingStatus(true) + setError('') + try { + const response = await client.sendRequest('linear.updateIssue', { + id: item.source.id, + workspaceId: item.source.workspaceId, + updates: { stateId: state.id } + }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const nextState = { + name: state.name, + type: state.type, + color: state.color ?? item.source.state.color + } + setItems((current) => + current.map((entry) => + entry.provider === 'linear' && entry.source.id === item.source.id + ? createLinearTask({ ...entry.source, state: nextState }) + : entry + ) + ) + setActionItem((current) => { + if (!current || current.provider !== 'linear' || current.source.id !== item.source.id) { + return current + } + if (options.closeDetail !== false) { + return null + } + return createLinearTask({ + ...current.source, + state: nextState + }) as Extract + }) + await loadTasks({ silent: true }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update Linear issue') + } finally { + setMutatingStatus(false) + } + }, + [client, loadTasks, mutatingStatus, taskUiReady] + ) + + const addLinearComment = useCallback( + async (item: Extract): Promise => { + if (!client || mutatingStatus) return + const body = linearCommentDraft.trim() + if (!body) return + setMutatingStatus(true) + setError('') + try { + const response = await client.sendRequest( + 'linear.addIssueComment', + { + issueId: item.source.id, + workspaceId: item.source.workspaceId, + body + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; id?: string; error?: string } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to add comment') + } + const comment: DetailComment = { + id: result.id ?? `local-${Date.now()}`, + body, + createdAt: new Date().toISOString(), + user: { displayName: 'You' } + } + setLinearCommentDraft('') + setDetailPayload((current) => + current?.provider === 'linear' + ? { ...current, comments: [...current.comments, comment] } + : current + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to add Linear comment') + } finally { + setMutatingStatus(false) + } + }, + [client, linearCommentDraft, mutatingStatus] + ) + + const openLinearSubIssue = useCallback( + async (child: LinearIssueChild, workspaceId?: string): Promise => { + if (!client || mutatingStatus) return + setMutatingStatus(true) + setError('') + try { + const response = await client.sendRequest( + 'linear.getIssue', + { id: child.id, workspaceId }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const issue = response.result as LinearIssue | null + if (!issue) { + throw new Error('Sub-issue not found') + } + setActionItem(createLinearTask(issue) as Extract) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load Linear sub-issue') + } finally { + setMutatingStatus(false) + } + }, + [client, mutatingStatus] + ) + + const createLinearSubIssue = useCallback( + async (item: Extract): Promise => { + if (!client || mutatingStatus) return + const title = linearSubIssueTitle.trim() + if (!title) return + setMutatingStatus(true) + setError('') + try { + const response = await client.sendRequest( + 'linear.createIssue', + { + teamId: item.source.team.id, + title, + workspaceId: item.source.workspaceId, + parentIssueId: item.source.id, + projectId: item.source.project?.id ?? null + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { + ok?: boolean + id?: string + identifier?: string + title?: string + url?: string + error?: string + } + if (result.ok === false || !result.id || !result.identifier) { + throw new Error(result.error ?? 'Failed to create sub-issue') + } + const child: LinearIssueChild = { + id: result.id, + identifier: result.identifier, + title: result.title ?? title, + url: result.url ?? '' + } + setLinearSubIssueTitle('') + setDetailPayload((current) => + current?.provider === 'linear' + ? { + ...current, + children: current.children.some((entry) => entry.id === child.id) + ? current.children + : [...current.children, child] + } + : current + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create Linear sub-issue') + } finally { + setMutatingStatus(false) + } + }, + [client, linearSubIssueTitle, mutatingStatus] + ) + + const createTask = useCallback(async (): Promise => { + if (!client || !tasksSupported || !taskStateHydrated || creatingTask) return + const title = createTitle.trim() + if (!title) return + setCreatingTask(true) + setError('') + try { + if (provider === 'github' || provider === 'gitlab') { + const repo = hostedRepos.find((entry) => entry.id === createRepoId) ?? hostedRepos[0] + if (!repo) { + throw new Error( + `Add a Git repository before creating a ${provider === 'github' ? 'GitHub' : 'GitLab'} issue.` + ) + } + const response = await client.sendRequest( + provider === 'github' ? 'github.createIssue' : 'gitlab.createIssue', + { + repo: `id:${repo.id}`, + title, + body: createBody + } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { + ok?: boolean + number?: number + url?: string + error?: string + } + if (result.ok === false) { + throw new Error( + result.error ?? `Failed to create ${provider === 'github' ? 'GitHub' : 'GitLab'} issue` + ) + } + if (typeof result.number === 'number') { + const createdAt = new Date().toISOString() + if (provider === 'github') { + setActionItem( + createGitHubTask(repo, { + id: `issue:${result.number}`, + type: 'issue', + number: result.number, + title, + state: 'open', + url: result.url ?? '', + labels: [], + updatedAt: createdAt, + author: null + }) + ) + } else { + setActionItem( + createGitLabTask(repo, { + id: `issue:${result.number}`, + type: 'issue', + number: result.number, + title, + state: 'opened', + url: result.url ?? '', + labels: [], + updatedAt: createdAt, + author: null + }) + ) + } + } + } else { + const team = linearTeams.find((entry) => entry.id === createTeamId) ?? linearTeams[0] + if (!team) { + throw new Error('Select a Linear team first.') + } + const response = await client.sendRequest('linear.createIssue', { + teamId: team.id, + title, + description: createBody.trim() || undefined, + workspaceId: team.workspaceId + }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { + ok?: boolean + id?: string + identifier?: string + title?: string + url?: string + error?: string + } + if (result.ok === false || !result.id || !result.identifier) { + throw new Error(result.error ?? 'Failed to create Linear issue') + } + setActionItem( + createLinearTask({ + id: result.id, + workspaceId: team.workspaceId, + workspaceName: team.workspaceName, + identifier: result.identifier, + title: result.title ?? title, + description: createBody.trim(), + url: result.url ?? '', + state: { name: 'Open', type: 'unstarted', color: colors.accentBlue }, + team, + labels: [], + priority: 0, + updatedAt: new Date().toISOString() + }) as Extract + ) + } + setShowCreateTask(false) + setCreateTitle('') + setCreateBody('') + await loadTasks({ silent: true }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create task') + } finally { + setCreatingTask(false) + } + }, [ + client, + createBody, + createRepoId, + createTeamId, + createTitle, + creatingTask, + hostedRepos, + linearTeams, + loadTasks, + provider, + taskStateHydrated, + tasksSupported + ]) + + const setGitHubIssueSourcePreference = useCallback( + async (repo: RepoSummary, preference: 'upstream' | 'origin'): Promise => { + if (!client || !taskUiReady) return + setError('') + try { + const response = await client.sendRequest( + 'repo.update', + { + repo: `id:${repo.id}`, + updates: { issueSourcePreference: preference } + }, + { timeoutMs: 15_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + setRepos((current) => + current.map((candidate) => + candidate.id === repo.id + ? { ...candidate, issueSourcePreference: preference } + : candidate + ) + ) + reposRef.current = reposRef.current.map((candidate) => + candidate.id === repo.id ? { ...candidate, issueSourcePreference: preference } : candidate + ) + await loadTasks({ silent: true }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update issue source') + } + }, + [client, loadTasks, taskUiReady] + ) + + const renderCommentComposer = (args: { + value: string + onChangeText: (next: string) => void + onSubmit: () => void + disabled?: boolean + }): ReactNode => { + const hasText = args.value.trim().length > 0 + return ( + + + {hasText ? ( + [ + styles.commentComposerSend, + pressed && !args.disabled && styles.commentComposerSendPressed, + args.disabled && styles.commentComposerSendDisabled + ]} + disabled={args.disabled} + onPress={args.onSubmit} + > + + + ) : null} + + ) + } + + const renderDetailComment = ( + comment: DetailComment, + options: { nested?: boolean } = {} + ): ReactNode => ( + + + {commentSourceLabel(comment)} + + + {commentAuthor(comment)} + {commentDate(comment.createdAt) ? ` · ${commentDate(comment.createdAt)}` : ''} + + + {renderCommentReactions(comment)} + {SHOW_MOBILE_COMMENT_THREAD_TOOLS && + actionItem?.provider === 'github' && + detailPayload?.provider === 'github' ? ( + + {SHOW_MOBILE_COMMENT_THREAD_TOOLS && + actionItem?.provider === 'github' && + detailPayload?.provider === 'github' ? ( + <> + {actionItem.source.type === 'pr' && comment.threadId ? ( + void toggleGitHubReviewThread(actionItem, comment)} + > + + {comment.isResolved ? 'Reopen thread' : 'Resolve thread'} + + + ) : null} + + setItemReplyDrafts((current) => ({ + ...current, + [String(comment.id)]: next + })) + } + placeholder="Reply" + placeholderTextColor={colors.textMuted} + multiline + textAlignVertical="top" + /> + void replyToGitHubComment(actionItem, comment)} + > + Reply + + + ) : null} + + ) : null} + + ) + + const renderDetailCommentGroup = (group: DetailCommentGroup): ReactNode => { + const id = detailCommentGroupId(group) + const root = detailCommentGroupRoot(group) + const count = detailCommentGroupCount(group) + const isCollapsedResolved = + isResolvedDetailCommentGroup(group) && !expandedResolvedCommentGroups.has(id) + + if (isCollapsedResolved) { + return ( + + setExpandedResolvedCommentGroups((current) => { + const next = new Set(current) + next.add(id) + return next + }) + } + > + + Resolved {group.kind === 'thread' ? 'thread' : 'comment'} by {commentAuthor(root)} + + {count > 1 ? `${count} comments` : 'Show'} + + ) + } + + return ( + + {group.kind === 'thread' + ? [ + renderDetailComment(group.root), + ...group.replies.map((reply) => renderDetailComment(reply, { nested: true })) + ] + : renderDetailComment(group.comment)} + + ) + } + + const createTargetOptions: PickerOption[] = + provider === 'github' || provider === 'gitlab' + ? hostedRepos.map((repo) => ({ + value: repo.id, + label: repo.displayName, + subtitle: repo.path, + renderIcon: () => ( + + ) + })) + : linearTeams.map((team) => ({ + value: team.id, + label: team.name, + subtitle: team.workspaceName + })) + const selectedCreateTarget = + provider === 'github' || provider === 'gitlab' + ? (hostedRepos.find((repo) => repo.id === createRepoId) ?? hostedRepos[0] ?? null) + : (linearTeams.find((team) => team.id === createTeamId) ?? linearTeams[0] ?? null) + const selectedCreateTargetLabel = + provider === 'github' || provider === 'gitlab' + ? ((selectedCreateTarget as RepoSummary | null)?.displayName ?? 'Select target') + : ((selectedCreateTarget as LinearTeam | null)?.name ?? 'Select target') + const providerLabel = + provider === 'github' ? 'GitHub' : provider === 'gitlab' ? 'GitLab' : 'Linear' + const showHeaderCreateTask = + provider === 'linear' || (provider === 'github' && githubMode === 'items') + const providerOptions = useMemo( + () => PROVIDER_OPTIONS.filter((option) => visibleProviders.includes(option.value)), + [visibleProviders] + ) + const selectedCreateRepo = + provider === 'github' || provider === 'gitlab' + ? (selectedCreateTarget as RepoSummary | null) + : null + const selectedCreateGitHubSources = + provider === 'github' && selectedCreateRepo + ? githubRepoSources[selectedCreateRepo.id] + : undefined + const selectedCreateIssuePreference = + selectedCreateRepo?.issueSourcePreference === 'origin' || + selectedCreateRepo?.issueSourcePreference === 'upstream' + ? selectedCreateRepo.issueSourcePreference + : 'upstream' + const githubIssueSourceRows = useMemo( + () => + selectedHostedRepos + .map((repo) => ({ repo, sources: githubRepoSources[repo.id] })) + .filter((entry): entry is { repo: RepoSummary; sources: GitHubRepoSources } => + hasGitHubIssueSourceChoice(entry.sources) + ), + [githubRepoSources, selectedHostedRepos] + ) + const githubIssueSourceLabel = + githubIssueSourceRows.length === 1 + ? issueSourceSlug( + githubIssueSourceRows[0]!.repo.issueSourcePreference === 'origin' + ? githubIssueSourceRows[0]!.sources.prs + : githubIssueSourceRows[0]!.sources.upstreamCandidate + ) + : `${githubIssueSourceRows.length} sources` + const repoPickerLabel = + selectedRepoIds.size === 0 || selectedHostedRepos.length === hostedRepos.length + ? 'All repos' + : selectedHostedRepos.length === 1 + ? selectedHostedRepos[0]!.displayName + : `${selectedHostedRepos.length} repos` + const repoPickerSelectedRepo = + selectedRepoIds.size > 0 && selectedHostedRepos.length === 1 ? selectedHostedRepos[0]! : null + const workspaceRepoOptions: PickerOption[] = workspaceRepos.map((repo) => ({ + value: repo.id, + label: repo.displayName, + subtitle: repo.path, + renderIcon: () => ( + + ) + })) + const sortedItems = useMemo(() => { + const next = [...items] + if (taskSort === 'repository') { + next.sort((a, b) => compareTasksByRepository(a, b, reposById)) + } else { + next.sort(compareTasksByUpdated) + } + return next + }, [items, reposById, taskSort]) + const displayedEntries = useMemo(() => { + if (taskSort !== 'repository') { + return sortedItems.map((item) => ({ type: 'item', key: item.key, item })) + } + const entries: TaskListEntry[] = [] + let previousRepoKey = '' + for (const item of sortedItems) { + const repo = taskRepositoryMeta(item, reposById) + if (repo.key !== previousRepoKey) { + entries.push({ + type: 'section', + key: `section:${repo.key}`, + label: repo.label, + color: repo.color + }) + previousRepoKey = repo.key + } + entries.push({ type: 'item', key: item.key, item }) + } + return entries + }, [reposById, sortedItems, taskSort]) + const sortLabel = SORT_OPTIONS.find((option) => option.value === taskSort)?.label ?? 'Updated' + const githubProjectFields = githubProjectTable?.selectedView.fields ?? [] + const githubProjectViewSort = githubProjectTable?.selectedView.sortByFields?.[0] ?? null + const githubProjectSortField = githubProjectSortOverride + ? githubProjectFields.find((field) => field.id === githubProjectSortOverride.fieldId) + : githubProjectViewSort?.field + const githubProjectSortDirection = + githubProjectSortOverride?.direction ?? githubProjectViewSort?.direction ?? null + const githubProjectSortLabel = githubProjectSortField + ? `${githubProjectSortField.name} ${githubProjectSortDirection === 'DESC' ? 'desc' : 'asc'}` + : 'View order' + const githubProjectFieldsLabel = + githubProjectAvailableSummaryFields.length > 0 + ? `${githubProjectSummaryFields.length}/${githubProjectAvailableSummaryFields.length} fields` + : 'Fields' + const githubProjectSortOptions = useMemo[]>( + () => [ + { + value: PROJECT_VIEW_DEFAULT_SORT, + label: 'View order', + subtitle: githubProjectViewSort + ? `Uses ${githubProjectViewSort.field.name} ${githubProjectViewSort.direction.toLowerCase()}` + : 'Uses GitHub rank order' + }, + ...githubProjectFields.map((field) => { + const active = githubProjectSortOverride?.fieldId === field.id + const nextDirection = + !active || githubProjectSortOverride.direction === 'DESC' ? 'ascending' : 'descending' + return { + value: field.id, + label: field.name, + subtitle: active + ? `Currently ${githubProjectSortOverride.direction.toLowerCase()} · tap for ${nextDirection}` + : 'Sort ascending' + } + }) + ], + [githubProjectFields, githubProjectSortOverride, githubProjectViewSort] + ) + const githubPresetOptions = githubKind === 'prs' ? PR_PRESETS : ISSUE_PRESETS + const githubPresetPickerOptions = useMemo( + () => + githubPresetOptions.map((option) => + option.value === defaultGitHubPreset + ? { ...option, subtitle: option.subtitle ? `${option.subtitle} · Default` : 'Default' } + : option + ), + [defaultGitHubPreset, githubPresetOptions] + ) + const githubPresetLabel = + githubPresetOptions.find((preset) => preset.value === githubPreset)?.label ?? 'Open' + const gitlabFilterLabel = + GITLAB_FILTER_OPTIONS.find((filter) => filter.value === gitlabFilter)?.label ?? 'Open' + const linearFilterLabel = + LINEAR_FILTER_OPTIONS.find((filter) => filter.value === linearFilter)?.label ?? 'All' + const linearViewLabel = + LINEAR_VIEW_OPTIONS.find((option) => option.value === linearViewMode)?.label ?? 'List' + const linearGroupLabel = + LINEAR_GROUP_OPTIONS.find((option) => option.value === linearGroupBy)?.label ?? 'No grouping' + const linearOrderLabel = + LINEAR_ORDER_OPTIONS.find((option) => option.value === linearOrderBy)?.label ?? 'Priority' + const linearWorkspaceLabel = + selectedLinearWorkspaceId === 'all' + ? 'All workspaces' + : (linearWorkspaces.find((workspace) => workspace.id === selectedLinearWorkspaceId) + ?.organizationName ?? + linearWorkspaces.find((workspace) => workspace.id === selectedLinearWorkspaceId) + ?.displayName ?? + 'Workspace') + const linearTeamLabel = + selectedLinearTeamIds.size === 0 || selectedLinearTeamIds.size === linearTeams.length + ? 'All teams' + : selectedLinearTeamIds.size === 1 + ? (linearTeams.find((team) => selectedLinearTeamIds.has(team.id))?.name ?? '1 team') + : `${selectedLinearTeamIds.size} teams` + const effectiveLinearDisplayProperties = useMemo(() => { + const next = new Set(linearDisplayProperties) + if (linearGroupBy === 'status') next.delete('state') + if (linearGroupBy === 'assignee') next.delete('assignee') + if (linearGroupBy === 'priority') next.delete('priority') + if (linearGroupBy === 'team') next.delete('team') + if (selectedLinearTeamIds.size <= 1 && !linearTeamPropertyTouched) { + next.delete('team') + } else if (selectedLinearTeamIds.size > 1 && !linearTeamPropertyTouched) { + next.add('team') + } + return next + }, [ + linearDisplayProperties, + linearGroupBy, + linearTeamPropertyTouched, + selectedLinearTeamIds.size + ]) + const linearIssuesForView = useMemo( + () => + items + .filter( + (item): item is Extract => item.provider === 'linear' + ) + .map((item) => item.source) + .sort((a, b) => compareLinearIssues(a, b, linearOrderBy)), + [items, linearOrderBy] + ) + const linearIssueSections = useMemo( + () => groupLinearIssues(linearIssuesForView, linearGroupBy, linearOrderBy), + [linearGroupBy, linearIssuesForView, linearOrderBy] + ) + const linearBoardSections = useMemo( + () => + groupLinearIssues( + linearIssuesForView, + linearGroupBy === 'none' ? 'status' : linearGroupBy, + linearOrderBy + ), + [linearGroupBy, linearIssuesForView, linearOrderBy] + ) + const githubModeLabel = + githubMode === 'project' ? 'Projects' : githubKind === 'prs' ? 'PRs' : 'Issues' + const activeProjectLabel = githubProjectTable + ? githubProjectTable.project.title + : activeGitHubProject + ? `${activeGitHubProject.owner} #${activeGitHubProject.number}` + : 'Choose project' + const selectedGitHubProjectViewUrl = githubProjectTable + ? `${githubProjectTable.project.url}/views/${githubProjectTable.selectedView.number}` + : null + const githubProjectsByKey = useMemo( + () => new Map(githubProjects.map((project) => [githubProjectKey(project), project])), + [githubProjects] + ) + const pinnedGitHubProjects = useMemo( + () => + githubProjectSettings.pinned.map((project) => ({ + ...project, + summary: githubProjectsByKey.get(githubProjectKey(project)) + })), + [githubProjectSettings.pinned, githubProjectsByKey] + ) + const recentGitHubProjects = useMemo( + () => + githubProjectSettings.recent + .filter( + (recent) => + !githubProjectSettings.pinned.some( + (pinned) => githubProjectKey(pinned) === githubProjectKey(recent) + ) + ) + .map((project) => ({ + ...project, + summary: githubProjectsByKey.get(githubProjectKey(project)) + })), + [githubProjectSettings.pinned, githubProjectSettings.recent, githubProjectsByKey] + ) + const browseGitHubProjects = useMemo(() => { + const queryText = githubProjectPickerSearch.trim().toLowerCase() + const pinnedOrRecentKeys = new Set([ + ...githubProjectSettings.pinned.map(githubProjectKey), + ...githubProjectSettings.recent.map(githubProjectKey) + ]) + return githubProjects.filter((project) => { + if (pinnedOrRecentKeys.has(githubProjectKey(project))) return false + if (!queryText) return true + return ( + project.title.toLowerCase().includes(queryText) || + project.owner.toLowerCase().includes(queryText) || + String(project.number).includes(queryText) + ) + }) + }, [ + githubProjectPickerSearch, + githubProjectSettings.pinned, + githubProjectSettings.recent, + githubProjects + ]) + + const toggleRepoSelection = useCallback( + (repoId: string) => { + setSelectedRepoIds((current) => { + const next = new Set(current) + if (next.has(repoId)) { + next.delete(repoId) + } else { + next.add(repoId) + } + const normalized = + next.size === 0 || next.size === hostedRepos.length ? new Set() : next + persistRepoSelection(normalized, hostedRepos) + return normalized + }) + }, + [hostedRepos, persistRepoSelection] + ) + + const applyGitHubProjectSearch = useCallback(() => { + const viewFilter = githubProjectTable?.selectedView.filter ?? '' + const next = githubProjectSearch + setAppliedGithubProjectSearch(next === viewFilter ? undefined : next) + }, [githubProjectSearch, githubProjectTable?.selectedView.filter]) + + const headerVerdict = classifyConnection({ + state: connState, + reconnectAttempts, + lastConnectedAt + }) + const emptyLabel = + connState !== 'connected' + ? 'Connect to a host to load tasks' + : query + ? 'No matching tasks' + : provider === 'github' + ? 'No GitHub tasks' + : provider === 'gitlab' + ? 'No GitLab tasks' + : 'No Linear tasks' + + return ( + + + + router.back()}> + + + + + Tasks + + { + if (!taskUiReady) return + if (provider === 'github' && githubMode === 'project') { + void loadGitHubProjectTable({ queryOverride: appliedGithubProjectSearch }) + return + } + void loadTasks({ silent: true }) + }} + > + + + {showHeaderCreateTask ? ( + { + if (!taskUiReady) return + if (provider === 'linear' && !linearConnected) { + setLinearApiKeyDraft('') + setLinearConnectState('idle') + setLinearConnectError('') + setShowLinearConnect(true) + return + } + setCreateTitle('') + setCreateBody('') + setShowCreateTask(true) + }} + > + + + ) : null} + + + + { + if (!taskUiReady) return + setShowProviderPicker(true) + }} + > + + {providerLabel} + + + {provider === 'gitlab' || (provider === 'github' && githubMode !== 'project') ? ( + { + if (!taskUiReady) return + setShowRepoPicker(true) + }} + > + {repoPickerSelectedRepo ? ( + + ) : null} + {repoPickerLabel} + + ) : null} + + {provider === 'github' && ( + <> + { + if (!taskUiReady) return + setShowGitHubKindPicker(true) + }} + > + {githubModeLabel} + + {githubMode === 'items' ? ( + <> + { + if (!taskUiReady) return + setShowGitHubPresetPicker(true) + }} + > + {githubPresetLabel} + + {githubIssueSourceRows.length > 0 ? ( + { + if (!taskUiReady) return + setShowGitHubIssueSourcePicker(true) + }} + > + + Source: {githubIssueSourceLabel} + + + ) : null} + + ) : ( + <> + { + if (!taskUiReady) return + setShowGitHubProjectPicker(true) + }} + > + {activeProjectLabel} + + {activeGitHubProjectView ? ( + { + if (!taskUiReady) return + setShowGitHubProjectViewPicker(true) + }} + > + + {activeGitHubProjectView.name} + + + ) : null} + {githubProjectTable ? ( + { + if (!taskUiReady) return + setShowGitHubProjectSortPicker(true) + }} + > + + Sort: {githubProjectSortLabel} + + + ) : null} + {githubProjectAvailableSummaryFields.length > 0 ? ( + { + if (!taskUiReady) return + setShowGitHubProjectFieldsPicker(true) + }} + > + + Fields: {githubProjectFieldsLabel} + + + ) : null} + {githubProjectTable ? ( + + + {visibleGitHubProjectRows.length} + + + ) : null} + {selectedGitHubProjectViewUrl ? ( + { + if (!taskUiReady) return + void Linking.openURL(selectedGitHubProjectViewUrl) + }} + > + + + ) : null} + + )} + + )} + + {provider === 'gitlab' && ( + <> + { + if (!taskUiReady) return + setShowGitLabViewPicker(true) + }} + > + + {gitlabView === 'project' ? 'Project MRs' : 'My Todos'} + + + {gitlabView === 'project' && ( + { + if (!taskUiReady) return + setShowGitLabFilterPicker(true) + }} + > + {gitlabFilterLabel} + + )} + + )} + + {provider === 'linear' && linearConnected && ( + <> + {linearWorkspaces.length > 1 ? ( + { + if (!taskUiReady) return + setShowLinearWorkspacePicker(true) + }} + > + {linearWorkspaceLabel} + + ) : null} + { + if (!taskUiReady) return + setShowLinearTeamPicker(true) + }} + > + {linearTeamLabel} + + { + if (!taskUiReady) return + setShowLinearFilterPicker(true) + }} + > + {linearFilterLabel} + + { + if (!taskUiReady) return + setShowLinearViewPicker(true) + }} + > + {linearViewLabel} + + { + if (!taskUiReady) return + setShowLinearGroupPicker(true) + }} + > + Group: {linearGroupLabel} + + { + if (!taskUiReady) return + setShowLinearOrderPicker(true) + }} + > + Order: {linearOrderLabel} + + { + if (!taskUiReady) return + setShowLinearDisplayPicker(true) + }} + > + Display + + + )} + + {provider !== 'linear' && !(provider === 'github' && githubMode === 'project') ? ( + { + if (!taskUiReady) return + setShowSortPicker(true) + }} + > + + Sort: {sortLabel} + + ) : null} + + + {provider === 'gitlab' && gitlabView === 'todos' ? null : provider === 'linear' && + !linearConnected ? null : ( + + + { + if (!taskUiReady) return + if (provider === 'github' && githubMode === 'project') { + applyGitHubProjectSearch() + return + } + const nextQuery = + provider === 'github' ? scopeGitHubTaskSearch(query, githubKind) : query.trim() + setQuery(nextQuery) + setAppliedQuery(nextQuery) + if (provider === 'github') { + persistTaskResumeState({ + githubItemsPreset: + nextQuery.trim() === getTaskPresetQuery(githubPreset) ? githubPreset : null, + githubItemsQuery: nextQuery.trim() + }) + } else if (provider === 'linear') { + persistTaskResumeState({ linearQuery: nextQuery.trim() }) + } + }} + onBlur={() => { + if (provider === 'github' && githubMode === 'project') { + applyGitHubProjectSearch() + } + }} + /> + {(provider === 'github' && githubMode === 'project' + ? githubProjectSearch.length > 0 || appliedGithubProjectSearch !== undefined + : query.length > 0) && ( + { + if (provider === 'github' && githubMode === 'project') { + const viewFilter = githubProjectTable?.selectedView.filter ?? '' + setGithubProjectSearch('') + setAppliedGithubProjectSearch(viewFilter ? '' : undefined) + return + } + if (provider === 'github') { + const nextQuery = getTaskPresetQuery(githubPreset) + setQuery(nextQuery) + setAppliedQuery(nextQuery) + persistTaskResumeState({ + githubItemsPreset: githubPreset, + githubItemsQuery: nextQuery + }) + return + } + setQuery('') + setAppliedQuery('') + if (provider === 'linear') { + persistTaskResumeState({ linearQuery: '' }) + } + }} + > + + + )} + + )} + + + {error ? ( + + {error} + + ) : null} + + {!error && provider === 'github' && githubMode === 'items' + ? githubSourceFallbacks.map((fallback) => ( + + + Preferred issue source upstream is unavailable for {fallback.repoLabel}. Using + origin. + + + )) + : null} + + {!error && provider === 'github' && githubMode === 'items' + ? githubSourceErrors.map((sourceError) => { + const isRetrying = retryingGithubSourceRepoPaths.has(sourceError.repoPath) + return ( + + + + Couldn't load issues from{' '} + + {sourceError.source.owner}/{sourceError.source.repo} + + . + + + {sourceError.message} + + + void retryGitHubIssueSourceFetch(sourceError.repoPath)} + > + + {isRetrying ? 'Retrying...' : 'Retry'} + + + + ) + }) + : null} + + {!error && + provider === 'github' && + githubMode === 'project' && + githubProjectTable?.parentFieldDropped === true ? ( + + + + Sub-issue data is unavailable for your token. + + + ) : null} + + {!tasksSupported ? ( + tasksUnsupported ? ( + + Update Orca desktop + + This mobile Tasks view needs a newer desktop runtime. + + + ) : ( + + + + ) + ) : provider === 'linear' && !linearConnected ? ( + + + Connect your Linear account + + Browse and start work on your assigned Linear issues directly from Tasks. + + { + if (!taskUiReady) return + setLinearApiKeyDraft('') + setLinearConnectState('idle') + setLinearConnectError('') + setShowLinearConnect(true) + }} + > + Connect Linear + + + ) : provider === 'github' && githubMode === 'project' ? ( + githubProjectLoading ? ( + + + + ) : !activeGitHubProject ? ( + + Choose a GitHub project + { + if (!taskUiReady) return + setShowGitHubProjectPicker(true) + }} + > + Browse projects + + + ) : githubProjectError ? ( + + {githubProjectError} + + ) : githubProjectTable && !githubProjectRepoSlugReady ? ( + + + + ) : !githubProjectTable || visibleGitHubProjectRows.length === 0 ? ( + + No project items + + ) : ( + + entry.type === 'group' ? `group:${entry.group.key}` : entry.row.id + } + ItemSeparatorComponent={() => } + contentContainerStyle={[styles.list, { paddingBottom: spacing.lg + insets.bottom }]} + refreshing={githubProjectLoading} + onRefresh={() => + void loadGitHubProjectTable({ queryOverride: appliedGithubProjectSearch }) + } + renderItem={({ item: entry }) => { + if (entry.type === 'group') { + return ( + + setCollapsedGitHubProjectGroups((current) => { + const next = new Set(current) + if (next.has(entry.group.key)) next.delete(entry.group.key) + else next.add(entry.group.key) + return next + }) + } + > + + + {entry.group.label || 'Items'} + + {projectGroupMeta(entry.group)} + + ) + } + const row = entry.row + const repo = findProjectRowRepo(row) + return ( + [styles.taskRow, pressed && styles.taskRowPressed]} + onPress={() => { + triggerMediumImpact() + setProjectRowItem(row) + }} + > + + + + + + + {row.content.title} + + {formatUpdatedAt(row.updatedAt)} + + + + + {row.itemType === 'PULL_REQUEST' + ? 'Pull request' + : row.itemType === 'ISSUE' + ? 'Issue' + : 'Project item'}{' '} + · {row.content.repository ?? githubProjectTable.project.title} + {row.content.number ? ` #${row.content.number}` : ''} + + + {githubProjectSummaryFields.length > 0 ? ( + + {githubProjectSummaryFields.slice(0, 4).map((field) => { + const value = projectFieldDisplayLabel(row, field) + const isEmpty = value === 'Empty' + return ( + + + {field.name}:{' '} + + {value} + + + + ) + })} + {githubProjectSummaryFields.length > 4 ? ( + + + +{githubProjectSummaryFields.length - 4} fields + + + ) : null} + + ) : null} + + + + {projectRowStatusLabel(row)} + + + + ) + }} + /> + ) + ) : provider === 'linear' ? ( + loading ? ( + + + + ) : linearIssuesForView.length === 0 ? ( + + {emptyLabel} + + ) : linearViewMode === 'board' ? ( + + {linearBoardSections.map((section) => ( + + + + + {section.label} + + {section.issues.length} + + + {section.issues.map((issue) => ( + [styles.boardCard, pressed && styles.taskRowPressed]} + onPress={() => { + triggerMediumImpact() + setActionItem( + createLinearTask(issue) as Extract + ) + }} + > + + {issue.title} + + + {linearIssueSecondaryParts(issue, effectiveLinearDisplayProperties).join( + ' · ' + )} + + {effectiveLinearDisplayProperties.has('state') ? ( + { + event.stopPropagation() + triggerMediumImpact() + setLinearStatusPickerItem( + createLinearTask(issue) as Extract + ) + }} + > + + + {issue.state.name} + + + + ) : null} + + ))} + + + ))} + + ) : ( + + linearGroupBy === 'none' + ? section.issues.map((issue) => ({ type: 'issue' as const, issue })) + : [ + { type: 'section' as const, section }, + ...section.issues.map((issue) => ({ type: 'issue' as const, issue })) + ] + )} + keyExtractor={(entry) => + entry.type === 'section' ? `linear-section:${entry.section.key}` : entry.issue.id + } + ItemSeparatorComponent={({ leadingItem, trailingItem }) => + leadingItem?.type === 'section' || trailingItem?.type === 'section' ? null : ( + + ) + } + contentContainerStyle={[styles.list, { paddingBottom: spacing.lg + insets.bottom }]} + refreshing={refreshing} + onRefresh={() => void loadTasks({ silent: true })} + renderItem={({ item: entry }) => { + if (entry.type === 'section') { + return ( + + + + {entry.section.label} + + {entry.section.issues.length} + + ) + } + const issue = entry.issue + const linearTask = createLinearTask(issue) as Extract< + TaskItem, + { provider: 'linear' } + > + return ( + [styles.taskRow, pressed && styles.taskRowPressed]} + onPress={() => { + triggerMediumImpact() + setActionItem(linearTask) + }} + > + + + + + + + {issue.title} + + {effectiveLinearDisplayProperties.has('updated') ? ( + {formatUpdatedAt(issue.updatedAt)} + ) : null} + + + + + {linearIssueSecondaryParts(issue, effectiveLinearDisplayProperties).join( + ' · ' + )} + + + + + {effectiveLinearDisplayProperties.has('state') ? ( + { + event.stopPropagation() + triggerMediumImpact() + setLinearStatusPickerItem(linearTask) + }} + > + + + {issue.state.name} + + + + ) : null} + + + ) + }} + /> + ) + ) : loading ? ( + + + + ) : sortedItems.length === 0 ? ( + + {emptyLabel} + + ) : ( + entry.key} + ItemSeparatorComponent={({ leadingItem, trailingItem }) => + leadingItem?.type === 'section' || trailingItem?.type === 'section' ? null : ( + + ) + } + contentContainerStyle={[styles.list, { paddingBottom: spacing.lg + insets.bottom }]} + refreshing={refreshing} + onRefresh={() => void loadTasks({ silent: true })} + ListFooterComponent={ + provider === 'github' && githubMode === 'items' && githubCanShowPagination ? ( + + void handleGitHubPageChange(githubCurrentPage - 1)} + > + + + { + if (!taskUiReady) return + setShowGitHubPagePicker(true) + }} + > + + {githubTotalCount === null + ? `Page ${githubCurrentPage + 1}` + : `Page ${githubCurrentPage + 1} of ${githubTotalPages}`} + + + = githubTotalPages - 1) || + githubPaginationLoading + }} + style={[ + styles.paginationButton, + ((!githubCanLoadUncountedNextPage && + githubCurrentPage >= githubTotalPages - 1) || + githubPaginationLoading) && + styles.paginationButtonDisabled + ]} + disabled={ + (!githubCanLoadUncountedNextPage && + githubCurrentPage >= githubTotalPages - 1) || + githubPaginationLoading + } + onPress={() => void handleGitHubPageChange(githubCurrentPage + 1)} + > + {githubLoadingTargetPage === githubCurrentPage + 1 ? ( + + ) : ( + + )} + + + ) : null + } + renderItem={({ item: entry }) => { + if (entry.type === 'section') { + return ( + + + + {entry.label} + + + ) + } + const item = entry.item + const repo = taskRepositoryMeta(item, reposById) + const isGitHubPr = item.provider === 'github' && item.source.type === 'pr' + const githubPrDelta = isGitHubPr ? formatGitHubPRDelta(item.source) : null + const branchSummary = hostedBranchSummary(item) + return ( + [styles.taskRow, pressed && styles.taskRowPressed]} + onPress={() => { + triggerMediumImpact() + if (item.provider === 'gitlabTodo') { + void Linking.openURL(item.source.targetUrl) + return + } + setActionItem(item) + }} + > + + + + + + + {item.title} + + {formatUpdatedAt(item.updatedAt)} + + + + + {taskKindLabel(item)} · {item.subtitle} + + + {branchSummary ? ( + + + + {branchSummary.head} + + + into {branchSummary.base} + + + ) : null} + {isGitHubPr ? ( + + {githubPrDelta ? ( + + + {githubPrDelta} + + + ) : null} + + + {getGitHubReviewSummary(item.source)} + + + + + {getGitHubChecksLabel(item.source)} + + + + + {getGitHubMergeLabel(item.source)} + + + + ) : null} + + + + + {item.status} + + + + + ) + }} + /> + )} + + { + const resume = taskResumeRef.current + persistTaskSource(next) + setProvider(next) + setItems([]) + if (next === 'github') { + const nextMode = resume.githubMode === 'project' ? 'project' : 'items' + setGithubMode(nextMode) + if (nextMode === 'project') { + setQuery('') + setAppliedQuery('') + return + } + const preset = + resume.githubItemsPreset === null + ? githubPreset + : normalizeGitHubPreset(resume.githubItemsPreset ?? githubPreset) + const nextQuery = + resume.githubItemsPreset === null + ? (resume.githubItemsQuery ?? '') + : getTaskPresetQuery(preset) + const nextKind = githubKindFromQuery(nextQuery, preset) + setGithubPreset(preset) + setGithubKind(nextKind) + setQuery(nextQuery) + setAppliedQuery(scopeGitHubTaskSearch(nextQuery, nextKind)) + } else if (next === 'linear') { + const nextQuery = resume.linearQuery ?? '' + setLinearFilter(normalizeLinearFilter(resume.linearPreset)) + setQuery(nextQuery) + setAppliedQuery(nextQuery.trim()) + } else { + setQuery('') + setAppliedQuery('') + } + }} + onClose={() => setShowProviderPicker(false)} + /> + + setShowRepoPicker(false)} + > + + Repositories + Choose which repositories to query. + + + + { + const allSelection = new Set() + setSelectedRepoIds(allSelection) + persistRepoSelection(allSelection, hostedRepos) + }} + > + + All repositories + {repositoryCount(hostedRepos.length)} + + {selectedRepoIds.size === 0 ? : null} + + + {hostedRepos.map((repo) => { + const selected = selectedRepoIds.has(repo.id) + return ( + + + toggleRepoSelection(repo.id)} + > + + + + {repo.displayName} + + + {repo.path} + + + {selected ? : null} + + + ) + })} + + + + setShowGitHubIssueSourcePicker(false)} + > + + GitHub Issue Sources + + Choose whether each repository queries and creates work from upstream or origin. + + + + + {githubIssueSourceRows.length === 0 ? ( + + No alternate issue sources available. + + ) : ( + githubIssueSourceRows.map(({ repo, sources }, index) => { + const selectedPreference = + repo.issueSourcePreference === 'origin' || repo.issueSourcePreference === 'upstream' + ? repo.issueSourcePreference + : 'upstream' + return ( + + {index > 0 ? : null} + + + + {repo.displayName} + + + Querying{' '} + {issueSourceSlug( + selectedPreference === 'origin' ? sources.prs : sources.upstreamCandidate + )} + + + + {(['upstream', 'origin'] as const).map((preference) => { + const selected = selectedPreference === preference + const slug = + preference === 'upstream' + ? issueSourceSlug(sources.upstreamCandidate) + : issueSourceSlug(sources.prs) + return ( + void setGitHubIssueSourcePreference(repo, preference)} + > + + {preference === 'upstream' ? 'Upstream' : 'Origin'} + + + {slug} + + + ) + })} + + + + ) + }) + )} + + + + { + if (kind === 'project') { + setGithubMode('project') + setItems([]) + persistTaskResumeState({ githubMode: 'project' }) + return + } + const preset = kind === 'prs' ? 'prs' : 'issues' + const nextQuery = getTaskPresetQuery(preset) + setGithubMode('items') + setGithubKind(kind) + setGithubPreset(preset) + setQuery(nextQuery) + setAppliedQuery(nextQuery) + persistTaskResumeState({ + githubMode: 'items', + githubItemsPreset: preset, + githubItemsQuery: nextQuery + }) + }} + onClose={() => setShowGitHubKindPicker(false)} + /> + + { + const nextQuery = getTaskPresetQuery(preset) + setGithubMode('items') + setGithubKind(preset === 'issues' || preset === 'my-issues' ? 'issues' : 'prs') + setGithubPreset(preset) + setQuery(nextQuery) + setAppliedQuery(nextQuery) + persistTaskResumeState({ + githubItemsPreset: preset, + githubItemsQuery: nextQuery + }) + }} + onLongSelect={persistDefaultGitHubPreset} + onClose={() => setShowGitHubPresetPicker(false)} + /> + + setShowGitHubPagePicker(false)} + > + + GitHub Pages + Jump to a loaded or available result page. + + + {githubPagePickerPages.map((index) => { + const selected = index === githubCurrentPage + const loaded = index < githubPages.length + return ( + { + setShowGitHubPagePicker(false) + void handleGitHubPageChange(index) + }} + > + + Page {index + 1} + + {loaded ? 'Loaded' : 'Loads older results'} + + + {selected ? : null} + + ) + })} + + + + setShowGitHubProjectPicker(false)} + > + + GitHub Projects + Choose a project view for the Tasks page. + + + + + + { + setGithubProjectPasteInput(next) + setGithubProjectPasteError('') + }} + onSubmitEditing={() => void resolveGitHubProjectFromInput()} + placeholder="Add by URL or owner/number" + placeholderTextColor={colors.textMuted} + autoCapitalize="none" + autoCorrect={false} + /> + void resolveGitHubProjectFromInput()} + > + + {githubProjectPasteBusy ? 'Adding...' : 'Add'} + + + + {githubProjectPasteError ? ( + {githubProjectPasteError} + ) : null} + + + {githubProjectError ? {githubProjectError} : null} + + {githubProjectPartialFailures.length > 0 ? ( + + + + + {githubProjectPartialFailures.length === 1 && + githubProjectPartialFailures[0]!.owner !== '*' + ? `Couldn't load projects from ${githubProjectPartialFailures[0]!.owner}.` + : `Some organizations didn't load (${githubProjectPartialFailures.length}).`} + + + Use Add by URL to reach missing projects. + + + {githubProjectPartialFailures + .map( + (failure) => + `${failure.owner === '*' ? 'orgs' : failure.owner}: ${failure.message}` + ) + .join(' · ')} + + + + ) : null} + + + {githubProjectLoading && githubProjects.length === 0 ? ( + + + + ) : githubProjects.length === 0 && + pinnedGitHubProjects.length === 0 && + recentGitHubProjects.length === 0 ? ( + + void loadGitHubProjects().catch((err) => { + setGithubProjectError( + err instanceof Error ? err.message : 'Failed to load projects' + ) + }) + } + > + + No projects loaded + Tap to retry. + + + ) : ( + <> + {pinnedGitHubProjects.length > 0 ? ( + <> + Pinned + {pinnedGitHubProjects.map((project, index) => { + const key = githubProjectKey(project) + const selected = + activeGitHubProject !== null && githubProjectKey(activeGitHubProject) === key + return ( + + {index > 0 ? : null} + { + setShowGitHubProjectPicker(false) + void selectGitHubProject(project) + }} + > + + + {project.summary?.title ?? `#${project.number}`} + + + {project.owner} · #{project.number} + + + { + event.stopPropagation() + persistGitHubProjectSettings({ + ...githubProjectSettings, + pinned: githubProjectSettings.pinned.filter( + (entry) => githubProjectKey(entry) !== key + ) + }) + }} + > + Remove + + {selected ? : null} + + + ) + })} + + ) : null} + + {recentGitHubProjects.length > 0 ? ( + <> + Recent + {recentGitHubProjects.map((project, index) => { + const key = githubProjectKey(project) + const selected = + activeGitHubProject !== null && githubProjectKey(activeGitHubProject) === key + return ( + + {index > 0 ? : null} + { + setShowGitHubProjectPicker(false) + void selectGitHubProject(project) + }} + > + + + {project.summary?.title ?? `#${project.number}`} + + + {project.owner} · #{project.number} + + + {githubProjectSettings.lastViewByProject[key]?.viewId ? ( + { + event.stopPropagation() + persistGitHubProjectSettings({ + ...githubProjectSettings, + pinned: [ + ...githubProjectSettings.pinned, + { + owner: project.owner, + ownerType: project.ownerType, + number: project.number + } + ].slice(0, 20) + }) + }} + > + Pin + + ) : null} + {selected ? : null} + + + ) + })} + + ) : null} + + + {githubProjectLoading ? 'Browse all (loading...)' : 'Browse all'} + + {browseGitHubProjects.length === 0 ? ( + + {githubProjectPickerSearch.trim() ? 'No matching projects.' : 'No more projects.'} + + ) : ( + browseGitHubProjects.map((project, index) => { + const selected = + activeGitHubProject !== null && + githubProjectKey(activeGitHubProject) === githubProjectKey(project) + return ( + + {index > 0 ? : null} + { + setShowGitHubProjectPicker(false) + void selectGitHubProject(project) + }} + > + + + {project.title} + + + {project.owner} · #{project.number} + + + {selected ? : null} + + + ) + }) + )} + + )} + + + + ({ + value: view.id, + label: view.name, + subtitle: + view.layout === 'TABLE_LAYOUT' + ? `View #${view.number}` + : 'Unsupported layout on mobile', + disabled: view.layout !== 'TABLE_LAYOUT' + }))} + selected={pendingGitHubProjectViewSelection ? '' : (activeGitHubProjectViewId ?? '')} + onSelect={(viewId) => { + const view = githubProjectViews.find((candidate) => candidate.id === viewId) + if (view && view.layout !== 'TABLE_LAYOUT') { + setGithubProjectError("Orca doesn't support this GitHub Project layout yet.") + return + } + if (pendingGitHubProjectViewSelection) { + commitGitHubProjectView(pendingGitHubProjectViewSelection, viewId) + setPendingGitHubProjectViewSelection(null) + return + } + if (!activeGitHubProject || !activeGitHubProjectKey) return + commitGitHubProjectView(activeGitHubProject, viewId) + }} + onClose={() => { + setShowGitHubProjectViewPicker(false) + if (pendingGitHubProjectViewSelection) { + setPendingGitHubProjectViewSelection(null) + } + }} + /> + + { + if (fieldId === PROJECT_VIEW_DEFAULT_SORT) { + setGithubProjectSortOverride(null) + return + } + setGithubProjectSortOverride((current) => { + if (!current || current.fieldId !== fieldId) { + return { fieldId, direction: 'ASC' } + } + if (current.direction === 'ASC') { + return { fieldId, direction: 'DESC' } + } + return null + }) + }} + onClose={() => setShowGitHubProjectSortPicker(false)} + /> + + setShowGitHubProjectFieldsPicker(false)} + > + + Project Fields + + Choose which Project fields appear on item cards. + + + + {githubProjectAvailableSummaryFields.length === 0 ? ( + This view has no extra fields to show. + ) : ( + githubProjectAvailableSummaryFields.map((field, index) => { + const visible = !githubProjectHiddenFieldIds.has(field.id) + return ( + + {index > 0 ? : null} + toggleGitHubProjectFieldVisibility(field.id)} + > + + + {field.name} + + + {visible ? 'Shown on cards' : 'Hidden from cards'} + + + {visible ? : null} + + + ) + }) + )} + + + + { + setGitlabView(view) + if (view === 'todos') { + // Why: GitLab Todos is a server-side pending-todos stream; the + // runtime method has no search query, so clear item-search state + // when entering that view instead of carrying an invisible filter. + setQuery('') + setAppliedQuery('') + } + }} + onClose={() => setShowGitLabViewPicker(false)} + /> + + setShowGitLabFilterPicker(false)} + /> + + { + setLinearFilter(filter) + setQuery('') + setAppliedQuery('') + persistTaskResumeState({ linearPreset: filter, linearQuery: '' }) + }} + onClose={() => setShowLinearFilterPicker(false)} + /> + + ({ + value: workspace.id, + label: workspace.organizationName ?? workspace.displayName ?? workspace.id + })) + ]} + selected={selectedLinearWorkspaceId ?? ''} + onSelect={(workspaceId) => { + setSelectedLinearWorkspaceId(workspaceId) + setSelectedLinearTeamIds(new Set()) + if (client) { + void client + .sendRequest('linear.selectWorkspace', { workspaceId }) + .then(() => loadLinearContext()) + .catch((err) => { + setError(err instanceof Error ? err.message : 'Failed to switch workspace') + }) + } + }} + onClose={() => setShowLinearWorkspacePicker(false)} + /> + + setShowLinearTeamPicker(false)} + > + + Linear Teams + Choose which teams appear in Tasks. + + + { + const next = new Set(linearTeams.map((team) => team.id)) + setSelectedLinearTeamIds(next) + persistLinearTeamSelection(next, linearTeams) + }} + > + + All teams + {linearTeams.length} teams + + {selectedLinearTeamIds.size === linearTeams.length ? ( + + ) : null} + + {linearTeams.map((team) => { + const selected = selectedLinearTeamIds.has(team.id) + return ( + + + { + const next = new Set(selectedLinearTeamIds) + if (next.has(team.id)) { + next.delete(team.id) + } else { + next.add(team.id) + } + const normalized = + next.size === 0 || next.size === linearTeams.length + ? new Set(linearTeams.map((entry) => entry.id)) + : next + setSelectedLinearTeamIds(normalized) + persistLinearTeamSelection(normalized, linearTeams) + }} + > + + + {team.name} + + + {team.workspaceName ?? team.key} + + + {selected ? : null} + + + ) + })} + + + + setLinearStatusPickerItem(null)} + zIndex={TASK_SECONDARY_DRAWER_Z_INDEX} + > + + Change Status + + {linearStatusPickerItem?.source.identifier ?? 'Linear issue'} + + + + {linearStatesLoading ? ( + + + Loading states... + + ) : linearStates.length === 0 ? ( + No states available + ) : ( + linearStates.map((state, index) => { + const selected = + state.name === linearStatusPickerItem?.source.state.name && + state.type === linearStatusPickerItem?.source.state.type + return ( + + {index > 0 ? : null} + { + if (!linearStatusPickerItem) return + void setLinearStatus(linearStatusPickerItem, state, { + closeDetail: false + }).then(() => setLinearStatusPickerItem(null)) + }} + > + + + + {state.name} + + + {state.type} + + + {selected ? : null} + + + ) + }) + )} + + + + setShowLinearViewPicker(false)} + /> + + setShowLinearGroupPicker(false)} + /> + + setShowLinearOrderPicker(false)} + /> + + setShowLinearDisplayPicker(false)} + > + + Display Properties + + + {LINEAR_DISPLAY_OPTIONS.map((property, index) => { + const selected = effectiveLinearDisplayProperties.has(property.value) + return ( + + {index > 0 ? : null} + { + if (property.value === 'team') { + setLinearTeamPropertyTouched(true) + } + setLinearDisplayProperties((current) => { + const next = new Set(current) + if (next.has(property.value)) { + next.delete(property.value) + } else { + next.add(property.value) + } + return next + }) + }} + > + + {property.label} + + {selected ? : null} + + + ) + })} + + + + setShowSortPicker(false)} + /> + + { + setShowCreateTargetPicker(false) + setShowCreateTask(false) + }} + > + + + + New {providerLabel} Issue + + + {provider === 'github' || provider === 'gitlab' + ? 'Create an issue in the selected repository.' + : 'Create an issue in the selected Linear team.'} + + + + + + {provider === 'github' || provider === 'gitlab' ? 'Repository' : 'Team'} + + { + if (!taskUiReady) return + setShowCreateTargetPicker(true) + }} + > + {provider === 'github' || provider === 'gitlab' ? ( + + ) : null} + + {selectedCreateTargetLabel} + + + + + {provider === 'github' && + selectedCreateRepo && + hasGitHubIssueSourceChoice(selectedCreateGitHubSources) ? ( + + Issue source + + File in{' '} + {selectedCreateIssuePreference === 'origin' + ? issueSourceSlug(selectedCreateGitHubSources?.prs) + : issueSourceSlug(selectedCreateGitHubSources?.upstreamCandidate)} + + + {(['upstream', 'origin'] as const).map((preference) => { + const selected = selectedCreateIssuePreference === preference + const slug = + preference === 'upstream' + ? issueSourceSlug(selectedCreateGitHubSources?.upstreamCandidate) + : issueSourceSlug(selectedCreateGitHubSources?.prs) + return ( + + void setGitHubIssueSourcePreference(selectedCreateRepo, preference) + } + > + + {preference === 'upstream' ? 'Upstream' : 'Origin'} + + + {slug} + + + ) + })} + + + ) : null} + + Title + + + Description + + + void createTask()} + > + {creatingTask ? ( + + ) : ( + Create Issue + )} + + + + + { + if (provider === 'github' || provider === 'gitlab') setCreateRepoId(value) + else setCreateTeamId(value) + }} + onClose={() => setShowCreateTargetPicker(false)} + /> + + { + if (linearConnectState !== 'connecting') setShowLinearConnect(false) + }} + > + + + + Connect Linear workspace + + + Paste a Personal API key to browse issues from that workspace. + + + + Personal API key + { + setLinearApiKeyDraft(next) + if (linearConnectState === 'error') { + setLinearConnectState('idle') + setLinearConnectError('') + } + }} + placeholder="lin_api_..." + placeholderTextColor={colors.textMuted} + autoCapitalize="none" + autoCorrect={false} + secureTextEntry + editable={linearConnectState !== 'connecting'} + onSubmitEditing={() => void connectLinearAccount()} + /> + {linearConnectState === 'error' && linearConnectError ? ( + {linearConnectError} + ) : null} + void Linking.openURL('https://linear.app/settings/account/security')} + > + + Linear Settings / Security / New API key + + + + + Your key is encrypted via the host OS keychain and stored locally. + + + void connectLinearAccount()} + > + {linearConnectState === 'connecting' ? ( + + ) : ( + Connect + )} + + + + + { + if (workspaceRepoPickerItem) { + openWorkspaceCreate(workspaceRepoPickerItem, repoId) + } + setWorkspaceRepoPickerItem(null) + }} + onClose={() => setWorkspaceRepoPickerItem(null)} + zIndex={TASK_SECONDARY_DRAWER_Z_INDEX} + /> + + setWorkspaceCreateDraft(null)} + zIndex={TASK_SECONDARY_DRAWER_Z_INDEX} + > + {workspaceCreateDraft ? ( + + + Create Workspace + + {workspaceCreateDraft.item.title} + + + + + + Repository + setShowWorkspaceCreateRepoPicker(true)} + > + {workspaceCreateTargetRepo ? ( + + ) : null} + + {workspaceCreateTargetRepo?.displayName ?? 'Select a repository'} + + {workspaceCreateCanPickRepo ? ( + + ) : null} + + + + {workspaceCreateTargetConnectionId ? ( + + SSH Connection + + + + + + {workspaceCreateTargetRepo?.displayName ?? 'Remote repository'} + + + {workspaceSshStatusLabel(workspaceCreateSshStatus)} + + + {workspaceCreateSshStatus === 'connected' ? null : ( + void connectWorkspaceSshRepo()} + > + + {workspaceCreateSshConnectInProgress ? 'Connecting...' : 'Connect'} + + + )} + + {workspaceCreateSshError ? ( + {workspaceCreateSshError} + ) : null} + + + ) : null} + + + + Workspace Name [Optional] + + + + + + Agent + setShowWorkspaceAgentPicker(true)} + > + + + {workspaceCreateRequiresSshConnection + ? 'Connect repository first' + : workspaceAgentDetectionPending + ? 'Detecting agents...' + : workspaceAgentLabel(resolvedWorkspaceAgent)} + + + + + + setShowWorkspaceAdvanced((current) => !current)} + > + Advanced + {showWorkspaceAdvanced ? ( + + ) : ( + + )} + + + {showWorkspaceAdvanced ? ( + + Start from + { + setWorkspaceBaseBranchQuery(workspaceBaseBranch?.refName ?? '') + setShowWorkspaceBaseBranchPicker(true) + }} + > + + + {workspaceBaseBranch?.refName ?? 'Default branch'} + + + + {workspaceBaseBranch ? ( + + Create from {workspaceBaseBranch.refName} + + ) : null} + + ) : null} + + + {workspaceCreateTargetRepo ? null : ( + + {workspaceCreateDraft.item.provider === 'linear' + ? 'Add a Git repository before creating a Linear workspace.' + : 'Repository not found.'} + + )} + + + { + // Why: this compact issue-to-workspace flow should match the + // basic create workspace path; sparse checkout can return later. + void createWorkspace( + workspaceCreateDraft.item, + workspaceCreateDraft.repoIdOverride, + undefined, + resolvedWorkspaceAgent, + workspaceNameDraft.trim(), + undefined, + workspaceBaseBranch?.refName, + workspaceBranchNameOverride && + workspaceNameDraft.trim() === workspaceBranchAutoName + ? workspaceBranchNameOverride + : undefined, + undefined + ) + }} + > + {creatingKey === workspaceCreateDraft.item.key ? ( + + ) : ( + + {workspaceAgentDetectionPending + ? 'Detecting agents...' + : workspaceCreateRequiresSshConnection + ? 'Connect Repository' + : 'Create Workspace'} + + )} + + + + ) : null} + + + { + setWorkspaceCreateDraft((current) => + current ? { ...current, repoIdOverride: repoId } : current + ) + setShowWorkspaceCreateRepoPicker(false) + }} + onClose={() => setShowWorkspaceCreateRepoPicker(false)} + zIndex={TASK_SECONDARY_DRAWER_Z_INDEX + 1} + /> + + { + setWorkspaceAgentOverridden(true) + setWorkspaceAgent(agent) + setShowWorkspaceAgentPicker(false) + }} + onClose={() => setShowWorkspaceAgentPicker(false)} + zIndex={TASK_SECONDARY_DRAWER_Z_INDEX + 1} + /> + + setShowWorkspaceBaseBranchPicker(false)} + zIndex={TASK_SECONDARY_DRAWER_Z_INDEX + 1} + > + + + Start From + Pick an existing branch or ref. + + + + { + clearWorkspaceBaseBranch() + }} + > + + {workspaceBaseBranch === null ? ( + + ) : null} + + + Default branch + Use this repository's configured base + + + {workspaceBaseBranchLoading ? ( + + + + ) : workspaceBaseBranchError ? ( + {workspaceBaseBranchError} + ) : workspaceBaseBranchQuery.trim() && workspaceBaseBranchResults.length === 0 ? ( + No branches match. + ) : null} + {workspaceBaseBranchResults.map((branch) => ( + + + { + selectWorkspaceBaseBranch(branch) + }} + > + + {workspaceBaseBranch?.refName === branch.refName ? ( + + ) : null} + + + + {branch.refName} + + {branch.localBranchName !== branch.refName ? ( + + Branch name: {branch.localBranchName} + + ) : null} + + + + ))} + + + + + setShowWorkspaceSparsePicker(false)} + zIndex={TASK_SECONDARY_DRAWER_Z_INDEX + 1} + > + + + Sparse Checkout + + + { + setWorkspaceSparsePresetId(null) + setShowWorkspaceSparsePicker(false) + }} + > + + {workspaceSparsePresetId === null ? ( + + ) : null} + + + Full checkout + Use the whole repository + + + {workspaceSparsePresets.map((preset) => ( + + + + { + setWorkspaceSparsePresetId(preset.id) + setShowWorkspaceSparsePicker(false) + }} + > + + {workspaceSparsePresetId === preset.id ? ( + + ) : null} + + + + {preset.name} + + + {preset.directories.join(', ')} + + + + startEditWorkspaceSparsePreset(preset)} + > + + + + + ))} + + + New preset + + + + + { + if (!workspaceSparseSaving) setWorkspaceSparseDraft(null) + }} + zIndex={TASK_SECONDARY_DRAWER_Z_INDEX + 2} + > + {workspaceSparseDraft ? ( + + + + {workspaceSparseDraft.mode === 'new' ? 'New Sparse Preset' : 'Edit Sparse Preset'} + + + + + Name + + setWorkspaceSparseDraft({ ...workspaceSparseDraft, name }) + } + placeholder="Renderer UI" + placeholderTextColor={colors.textMuted} + autoCapitalize="none" + autoCorrect={false} + maxLength={80} + /> + + + Directories + + setWorkspaceSparseDraft({ ...workspaceSparseDraft, directoriesText }) + } + placeholder={'src/renderer\npackages/ui'} + placeholderTextColor={colors.textMuted} + autoCapitalize="none" + autoCorrect={false} + multiline + textAlignVertical="top" + /> + + + {workspaceSparseDraftError ?? + (workspaceSparseDraftParsed?.directories.length === 1 + ? '1 directory' + : `${workspaceSparseDraftParsed?.directories.length ?? 0} directories`)} + + + + setWorkspaceSparseDraft(null)} + > + Cancel + + void saveWorkspaceSparsePreset()} + > + {workspaceSparseSaving ? ( + + ) : null} + Save + + + + ) : null} + + + setSetupPrompt(null)} + zIndex={TASK_SECONDARY_DRAWER_Z_INDEX + 1} + > + {setupPrompt ? ( + + + Run Setup Script? + + {setupPrompt.repoName} requires a setup choice before creating this workspace. + + + + + + + {setupSourceLabel(setupPrompt.source)} + + + {setupPrompt.command} + + + + + void createWorkspace( + setupPrompt.item, + setupPrompt.repoIdOverride, + 'run', + setupPrompt.agentOverride, + setupPrompt.workspaceNameOverride, + setupPrompt.noteOverride, + setupPrompt.baseBranchOverride, + setupPrompt.branchNameOverride, + setupPrompt.sparseCheckoutOverride + ) + } + > + + + {creatingKey === setupPrompt.item.key ? 'Creating...' : 'Run setup and create'} + + + + + void createWorkspace( + setupPrompt.item, + setupPrompt.repoIdOverride, + 'skip', + setupPrompt.agentOverride, + setupPrompt.workspaceNameOverride, + setupPrompt.noteOverride, + setupPrompt.baseBranchOverride, + setupPrompt.branchNameOverride, + setupPrompt.sparseCheckoutOverride + ) + } + > + + Skip setup and create + + + + ) : null} + + + setOrcaYamlTrustPrompt(null)} + zIndex={TASK_SECONDARY_DRAWER_Z_INDEX + 1} + > + {orcaYamlTrustPrompt ? ( + + + + {orcaYamlTrustPrompt.previouslyApproved + ? `${orcaYamlTrustPrompt.repoName}'s setup script changed` + : `Run setup from ${orcaYamlTrustPrompt.repoName}?`} + + + This repository's orca.yaml runs on your machine before the workspace starts. Only + run it if you trust this repository. + + + + + + + {orcaYamlTrustPrompt.previouslyApproved ? 'New setup script' : 'Setup script'} + + + {orcaYamlTrustPrompt.scriptContent} + + + + + void (async () => { + try { + await persistSetupHookTrust( + orcaYamlTrustPrompt.repoId, + orcaYamlTrustPrompt.contentHash, + false + ) + setOrcaYamlTrustPrompt(null) + await createWorkspace( + orcaYamlTrustPrompt.item, + orcaYamlTrustPrompt.repoIdOverride, + 'run', + orcaYamlTrustPrompt.agentOverride, + orcaYamlTrustPrompt.workspaceNameOverride, + orcaYamlTrustPrompt.noteOverride, + orcaYamlTrustPrompt.baseBranchOverride, + orcaYamlTrustPrompt.branchNameOverride, + orcaYamlTrustPrompt.sparseCheckoutOverride, + orcaYamlTrustPrompt.contentHash + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to trust setup script.') + } + })() + } + > + + Run hooks + + + + void (async () => { + try { + await persistSetupHookTrust( + orcaYamlTrustPrompt.repoId, + orcaYamlTrustPrompt.contentHash, + true + ) + setOrcaYamlTrustPrompt(null) + await createWorkspace( + orcaYamlTrustPrompt.item, + orcaYamlTrustPrompt.repoIdOverride, + 'run', + orcaYamlTrustPrompt.agentOverride, + orcaYamlTrustPrompt.workspaceNameOverride, + orcaYamlTrustPrompt.noteOverride, + orcaYamlTrustPrompt.baseBranchOverride, + orcaYamlTrustPrompt.branchNameOverride, + orcaYamlTrustPrompt.sparseCheckoutOverride, + orcaYamlTrustPrompt.contentHash + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to trust setup script.') + } + })() + } + > + + Always trust and run + + + { + const prompt = orcaYamlTrustPrompt + setOrcaYamlTrustPrompt(null) + void createWorkspace( + prompt.item, + prompt.repoIdOverride, + 'skip', + prompt.agentOverride, + prompt.workspaceNameOverride, + prompt.noteOverride, + prompt.baseBranchOverride, + prompt.branchNameOverride, + prompt.sparseCheckoutOverride + ) + }} + > + + Don't run + + + + ) : null} + + + { + setProjectRepoNotInOrca(null) + }} + > + {projectRepoNotInOrca ? ( + + + Repository not in Orca + + {projectRepoNotInOrca.owner}/{projectRepoNotInOrca.repo} is not added to Orca. Add + this repository from the desktop app, then refresh mobile Tasks. + + + + + {projectRepoNotInOrca.url ? ( + { + if (projectRepoNotInOrca.url) void Linking.openURL(projectRepoNotInOrca.url) + }} + > + + Open in GitHub + + ) : null} + {projectRepoNotInOrca.url ? : null} + + void copyTextToClipboard( + `project-repo:${projectRepoNotInOrca.owner}/${projectRepoNotInOrca.repo}`, + `${projectRepoNotInOrca.owner}/${projectRepoNotInOrca.repo}` + ) + } + > + + + {copiedLinkKey === + `project-repo:${projectRepoNotInOrca.owner}/${projectRepoNotInOrca.repo}` + ? 'Copied' + : 'Copy repository'} + + + + + ) : null} + + + setProjectRowItem(null)} + > + {projectRowItem ? ( + + + + + + {projectRowItem.content.title} + + + + GitHub Project · {projectRowItem.content.repository ?? activeProjectLabel} + {projectRowItem.content.number ? ` #${projectRowItem.content.number}` : ''} + + + + + + + Type + + {projectRowItem.itemType === 'PULL_REQUEST' + ? 'Pull request' + : projectRowItem.itemType === 'ISSUE' + ? 'Issue' + : projectRowItem.itemType === 'DRAFT_ISSUE' + ? 'Draft issue' + : 'Project item'} + + + + Status + + {projectRowStatusLabel(projectRowItem)} + + + + {SHOW_MOBILE_DETAIL_LABEL_CHIPS && + (projectRowDetail?.provider === 'github' + ? projectRowDetail.labels + : projectRowItem.content.labels.map((label) => label.name) + ).length > 0 ? ( + + {(projectRowDetail?.provider === 'github' + ? projectRowDetail.labels + : projectRowItem.content.labels.map((label) => label.name) + ) + .slice(0, 6) + .map((label) => ( + + {label} + + ))} + + ) : null} + {SHOW_MOBILE_PROJECT_METADATA_EDITORS && projectRowItem.itemType === 'ISSUE' ? ( + + + Issue type + + {projectRowItem.content.issueType?.name ?? 'No type'} + + + {projectIssueTypesLoading ? ( + + + Loading issue types... + + ) : projectIssueTypesError ? ( + {projectIssueTypesError} + ) : projectIssueTypes.length === 0 ? ( + + No issue types configured for this repository. + + ) : ( + + {projectIssueTypes.map((issueType) => { + const selected = projectRowItem.content.issueType?.id === issueType.id + return ( + + void mutateProjectRowIssueType(projectRowItem, issueType) + } + > + + + {issueType.name} + + + ) + })} + {projectRowItem.content.issueType ? ( + void mutateProjectRowIssueType(projectRowItem, null)} + > + Clear type + + ) : null} + + )} + + ) : null} + {SHOW_MOBILE_PROJECT_METADATA_EDITORS && + editableProjectFields(githubProjectTable).length > 0 ? ( + + Project fields + {editableProjectFields(githubProjectTable).map((field) => { + const currentLabel = projectFieldValueLabel(projectRowItem, field) + const draftValue = projectFieldDrafts[field.id] ?? '' + const saveTextField = (): void => { + if (field.dataType === 'NUMBER') { + const number = Number(draftValue) + if (!Number.isFinite(number)) { + setProjectRowDetailError('Enter a valid number.') + return + } + void mutateProjectRowField(projectRowItem, field, { + kind: 'number', + number + }) + return + } + if (field.dataType === 'DATE') { + const date = draftValue.trim() + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + setProjectRowDetailError('Enter a date as YYYY-MM-DD.') + return + } + void mutateProjectRowField(projectRowItem, field, { kind: 'date', date }) + return + } + void mutateProjectRowField(projectRowItem, field, { + kind: 'text', + text: draftValue + }) + } + return ( + + + {field.name} + + {currentLabel} + + + {field.dataType === 'SINGLE_SELECT' && field.kind === 'single-select' ? ( + + {field.options.map((option) => { + const fieldValue = projectRowItem.fieldValuesByFieldId?.[field.id] + const selected = + fieldValue?.kind === 'single-select' && + fieldValue.optionId === option.id + return ( + + void mutateProjectRowField(projectRowItem, field, { + kind: 'single-select', + optionId: option.id + }) + } + > + + {selected ? ( + + ) : null} + {option.name} + + + ) + })} + + ) : field.dataType === 'ITERATION' && field.kind === 'iteration' ? ( + + {field.iterations.length === 0 ? ( + No iterations available. + ) : ( + field.iterations.map((iteration) => { + const fieldValue = projectRowItem.fieldValuesByFieldId?.[field.id] + const selected = + fieldValue?.kind === 'iteration' && + fieldValue.iterationId === iteration.id + return ( + + void mutateProjectRowField(projectRowItem, field, { + kind: 'iteration', + iterationId: iteration.id + }) + } + > + + {iteration.title} + + {iteration.completed ? 'Completed' : 'Current & upcoming'} ·{' '} + {iteration.startDate} · {iteration.duration}d + + + {selected ? ( + + ) : null} + + ) + }) + )} + + ) : ( + <> + + setProjectFieldDrafts((current) => ({ + ...current, + [field.id]: next + })) + } + placeholder={ + field.dataType === 'DATE' + ? 'YYYY-MM-DD' + : field.dataType === 'NUMBER' + ? 'Number' + : 'Text' + } + placeholderTextColor={colors.textMuted} + keyboardType={field.dataType === 'NUMBER' ? 'numeric' : 'default'} + autoCapitalize="none" + /> + + Save field + + + )} + void mutateProjectRowField(projectRowItem, field, null)} + > + Clear field + + + ) + })} + + ) : null} + {SHOW_MOBILE_PROJECT_METADATA_EDITORS && projectRowType(projectRowItem) ? ( + + + Labels + + {(projectRowDetail?.provider === 'github' + ? projectRowDetail.labels + : projectRowItem.content.labels.map((label) => label.name) + ).length || 'None'} + + + {projectLabelsLoading ? ( + + + Loading labels... + + ) : projectLabelsError ? ( + {projectLabelsError} + ) : projectAvailableLabels.length === 0 ? ( + No labels in this repository. + ) : ( + + {[ + ...new Set([ + ...projectAvailableLabels, + ...(projectRowDetail?.provider === 'github' + ? projectRowDetail.labels + : projectRowItem.content.labels.map((label) => label.name)) + ]) + ].map((label) => { + const selected = ( + projectRowDetail?.provider === 'github' + ? projectRowDetail.labels + : projectRowItem.content.labels.map((entry) => entry.name) + ).includes(label) + return ( + + void mutateProjectRowMetadata( + projectRowItem, + selected ? { removeLabels: [label] } : { addLabels: [label] } + ) + } + > + + {selected ? : null} + {label} + + + ) + })} + + )} + + ) : null} + {SHOW_MOBILE_PROJECT_METADATA_EDITORS && projectRowType(projectRowItem) ? ( + + + Assignees + + {(projectRowDetail?.provider === 'github' + ? projectRowDetail.assignees + : projectRowItem.content.assignees.map((assignee) => assignee.login) + ).length || 'None'} + + + {projectAssignableUsersLoading ? ( + + + Loading assignees... + + ) : projectAssignableUsersError ? ( + {projectAssignableUsersError} + ) : projectAssignableUsers.length === 0 ? ( + + No assignable users found for this repository. + + ) : ( + + {[ + ...new Map( + [ + ...projectAssignableUsers, + ...projectRowItem.content.assignees, + ...(projectRowDetail?.provider === 'github' + ? projectRowDetail.assignees.map((login) => ({ + login, + name: null, + avatarUrl: null + })) + : []) + ].map((user) => [user.login, user]) + ).values() + ].map((user) => { + const selected = ( + projectRowDetail?.provider === 'github' + ? projectRowDetail.assignees + : projectRowItem.content.assignees.map((assignee) => assignee.login) + ).includes(user.login) + return ( + + void mutateProjectRowMetadata( + projectRowItem, + selected + ? { removeAssignees: [user.login] } + : { addAssignees: [user.login] } + ) + } + > + + {selected ? : null} + {user.login} + + + ) + })} + + )} + + ) : null} + {projectRowDetailLoading ? ( + + + + ) : projectRowDetailError ? ( + {projectRowDetailError} + ) : ( + <> + {SHOW_MOBILE_PROJECT_METADATA_EDITORS && projectRowType(projectRowItem) ? ( + <> + + Title + + + void mutateProjectRowIssueOrPr(projectRowItem, { + title: projectTitleDraft.trim() + }) + } + > + Save title + + + + Description + + + void mutateProjectRowIssueOrPr(projectRowItem, { + body: projectBodyDraft + }) + } + > + Save description + + + + + ) : ( + <> + + Title + {projectRowItem.content.title} + + + Description + + + + )} + {SHOW_MOBILE_PROJECT_REVIEW_PANELS && + projectRowItem.itemType === 'PULL_REQUEST' && + projectRowDetail?.provider === 'github' && + projectRowHostedRepo ? ( + <> + + + Reviewers + + {getGitHubReviewSummary(projectRowDetail)} + + + {getGitHubReviewerRows(projectRowDetail).length === 0 ? ( + No reviewers requested. + ) : ( + getGitHubReviewerRows(projectRowDetail).map((reviewer) => ( + + + + {reviewer.login.slice(0, 1).toUpperCase()} + + + + + {reviewer.login} + + {reviewer.name ? ( + + {reviewer.name} + + ) : null} + + {reviewer.stateLabel} + + )) + )} + {projectAssignableUsersLoading ? ( + + + Loading reviewers... + + ) : projectAssignableUsersError ? ( + {projectAssignableUsersError} + ) : projectReviewerCandidates.length === 0 ? ( + No reviewer suggestions found. + ) : ( + + {projectReviewerCandidates.map((user) => { + const selected = projectSelectedReviewerLogins.has( + user.login.trim().toLowerCase() + ) + return ( + + void requestProjectGitHubReviewers(projectRowItem, [user.login]) + } + > + + {selected ? ( + + ) : null} + {user.login} + + + ) + })} + + )} + + void requestProjectGitHubReviewers(projectRowItem)} + > + Request review + + + + {projectRowType(projectRowItem) === 'pr' ? ( + + + Checks + + void refreshProjectGitHubChecks(projectRowItem)} + > + Refresh + + void rerunProjectGitHubChecks(projectRowItem, true)} + > + Rerun failed + + void rerunProjectGitHubChecks(projectRowItem, false)} + > + Rerun all + + + + {projectRowDetail.checks.length === 0 ? ( + No checks found. + ) : ( + projectRowDetail.checks.map((check) => ( + { + if (check.url) void Linking.openURL(check.url) + }} + > + + {check.name} · {check.conclusion ?? check.status} + + {check.url ? ( + + ) : null} + + )) + )} + + ) : null} + + {projectRowDetail.files.length > 0 ? ( + + Changed files + {projectRowDetail.files.map((file) => ( + + + void toggleProjectGitHubFileExpansion(projectRowItem, file) + } + > + + {file.path} + {typeof file.additions === 'number' || + typeof file.deletions === 'number' + ? ` · +${file.additions ?? 0} -${file.deletions ?? 0}` + : ''} + + + {expandedPrFilePath === file.path ? 'Hide' : 'View'} + + + + void toggleProjectGitHubFileViewed(projectRowItem, file) + } + > + + {file.viewerViewedState === 'VIEWED' + ? 'Mark unviewed' + : 'Mark viewed'} + + + {expandedPrFilePath === file.path ? ( + + {prFileLoadingPath === file.path ? ( + + ) : prFileContents[file.path]?.originalIsBinary || + prFileContents[file.path]?.modifiedIsBinary ? ( + Binary file. + ) : prFileContents[file.path] ? ( + + setPrFileCommentDrafts((current) => ({ + ...current, + [draftKey]: next + })) + } + onSubmitComment={(line) => + void addProjectGitHubFileReviewComment( + projectRowItem, + file, + line + ) + } + /> + ) : ( + + File contents unavailable. + + )} + + ) : null} + + ))} + + ) : null} + + ) : null} + {projectRowDetail?.provider === 'github' ? ( + + + Discussion + + {discussionSummary(projectRowDetail.comments.length)} + + + {projectRowDetail.comments.length === 0 ? ( + No comments. + ) : ( + groupDetailComments(projectRowDetail.comments).map((group) => { + const groupId = detailCommentGroupId(group) + const root = detailCommentGroupRoot(group) + const count = detailCommentGroupCount(group) + const isCollapsedResolved = + isResolvedDetailCommentGroup(group) && + !expandedResolvedCommentGroups.has(groupId) + if (isCollapsedResolved) { + return ( + + setExpandedResolvedCommentGroups((current) => { + const next = new Set(current) + next.add(groupId) + return next + }) + } + > + + Resolved {group.kind === 'thread' ? 'thread' : 'comment'} by{' '} + {commentAuthor(root)} + + + {count > 1 ? `${count} comments` : 'Show'} + + + ) + } + + const renderProjectComment = ( + comment: DetailComment, + options: { nested?: boolean } = {} + ): ReactNode => { + const commentId = String(comment.id) + const isEditingComment = + SHOW_MOBILE_COMMENT_THREAD_TOOLS && + projectEditingCommentId === commentId + return ( + + + {commentSourceLabel(comment)} + + + {commentAuthor(comment)} + {commentDate(comment.createdAt) + ? ` · ${commentDate(comment.createdAt)}` + : ''} + + {isEditingComment ? ( + <> + + + + void updateProjectRowComment(projectRowItem, comment) + } + > + Save + + { + setProjectEditingCommentId(null) + setProjectEditingCommentDraft('') + }} + > + Cancel + + + + ) : ( + <> + + {renderCommentReactions(comment)} + {SHOW_MOBILE_COMMENT_THREAD_TOOLS ? ( + + {projectRowType(projectRowItem) === 'pr' && + comment.threadId ? ( + + void toggleProjectGitHubReviewThread( + projectRowItem, + comment + ) + } + > + + {comment.isResolved + ? 'Reopen thread' + : 'Resolve thread'} + + + ) : null} + + setItemReplyDrafts((current) => ({ + ...current, + [commentId]: next + })) + } + placeholder="Reply" + placeholderTextColor={colors.textMuted} + multiline + textAlignVertical="top" + /> + + void replyToProjectGitHubComment( + projectRowItem, + comment + ) + } + > + Reply + + { + setProjectEditingCommentId(commentId) + setProjectEditingCommentDraft(comment.body) + }} + > + Edit + + + void deleteProjectRowComment(projectRowItem, comment) + } + > + Delete + + + ) : null} + + )} + + ) + } + + return ( + + {group.kind === 'thread' + ? [ + renderProjectComment(group.root), + ...group.replies.map((reply) => + renderProjectComment(reply, { nested: true }) + ) + ] + : renderProjectComment(group.comment)} + + ) + }) + )} + {renderCommentComposer({ + value: projectCommentDraft, + onChangeText: setProjectCommentDraft, + disabled: projectMutating, + onSubmit: () => void addProjectRowComment(projectRowItem) + })} + + ) : null} + + )} + + + + {canCreateWorkspaceFromProjectRow(projectRowItem) ? ( + void createWorkspaceFromProjectRow(projectRowItem)} + > + + Create Workspace + + ) : ( + + Workspaces can only be created from GitHub issues and pull requests. + + )} + + {projectRowItem.content.url ? ( + <> + {canCreateWorkspaceFromProjectRow(projectRowItem) ? ( + + ) : null} + { + if (projectRowItem.content.url) { + void Linking.openURL(projectRowItem.content.url) + } + }} + > + + Open in GitHub + + + + projectRowItem.content.url + ? void copyTaskLink( + `github-project:${projectRowItem.id}`, + projectRowItem.content.url + ) + : undefined + } + > + + + {copiedLinkKey === `github-project:${projectRowItem.id}` + ? 'Copied' + : 'Copy GitHub link'} + + + + ) : null} + {projectRowType(projectRowItem) && + projectRowItem.content.state !== 'MERGED' && + projectRowItem.itemType !== 'DRAFT_ISSUE' ? ( + <> + + { + const nextState = + projectRowItem.content.state === 'CLOSED' ? 'open' : 'closed' + if (projectRowItem.itemType === 'PULL_REQUEST') { + setPendingHostedStateChange({ + source: 'project', + row: projectRowItem, + nextState + }) + return + } + setPendingHostedStateChange({ + source: 'project', + row: projectRowItem, + nextState + }) + }} + > + {projectRowItem.content.state === 'CLOSED' ? ( + + ) : ( + + )} + + {projectRowItem.content.state === 'CLOSED' ? 'Reopen item' : 'Close item'} + + + + ) : null} + {projectRowItem.itemType === 'PULL_REQUEST' && + projectRowItem.content.state !== 'CLOSED' && + projectRowItem.content.state !== 'MERGED' ? ( + <> + + setMergeMethodProjectRow(projectRowItem)} + > + + Merge pull request + + {!projectRowHostedRepo ? ( + + Merge requires this repository in Orca. + + ) : null} + + ) : null} + + + ) : null} + + + setActionItem(null)}> + {actionItem ? ( + + + + + + {actionItem.title} + + setDetailRefreshSeq((current) => current + 1)} + > + + + + + {taskKindLabel(actionItem)} · {actionItem.subtitle} + + + + + {detailLoading ? ( + + + + ) : detailError ? ( + {detailError} + ) : detailPayload ? ( + <> + + + Type + {taskKindLabel(actionItem)} + + + Status + {actionItem.status} + + {detailPayload.provider === 'linear' && detailPayload.assignee ? ( + + Assignee + {detailPayload.assignee} + + ) : null} + {detailPayload.provider === 'linear' && detailPayload.project ? ( + + Project + {detailPayload.project.name} + + ) : null} + {(detailPayload.provider === 'github' || detailPayload.provider === 'gitlab') && + detailPayload.assignees.length > 0 ? ( + + Assignees + + {detailPayload.assignees.join(', ')} + + + ) : null} + + + {SHOW_MOBILE_DETAIL_LABEL_CHIPS && detailPayload.labels.length > 0 ? ( + + {detailPayload.labels.map((label) => ( + + {label} + + ))} + + ) : null} + + + Description + {SHOW_MOBILE_DETAIL_METADATA_EDITORS && + ((actionItem.provider === 'github' && + detailPayload.provider === 'github' && + (actionItem.source.type === 'issue' || actionItem.source.type === 'pr')) || + (actionItem.provider === 'gitlab' && + detailPayload.provider === 'gitlab' && + (actionItem.source.type === 'issue' || + actionItem.source.type === 'mr'))) ? ( + <> + + { + if ( + actionItem.provider === 'github' && + actionItem.source.type === 'pr' + ) { + void updateGitHubPullRequestMetadata(actionItem, { + body: itemBodyDraft + }) + return + } + if ( + actionItem.provider === 'github' && + actionItem.source.type === 'issue' + ) { + void updateGitHubIssueMetadata(actionItem, { + body: itemBodyDraft + }) + return + } + if ( + actionItem.provider === 'gitlab' && + (actionItem.source.type === 'issue' || + actionItem.source.type === 'mr') + ) { + void updateGitLabIssueMetadata(actionItem, { + body: itemBodyDraft + }) + } + }} + > + Save description + + + + ) : ( + + )} + + + {SHOW_MOBILE_DETAIL_METADATA_EDITORS && + ((actionItem.provider === 'github' && + detailPayload.provider === 'github' && + (actionItem.source.type === 'issue' || actionItem.source.type === 'pr')) || + (actionItem.provider === 'gitlab' && + (actionItem.source.type === 'issue' || actionItem.source.type === 'mr') && + detailPayload.provider === 'gitlab')) ? ( + <> + + Title + + { + if ( + actionItem.provider === 'github' && + actionItem.source.type === 'pr' + ) { + void updateGitHubPullRequestMetadata(actionItem, { + title: itemTitleDraft.trim() + }) + return + } + if (actionItem.provider === 'github') { + void updateGitHubIssueMetadata(actionItem, { + title: itemTitleDraft.trim() + }) + return + } + if (actionItem.provider === 'gitlab') { + void updateGitLabIssueMetadata(actionItem, { + title: itemTitleDraft.trim() + }) + } + }} + > + Save title + + + + + + Labels + + {detailPayload.labels.length || 'None'} + + + {actionItem.provider === 'github' ? ( + itemLabelsLoading ? ( + + + Loading labels... + + ) : itemLabelsError ? ( + {itemLabelsError} + ) : itemAvailableLabels.length === 0 ? ( + No labels in this repository. + ) : ( + + {[...new Set([...itemAvailableLabels, ...detailPayload.labels])].map( + (label) => { + const selected = detailPayload.labels.includes(label) + return ( + + void updateGitHubIssueMetadata( + actionItem, + selected + ? { removeLabels: [label] } + : { addLabels: [label] } + ) + } + > + + {selected ? ( + + ) : null} + {label} + + + ) + } + )} + + ) + ) : ( + <> + + + + void updateGitLabIssueMetadata(actionItem, { + addLabels: splitCommaList(itemAddLabelsDraft), + removeLabels: splitCommaList(itemRemoveLabelsDraft) + }) + } + > + Update labels + + + )} + + + + + Assignees + + {detailPayload.assignees.length || 'None'} + + + {actionItem.provider === 'github' ? ( + itemAssignableUsersLoading ? ( + + + Loading assignees... + + ) : itemAssignableUsersError ? ( + {itemAssignableUsersError} + ) : itemAssignableUsers.length === 0 ? ( + + No assignable users found for this repository. + + ) : ( + + {[ + ...new Map( + [ + ...itemAssignableUsers, + ...detailPayload.assignees.map((login) => ({ + login, + name: null, + avatarUrl: null + })) + ].map((user) => [user.login, user]) + ).values() + ].map((user) => { + const selected = detailPayload.assignees.includes(user.login) + return ( + + void updateGitHubIssueMetadata( + actionItem, + selected + ? { removeAssignees: [user.login] } + : { addAssignees: [user.login] } + ) + } + > + + {selected ? ( + + ) : null} + {user.login} + + + ) + })} + + ) + ) : actionItem.source.type === 'issue' ? ( + <> + + + + void updateGitLabIssueMetadata(actionItem, { + addAssignees: splitCommaList(itemAddAssigneesDraft), + removeAssignees: splitCommaList(itemRemoveAssigneesDraft) + }) + } + > + Update assignees + + + ) : null} + + + ) : null} + + {SHOW_MOBILE_DETAIL_REVIEW_PANELS && + actionItem.provider === 'github' && + actionItem.source.type === 'pr' ? ( + + + Reviewers + {detailPayload.provider === 'github' ? ( + + {getGitHubReviewSummary(detailPayload)} + + ) : null} + + {detailPayload.provider === 'github' ? ( + getGitHubReviewerRows(detailPayload).length === 0 ? ( + No reviewers requested. + ) : ( + getGitHubReviewerRows(detailPayload).map((reviewer) => ( + + + + {reviewer.login.slice(0, 1).toUpperCase()} + + + + + {reviewer.login} + + {reviewer.name ? ( + + {reviewer.name} + + ) : null} + + {reviewer.stateLabel} + + )) + ) + ) : null} + {itemAssignableUsersLoading ? ( + + + Loading reviewers... + + ) : itemAssignableUsersError ? ( + {itemAssignableUsersError} + ) : itemReviewerCandidates.length === 0 ? ( + No reviewer suggestions found. + ) : ( + + {itemReviewerCandidates.map((user) => { + const selected = itemSelectedReviewerLogins.has( + user.login.trim().toLowerCase() + ) + return ( + + void requestGitHubReviewers(actionItem, [user.login]) + } + > + + {selected ? : null} + {user.login} + + + ) + })} + + )} + + void requestGitHubReviewers(actionItem)} + > + Request review + + + ) : null} + + {SHOW_MOBILE_DETAIL_REVIEW_PANELS && + detailPayload.provider === 'github' && + actionItem.provider === 'github' && + actionItem.source.type === 'pr' ? ( + + + Checks + + void refreshGitHubChecks(actionItem)} + > + Refresh + + void rerunGitHubChecks(actionItem, true)} + > + Rerun failed + + void rerunGitHubChecks(actionItem, false)} + > + Rerun all + + + + {detailPayload.checks.length === 0 ? ( + No checks found. + ) : ( + detailPayload.checks.map((check) => ( + { + if (check.url) void Linking.openURL(check.url) + }} + > + + {check.name} · {check.conclusion ?? check.status} + + {check.url ? ( + + ) : null} + + )) + )} + + ) : null} + + {SHOW_MOBILE_DETAIL_REVIEW_PANELS && + detailPayload.provider === 'github' && + detailPayload.files.length > 0 ? ( + + Changed files + {detailPayload.files.map((file) => + actionItem.provider === 'github' && actionItem.source.type === 'pr' ? ( + + void toggleGitHubFileExpansion(actionItem, file)} + > + + {file.path} + {typeof file.additions === 'number' || + typeof file.deletions === 'number' + ? ` · +${file.additions ?? 0} -${file.deletions ?? 0}` + : ''} + + + {expandedPrFilePath === file.path ? 'Hide' : 'View'} + + + void toggleGitHubFileViewed(actionItem, file)} + > + + {file.viewerViewedState === 'VIEWED' + ? 'Mark unviewed' + : 'Mark viewed'} + + + {expandedPrFilePath === file.path ? ( + + {prFileLoadingPath === file.path ? ( + + ) : prFileContents[file.path]?.originalIsBinary || + prFileContents[file.path]?.modifiedIsBinary ? ( + Binary file. + ) : prFileContents[file.path] ? ( + + setPrFileCommentDrafts((current) => ({ + ...current, + [draftKey]: next + })) + } + onSubmitComment={(line) => + void addGitHubFileReviewComment(actionItem, file, line) + } + /> + ) : ( + File contents unavailable. + )} + + ) : null} + + ) : ( + + {file.path} + {typeof file.additions === 'number' || + typeof file.deletions === 'number' + ? ` · +${file.additions ?? 0} -${file.deletions ?? 0}` + : ''} + + ) + )} + + ) : null} + + {SHOW_MOBILE_DETAIL_REVIEW_PANELS && + detailPayload.provider === 'gitlab' && + actionItem.provider === 'gitlab' && + actionItem.source.type === 'mr' ? ( + + + Pipeline + + {detailPayload.pipelineJobs.length + ? `${detailPayload.pipelineJobs.length} jobs` + : 'None'} + + + {detailPayload.pipelineJobs.length === 0 ? ( + No pipeline runs for this MR. + ) : ( + detailPayload.pipelineJobs.map((job) => { + const duration = formatDurationSeconds(job.duration) + return ( + { + if (job.webUrl) void Linking.openURL(job.webUrl) + }} + > + + + {job.name} + + + {job.status} + + + + {[job.stage, duration].filter(Boolean).join(' · ')} + + + ) + }) + )} + + ) : null} + + {SHOW_MOBILE_LINEAR_DETAIL_TOOLS && + detailPayload.provider === 'linear' && + actionItem.provider === 'linear' ? ( + + + Sub-issues + + {detailPayload.children.length || 'None'} + + + {detailPayload.children.length === 0 ? ( + No sub-issues. + ) : ( + detailPayload.children.map((child) => ( + + void openLinearSubIssue(child, actionItem.source.workspaceId) + } + > + + {child.identifier} · {child.title} + + Open + + )) + )} + + void createLinearSubIssue(actionItem)} + > + Add sub-issue + + + ) : null} + + + + Discussion + + {discussionSummary(detailPayload.comments.length)} + + + {detailPayload.comments.length === 0 ? ( + No comments. + ) : ( + detailCommentGroups.map(renderDetailCommentGroup) + )} + {(detailPayload.provider === 'github' && actionItem.provider === 'github') || + (detailPayload.provider === 'gitlab' && actionItem.provider === 'gitlab') + ? renderCommentComposer({ + value: itemCommentDraft, + onChangeText: setItemCommentDraft, + disabled: mutatingStatus, + onSubmit: () => void addHostedItemComment(actionItem) + }) + : null} + {detailPayload.provider === 'linear' && actionItem.provider === 'linear' + ? renderCommentComposer({ + value: linearCommentDraft, + onChangeText: setLinearCommentDraft, + disabled: mutatingStatus, + onSubmit: () => void addLinearComment(actionItem) + }) + : null} + + + ) : null} + + + + { + if (actionItem.provider === 'linear' && workspaceRepos.length > 1) { + setWorkspaceRepoPickerItem(actionItem) + return + } + openWorkspaceCreate(actionItem) + }} + > + + + {creatingKey === actionItem.key ? 'Creating...' : 'Create Workspace'} + + + + + void Linking.openURL(actionItem.source.url)} + > + + {taskExternalOpenLabel(actionItem)} + + + {actionItem.provider === 'linear' ? ( + <> + + + void copyTextToClipboard( + `linear-url:${actionItem.key}`, + actionItem.source.url + ) + } + > + + + {copiedLinkKey === `linear-url:${actionItem.key}` + ? 'Copied' + : 'Copy Linear link'} + + + + ) : null} + + {actionItem.provider === 'github' ? ( + <> + + + void copyTaskLink(`task:${actionItem.key}`, actionItem.source.url) + } + > + + + {copiedLinkKey === `task:${actionItem.key}` ? 'Copied' : 'Copy GitHub link'} + + + + ) : null} + + {actionItem.provider === 'github' && actionItem.source.state !== 'merged' ? ( + <> + + { + const githubItem = actionItem as Extract + setPendingHostedStateChange({ + source: 'task', + item: githubItem, + nextState: githubItem.source.state === 'closed' ? 'open' : 'closed' + }) + }} + > + {actionItem.source.state === 'closed' ? ( + + ) : ( + + )} + {taskStatusActionLabel(actionItem)} + + + ) : null} + + {actionItem.provider === 'github' && + actionItem.source.type === 'pr' && + actionItem.source.state === 'open' ? ( + <> + + + setMergeMethodTaskItem( + actionItem as Extract + ) + } + > + + Merge pull request + + {isGitHubPrMergeBlocked(actionItem) ? ( + GitHub reports merge conflicts. + ) : null} + + ) : null} + + {actionItem.provider === 'gitlab' && + actionItem.source.state !== 'merged' && + actionItem.source.state !== 'locked' ? ( + <> + + { + const gitlabItem = actionItem as Extract + setPendingHostedStateChange({ + source: 'task', + item: gitlabItem, + nextState: gitlabItem.source.state === 'closed' ? 'opened' : 'closed' + }) + }} + > + {actionItem.source.state === 'closed' ? ( + + ) : ( + + )} + {taskStatusActionLabel(actionItem)} + + + ) : null} + + {actionItem.provider === 'gitlab' && + actionItem.source.type === 'mr' && + actionItem.source.state === 'opened' ? ( + <> + + + setMergeMethodTaskItem( + actionItem as Extract + ) + } + > + + Merge merge request + + + ) : null} + + {actionItem.provider === 'linear' ? ( + <> + + { + setLinearStatusPickerItem( + actionItem as Extract + ) + }} + > + + Change status + + + ) : null} + + + ) : null} + + + ({ + label: getHostedReviewMergeMethodLabel(method), + icon: GitBranch, + onPress: () => { + setPendingProjectGitHubMerge({ row: mergeMethodProjectRow, method }) + } + })) + : [] + } + onClose={() => setMergeMethodProjectRow(null)} + /> + ({ + label: + mergeMethodTaskItem.provider === 'gitlab' && method === 'merge' + ? 'Merge' + : getHostedReviewMergeMethodLabel(method), + icon: GitBranch, + onPress: () => { + const item = mergeMethodTaskItem + setPendingHostedMerge({ item, method }) + } + })) + : [] + } + onClose={() => setMergeMethodTaskItem(null)} + /> + { + if (!taskUiReady || !pendingHostedMerge) return + void mergeHostedReview(pendingHostedMerge.item, pendingHostedMerge.method) + }} + onCancel={() => setPendingHostedMerge(null)} + /> + { + if (!taskUiReady || !pendingProjectGitHubMerge) return + void mergeProjectGitHubPullRequest( + pendingProjectGitHubMerge.row, + pendingProjectGitHubMerge.method + ) + }} + onCancel={() => setPendingProjectGitHubMerge(null)} + /> + { + if (!taskUiReady || !pendingHostedStateChange) return + if (pendingHostedStateChange.source === 'task') { + if (pendingHostedStateChange.item.provider === 'gitlab') { + void toggleGitLabStatus(pendingHostedStateChange.item) + return + } + void toggleGitHubStatus(pendingHostedStateChange.item) + return + } + void mutateProjectRowIssueOrPr(pendingHostedStateChange.row, { + state: pendingHostedStateChange.nextState + }) + }} + onCancel={() => setPendingHostedStateChange(null)} + /> + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase + }, + topChrome: { + backgroundColor: colors.bgPanel, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle + }, + statusBar: { + minHeight: 38, + paddingTop: spacing.xs, + paddingHorizontal: spacing.lg, + flexDirection: 'row', + alignItems: 'center' + }, + backButton: { + width: 32, + height: 32, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.xs + }, + titleWrap: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + minWidth: 0 + }, + title: { + fontSize: 16, + fontWeight: '700', + color: colors.textPrimary + }, + iconButton: { + width: 32, + height: 32, + alignItems: 'center', + justifyContent: 'center' + }, + toolbar: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs + 2 + }, + toolbarScroll: { + borderTopWidth: 1, + borderTopColor: colors.borderSubtle + }, + segmentButton: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.button, + paddingHorizontal: spacing.sm + 2, + paddingVertical: spacing.xs + }, + segmentIconButton: { + width: 32, + height: 30, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.button + }, + segmentCountPill: { + minWidth: 32, + height: 30, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.button, + paddingHorizontal: spacing.sm + }, + segmentRepoDot: { + width: 8, + height: 8, + borderRadius: 4 + }, + segmentButtonText: { + fontSize: 12, + fontWeight: '600', + color: colors.textPrimary + }, + segmentSecondaryText: { + fontSize: 12, + color: colors.textSecondary + }, + searchBar: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs + 2, + gap: spacing.sm, + borderTopWidth: 1, + borderTopColor: colors.borderSubtle + }, + searchInput: { + flex: 1, + color: colors.textPrimary, + fontSize: 13, + paddingVertical: 2 + }, + errorBanner: { + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + backgroundColor: colors.bgPanel, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle + }, + errorText: { + color: colors.statusRed, + fontSize: 13 + }, + sourceErrorBanner: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + backgroundColor: colors.bgPanel, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle + }, + sourceErrorCopy: { + flex: 1, + minWidth: 0 + }, + sourceErrorText: { + color: colors.statusAmber, + fontSize: 13, + fontWeight: '600' + }, + sourceErrorSlug: { + fontFamily: typography.monoFamily, + color: colors.textPrimary + }, + sourceErrorMessage: { + marginTop: 2, + color: colors.textSecondary, + fontSize: 12 + }, + sourceErrorRetry: { + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.button, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs + }, + sourceErrorRetryText: { + color: colors.textPrimary, + fontSize: 12, + fontWeight: '600' + }, + sourceNoticeBanner: { + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + backgroundColor: colors.bgPanel, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle + }, + sourceNoticeText: { + color: colors.statusAmber, + fontSize: 13 + }, + centered: { + flex: 1, + alignItems: 'center', + justifyContent: 'center' + }, + emptyText: { + color: colors.textSecondary, + fontSize: typography.bodySize + }, + centeredHint: { + color: colors.textMuted, + fontSize: 12, + lineHeight: 17, + marginTop: spacing.sm, + maxWidth: 280, + textAlign: 'center' + }, + centerActionButton: { + marginTop: spacing.md, + minWidth: 160 + }, + list: { + paddingTop: spacing.xs + }, + repoSectionHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.lg, + paddingTop: spacing.md, + paddingBottom: spacing.xs, + backgroundColor: colors.bgBase + }, + repoSectionDot: { + width: 8, + height: 8, + borderRadius: 4 + }, + repoSectionTitle: { + flex: 1, + minWidth: 0, + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 0.5 + }, + separator: { + height: 1, + backgroundColor: colors.borderSubtle, + marginLeft: spacing.lg + 26, + marginRight: spacing.lg + }, + taskRow: { + flexDirection: 'row', + alignItems: 'flex-start', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm + 2 + }, + taskRowPressed: { + backgroundColor: colors.bgRaised + }, + taskIcon: { + width: 20, + paddingTop: 3, + marginRight: spacing.sm, + alignItems: 'center' + }, + taskMain: { + flex: 1, + minWidth: 0, + marginRight: spacing.sm + }, + taskTitleRow: { + flexDirection: 'row', + gap: spacing.sm, + alignItems: 'flex-start' + }, + taskTitle: { + flex: 1, + fontSize: 14, + fontWeight: '600', + color: colors.textPrimary, + lineHeight: 18 + }, + updatedAt: { + fontSize: 11, + color: colors.textMuted, + paddingTop: 2 + }, + metaRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 4, + gap: spacing.xs + }, + repoDot: { + width: 7, + height: 7, + borderRadius: 4 + }, + pickerRepoDot: { + width: 9, + height: 9, + borderRadius: 4.5 + }, + subtitle: { + flex: 1, + fontSize: 11, + color: colors.textSecondary + }, + branchMetaRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + marginTop: 3, + minWidth: 0 + }, + branchMetaText: { + flexShrink: 1, + minWidth: 0, + maxWidth: 180, + fontSize: 11, + color: colors.textPrimary + }, + branchMetaBase: { + flexShrink: 1, + minWidth: 0, + fontSize: 10, + color: colors.textMuted + }, + prSignalRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.xs, + marginTop: spacing.xs + 1 + }, + prSignalChip: { + alignSelf: 'flex-start', + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.button, + backgroundColor: colors.bgPanel, + paddingHorizontal: spacing.xs + 2, + paddingVertical: 2 + }, + prSignalSuccess: { + borderColor: colors.statusGreen + }, + prSignalWarning: { + borderColor: colors.statusAmber + }, + prSignalDanger: { + borderColor: colors.statusRed + }, + prSignalText: { + fontSize: 10, + color: colors.textSecondary, + fontWeight: '600' + }, + statusPill: { + maxWidth: 112, + backgroundColor: colors.bgRaised, + borderRadius: 999, + paddingHorizontal: spacing.sm, + paddingVertical: 3, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + statusPillSelf: { + alignSelf: 'flex-start', + backgroundColor: colors.bgRaised, + borderRadius: 999, + paddingHorizontal: spacing.sm, + paddingVertical: 3, + borderWidth: 1, + borderColor: colors.borderSubtle, + marginTop: spacing.sm + }, + linearStatePill: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs + }, + linearStateDot: { + width: 7, + height: 7, + borderRadius: 4 + }, + linearListTrailing: { + alignItems: 'flex-end', + gap: spacing.xs + }, + taskRowTrailing: { + alignItems: 'flex-end', + gap: spacing.xs + }, + statusText: { + fontSize: 11, + color: colors.textSecondary + }, + statusTextFlex: { + flex: 1, + minWidth: 0 + }, + paginationFooter: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingTop: spacing.md, + paddingBottom: spacing.sm + }, + paginationButton: { + width: 44, + minHeight: 38, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.button, + backgroundColor: colors.bgRaised, + paddingVertical: spacing.sm + }, + paginationButtonDisabled: { + opacity: 0.45 + }, + paginationLabel: { + color: colors.textSecondary, + fontSize: 12, + textAlign: 'center' + }, + paginationLabelButton: { + flex: 1, + alignItems: 'center', + borderRadius: radii.button, + paddingHorizontal: spacing.xs, + paddingVertical: spacing.sm + }, + boardContainer: { + gap: spacing.md, + padding: spacing.md + }, + boardColumn: { + width: 280, + maxHeight: '100%', + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.card, + backgroundColor: colors.bgPanel, + overflow: 'hidden' + }, + boardHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle + }, + boardTitle: { + flex: 1, + color: colors.textPrimary, + fontSize: 13, + fontWeight: '600' + }, + boardCount: { + color: colors.textMuted, + fontSize: 11 + }, + boardCard: { + margin: spacing.sm, + marginBottom: 0, + borderRadius: radii.card, + borderWidth: 1, + borderColor: colors.borderSubtle, + backgroundColor: colors.bgBase, + padding: spacing.md + }, + repoPickerGroup: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + pagePickerList: { + maxHeight: 420, + backgroundColor: colors.bgPanel, + borderRadius: 12 + }, + projectPickerControls: { + gap: spacing.sm, + marginBottom: spacing.md + }, + projectWarningBanner: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + marginBottom: spacing.md, + borderWidth: 1, + borderColor: colors.statusAmber, + borderRadius: radii.card, + backgroundColor: colors.bgPanel + }, + projectWarningTextWrap: { + flex: 1, + minWidth: 0 + }, + projectWarningTitle: { + color: colors.textPrimary, + fontSize: 12, + fontWeight: '600' + }, + projectWarningText: { + color: colors.textSecondary, + fontSize: 11, + marginTop: 2 + }, + projectDataNotice: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + backgroundColor: colors.bgPanel, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle + }, + projectDataNoticeText: { + flex: 1, + color: colors.statusAmber, + fontSize: 13 + }, + projectGroupHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + backgroundColor: colors.bgPanel + }, + projectGroupChevronCollapsed: { + transform: [{ rotate: '-90deg' }] + }, + projectGroupTitle: { + flex: 1, + minWidth: 0, + color: colors.textPrimary, + fontSize: 12, + fontWeight: '600' + }, + projectGroupMeta: { + color: colors.textMuted, + fontSize: 11 + }, + projectFieldPillRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.xs, + marginTop: spacing.xs + }, + projectFieldPill: { + maxWidth: '100%', + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: 999, + backgroundColor: colors.bgPanel, + paddingHorizontal: spacing.sm, + paddingVertical: 2 + }, + projectFieldPillText: { + color: colors.textSecondary, + fontSize: 11 + }, + projectFieldPillEmptyText: { + color: colors.textMuted + }, + projectPasteRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + projectPasteInput: { + flex: 1 + }, + projectPasteButton: { + minHeight: 40, + alignItems: 'center', + justifyContent: 'center' + }, + pickerRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md + 2, + paddingVertical: spacing.md, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle + }, + pickerRowSelected: { + backgroundColor: colors.bgRaised + }, + pickerRowContent: { + flex: 1, + minWidth: 0 + }, + pickerRowLabel: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + pickerRowSubtitle: { + fontSize: 11, + color: colors.textMuted, + marginTop: 1 + }, + pickerRowWithAction: { + flexDirection: 'row', + alignItems: 'center' + }, + pickerRowMain: { + flex: 1, + minWidth: 0, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md + 2, + paddingVertical: spacing.md + }, + pickerCheck: { + width: 18, + alignItems: 'center' + }, + pickerContent: { + flex: 1, + minWidth: 0 + }, + pickerLabel: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + monoText: { + fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' }) + }, + pickerSubtitle: { + fontSize: 11, + color: colors.textMuted, + marginTop: 1 + }, + iconActionButton: { + width: 44, + height: 44, + alignItems: 'center', + justifyContent: 'center' + }, + groupSeparator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + repoPickerRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md + 2, + paddingVertical: spacing.md + }, + repoPickerTextWrap: { + flex: 1, + minWidth: 0 + }, + repoPickerTitle: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + repoPickerSubtitle: { + fontSize: 11, + color: colors.textMuted, + marginTop: 1 + }, + sheetHeader: { + paddingHorizontal: spacing.xs, + marginBottom: spacing.md + }, + sheetTitleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + sheetTitle: { + flex: 1, + minWidth: 0, + fontSize: 15, + fontWeight: '700', + color: colors.textPrimary, + lineHeight: 20 + }, + sheetSubtitle: { + fontSize: 12, + color: colors.textMuted, + marginTop: 2 + }, + actionGroup: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + detailGroup: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + padding: spacing.md, + marginBottom: spacing.md, + gap: spacing.md + }, + detailLoading: { + paddingVertical: spacing.lg, + alignItems: 'center' + }, + detailLoadingInline: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + detailError: { + color: colors.statusRed, + fontSize: 13 + }, + detailMetaGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm + }, + detailMetaItem: { + minWidth: 96, + flexGrow: 1 + }, + detailMetaLabel: { + fontSize: 11, + color: colors.textMuted, + marginBottom: 2 + }, + detailMetaValue: { + fontSize: 13, + color: colors.textPrimary, + fontWeight: '600' + }, + chipRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.xs + }, + detailChip: { + borderWidth: 1, + borderColor: colors.borderSubtle, + backgroundColor: colors.bgRaised, + borderRadius: 999, + paddingHorizontal: spacing.sm, + paddingVertical: 2 + }, + detailChipSelected: { + borderColor: colors.accentBlue, + backgroundColor: colors.bgRaised + }, + detailChipText: { + fontSize: 11, + color: colors.textSecondary + }, + issueTypeChipContent: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs + }, + issueTypeDot: { + width: 7, + height: 7, + borderRadius: 999 + }, + detailSection: { + gap: spacing.xs + }, + detailSectionHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.sm + }, + detailSectionTitle: { + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 0.5 + }, + detailSectionMeta: { + flexShrink: 0, + fontSize: 11, + color: colors.textMuted + }, + fieldButton: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + fieldButtonDisabled: { + opacity: 0.55 + }, + fieldButtonPlaceholder: { + color: colors.textMuted + }, + fieldButtonText: { + flex: 1, + fontSize: typography.bodySize, + color: colors.textPrimary + }, + workspaceCreateForm: { + gap: 0 + }, + workspaceCreateField: { + marginBottom: spacing.md + }, + workspaceCreateLabel: { + fontSize: 13, + fontWeight: '500', + color: colors.textSecondary, + marginBottom: spacing.xs + }, + workspaceCreateLabelHint: { + fontWeight: '400', + color: colors.textMuted + }, + workspaceAdvancedToggle: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingVertical: spacing.sm, + marginBottom: spacing.xs + }, + workspaceAdvancedText: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textSecondary + }, + workspaceCreateActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + marginTop: spacing.sm + }, + workspaceCreateButton: { + minWidth: 160, + paddingHorizontal: spacing.lg + }, + sshConnectCard: { + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + borderWidth: 1, + borderColor: colors.borderSubtle, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + gap: spacing.xs + }, + sshStatusRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + sshStatusDot: { + width: 8, + height: 8, + borderRadius: 999 + }, + sshStatusDotConnected: { + backgroundColor: colors.statusGreen + }, + sshStatusDotProgress: { + backgroundColor: colors.statusAmber + }, + sshStatusDotDisconnected: { + backgroundColor: colors.statusRed + }, + sshStatusCopy: { + flex: 1, + minWidth: 0 + }, + sshStatusTitle: { + fontSize: typography.bodySize, + color: colors.textPrimary, + fontWeight: '600' + }, + reviewerRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.card, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs + }, + reviewerAvatar: { + width: 24, + height: 24, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgRaised + }, + reviewerAvatarText: { + fontSize: 11, + fontWeight: '700', + color: colors.textSecondary + }, + reviewerInfo: { + flex: 1, + minWidth: 0 + }, + reviewerName: { + fontSize: 12, + fontWeight: '600', + color: colors.textPrimary + }, + reviewerMeta: { + fontSize: 11, + color: colors.textMuted + }, + reviewerState: { + flexShrink: 0, + fontSize: 11, + color: colors.textSecondary + }, + projectFieldCard: { + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.card, + padding: spacing.sm, + gap: spacing.xs + }, + projectFieldName: { + flex: 1, + minWidth: 0, + fontSize: 12, + fontWeight: '600', + color: colors.textPrimary + }, + projectFieldValue: { + maxWidth: 140, + fontSize: 12, + color: colors.textMuted + }, + projectIterationList: { + gap: spacing.xs + }, + projectIterationCopy: { + flex: 1, + minWidth: 0, + gap: 2 + }, + detailLine: { + fontSize: 12, + lineHeight: 17, + color: colors.textSecondary + }, + fileActionRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.sm, + paddingVertical: 2 + }, + fileCard: { + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.card, + padding: spacing.sm, + gap: spacing.xs + }, + pipelineStatusChip: { + flexShrink: 0, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: 999, + backgroundColor: colors.bgRaised, + paddingHorizontal: spacing.sm, + paddingVertical: 2 + }, + pipelineStatusSuccess: { + borderColor: colors.statusGreen + }, + pipelineStatusWarning: { + borderColor: colors.statusAmber + }, + pipelineStatusDanger: { + borderColor: colors.statusRed + }, + pipelineStatusActive: { + borderColor: colors.accentBlue + }, + pipelineStatusText: { + fontSize: 10, + color: colors.textSecondary, + fontWeight: '600', + textTransform: 'uppercase' + }, + filePreview: { + gap: spacing.xs, + marginTop: spacing.xs + }, + fileDiff: { + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.row, + overflow: 'hidden' + }, + diffLineBlock: { + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle, + borderLeftWidth: 2, + borderLeftColor: colors.borderSubtle, + paddingHorizontal: spacing.xs, + paddingVertical: spacing.xs, + gap: spacing.xs + }, + diffLineAdded: { + borderLeftColor: colors.statusGreen + }, + diffLineRemoved: { + borderLeftColor: colors.statusRed + }, + diffCodeRow: { + flexDirection: 'row', + gap: spacing.xs, + alignItems: 'flex-start' + }, + diffLineNumbers: { + width: 76, + flexShrink: 0, + fontFamily: typography.monoFamily, + fontSize: 10, + lineHeight: 16, + color: colors.textMuted + }, + codeLine: { + flex: 1, + fontFamily: typography.monoFamily, + fontSize: 11, + lineHeight: 16, + color: colors.textSecondary + }, + diffCodeAdded: { + color: colors.statusGreen + }, + diffCodeRemoved: { + color: colors.statusRed + }, + detailMuted: { + fontSize: 12, + color: colors.textSecondary + }, + commentBlock: { + borderTopWidth: 1, + borderTopColor: colors.borderSubtle, + paddingTop: spacing.sm + }, + commentThreadGroup: { + gap: spacing.xs + }, + commentReplyBlock: { + marginLeft: spacing.md, + paddingLeft: spacing.sm, + borderLeftWidth: 1, + borderLeftColor: colors.borderSubtle + }, + commentResolvedBlock: { + opacity: 0.6 + }, + resolvedCommentSummary: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.card, + backgroundColor: colors.bgPanel, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.sm + }, + resolvedCommentTitle: { + flex: 1, + minWidth: 0, + fontSize: 12, + color: colors.textSecondary + }, + commentSource: { + fontSize: 11, + lineHeight: 15, + color: colors.textSecondary, + marginBottom: 2 + }, + commentMeta: { + fontSize: 11, + color: colors.textMuted, + marginBottom: 2 + }, + commentControls: { + gap: spacing.xs, + marginTop: spacing.sm + }, + reactionRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.xs, + marginTop: spacing.xs + }, + reactionChip: { + borderWidth: 1, + borderColor: colors.borderSubtle, + backgroundColor: colors.bgRaised, + borderRadius: 999, + paddingHorizontal: spacing.sm, + paddingVertical: 2 + }, + reactionText: { + fontSize: 11, + color: colors.textSecondary + }, + inlineActionRow: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + gap: spacing.xs + }, + actionRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md + 2, + paddingVertical: spacing.md + }, + actionText: { + flex: 1, + fontSize: typography.bodySize, + color: colors.textPrimary + }, + actionSeparator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + setupPromptBox: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + borderWidth: 1, + borderColor: colors.borderSubtle, + padding: spacing.md, + marginBottom: spacing.md, + gap: spacing.xs + }, + setupPromptCommand: { + fontFamily: typography.monoFamily, + fontSize: 12, + lineHeight: 17, + color: colors.textPrimary + }, + linearStatesBlock: { + paddingTop: spacing.sm + }, + linearStatesTitle: { + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 0.5, + paddingHorizontal: spacing.md + 2, + paddingBottom: spacing.xs + }, + emptyInlineText: { + color: colors.textSecondary, + fontSize: 13, + paddingHorizontal: spacing.md + 2, + paddingBottom: spacing.md + }, + createForm: { + gap: spacing.sm + }, + fieldLabel: { + fontSize: 12, + fontWeight: '600', + color: colors.textSecondary + }, + inlineTextLink: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + alignSelf: 'flex-start', + paddingVertical: spacing.xs + }, + inlineTextLinkText: { + color: colors.textSecondary, + fontSize: 12, + textDecorationLine: 'underline' + }, + securityHintRow: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.xs + }, + securityHintText: { + flex: 1, + color: colors.textMuted, + fontSize: 11, + lineHeight: 16 + }, + targetButton: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + borderWidth: 1, + borderColor: colors.borderSubtle, + backgroundColor: colors.bgPanel, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + 2 + }, + targetButtonText: { + flex: 1, + color: colors.textPrimary, + fontSize: typography.bodySize + }, + issueSourceBox: { + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.card, + backgroundColor: colors.bgPanel, + padding: spacing.sm, + gap: spacing.xs + }, + issueSourceHint: { + fontSize: 12, + color: colors.textSecondary + }, + issueSourceSegment: { + flexDirection: 'row', + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.input, + backgroundColor: colors.bgBase, + padding: 2, + gap: 2 + }, + issueSourceSegmentButton: { + flex: 1, + borderRadius: radii.input - 2, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs + }, + issueSourceSegmentButtonActive: { + backgroundColor: colors.bgRaised + }, + issueSourceSegmentText: { + fontSize: 12, + fontWeight: '700', + color: colors.textMuted + }, + issueSourceSegmentTextActive: { + color: colors.textPrimary + }, + issueSourceSlug: { + marginTop: 1, + fontSize: 10, + color: colors.textMuted + }, + drawerLoadingRow: { + paddingVertical: spacing.lg, + alignItems: 'center' + }, + input: { + borderWidth: 1, + borderColor: colors.borderSubtle, + backgroundColor: colors.bgPanel, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + color: colors.textPrimary, + fontSize: typography.bodySize + }, + bodyInput: { + minHeight: 88 + }, + monoInput: { + fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' }) + }, + commentInput: { + minHeight: 72, + marginTop: spacing.sm + }, + commentComposer: { + position: 'relative', + marginTop: spacing.sm + }, + commentComposerInput: { + minHeight: 40, + maxHeight: 120, + marginTop: 0, + paddingRight: 44 + }, + commentComposerSend: { + position: 'absolute', + right: spacing.xs, + bottom: spacing.xs, + width: 32, + height: 32, + borderRadius: radii.button, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgRaised + }, + commentComposerSendPressed: { + opacity: 0.75 + }, + commentComposerSendDisabled: { + opacity: 0.5 + }, + replyInput: { + minHeight: 48, + marginTop: spacing.xs + }, + stackedInput: { + marginTop: spacing.sm + }, + inlineSaveButton: { + alignSelf: 'flex-start', + marginTop: spacing.sm, + borderRadius: radii.button, + borderWidth: 1, + borderColor: colors.borderSubtle, + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs + }, + inlineSaveButtonCompact: { + alignSelf: 'flex-start', + borderRadius: radii.button, + borderWidth: 1, + borderColor: colors.borderSubtle, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs + }, + inlineSaveText: { + color: colors.textPrimary, + fontSize: 12, + fontWeight: '600' + }, + inlineButtonRow: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + gap: spacing.xs, + marginTop: spacing.xs + }, + drawerActionRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + secondaryActionButton: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button, + borderWidth: 1, + borderColor: colors.borderSubtle, + paddingVertical: spacing.sm + }, + secondaryActionText: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + primaryActionButton: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.xs, + borderRadius: radii.button, + backgroundColor: colors.textPrimary, + paddingVertical: spacing.sm + }, + primaryActionText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '700' + }, + inlineDeleteText: { + color: colors.statusRed, + fontSize: 12, + fontWeight: '600' + }, + createButton: { + marginTop: spacing.sm, + backgroundColor: colors.textPrimary, + borderRadius: radii.button, + paddingVertical: spacing.sm + 2, + alignItems: 'center' + }, + createButtonDisabled: { + opacity: 0.5 + }, + createButtonText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '700' + } +}) + +function getPrSignalToneStyle(tone: 'neutral' | 'success' | 'warning' | 'danger') { + if (tone === 'success') return styles.prSignalSuccess + if (tone === 'warning') return styles.prSignalWarning + if (tone === 'danger') return styles.prSignalDanger + return null +} + +function getGitLabPipelineStatusStyle(status: string) { + switch (status) { + case 'success': + return styles.pipelineStatusSuccess + case 'failed': + return styles.pipelineStatusDanger + case 'manual': + return styles.pipelineStatusWarning + case 'running': + case 'pending': + case 'created': + case 'preparing': + case 'waiting_for_resource': + case 'scheduled': + return styles.pipelineStatusActive + case 'canceled': + case 'skipped': + default: + return null + } +} diff --git a/mobile/app/h/_layout.tsx b/mobile/app/h/_layout.tsx index c4071605cd2..171508bb636 100644 --- a/mobile/app/h/_layout.tsx +++ b/mobile/app/h/_layout.tsx @@ -11,6 +11,7 @@ export default function HostGroupLayout() { > + = { + github: 'GitHub', + gitlab: 'GitLab', + linear: 'Linear' +} + function formatDuration(ms: number): string { const totalMinutes = Math.floor(ms / 60_000) const totalHours = Math.floor(totalMinutes / 60) @@ -196,6 +218,44 @@ function fetchAccountsSnapshot( .catch(() => {}) } +function fetchTaskProviders( + client: RpcClient, + hostId: string, + setProviders: ( + updater: (prev: Record) => Record + ) => void, + disposed: () => boolean +) { + Promise.all([ + client.sendRequest('settings.get'), + client.sendRequest('preflight.check'), + client.sendRequest('linear.status') + ]) + .then(([settingsResponse, preflightResponse, linearResponse]) => { + if (disposed()) return + const settings = settingsResponse.ok + ? (((settingsResponse.result as { settings?: HomeTaskSettings }).settings ?? + {}) as HomeTaskSettings) + : {} + const preflight = preflightResponse.ok + ? (preflightResponse.result as HomePreflightStatus) + : null + const linear = linearResponse.ok ? (linearResponse.result as HomeLinearStatus) : null + const providers = filterAvailableTaskProviders( + normalizeVisibleTaskProviders(settings.visibleTaskProviders), + { + gitlabInstalled: preflight?.glab?.installed === true, + linearConnected: linear?.connected === true + } + ) + setProviders((prev) => ({ ...prev, [hostId]: providers })) + }) + .catch(() => { + if (disposed()) return + setProviders((prev) => (prev[hostId] ? prev : { ...prev, [hostId]: ['github'] })) + }) +} + // Why: repo names get a stable color derived from hashing, matching the // host detail page's colored dots for visual consistency. const REPO_COLORS = ['#8b5cf6', '#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4'] @@ -220,6 +280,7 @@ export default function HomeScreen() { const [stats, setStats] = useState(null) const [worktreeInfo, setWorktreeInfo] = useState>({}) const [accountsByHost, setAccountsByHost] = useState>({}) + const [taskProvidersByHost, setTaskProvidersByHost] = useState>({}) const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>( null ) @@ -305,6 +366,7 @@ export default function HomeScreen() { fetchStats(entry.client, setStats, () => stale) fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale) fetchAccountsSnapshot(entry.client, entry.hostId, setAccountsByHost, () => stale) + fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => stale) } } return () => { @@ -415,6 +477,7 @@ export default function HomeScreen() { statsFetched = true fetchStats(entry.client, setStats, () => false) fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false) + fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false) } } else { if (unsubNotif) { @@ -499,6 +562,75 @@ export default function HomeScreen() { return items }, [sortedHosts, hostStates, accountsByHost]) + const primaryConnectedHost = useMemo( + () => sortedHosts.find((host) => hostStates[host.id] === 'connected') ?? null, + [sortedHosts, hostStates] + ) + const primaryTaskProviders = primaryConnectedHost + ? (taskProvidersByHost[primaryConnectedHost.id] ?? ['github']) + : [] + const openTasks = useCallback( + (provider?: TaskProvider) => { + if (!primaryConnectedHost) return + const suffix = provider ? `?taskSource=${provider}` : '' + router.push(`/h/${primaryConnectedHost.id}/tasks${suffix}`) + }, + [primaryConnectedHost, router] + ) + const renderTaskHomeCard = () => ( + [ + styles.taskHomeCard, + !primaryConnectedHost && styles.quickActionDisabled, + pressed && styles.hostCardPressed + ]} + onPress={() => { + openTasks() + }} + > + + + + + Tasks + + {primaryTaskProviders.length > 0 + ? primaryTaskProviders.map((provider) => TASK_PROVIDER_LABELS[provider]).join(' · ') + : 'No task sources connected'} + + + + TASK_PROVIDER_LABELS[provider]) + .join(', ')} + > + {primaryTaskProviders.map((provider) => ( + [ + styles.taskHomeProviderButton, + pressed && styles.taskHomeProviderButtonPressed + ]} + onPress={(event) => { + event.stopPropagation() + openTasks(provider) + }} + > + + + ))} + + + + + ) + async function handleRename(newName: string) { if (!renameTarget) return try { @@ -590,25 +722,16 @@ export default function HomeScreen() { {stats && ( - - - {stats.totalAgentsSpawned.toLocaleString()} Agents spawned - - - {formatDuration(stats.totalAgentTimeMs)} Agent time - - - {stats.totalPRsCreated.toLocaleString()} PRs created @@ -676,7 +799,7 @@ export default function HomeScreen() { {/* ─── Resume card ─── */} {resumeWorktree ? ( <> - Resume + Resume [styles.resumeCard, pressed && styles.hostCardPressed]} onPress={() => @@ -708,8 +831,47 @@ export default function HomeScreen() { + Tasks + {renderTaskHomeCard()} - ) : null} + ) : ( + <> + Tasks + {renderTaskHomeCard()} + + )} + + {/* ─── Quick actions ─── */} + Quick Actions + + [styles.quickAction, pressed && styles.hostCardPressed]} + onPress={() => router.push('/pair-scan')} + > + + + + Pair Desktop + + [ + styles.quickAction, + !primaryConnectedHost && styles.quickActionDisabled, + pressed && styles.hostCardPressed + ]} + onPress={() => { + if (primaryConnectedHost) { + router.push(`/h/${primaryConnectedHost.id}?action=newWorktree`) + } + }} + > + + + + New Workspace + + {/* ─── Account usage ─── */} {accountsHosts.length > 0 ? ( @@ -789,34 +951,6 @@ export default function HomeScreen() { })} ) : null} - - {/* ─── Quick actions ─── */} - Quick Actions - - [styles.quickAction, pressed && styles.hostCardPressed]} - onPress={() => router.push('/pair-scan')} - > - - - - Pair Desktop - - [styles.quickAction, pressed && styles.hostCardPressed]} - onPress={() => { - const connectedHost = sortedHosts.find((h) => hostStates[h.id] === 'connected') - if (connectedHost) { - router.push(`/h/${connectedHost.id}?action=newWorktree`) - } - }} - > - - - - New Worktree - - } /> @@ -965,12 +1099,12 @@ const styles = StyleSheet.create({ /* ─── Hero / greeting ─── */ hero: { - paddingTop: spacing.md, - paddingBottom: spacing.lg + paddingTop: spacing.xs, + paddingBottom: spacing.md }, heroTitle: { color: colors.textPrimary, - fontSize: 26, + fontSize: 24, fontWeight: '800', letterSpacing: -0.3 }, @@ -979,7 +1113,7 @@ const styles = StyleSheet.create({ statsRow: { flexDirection: 'row', gap: 10, - marginBottom: spacing.xl + marginBottom: spacing.lg }, statCard: { flex: 1, @@ -987,18 +1121,9 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: colors.borderSubtle, borderRadius: 10, - paddingVertical: 10, + paddingVertical: 8, paddingHorizontal: spacing.md }, - statIcon: { - width: 26, - height: 26, - borderRadius: 6, - backgroundColor: 'rgba(255,255,255,0.04)', - alignItems: 'center', - justifyContent: 'center', - marginBottom: 6 - }, statValue: { color: colors.textPrimary, fontSize: 18, @@ -1022,6 +1147,9 @@ const styles = StyleSheet.create({ marginBottom: spacing.sm, paddingHorizontal: spacing.xs }, + sectionHeadingTightTop: { + marginTop: spacing.lg + }, /* ─── List ─── */ list: { @@ -1038,7 +1166,7 @@ const styles = StyleSheet.create({ alignItems: 'center', paddingLeft: spacing.md, paddingRight: spacing.md, - paddingVertical: 14, + paddingVertical: 12, borderRadius: radii.card, backgroundColor: colors.bgPanel, borderWidth: 1, @@ -1101,7 +1229,7 @@ const styles = StyleSheet.create({ borderRadius: radii.card, paddingLeft: spacing.md, paddingRight: spacing.md, - paddingVertical: 14 + paddingVertical: 12 }, resumeIcon: { width: 46, @@ -1138,6 +1266,65 @@ const styles = StyleSheet.create({ flex: 1 }, + /* ─── Tasks card ─── */ + taskHomeCard: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.card, + minHeight: 72, + paddingLeft: spacing.md, + paddingRight: spacing.md, + paddingVertical: 12 + }, + taskHomeIcon: { + width: 46, + height: 46, + borderRadius: 13, + backgroundColor: colors.bgRaised, + alignItems: 'center', + justifyContent: 'center', + marginRight: 14 + }, + taskHomeMain: { + flex: 1, + minWidth: 0 + }, + taskHomeTitle: { + fontSize: 13, + fontWeight: '600', + color: colors.textPrimary + }, + taskHomeSubtitle: { + fontSize: 12, + color: colors.textSecondary, + marginTop: 3 + }, + taskHomeTrailing: { + flexDirection: 'row', + alignItems: 'center', + flexShrink: 0, + marginLeft: spacing.sm + }, + taskHomeProviderRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + gap: 2 + }, + taskHomeProviderButton: { + width: 34, + height: 34, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button + }, + taskHomeProviderButtonPressed: { + backgroundColor: colors.bgRaised + }, + /* ─── Account usage ─── */ accountsCard: { backgroundColor: colors.bgPanel, @@ -1202,6 +1389,9 @@ const styles = StyleSheet.create({ alignItems: 'center', gap: 10 }, + quickActionDisabled: { + opacity: 0.45 + }, quickActionIcon: { width: 28, height: 28, diff --git a/mobile/mock-homepage.html b/mobile/mock-homepage.html index 65f51a3dc8b..b541dbb0a95 100644 --- a/mobile/mock-homepage.html +++ b/mobile/mock-homepage.html @@ -5,8 +5,6 @@ Orca Mobile – Homepage Redesign + + +
+
+
+
+
+ +
+ +

Tasks

+
+ + +
+ +
+ + +
+ + +
+ +
+

+
+ +
+
No GitHub tasks
+
+
+ + + +
+
+
+ + + + diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 15756a84161..c8f59ab72f9 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -1418,56 +1418,48 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-arm64-musl@0.47.0': resolution: {integrity: sha512-IxtQC/sbBi4ubbY+MdwdanRWrG9InQJVZqyMsBa5IUaQcnSg86gQme574HxXMC1p4bo4YhV99zQ+wNnGCvEgzw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@oxfmt/binding-linux-ppc64-gnu@0.47.0': resolution: {integrity: sha512-EWXEhOMbWO0q6eJSbu0QLkU8cKi0ljlYLngeDs2Ocu/pm1rrLwyQiYzlFbdnMRURI4w9ndr1sI9rSbhlJ5o23Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-riscv64-gnu@0.47.0': resolution: {integrity: sha512-tZrjS11TUiDuEpRaqdk8K9F9xETRyKXfuZKmdeW+Gj7coBnm7+8sBEfyt033EAFEQSlkniAXvBLh+Qja2ioGBQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-riscv64-musl@0.47.0': resolution: {integrity: sha512-KBFy+2CFKUCZzYwX2ZOPQKck1vjQbz+hextuc19G4r0WRJwadfAeuQMQRQvB+Ivc8brlbOVg7et8K7E467440g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [musl] '@oxfmt/binding-linux-s390x-gnu@0.47.0': resolution: {integrity: sha512-REUPFKVGSiK99B+9eaPhluEVglzaoj/SMykNC5SUiV2RSsBfV5lWN7Y0iCIc251Wz3GaeAGZsJ/zj3gjarxdFg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-x64-gnu@0.47.0': resolution: {integrity: sha512-KVftVSVEDeIfRW3TIeLe3aNI/iY4m1fu5mDwHcisKMZSCMKLkrhFsjowC7o9RoqNPxbbglm2+/6KAKBIts2t0Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-x64-musl@0.47.0': resolution: {integrity: sha512-DTsmGEaA2860Aq5VUyDO8/MT9NFxwVL93RnRYmpMwK6DsSkThmvEpqoUDDljziEpAedMRG19SCogrNbINSbLUQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@oxfmt/binding-openharmony-arm64@0.47.0': resolution: {integrity: sha512-8r5BDro7fLOBoq1JXHLVSs55OlrxQhEso4HVo0TcY7OXJUPYfjPoOaYL5us+yIwqyP9rQwN+rxuiNFSmaxSuOQ==} @@ -1540,56 +1532,48 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-arm64-musl@1.63.0': resolution: {integrity: sha512-Q5mmZy/XWjuYFUuQyYjOvZ5U/JkKEwnpir6hGxhh6HcdP0V/BKxLo8dqkfF/t7r7AguB17dfS/8+go5AQDRR6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@oxlint/binding-linux-ppc64-gnu@1.63.0': resolution: {integrity: sha512-uBGtuZ0TzLB4x5wVa82HGNvYqY8buwDhyCnCP0R0gkk9szqVsP0MeTtD5HX7EsEuFIt+aYmYxuxeVxs3nTSwtQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-riscv64-gnu@1.63.0': resolution: {integrity: sha512-h4s6FwxE+9MeA181o0dnDwHP32Y/bG8EiB/vrD6Ib+AMt6haigDc/0bUtI/sLmQDBMJnUfaCmtSSrEAqjtEVrA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-riscv64-musl@1.63.0': resolution: {integrity: sha512-2EaNcCBR8Mcjl5ARtuN3BdEpVkX7KpjSjMGZ/mJMIeaXgTtdz5ytg2VwygMSStA/k0ixfvZFoZOfjDEcouV5vQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [musl] '@oxlint/binding-linux-s390x-gnu@1.63.0': resolution: {integrity: sha512-p4hlf/fd7TrYYl3QrWWD0GocqJefwMu3cHQhmi2FvEB/YOvFb5DZN3SMBaPi7B1TM5DeypkEtrVib674q1KKPg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@oxlint/binding-linux-x64-gnu@1.63.0': resolution: {integrity: sha512-Vgq9rkRVcPcjbcH+ihYTfpeR7vCXfqpd+z5ItTGc0yYUV59L5ceHYN1iV4H9bKGV7Rn5hkVc7x3mSvHegduENA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-x64-musl@1.63.0': resolution: {integrity: sha512-3/Lkq/ncooA61rorrC+ZQed1Bc4VpGj+WnGsp58zmxKgvZ2vhreu+dcVyr3mX8NUpq7mfZ4gDDTou/yrF1Pd7A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@oxlint/binding-openharmony-arm64@1.63.0': resolution: {integrity: sha512-0/EdD/6hDkx5Mfd769PTjvEM8mZ/6Dfukp1dBCL/2PjlIVGEtYdNZyok6ChqYPsT9JcFnlQnUeQzO0/1L/oC9w==} @@ -2051,42 +2035,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0': resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0': resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0': resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0': resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0': resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0': resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==} @@ -4263,28 +4241,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} diff --git a/mobile/src/components/BottomDrawer.tsx b/mobile/src/components/BottomDrawer.tsx index 4d98f2a92e2..88b6e0e7293 100644 --- a/mobile/src/components/BottomDrawer.tsx +++ b/mobile/src/components/BottomDrawer.tsx @@ -38,9 +38,16 @@ type Props = { onClose: () => void children: ReactNode dragContentToDismiss?: boolean + zIndex?: number } -export function BottomDrawer({ visible, onClose, children, dragContentToDismiss = false }: Props) { +export function BottomDrawer({ + visible, + onClose, + children, + dragContentToDismiss = true, + zIndex +}: Props) { const [mounted, setMounted] = useState(visible) useEffect(() => { @@ -59,6 +66,7 @@ export function BottomDrawer({ visible, onClose, children, dragContentToDismiss onClose={onClose} onHidden={() => setMounted(false)} dragContentToDismiss={dragContentToDismiss} + zIndex={zIndex} > {children} @@ -74,7 +82,8 @@ function MountedBottomDrawer({ onClose, onHidden, children, - dragContentToDismiss = false + dragContentToDismiss = true, + zIndex = 1000 }: MountedBottomDrawerProps) { const translateY = useSharedValue(0) const progress = useSharedValue(0) @@ -239,7 +248,11 @@ function MountedBottomDrawer({ ) return ( - + diff --git a/mobile/src/components/MobileAgentIcon.tsx b/mobile/src/components/MobileAgentIcon.tsx new file mode 100644 index 00000000000..cc6f439d1e6 --- /dev/null +++ b/mobile/src/components/MobileAgentIcon.tsx @@ -0,0 +1,89 @@ +import { Image, StyleSheet, Text, View } from 'react-native' +import { Terminal } from 'lucide-react-native' +import Svg, { G, Path } from 'react-native-svg' +import { colors } from '../theme/mobile-theme' +import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog' +import { ClaudeIcon, OpenAIIcon } from './AgentIcons' + +// Why: agent branding should match the desktop/new-worktree picker everywhere +// mobile lets users choose the agent that will own a workspace. + +function PiIcon({ size = 16 }: { size?: number }) { + return ( + + + + + ) +} + +function AiderIcon({ size = 16 }: { size?: number }) { + return ( + + + + + + + ) +} + +function FaviconIcon({ domain, size = 16 }: { domain: string; size?: number }) { + return ( + + ) +} + +function AgentLetterIcon({ letter, size = 16 }: { letter: string; size?: number }) { + return ( + + + {letter} + + + ) +} + +export function MobileAgentIcon({ agentId, size = 16 }: { agentId: string; size?: number }) { + if (agentId === 'claude') return + if (agentId === 'codex') return + if (agentId === 'pi') return + if (agentId === 'aider') return + if (agentId === '__blank__' || agentId === 'blank') { + return + } + + const agent = MOBILE_AGENT_CATALOG.find((entry) => entry.id === agentId) + if (agent?.faviconDomain) { + return + } + const label = agent?.label ?? agentId + return +} + +const styles = StyleSheet.create({ + letterIcon: { + alignItems: 'center', + justifyContent: 'center' + }, + letterIconText: { + fontWeight: '700' + } +}) diff --git a/mobile/src/components/MobileMarkdown.test.ts b/mobile/src/components/MobileMarkdown.test.ts new file mode 100644 index 00000000000..32470043a75 --- /dev/null +++ b/mobile/src/components/MobileMarkdown.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { parseMobileMarkdown } from './mobile-markdown-parser' + +describe('parseMobileMarkdown', () => { + it('parses GFM tables into table blocks', () => { + expect(parseMobileMarkdown('| Name | State |\n| --- | --- |\n| Orca | Open |')).toEqual([ + { + type: 'table', + headers: ['Name', 'State'], + rows: [['Orca', 'Open']] + } + ]) + }) + + it('parses standalone HTTPS images without folding them into paragraphs', () => { + expect(parseMobileMarkdown('![Screenshot](https://example.com/screen.png)')).toEqual([ + { + type: 'image', + alt: 'Screenshot', + url: 'https://example.com/screen.png' + } + ]) + }) +}) diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx new file mode 100644 index 00000000000..d56e1ac293a --- /dev/null +++ b/mobile/src/components/MobileMarkdown.tsx @@ -0,0 +1,354 @@ +import { Fragment, memo, useMemo, type ReactNode } from 'react' +import { Linking, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { parseMobileMarkdown } from './mobile-markdown-parser' + +type Props = { + content?: string + fallback?: string +} + +const MAX_TABLE_ROWS = 40 +const MAX_TABLE_COLUMNS = 8 + +function openMarkdownUrl(url: string): void { + const trimmed = url.trim() + if (/^(https?:|mailto:)/i.test(trimmed)) { + void Linking.openURL(trimmed) + } +} + +function renderInline(text: string): ReactNode[] { + const parts: ReactNode[] = [] + const pattern = + /(!\[[^\]]*\]\([^)]+\)|`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)]+\)|https?:\/\/[^\s<]+)/g + let lastIndex = 0 + let match: RegExpExecArray | null + + while ((match = pattern.exec(text))) { + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)) + } + const token = match[0] + const key = `${match.index}:${token}` + const image = token.match(/^!\[([^\]]*)\]\(([^)]+)\)$/) + const link = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/) + if (image) { + parts.push( + openMarkdownUrl(image[2]!)}> + {image[1] || 'image'} + + ) + } else if (link) { + parts.push( + openMarkdownUrl(link[2]!)}> + {link[1]} + + ) + } else if (/^https?:\/\//i.test(token)) { + parts.push( + openMarkdownUrl(token)}> + {token} + + ) + } else if (token.startsWith('`')) { + parts.push( + + {token.slice(1, -1)} + + ) + } else if (token.startsWith('~~')) { + parts.push( + + {token.slice(2, -2)} + + ) + } else if (token.startsWith('**') || token.startsWith('__')) { + parts.push( + + {token.slice(2, -2)} + + ) + } else { + parts.push( + + {token.slice(1, -1)} + + ) + } + lastIndex = pattern.lastIndex + } + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)) + } + return parts +} + +function MobileMarkdownInner({ content, fallback = '' }: Props) { + const text = content?.trim() ?? '' + const blocks = useMemo(() => parseMobileMarkdown(text), [text]) + if (!text) { + return fallback ? {fallback} : null + } + + return ( + + {blocks.map((block, index) => { + if (block.type === 'heading') { + return ( + + {renderInline(block.text)} + + ) + } + if (block.type === 'quote') { + return ( + + {renderInline(block.text)} + + ) + } + if (block.type === 'code') { + return ( + + {block.language ? {block.language} : null} + {block.text} + + ) + } + if (block.type === 'image') { + return ( + openMarkdownUrl(block.url)} + > + {block.alt || 'Open image'} + + {block.url} + + + ) + } + if (block.type === 'table') { + const visibleHeaders = block.headers.slice(0, MAX_TABLE_COLUMNS) + const visibleRows = block.rows.slice(0, MAX_TABLE_ROWS) + const hiddenRows = Math.max(0, block.rows.length - visibleRows.length) + const hiddenColumns = Math.max(0, block.headers.length - visibleHeaders.length) + return ( + + + + {visibleHeaders.map((header, cellIndex) => ( + + {renderInline(header)} + + ))} + + {visibleRows.map((row, rowIndex) => ( + + {visibleHeaders.map((_, cellIndex) => ( + + {renderInline(row[cellIndex] ?? '')} + + ))} + + ))} + {hiddenRows > 0 || hiddenColumns > 0 ? ( + + {hiddenRows > 0 ? `${hiddenRows} more rows` : ''} + {hiddenRows > 0 && hiddenColumns > 0 ? ' · ' : ''} + {hiddenColumns > 0 ? `${hiddenColumns} more columns` : ''} + + ) : null} + + + ) + } + if (block.type === 'list') { + return ( + + {block.items.map((item, itemIndex) => ( + + + {item.checked == null + ? block.ordered + ? `${itemIndex + 1}.` + : '-' + : item.checked + ? '[x]' + : '[ ]'} + + {renderInline(item.text)} + + ))} + + ) + } + if (block.type === 'rule') { + return + } + return ( + + {block.text.split('\n').map((line, lineIndex) => ( + + {lineIndex > 0 ? '\n' : null} + {renderInline(line)} + + ))} + + ) + })} + + ) +} + +export const MobileMarkdown = memo(MobileMarkdownInner) + +const styles = StyleSheet.create({ + root: { + gap: spacing.sm + }, + paragraph: { + fontSize: 13, + lineHeight: 19, + color: colors.textPrimary + }, + heading: { + fontSize: 14, + lineHeight: 20, + fontWeight: '700', + color: colors.textPrimary + }, + headingLarge: { + fontSize: 15, + lineHeight: 21 + }, + bold: { + fontWeight: '700', + color: colors.textPrimary + }, + italic: { + fontStyle: 'italic' + }, + strike: { + textDecorationLine: 'line-through' + }, + link: { + color: colors.accentBlue, + textDecorationLine: 'underline' + }, + inlineCode: { + fontFamily: typography.monoFamily, + fontSize: 12, + color: colors.textPrimary, + backgroundColor: colors.bgRaised, + borderRadius: radii.row, + paddingHorizontal: 4 + }, + quote: { + borderLeftWidth: 2, + borderLeftColor: colors.borderSubtle, + paddingLeft: spacing.sm + }, + quoteText: { + fontSize: 13, + lineHeight: 19, + color: colors.textSecondary + }, + codeBlock: { + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.input, + padding: spacing.sm + }, + codeLanguage: { + fontSize: 10, + color: colors.textMuted, + marginBottom: spacing.xs, + textTransform: 'uppercase' + }, + codeText: { + fontFamily: typography.monoFamily, + fontSize: 12, + lineHeight: 17, + color: colors.textPrimary + }, + imageFrame: { + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.input, + backgroundColor: colors.bgRaised, + overflow: 'hidden', + padding: spacing.sm + }, + imageCaption: { + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + fontSize: 11, + color: colors.textSecondary + }, + table: { + borderTopWidth: 1, + borderLeftWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.input, + overflow: 'hidden', + backgroundColor: colors.bgPanel + }, + tableRow: { + flexDirection: 'row' + }, + tableCell: { + minWidth: 112, + maxWidth: 220, + borderRightWidth: 1, + borderBottomWidth: 1, + borderColor: colors.borderSubtle, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + fontSize: 12, + lineHeight: 17, + color: colors.textPrimary + }, + tableHeader: { + fontWeight: '700', + backgroundColor: colors.bgRaised + }, + tableTruncated: { + padding: spacing.sm, + fontSize: 12, + color: colors.textMuted + }, + list: { + gap: spacing.xs + }, + listItem: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.sm + }, + listMarker: { + width: 22, + fontSize: 13, + lineHeight: 19, + color: colors.textSecondary, + fontFamily: typography.monoFamily + }, + listText: { + flex: 1, + minWidth: 0, + fontSize: 13, + lineHeight: 19, + color: colors.textPrimary + }, + rule: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle + } +}) diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 20f74e7d643..e52062b2bfa 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -7,22 +7,62 @@ import { Switch, StyleSheet, Platform, - ActivityIndicator, - Image + ActivityIndicator } from 'react-native' -import { ChevronDown, ChevronUp, Check, Terminal } from 'lucide-react-native' -import Svg, { Path, G } from 'react-native-svg' +import { ChevronDown, ChevronUp, Check } from 'lucide-react-native' import type { RpcClient } from '../transport/rpc-client' import type { RpcSuccess } from '../transport/types' import { colors, spacing, radii, typography } from '../theme/mobile-theme' import { BottomDrawer } from './BottomDrawer' -import { ClaudeIcon, OpenAIIcon } from './AgentIcons' +import { MobileAgentIcon } from './MobileAgentIcon' import { getSuggestedCreatureName } from './worktree-name-suggestion' +import { deriveWorkspaceSshGate, workspaceSshStatusLabel } from '../tasks/workspace-ssh-gate' +import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog' +import { WORKTREE_CREATE_TIMEOUT_MS } from '../tasks/workspace-create-timeout' +import { + isSetupHookTrusted, + normalizeSetupHookTrust, + trustedOrcaHooksWithSetupApproval, + wasSetupHookPreviouslyApproved, + type SetupHookTrust +} from '../tasks/setup-hook-trust' +import { isMobileTuiAgent, MOBILE_TUI_AGENT_LAUNCH_COMMANDS } from '../tasks/mobile-tui-agents' +import type { PersistedTrustedOrcaHooks } from '../../../src/shared/types' +import type { SshConnectionState } from '../../../src/shared/ssh-types' type Repo = { id: string displayName: string path: string + badgeColor?: string + connectionId?: string | null +} + +type SetupDecision = 'inherit' | 'run' | 'skip' +type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default' +type RuntimeSettings = { + defaultTuiAgent?: string | 'blank' | null + agentCmdOverrides?: Record +} + +type RepoHooksResponse = { + hooks: { scripts?: { setup?: string } } | null + source: string | null + setupRunPolicy?: SetupRunPolicy + setupTrust?: SetupHookTrust +} + +type CreateOptions = { + setupOverride?: Exclude + approvedSetupContentHash?: string +} + +type SetupTrustPrompt = { + repoId: string + repoName: string + scriptContent: string + contentHash: string + previouslyApproved: boolean } type AgentOption = { @@ -31,139 +71,41 @@ type AgentOption = { faviconDomain?: string } -// Why: matches the AGENT_CATALOG ordering and faviconDomain values from -// src/renderer/src/lib/agent-catalog.tsx so mobile uses the same icon sources. -const AGENT_OPTIONS: AgentOption[] = [ - { id: 'claude', label: 'Claude' }, - { id: 'codex', label: 'Codex' }, - { id: 'grok', label: 'Grok', faviconDomain: 'x.ai' }, - { id: 'copilot', label: 'GitHub Copilot', faviconDomain: 'github.com' }, - { id: 'opencode', label: 'OpenCode', faviconDomain: 'opencode.ai' }, - { id: 'pi', label: 'Pi' }, - { id: 'gemini', label: 'Gemini', faviconDomain: 'gemini.google.com' }, - { id: 'aider', label: 'Aider' }, - { id: 'goose', label: 'Goose', faviconDomain: 'goose-docs.ai' }, - { id: 'amp', label: 'Amp', faviconDomain: 'ampcode.com' }, - { id: 'kilo', label: 'Kilocode', faviconDomain: 'kilo.ai' }, - { id: 'kiro', label: 'Kiro', faviconDomain: 'kiro.dev' }, - { id: 'crush', label: 'Charm', faviconDomain: 'charm.sh' }, - { id: 'aug', label: 'Auggie', faviconDomain: 'augmentcode.com' }, - { id: 'cline', label: 'Cline', faviconDomain: 'cline.bot' }, - { id: 'codebuff', label: 'Codebuff', faviconDomain: 'codebuff.com' }, - { id: 'continue', label: 'Continue', faviconDomain: 'continue.dev' }, - { id: 'cursor', label: 'Cursor', faviconDomain: 'cursor.com' }, - { id: 'droid', label: 'Droid', faviconDomain: 'factory.ai' }, - { id: 'kimi', label: 'Kimi', faviconDomain: 'moonshot.cn' }, - { id: 'mistral-vibe', label: 'Mistral Vibe', faviconDomain: 'mistral.ai' }, - { id: 'qwen-code', label: 'Qwen Code', faviconDomain: 'qwenlm.github.io' }, - { id: 'rovo', label: 'Rovo Dev', faviconDomain: 'atlassian.com' }, - { id: 'hermes', label: 'Hermes', faviconDomain: 'nousresearch.com' } -] +const AGENT_OPTIONS: AgentOption[] = MOBILE_AGENT_CATALOG const BLANK_TERMINAL: AgentOption = { id: '__blank__', label: 'Blank Terminal' } -const ALL_AGENTS = [...AGENT_OPTIONS, BLANK_TERMINAL] -// Why: mirrors launchCmd from src/shared/tui-agent-config.ts so terminal.create -// gets the correct binary name for each agent. -const AGENT_COMMANDS: Record = { - claude: 'claude', - codex: 'codex', - grok: 'grok', - copilot: 'copilot', - opencode: 'opencode', - pi: 'pi', - gemini: 'gemini', - aider: 'aider', - goose: 'goose', - amp: 'amp', - kilo: 'kilo', - kiro: 'kiro', - crush: 'crush', - aug: 'auggie', - cline: 'cline', - codebuff: 'codebuff', - continue: 'continue', - cursor: 'cursor-agent', - droid: 'droid', - kimi: 'kimi', - 'mistral-vibe': 'mistral-vibe', - 'qwen-code': 'qwen-code', - rovo: 'rovo', - hermes: 'hermes' +function agentOptionFor(id: string | null | undefined): AgentOption | null { + if (!id) return null + if (id === 'blank' || id === '__blank__') return BLANK_TERMINAL + return AGENT_OPTIONS.find((agent) => agent.id === id) ?? null } -// ── Agent icons ───────────────────────────────────────────────────── -// SVG paths sourced from the desktop codebase: -// Claude & OpenAI: shared in ./AgentIcons.tsx -// Pi & Aider: src/renderer/src/lib/agent-catalog.tsx -// Agents with a faviconDomain use Google's favicon service (same as desktop). - -function PiIcon({ size = 16 }: { size?: number }) { - return ( - - - - - ) -} - -function AiderIcon({ size = 16 }: { size?: number }) { - return ( - - - - - - - ) -} - -function FaviconIcon({ domain, size = 16 }: { domain: string; size?: number }) { - return ( - - ) -} - -function AgentLetterIcon({ letter, size = 16 }: { letter: string; size?: number }) { - return ( - - - {letter} - - - ) -} - -function AgentIcon({ agentId, size = 16 }: { agentId: string; size?: number }) { - if (agentId === 'claude') return - if (agentId === 'codex') return - if (agentId === 'pi') return - if (agentId === 'aider') return - if (agentId === '__blank__') return - - const agent = AGENT_OPTIONS.find((a) => a.id === agentId) - if (agent?.faviconDomain) { - return +function pickPreferredAgent( + settings: RuntimeSettings | null, + detectedAgentIds: Set | null +): AgentOption { + const preferred = agentOptionFor(settings?.defaultTuiAgent) + if (preferred?.id === '__blank__') { + return preferred } - const label = agent?.label ?? agentId - return + if (preferred && (detectedAgentIds === null || detectedAgentIds.has(preferred.id))) + return preferred + const detectedOption = AGENT_OPTIONS.find( + (agent) => detectedAgentIds === null || detectedAgentIds.has(agent.id) + ) + return detectedOption ?? BLANK_TERMINAL +} + +function repoColor(name: string): string { + const palette = ['#f97316', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f59e0b', '#6366f1'] + let hash = 0 + for (let i = 0; i < name.length; i += 1) hash = (hash * 31 + name.charCodeAt(i)) | 0 + return palette[Math.abs(hash) % palette.length]! +} + +function repoBadgeColor(repo: Repo | null): string { + return repo?.badgeColor || repoColor(repo?.displayName ?? 'repository') } // ── Picker sub-modal ──────────────────────────────────────────────── @@ -249,12 +191,25 @@ export function NewWorktreeModal({ const [selectedRepo, setSelectedRepo] = useState(null) const [showRepoPicker, setShowRepoPicker] = useState(false) const [selectedAgent, setSelectedAgent] = useState(AGENT_OPTIONS[0]!) + const [runtimeSettings, setRuntimeSettings] = useState(null) + const [detectedAgentIds, setDetectedAgentIds] = useState | null>(null) + const [agentOverridden, setAgentOverridden] = useState(false) const [showAgentPicker, setShowAgentPicker] = useState(false) + const [sshState, setSshState] = useState(null) + const [sshConnecting, setSshConnecting] = useState(false) const [name, setName] = useState('') const [note, setNote] = useState('') const [showAdvanced, setShowAdvanced] = useState(false) const [setupCommand, setSetupCommand] = useState(null) const [setupSource, setSetupSource] = useState(null) + const [setupTrust, setSetupTrust] = useState(null) + const [trustedOrcaHooks, setTrustedOrcaHooks] = useState({}) + const [setupTrustPrompt, setSetupTrustPrompt] = useState(null) + const [setupRunPolicy, setSetupRunPolicy] = useState('run-by-default') + const [setupDecisionChoice, setSetupDecisionChoice] = useState | null>(null) const [runSetup, setRunSetup] = useState(true) const [creating, setCreating] = useState(false) const [error, setError] = useState('') @@ -266,6 +221,13 @@ export function NewWorktreeModal({ // it's recomputed lazily inside handleCreate() to stay fresh against // existingWorktreePaths at submission time. + const selectedRepoConnectionId = selectedRepo?.connectionId ?? null + const sshGate = deriveWorkspaceSshGate({ + connectionId: selectedRepoConnectionId, + state: sshState, + connecting: sshConnecting + }) + useEffect(() => { if (!visible) { setShowRepoPicker(false) @@ -279,20 +241,44 @@ export function NewWorktreeModal({ setShowAdvanced(false) setSetupCommand(null) setSetupSource(null) + setSetupTrust(null) + setTrustedOrcaHooks({}) + setSetupTrustPrompt(null) + setSetupRunPolicy('run-by-default') + setSetupDecisionChoice(null) setRunSetup(true) setError('') setCreating(false) setShowRepoPicker(false) setShowAgentPicker(false) + setRuntimeSettings(null) + setDetectedAgentIds(null) + setAgentOverridden(false) + setSshState(null) + setSshConnecting(false) setSelectedAgent(AGENT_OPTIONS[0]!) setLoading(true) void (async () => { try { - const response = await client.sendRequest('repo.list') + const [repoResponse, settingsResponse, uiResponse] = await Promise.all([ + client.sendRequest('repo.list'), + client.sendRequest('settings.get'), + client.sendRequest('ui.get') + ]) if (stale) return - if (response.ok) { - const result = (response as RpcSuccess).result as { repos: Repo[] } + if (settingsResponse.ok) { + const result = (settingsResponse as RpcSuccess).result as { settings: RuntimeSettings } + setRuntimeSettings(result.settings) + } + if (uiResponse.ok) { + const result = (uiResponse as RpcSuccess).result as { + ui?: { trustedOrcaHooks?: PersistedTrustedOrcaHooks } + } + setTrustedOrcaHooks(result.ui?.trustedOrcaHooks ?? {}) + } + if (repoResponse.ok) { + const result = (repoResponse as RpcSuccess).result as { repos: Repo[] } setRepos(result.repos) if (result.repos.length === 1) { setSelectedRepo(result.repos[0]!) @@ -311,10 +297,90 @@ export function NewWorktreeModal({ } }, [visible, client]) + useEffect(() => { + if (!visible || !client || !selectedRepoConnectionId) { + setSshState(null) + setSshConnecting(false) + return + } + let stale = false + void client + .sendRequest('ssh.getState', { targetId: selectedRepoConnectionId }) + .then((response) => { + if (stale) return + if (!response.ok) { + throw new Error(response.error.message) + } + const state = (response as RpcSuccess).result as { state?: SshConnectionState | null } + setSshState( + state.state ?? { + targetId: selectedRepoConnectionId, + status: 'disconnected', + error: null, + reconnectAttempt: 0 + } + ) + }) + .catch((err) => { + if (!stale) { + setSshState({ + targetId: selectedRepoConnectionId, + status: 'error', + error: err instanceof Error ? err.message : 'Failed to read SSH connection state.', + reconnectAttempt: 0 + }) + } + }) + return () => { + stale = true + } + }, [client, selectedRepoConnectionId, visible]) + + useEffect(() => { + if (!visible || !client) return + if (selectedRepoConnectionId && sshGate.status !== 'connected') { + setDetectedAgentIds(null) + return + } + let stale = false + setDetectedAgentIds(null) + void (async () => { + try { + const response = selectedRepoConnectionId + ? await client.sendRequest('preflight.detectRemoteAgents', { + connectionId: selectedRepoConnectionId + }) + : await client.sendRequest('preflight.detectAgents') + if (stale) return + setDetectedAgentIds( + response.ok ? new Set((response as RpcSuccess).result as string[]) : new Set() + ) + } catch { + if (!stale) setDetectedAgentIds(new Set()) + } + })() + return () => { + stale = true + } + }, [client, selectedRepoConnectionId, sshGate.status, visible]) + + useEffect(() => { + if (!visible || agentOverridden) return + setSelectedAgent(pickPreferredAgent(runtimeSettings, detectedAgentIds)) + }, [agentOverridden, detectedAgentIds, runtimeSettings, visible]) + + useEffect(() => { + if (!visible || detectedAgentIds === null || selectedAgent.id === '__blank__') return + if (detectedAgentIds.has(selectedAgent.id)) return + setSelectedAgent(pickPreferredAgent(runtimeSettings, detectedAgentIds)) + setAgentOverridden(false) + }, [detectedAgentIds, runtimeSettings, selectedAgent.id, visible]) + useEffect(() => { if (!client || !selectedRepo) { setSetupCommand(null) setSetupSource(null) + setSetupTrust(null) return } let stale = false @@ -325,20 +391,26 @@ export function NewWorktreeModal({ }) if (stale) return if (response.ok) { - const result = (response as RpcSuccess).result as { - hooks: { scripts: { setup?: string } } | null - source: string | null - setupRunPolicy: string - } - const cmd = result.hooks?.scripts.setup ?? null + const result = (response as RpcSuccess).result as RepoHooksResponse + const cmd = result.hooks?.scripts?.setup?.trim() || null + const policy = result.setupRunPolicy ?? 'run-by-default' setSetupCommand(cmd) setSetupSource(result.source) - setRunSetup(result.setupRunPolicy !== 'skip-by-default') + setSetupTrust(normalizeSetupHookTrust(result.setupTrust)) + setSetupRunPolicy(policy) + setSetupDecisionChoice(null) + setRunSetup(policy !== 'skip-by-default') + if (cmd && policy === 'ask') { + setShowAdvanced(true) + } } } catch { if (!stale) { setSetupCommand(null) setSetupSource(null) + setSetupTrust(null) + setSetupRunPolicy('run-by-default') + setSetupDecisionChoice(null) } } })() @@ -347,14 +419,81 @@ export function NewWorktreeModal({ } }, [client, selectedRepo]) - async function handleCreate() { + async function connectSelectedSshRepo(): Promise { + if (!client || !selectedRepoConnectionId) return + setSshConnecting(true) + setSshState({ + targetId: selectedRepoConnectionId, + status: 'connecting', + error: null, + reconnectAttempt: 0 + }) + try { + const response = await client.sendRequest( + 'ssh.connect', + { targetId: selectedRepoConnectionId }, + { timeoutMs: 120_000 } + ) + if (!response.ok) { + throw new Error(response.error.message) + } + const result = (response as RpcSuccess).result as { state?: SshConnectionState | null } + setSshState( + result.state ?? { + targetId: selectedRepoConnectionId, + status: 'connected', + error: null, + reconnectAttempt: 0 + } + ) + } catch (err) { + setSshState({ + targetId: selectedRepoConnectionId, + status: 'error', + error: err instanceof Error ? err.message : 'Failed to connect to SSH repository.', + reconnectAttempt: 0 + }) + } finally { + setSshConnecting(false) + } + } + + async function persistSetupHookTrust( + repoId: string, + contentHash: string, + alwaysTrust: boolean + ): Promise { + if (!client) return + const next = trustedOrcaHooksWithSetupApproval({ + trust: trustedOrcaHooks, + repoId, + contentHash, + alwaysTrust + }) + const response = await client.sendRequest('ui.set', { trustedOrcaHooks: next }) + if (!response.ok) { + throw new Error(response.error.message) + } + setTrustedOrcaHooks(next) + } + + async function handleCreate(options: CreateOptions = {}) { if (!client || !selectedRepo) return setCreating(true) setError('') try { + if (sshGate.requiresConnection) { + setError(`Connect ${selectedRepo.displayName} before creating a workspace.`) + return + } const command = - selectedAgent.id !== '__blank__' ? AGENT_COMMANDS[selectedAgent.id] : undefined + selectedAgent.id !== '__blank__' + ? (runtimeSettings?.agentCmdOverrides?.[selectedAgent.id] ?? + (isMobileTuiAgent(selectedAgent.id) + ? MOBILE_TUI_AGENT_LAUNCH_COMMANDS[selectedAgent.id] + : undefined)) + : undefined // Why: blank name field — match desktop behavior by computing the // next available marine-creature name at submit time and passing it @@ -381,6 +520,37 @@ export function NewWorktreeModal({ ] const candidateFor = (attempt: number): string => attempt === 0 ? baseName : `${baseName}-${attempt + 1}` + let setupDecision: SetupDecision = 'inherit' + if (setupCommand) { + if (options.setupOverride) { + setupDecision = options.setupOverride + } else if (setupRunPolicy === 'ask') { + if (!setupDecisionChoice) { + setError('Choose whether to run the setup script.') + return + } + setupDecision = setupDecisionChoice + } else { + setupDecision = runSetup ? 'run' : 'skip' + } + } + if ( + setupDecision === 'run' && + setupTrust && + setupTrust.contentHash !== options.approvedSetupContentHash && + !isSetupHookTrusted(trustedOrcaHooks, selectedRepo.id, setupTrust.contentHash) + ) { + // Why: desktop prompts before running repo-owned orca.yaml setup hooks. + // Mobile stores the same trust hash so approvals carry across surfaces. + setSetupTrustPrompt({ + repoId: selectedRepo.id, + repoName: selectedRepo.displayName, + scriptContent: setupTrust.scriptContent, + contentHash: setupTrust.contentHash, + previouslyApproved: wasSetupHookPreviouslyApproved(trustedOrcaHooks, selectedRepo.id) + }) + return + } let lastError: string | null = null for (let attempt = 0; attempt < 25; attempt += 1) { @@ -388,12 +558,15 @@ export function NewWorktreeModal({ const params: Record = { repo: `id:${selectedRepo.id}`, startupCommand: command, - setupDecision: runSetup ? 'inherit' : 'skip', + setupDecision, name: candidateName } + if (selectedAgent.id !== '__blank__') params.createdWithAgent = selectedAgent.id if (note.trim()) params.comment = note.trim() - const response = await client.sendRequest('worktree.create', params) + const response = await client.sendRequest('worktree.create', params, { + timeoutMs: WORKTREE_CREATE_TIMEOUT_MS + }) if (response.ok) { const result = (response as RpcSuccess).result as { worktree: { id: string } } onClose() @@ -414,7 +587,20 @@ export function NewWorktreeModal({ } } - const canCreate = selectedRepo != null && !creating + const needsSetupChoice = Boolean(setupCommand) && setupRunPolicy === 'ask' + const agentDetectionPending = + selectedRepo != null && !sshGate.requiresConnection && detectedAgentIds === null + const canCreate = + selectedRepo != null && + !creating && + !sshGate.requiresConnection && + !agentDetectionPending && + (!needsSetupChoice || setupDecisionChoice != null) + const visibleAgentOptions = + detectedAgentIds === null + ? AGENT_OPTIONS + : AGENT_OPTIONS.filter((agent) => detectedAgentIds.has(agent.id)) + const pickerAgentOptions = [...visibleAgentOptions, BLANK_TERMINAL] return ( <> @@ -439,6 +625,11 @@ export function NewWorktreeModal({ Repository setShowRepoPicker(true)}> + {selectedRepo ? ( + + ) : null} + {selectedRepoConnectionId ? ( + + SSH Connection + + + + + + {selectedRepo?.displayName ?? 'Remote repository'} + + + {workspaceSshStatusLabel(sshGate.status)} + + + {sshGate.status === 'connected' ? null : ( + void connectSelectedSshRepo()} + > + + {sshGate.connectInProgress ? 'Connecting...' : 'Connect'} + + + )} + + {sshGate.error ? {sshGate.error} : null} + + + ) : null} + Workspace Name [Optional] @@ -474,10 +708,14 @@ export function NewWorktreeModal({ Agent - setShowAgentPicker(true)}> - + setShowAgentPicker(true)} + > + - {selectedAgent.label} + {sshGate.requiresConnection ? 'Connect repository first' : selectedAgent.label} @@ -520,16 +758,39 @@ export function NewWorktreeModal({ )} - - Run setup command - - + {setupRunPolicy === 'ask' ? ( + + setSetupDecisionChoice('run')} + > + Run + + setSetupDecisionChoice('skip')} + > + Skip + + + ) : ( + + Run setup command + + + )} {setupCommand} @@ -550,7 +811,9 @@ export function NewWorktreeModal({ {creating ? ( ) : ( - Create Workspace + + {sshGate.requiresConnection ? 'Connect Repository' : 'Create Workspace'} + )} @@ -567,17 +830,119 @@ export function NewWorktreeModal({ selectedId={selectedRepo?.id ?? ''} onSelect={(item) => setSelectedRepo((item as { _repo: Repo })._repo)} onClose={() => setShowRepoPicker(false)} + renderIcon={(item) => { + const repo = (item as { _repo: Repo })._repo + return + }} /> setSelectedAgent(agent)} + onSelect={(agent) => { + setAgentOverridden(true) + setSelectedAgent(agent) + }} onClose={() => setShowAgentPicker(false)} - renderIcon={(agent) => } + renderIcon={(agent) => } /> + + setSetupTrustPrompt(null)} + > + {setupTrustPrompt ? ( + + + + {setupTrustPrompt.previouslyApproved + ? `${setupTrustPrompt.repoName}'s setup script changed` + : `Run setup from ${setupTrustPrompt.repoName}?`} + + + This repository's orca.yaml runs before the workspace starts. Only run it if you + trust this repository. + + + + + + {setupTrustPrompt.previouslyApproved ? 'New setup script' : 'Setup script'} + + {setupTrustPrompt.scriptContent} + + + + + void (async () => { + try { + await persistSetupHookTrust( + setupTrustPrompt.repoId, + setupTrustPrompt.contentHash, + false + ) + const approvedHash = setupTrustPrompt.contentHash + setSetupTrustPrompt(null) + await handleCreate({ + setupOverride: 'run', + approvedSetupContentHash: approvedHash + }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to trust setup script.') + } + })() + } + > + + Run hooks + + + + void (async () => { + try { + await persistSetupHookTrust( + setupTrustPrompt.repoId, + setupTrustPrompt.contentHash, + true + ) + const approvedHash = setupTrustPrompt.contentHash + setSetupTrustPrompt(null) + await handleCreate({ + setupOverride: 'run', + approvedSetupContentHash: approvedHash + }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to trust setup script.') + } + })() + } + > + + Always trust and run + + + { + setSetupTrustPrompt(null) + void handleCreate({ setupOverride: 'skip' }) + }} + > + Don't run + + + + ) : null} + ) } @@ -637,6 +1002,72 @@ const styles = StyleSheet.create({ fieldButtonPlaceholder: { color: colors.textMuted }, + repoDot: { + width: 8, + height: 8, + borderRadius: 999 + }, + disabled: { + opacity: 0.55 + }, + sshBox: { + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + borderWidth: 1, + borderColor: colors.borderSubtle, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + gap: spacing.xs + }, + sshRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + sshDot: { + width: 8, + height: 8, + borderRadius: 999 + }, + sshDotConnected: { + backgroundColor: colors.statusGreen + }, + sshDotProgress: { + backgroundColor: colors.statusAmber + }, + sshDotDisconnected: { + backgroundColor: colors.statusRed + }, + sshCopy: { + flex: 1, + minWidth: 0 + }, + sshTitle: { + fontSize: typography.bodySize, + color: colors.textPrimary, + fontWeight: '600' + }, + sshSubtitle: { + fontSize: 12, + color: colors.textSecondary, + marginTop: 1 + }, + sshConnectButton: { + borderRadius: radii.button, + borderWidth: 1, + borderColor: colors.borderSubtle, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs + }, + sshConnectText: { + color: colors.textPrimary, + fontSize: 12, + fontWeight: '600' + }, + errorInline: { + color: colors.statusRed, + fontSize: 12 + }, input: { backgroundColor: colors.bgRaised, color: colors.textPrimary, @@ -699,6 +1130,28 @@ const styles = StyleSheet.create({ fontSize: 13, color: colors.textSecondary }, + setupChoiceRow: { + flexDirection: 'row', + gap: spacing.sm, + marginBottom: spacing.sm + }, + setupChoiceButton: { + flex: 1, + alignItems: 'center', + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.button, + paddingVertical: spacing.sm + }, + setupChoiceButtonSelected: { + backgroundColor: colors.bgPanel, + borderColor: colors.textSecondary + }, + setupChoiceText: { + fontSize: 13, + fontWeight: '600', + color: colors.textPrimary + }, setupSwitch: { transform: [{ scaleX: 0.7 }, { scaleY: 0.7 }] }, @@ -713,6 +1166,52 @@ const styles = StyleSheet.create({ fontFamily: typography.monoFamily, color: colors.textPrimary }, + trustHeader: { + paddingHorizontal: spacing.xs, + marginBottom: spacing.md + }, + trustScriptBox: { + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + borderWidth: 1, + borderColor: colors.borderSubtle, + padding: spacing.md, + marginBottom: spacing.md + }, + trustScriptLabel: { + fontSize: 12, + fontWeight: '600', + color: colors.textSecondary, + marginBottom: spacing.sm + }, + trustScriptText: { + fontSize: 13, + fontFamily: typography.monoFamily, + color: colors.textPrimary + }, + trustActionGroup: { + backgroundColor: colors.bgPanel, + borderRadius: radii.input, + overflow: 'hidden' + }, + trustActionRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + }, + trustActionText: { + flex: 1, + fontSize: typography.bodySize, + color: colors.textPrimary, + fontWeight: '500' + }, + trustActionSeparator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, actions: { flexDirection: 'row', justifyContent: 'flex-end', @@ -734,14 +1233,6 @@ const styles = StyleSheet.create({ fontSize: typography.bodySize, fontWeight: '600' }, - letterIcon: { - alignItems: 'center', - justifyContent: 'center' - }, - letterIconText: { - fontWeight: '700' - }, - // Picker sub-modal styles pickerHeader: { paddingHorizontal: spacing.xs, diff --git a/mobile/src/components/PickerModal.tsx b/mobile/src/components/PickerModal.tsx index eecc653e8da..f786f99004c 100644 --- a/mobile/src/components/PickerModal.tsx +++ b/mobile/src/components/PickerModal.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react' import { View, Text, Pressable, StyleSheet } from 'react-native' import { Check } from 'lucide-react-native' import { colors, spacing, typography } from '../theme/mobile-theme' @@ -7,6 +8,8 @@ export type PickerOption = { value: T label: string subtitle?: string + disabled?: boolean + renderIcon?: (selected: boolean) => ReactNode } type Props = { @@ -15,7 +18,9 @@ type Props = { options: PickerOption[] selected: T onSelect: (value: T) => void + onLongSelect?: (value: T) => void onClose: () => void + zIndex?: number } export function PickerModal({ @@ -24,10 +29,12 @@ export function PickerModal({ options, selected, onSelect, - onClose + onLongSelect, + onClose, + zIndex }: Props) { return ( - + {title} @@ -39,12 +46,30 @@ export function PickerModal({ {i > 0 && } [styles.row, pressed && styles.rowPressed]} + disabled={opt.disabled} + style={({ pressed }) => [ + styles.row, + pressed && !opt.disabled && styles.rowPressed, + opt.disabled && styles.rowDisabled + ]} onPress={() => { + if (opt.disabled) return onSelect(opt.value) onClose() }} + onLongPress={ + onLongSelect + ? () => { + if (opt.disabled) return + onLongSelect(opt.value) + onClose() + } + : undefined + } > + {opt.renderIcon ? ( + {opt.renderIcon(isSelected)} + ) : null} {opt.label} @@ -90,8 +115,17 @@ const styles = StyleSheet.create({ rowPressed: { backgroundColor: colors.bgRaised }, + rowDisabled: { + opacity: 0.45 + }, rowContent: { - flex: 1 + flex: 1, + minWidth: 0 + }, + rowIcon: { + width: 22, + alignItems: 'center', + marginRight: spacing.sm }, rowLabel: { fontSize: typography.bodySize, diff --git a/mobile/src/components/TaskProviderLogo.tsx b/mobile/src/components/TaskProviderLogo.tsx new file mode 100644 index 00000000000..041a8700c13 --- /dev/null +++ b/mobile/src/components/TaskProviderLogo.tsx @@ -0,0 +1,55 @@ +import Svg, { Path } from 'react-native-svg' + +export type TaskProviderLogoKind = 'github' | 'gitlab' | 'linear' + +type Props = { + provider: TaskProviderLogoKind + size?: number + color: string +} + +export function TaskProviderLogo({ provider, size = 16, color }: Props) { + if (provider === 'github') { + return ( + + {/* Why: lucide-react-native omits deprecated brand icons; keep parity with desktop lucide paths. */} + + + + ) + } + + if (provider === 'gitlab') { + return ( + + {/* Why: lucide-react-native omits deprecated brand icons; keep parity with desktop lucide paths. */} + + + ) + } + + return ( + + {/* Why: keep Linear's mobile glyph aligned with the desktop sidebar logo. */} + + + ) +} diff --git a/mobile/src/components/mobile-markdown-parser.ts b/mobile/src/components/mobile-markdown-parser.ts new file mode 100644 index 00000000000..e4dbe6024f9 --- /dev/null +++ b/mobile/src/components/mobile-markdown-parser.ts @@ -0,0 +1,133 @@ +export type MobileMarkdownBlock = + | { type: 'paragraph'; text: string } + | { type: 'heading'; level: number; text: string } + | { type: 'quote'; text: string } + | { type: 'code'; text: string; language?: string } + | { type: 'list'; ordered: boolean; items: Array<{ text: string; checked?: boolean }> } + | { type: 'image'; alt: string; url: string } + | { type: 'table'; headers: string[]; rows: string[][] } + | { type: 'rule' } + +function splitTableRow(line: string): string[] { + return line + .trim() + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map((cell) => cell.trim()) +} + +function isTableSeparator(line: string): boolean { + const cells = splitTableRow(line) + return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell)) +} + +export function parseMobileMarkdown(content: string): MobileMarkdownBlock[] { + const lines = content.replace(/\r\n?/g, '\n').split('\n') + const blocks: MobileMarkdownBlock[] = [] + let index = 0 + + while (index < lines.length) { + const line = lines[index] ?? '' + if (!line.trim()) { + index += 1 + continue + } + + const fence = line.match(/^```([A-Za-z0-9_-]+)?\s*$/) + if (fence) { + index += 1 + const code: string[] = [] + while (index < lines.length && !/^```\s*$/.test(lines[index] ?? '')) { + code.push(lines[index] ?? '') + index += 1 + } + if (index < lines.length) index += 1 + blocks.push({ type: 'code', text: code.join('\n'), language: fence[1] }) + continue + } + + if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { + blocks.push({ type: 'rule' }) + index += 1 + continue + } + + const standaloneImage = line.match(/^!\[([^\]]*)\]\((https?:\/\/[^)\s]+)(?:\s+"[^"]*")?\)\s*$/i) + if (standaloneImage) { + blocks.push({ type: 'image', alt: standaloneImage[1] ?? '', url: standaloneImage[2]! }) + index += 1 + continue + } + + if ( + line.includes('|') && + index + 1 < lines.length && + isTableSeparator(lines[index + 1] ?? '') + ) { + const headers = splitTableRow(line) + index += 2 + const rows: string[][] = [] + while (index < lines.length && (lines[index] ?? '').includes('|') && lines[index]?.trim()) { + rows.push(splitTableRow(lines[index] ?? '')) + index += 1 + } + blocks.push({ type: 'table', headers, rows }) + continue + } + + const heading = line.match(/^(#{1,6})\s+(.+)$/) + if (heading) { + blocks.push({ type: 'heading', level: heading[1]!.length, text: heading[2]!.trim() }) + index += 1 + continue + } + + if (/^>\s?/.test(line)) { + const quote: string[] = [] + while (index < lines.length && /^>\s?/.test(lines[index] ?? '')) { + quote.push((lines[index] ?? '').replace(/^>\s?/, '')) + index += 1 + } + blocks.push({ type: 'quote', text: quote.join('\n').trim() }) + continue + } + + if (/^\s*(?:[-*+]|\d+[.)])\s+/.test(line)) { + const items: Array<{ text: string; checked?: boolean }> = [] + let ordered = false + while (index < lines.length && /^\s*(?:[-*+]|\d+[.)])\s+/.test(lines[index] ?? '')) { + const current = lines[index] ?? '' + const orderedMatch = current.match(/^\s*\d+[.)]\s+(.+)$/) + const unorderedMatch = current.match(/^\s*[-*+]\s+(.+)$/) + ordered ||= Boolean(orderedMatch) + const rawText = (orderedMatch?.[1] ?? unorderedMatch?.[1] ?? '').trim() + const task = rawText.match(/^\[([ xX])\]\s+(.+)$/) + items.push({ + text: task?.[2] ?? rawText, + checked: task ? task[1]?.toLowerCase() === 'x' : undefined + }) + index += 1 + } + blocks.push({ type: 'list', ordered, items }) + continue + } + + const paragraph: string[] = [] + while ( + index < lines.length && + lines[index]?.trim() && + !(lines[index] ?? '').startsWith('```') && + !/^(#{1,6})\s+/.test(lines[index] ?? '') && + !/^>\s?/.test(lines[index] ?? '') && + !/^\s*(?:[-*+]|\d+[.)])\s+/.test(lines[index] ?? '') && + !/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(lines[index] ?? '') + ) { + paragraph.push(lines[index] ?? '') + index += 1 + } + blocks.push({ type: 'paragraph', text: paragraph.join('\n').trim() }) + } + + return blocks +} diff --git a/mobile/src/tasks/github-check-summary.test.ts b/mobile/src/tasks/github-check-summary.test.ts new file mode 100644 index 00000000000..955059bbbe4 --- /dev/null +++ b/mobile/src/tasks/github-check-summary.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { buildGitHubCheckSummary } from './github-check-summary' + +describe('buildGitHubCheckSummary', () => { + it('returns none for empty check lists', () => { + expect(buildGitHubCheckSummary([])).toEqual({ + state: 'none', + total: 0, + passed: 0, + failed: 0, + pending: 0 + }) + }) + + it('prioritizes failed checks over pending checks', () => { + expect( + buildGitHubCheckSummary([ + { status: 'completed', conclusion: 'success' }, + { status: 'queued', conclusion: null }, + { status: 'completed', conclusion: 'timed_out' } + ]) + ).toEqual({ + state: 'failure', + total: 3, + passed: 1, + failed: 1, + pending: 1 + }) + }) + + it('marks all completed non-failing checks as successful', () => { + expect( + buildGitHubCheckSummary([ + { status: 'completed', conclusion: 'success' }, + { status: 'completed', conclusion: 'neutral' } + ]) + ).toEqual({ + state: 'success', + total: 2, + passed: 2, + failed: 0, + pending: 0 + }) + }) +}) diff --git a/mobile/src/tasks/github-check-summary.ts b/mobile/src/tasks/github-check-summary.ts new file mode 100644 index 00000000000..c2064f302cd --- /dev/null +++ b/mobile/src/tasks/github-check-summary.ts @@ -0,0 +1,45 @@ +export type GitHubCheckLike = { + status: string + conclusion?: string | null +} + +export type GitHubCheckSummary = { + state: 'success' | 'failure' | 'pending' | 'none' + total: number + passed: number + failed: number + pending: number +} + +function isFailedCheck(check: GitHubCheckLike): boolean { + return ( + check.conclusion === 'failure' || + check.conclusion === 'timed_out' || + check.conclusion === 'cancelled' + ) +} + +function isPendingCheck(check: GitHubCheckLike): boolean { + return ( + check.status === 'queued' || check.status === 'in_progress' || check.conclusion === 'pending' + ) +} + +export function buildGitHubCheckSummary(checks: GitHubCheckLike[]): GitHubCheckSummary { + let failed = 0 + let pending = 0 + + for (const check of checks) { + if (isFailedCheck(check)) { + failed += 1 + } else if (isPendingCheck(check)) { + pending += 1 + } + } + + const total = checks.length + const passed = Math.max(0, total - failed - pending) + const state = total === 0 ? 'none' : failed > 0 ? 'failure' : pending > 0 ? 'pending' : 'success' + + return { state, total, passed, failed, pending } +} diff --git a/mobile/src/tasks/github-pr-file-diff.test.ts b/mobile/src/tasks/github-pr-file-diff.test.ts new file mode 100644 index 00000000000..13a2008a395 --- /dev/null +++ b/mobile/src/tasks/github-pr-file-diff.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { buildGitHubPrFileDiffLines } from './github-pr-file-diff' + +describe('buildGitHubPrFileDiffLines', () => { + it('preserves context and marks added and removed lines', () => { + expect(buildGitHubPrFileDiffLines('one\ntwo\nthree\n', 'one\ntoo\nthree\n')).toEqual([ + { + key: '0:context:1:1', + kind: 'context', + oldLineNumber: 1, + newLineNumber: 1, + text: 'one' + }, + { + key: '1:removed:2', + kind: 'removed', + oldLineNumber: 2, + text: 'two' + }, + { + key: '2:added:2', + kind: 'added', + newLineNumber: 2, + text: 'too' + }, + { + key: '3:context:3:3', + kind: 'context', + oldLineNumber: 3, + newLineNumber: 3, + text: 'three' + } + ]) + }) + + it('shows added files without a fake empty original line', () => { + expect(buildGitHubPrFileDiffLines('', 'first\nsecond')).toEqual([ + { key: '0:added:1', kind: 'added', newLineNumber: 1, text: 'first' }, + { key: '1:added:2', kind: 'added', newLineNumber: 2, text: 'second' } + ]) + }) + + it('keeps all lines for large files without exact diff truncation', () => { + const original = Array.from({ length: 500 }, (_, index) => `old-${index}`).join('\n') + const modified = Array.from({ length: 500 }, (_, index) => `new-${index}`).join('\n') + + const lines = buildGitHubPrFileDiffLines(original, modified) + + expect(lines).toHaveLength(1000) + expect(lines[0]).toMatchObject({ kind: 'removed', oldLineNumber: 1, text: 'old-0' }) + expect(lines.at(-1)).toMatchObject({ kind: 'added', newLineNumber: 500, text: 'new-499' }) + }) +}) diff --git a/mobile/src/tasks/github-pr-file-diff.ts b/mobile/src/tasks/github-pr-file-diff.ts new file mode 100644 index 00000000000..274f0631f12 --- /dev/null +++ b/mobile/src/tasks/github-pr-file-diff.ts @@ -0,0 +1,168 @@ +export type GitHubPrFileDiffLine = { + key: string + kind: 'context' | 'added' | 'removed' + oldLineNumber?: number + newLineNumber?: number + text: string +} + +type DiffOperation = + | { kind: 'context'; oldLine: string; newLine: string } + | { kind: 'removed'; oldLine: string } + | { kind: 'added'; newLine: string } + +const EXACT_DIFF_CELL_LIMIT = 160_000 + +function splitContentLines(value: string): string[] { + if (!value) { + return [] + } + const lines = value.split(/\r?\n/) + return lines.at(-1) === '' ? lines.slice(0, -1) : lines +} + +function exactLineDiff(original: string[], modified: string[]): DiffOperation[] { + const rowWidth = modified.length + 1 + const table = new Uint16Array((original.length + 1) * rowWidth) + + for (let oldIndex = original.length - 1; oldIndex >= 0; oldIndex -= 1) { + for (let newIndex = modified.length - 1; newIndex >= 0; newIndex -= 1) { + const cell = oldIndex * rowWidth + newIndex + if (original[oldIndex] === modified[newIndex]) { + table[cell] = table[(oldIndex + 1) * rowWidth + newIndex + 1] + 1 + } else { + table[cell] = Math.max( + table[(oldIndex + 1) * rowWidth + newIndex], + table[oldIndex * rowWidth + newIndex + 1] + ) + } + } + } + + const operations: DiffOperation[] = [] + let oldIndex = 0 + let newIndex = 0 + while (oldIndex < original.length && newIndex < modified.length) { + const oldLine = original[oldIndex] + const newLine = modified[newIndex] + if (oldLine === newLine) { + operations.push({ kind: 'context', oldLine, newLine }) + oldIndex += 1 + newIndex += 1 + continue + } + const removeScore = table[(oldIndex + 1) * rowWidth + newIndex] + const addScore = table[oldIndex * rowWidth + newIndex + 1] + if (removeScore >= addScore) { + operations.push({ kind: 'removed', oldLine }) + oldIndex += 1 + } else { + operations.push({ kind: 'added', newLine }) + newIndex += 1 + } + } + while (oldIndex < original.length) { + operations.push({ kind: 'removed', oldLine: original[oldIndex] }) + oldIndex += 1 + } + while (newIndex < modified.length) { + operations.push({ kind: 'added', newLine: modified[newIndex] }) + newIndex += 1 + } + return operations +} + +function buildMiddleDiff(original: string[], modified: string[]): DiffOperation[] { + if (original.length === 0) { + return modified.map((newLine) => ({ kind: 'added', newLine })) + } + if (modified.length === 0) { + return original.map((oldLine) => ({ kind: 'removed', oldLine })) + } + if (original.length * modified.length <= EXACT_DIFF_CELL_LIMIT) { + return exactLineDiff(original, modified) + } + // Why: very large files must still show all content without an O(n*m) mobile stall. + return [ + ...original.map((oldLine) => ({ kind: 'removed' as const, oldLine })), + ...modified.map((newLine) => ({ kind: 'added' as const, newLine })) + ] +} + +export function buildGitHubPrFileDiffLines( + originalContent: string, + modifiedContent: string +): GitHubPrFileDiffLine[] { + const originalLines = splitContentLines(originalContent) + const modifiedLines = splitContentLines(modifiedContent) + let prefixLength = 0 + while ( + prefixLength < originalLines.length && + prefixLength < modifiedLines.length && + originalLines[prefixLength] === modifiedLines[prefixLength] + ) { + prefixLength += 1 + } + + let suffixLength = 0 + while ( + suffixLength < originalLines.length - prefixLength && + suffixLength < modifiedLines.length - prefixLength && + originalLines[originalLines.length - suffixLength - 1] === + modifiedLines[modifiedLines.length - suffixLength - 1] + ) { + suffixLength += 1 + } + + const prefix = originalLines.slice(0, prefixLength) + const originalMiddle = originalLines.slice( + prefixLength, + suffixLength === 0 ? originalLines.length : originalLines.length - suffixLength + ) + const modifiedMiddle = modifiedLines.slice( + prefixLength, + suffixLength === 0 ? modifiedLines.length : modifiedLines.length - suffixLength + ) + const suffix = originalLines.slice(originalLines.length - suffixLength) + const operations: DiffOperation[] = [ + ...prefix.map((line) => ({ kind: 'context' as const, oldLine: line, newLine: line })), + ...buildMiddleDiff(originalMiddle, modifiedMiddle), + ...suffix.map((line) => ({ kind: 'context' as const, oldLine: line, newLine: line })) + ] + + const result: GitHubPrFileDiffLine[] = [] + let oldLineNumber = 1 + let newLineNumber = 1 + operations.forEach((operation, index) => { + if (operation.kind === 'context') { + result.push({ + key: `${index}:context:${oldLineNumber}:${newLineNumber}`, + kind: 'context', + oldLineNumber, + newLineNumber, + text: operation.newLine + }) + oldLineNumber += 1 + newLineNumber += 1 + return + } + if (operation.kind === 'removed') { + result.push({ + key: `${index}:removed:${oldLineNumber}`, + kind: 'removed', + oldLineNumber, + text: operation.oldLine + }) + oldLineNumber += 1 + return + } + result.push({ + key: `${index}:added:${newLineNumber}`, + kind: 'added', + newLineNumber, + text: operation.newLine + }) + newLineNumber += 1 + }) + return result +} diff --git a/mobile/src/tasks/github-project-repo-match.test.ts b/mobile/src/tasks/github-project-repo-match.test.ts new file mode 100644 index 00000000000..7979f690d6e --- /dev/null +++ b/mobile/src/tasks/github-project-repo-match.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import { + filterGitHubProjectRowsForRepos, + findRepoForGitHubProjectRepository, + normalizeGitHubRepositorySlug +} from './github-project-repo-match' + +const repos = [ + { id: 'repo-1', path: '/Users/me/orca', displayName: 'orca' }, + { id: 'repo-2', path: '/Users/me/other', displayName: 'other' } +] + +describe('GitHub project repo matching', () => { + it('normalizes owner/repo slugs case-insensitively', () => { + expect(normalizeGitHubRepositorySlug(' StablyAI/Orca ')).toBe('stablyai/orca') + expect(normalizeGitHubRepositorySlug('orca')).toBeNull() + expect(normalizeGitHubRepositorySlug('stablyai/orca/extra')).toBeNull() + }) + + it('matches project rows by resolved repo slug before path/display heuristics', () => { + expect( + findRepoForGitHubProjectRepository('stablyai/orca', repos, { + 'repo-1': { path: '/Users/me/orca', slug: 'stablyai/orca' } + }) + ).toBe(repos[0]) + }) + + it('does not pick a repo when resolved slugs are ambiguous', () => { + expect( + findRepoForGitHubProjectRepository('stablyai/orca', repos, { + 'repo-1': { path: '/Users/me/orca', slug: 'stablyai/orca' }, + 'repo-2': { path: '/Users/me/other', slug: 'stablyai/orca' } + }) + ).toBeNull() + }) + + it('falls back to exact display/path slug matching when slug resolution is unavailable', () => { + expect( + findRepoForGitHubProjectRepository('stablyai/orca', [ + { id: 'repo-1', path: '/Users/me/stablyai/orca', displayName: 'orca' } + ]) + ).toEqual({ id: 'repo-1', path: '/Users/me/stablyai/orca', displayName: 'orca' }) + }) + + it('normalizes Windows paths before path slug fallback matching', () => { + expect( + findRepoForGitHubProjectRepository('stablyai/orca', [ + { id: 'repo-1', path: 'C:\\Users\\me\\stablyai\\orca', displayName: 'orca' } + ]) + ).toEqual({ id: 'repo-1', path: 'C:\\Users\\me\\stablyai\\orca', displayName: 'orca' }) + }) + + it('does not path-match a repo whose resolved slug points somewhere else', () => { + expect( + findRepoForGitHubProjectRepository( + 'stablyai/orca', + [{ id: 'repo-1', path: '/Users/me/stablyai/orca', displayName: 'orca' }], + { + 'repo-1': { path: '/Users/me/stablyai/orca', slug: 'fork/orca' } + } + ) + ).toBeNull() + }) + + it('filters project rows to rows backed by open repositories', () => { + const rows = [ + { id: 'row-1', content: { repository: 'stablyai/orca' } }, + { id: 'row-2', content: { repository: 'other/missing' } }, + { id: 'row-3', content: { repository: null } } + ] + + expect( + filterGitHubProjectRowsForRepos(rows, repos, { + 'repo-1': { path: '/Users/me/orca', slug: 'stablyai/orca' } + }).map((row) => row.id) + ).toEqual(['row-1']) + }) +}) diff --git a/mobile/src/tasks/github-project-repo-match.ts b/mobile/src/tasks/github-project-repo-match.ts new file mode 100644 index 00000000000..a3457d90fff --- /dev/null +++ b/mobile/src/tasks/github-project-repo-match.ts @@ -0,0 +1,76 @@ +export type GitHubProjectRepoMatch = { + id: string + path: string + displayName: string +} + +export type GitHubRepoSlugCacheEntry = { + path: string + slug: string | null +} + +type CachedSlugState = + | { status: 'missing' } + | { status: 'stale' } + | { status: 'resolved'; slug: string | null } + +export function normalizeGitHubRepositorySlug(value: string | null | undefined): string | null { + const trimmed = value?.trim() + if (!trimmed) return null + const [owner, repo, extra] = trimmed.split('/') + if (!owner || !repo || extra) return null + return `${owner}/${repo}`.toLowerCase() +} + +function cachedSlugStateForRepo( + repo: GitHubProjectRepoMatch, + slugsByRepoId: Record +): CachedSlugState { + const cached = slugsByRepoId[repo.id] + if (!cached) return { status: 'missing' } + if (cached.path !== repo.path) return { status: 'stale' } + return { status: 'resolved', slug: normalizeGitHubRepositorySlug(cached.slug) } +} + +export function findRepoForGitHubProjectRepository( + repository: string | null | undefined, + repos: GitHubProjectRepoMatch[], + slugsByRepoId: Record = {} +): GitHubProjectRepoMatch | null { + const slug = normalizeGitHubRepositorySlug(repository) + if (!slug) return null + + const slugStates = new Map( + repos.map((repo) => [repo.id, cachedSlugStateForRepo(repo, slugsByRepoId)]) + ) + const slugMatches = repos.filter((repo) => { + const state = slugStates.get(repo.id) + return state?.status === 'resolved' && state.slug === slug + }) + if (slugMatches.length === 1) return slugMatches[0]! + if (slugMatches.length > 1) return null + + return ( + repos.find((repo) => { + const state = slugStates.get(repo.id) + if (state?.status === 'resolved' && state.slug !== null) { + return false + } + const display = repo.displayName.trim().toLowerCase() + const path = repo.path.trim().toLowerCase().replace(/\\/g, '/') + return display === slug || path.endsWith(`/${slug}`) + }) ?? null + ) +} + +export function filterGitHubProjectRowsForRepos< + Row extends { content: { repository?: string | null } } +>( + rows: readonly Row[], + repos: GitHubProjectRepoMatch[], + slugsByRepoId: Record = {} +): Row[] { + return rows.filter((row) => + Boolean(findRepoForGitHubProjectRepository(row.content.repository, repos, slugsByRepoId)) + ) +} diff --git a/mobile/src/tasks/github-work-item-source-errors.test.ts b/mobile/src/tasks/github-work-item-source-errors.test.ts new file mode 100644 index 00000000000..86d39472089 --- /dev/null +++ b/mobile/src/tasks/github-work-item-source-errors.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { + extractGitHubIssueSourceError, + extractGitHubIssueSourceFallback +} from './github-work-item-source-errors' + +describe('extractGitHubIssueSourceError', () => { + it('keeps the failing issue source slug with the repo that produced it', () => { + expect( + extractGitHubIssueSourceError( + { id: 'repo-1', path: '/work/orca' }, + { + sources: { issues: { owner: 'upstream', repo: 'orca' } }, + errors: { issues: { message: 'HTTP 403: resource not accessible' } } + } + ) + ).toEqual({ + repoId: 'repo-1', + repoPath: '/work/orca', + source: { owner: 'upstream', repo: 'orca' }, + message: 'HTTP 403: resource not accessible' + }) + }) + + it('drops issue errors when the source slug is unavailable', () => { + expect( + extractGitHubIssueSourceError( + { id: 'repo-1', path: '/work/orca' }, + { + sources: { issues: null }, + errors: { issues: { message: 'failed' } } + } + ) + ).toBeNull() + }) + + it('returns null when the envelope has no issue-side error', () => { + expect( + extractGitHubIssueSourceError( + { id: 'repo-1', path: '/work/orca' }, + { + sources: { issues: { owner: 'stablyai', repo: 'orca' } } + } + ) + ).toBeNull() + }) +}) + +describe('extractGitHubIssueSourceFallback', () => { + it('reports the repo whose upstream issue source fell back to origin', () => { + expect( + extractGitHubIssueSourceFallback( + { id: 'repo-1', path: '/work/orca', displayName: 'orca' }, + { + issueSourceFellBack: true, + sources: { + issues: { owner: 'stablyai', repo: 'orca-fork' }, + prs: { owner: 'stablyai', repo: 'orca' } + } + } + ) + ).toEqual({ + repoId: 'repo-1', + repoPath: '/work/orca', + repoLabel: 'stablyai/orca' + }) + }) + + it('uses the Orca repo display name when the PR source is unavailable', () => { + expect( + extractGitHubIssueSourceFallback( + { id: 'repo-1', path: '/work/orca', displayName: 'orca' }, + { + issueSourceFellBack: true, + sources: { issues: null, prs: null } + } + ) + ).toEqual({ + repoId: 'repo-1', + repoPath: '/work/orca', + repoLabel: 'orca' + }) + }) + + it('returns null when the source resolver did not fall back', () => { + expect( + extractGitHubIssueSourceFallback( + { id: 'repo-1', path: '/work/orca', displayName: 'orca' }, + { + sources: { issues: { owner: 'stablyai', repo: 'orca' } } + } + ) + ).toBeNull() + }) +}) diff --git a/mobile/src/tasks/github-work-item-source-errors.ts b/mobile/src/tasks/github-work-item-source-errors.ts new file mode 100644 index 00000000000..fa663569f06 --- /dev/null +++ b/mobile/src/tasks/github-work-item-source-errors.ts @@ -0,0 +1,62 @@ +export type GitHubSourceOwnerRepo = { + owner: string + repo: string +} + +export type GitHubSourceErrorEnvelope = { + sources?: { + issues: GitHubSourceOwnerRepo | null + prs?: GitHubSourceOwnerRepo | null + } | null + errors?: { + issues?: { + message: string + } | null + } | null + issueSourceFellBack?: true +} + +export type GitHubIssueSourceError = { + repoId: string + repoPath: string + source: GitHubSourceOwnerRepo + message: string +} + +export type GitHubIssueSourceFallback = { + repoId: string + repoPath: string + repoLabel: string +} + +export function extractGitHubIssueSourceError( + repo: { id: string; path: string }, + envelope: GitHubSourceErrorEnvelope +): GitHubIssueSourceError | null { + const issueError = envelope.errors?.issues + const issueSource = envelope.sources?.issues + if (!issueError || !issueSource) { + return null + } + return { + repoId: repo.id, + repoPath: repo.path, + source: issueSource, + message: issueError.message + } +} + +export function extractGitHubIssueSourceFallback( + repo: { id: string; path: string; displayName: string }, + envelope: GitHubSourceErrorEnvelope +): GitHubIssueSourceFallback | null { + if (envelope.issueSourceFellBack !== true) { + return null + } + const prSource = envelope.sources?.prs + return { + repoId: repo.id, + repoPath: repo.path, + repoLabel: prSource ? `${prSource.owner}/${prSource.repo}` : repo.displayName + } +} diff --git a/mobile/src/tasks/hosted-review-start-point.test.ts b/mobile/src/tasks/hosted-review-start-point.test.ts new file mode 100644 index 00000000000..32c2769d7aa --- /dev/null +++ b/mobile/src/tasks/hosted-review-start-point.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { shouldResolveHostedReviewStartPoint } from './hosted-review-start-point' + +describe('shouldResolveHostedReviewStartPoint', () => { + it('resolves PR and MR start points when no explicit base branch was selected', () => { + expect(shouldResolveHostedReviewStartPoint({ type: 'pr' })).toBe(true) + expect(shouldResolveHostedReviewStartPoint({ type: 'mr', baseBranchOverride: '' })).toBe(true) + }) + + it('does not resolve a hosted review start point when the user selected a base branch', () => { + expect( + shouldResolveHostedReviewStartPoint({ + type: 'pr', + baseBranchOverride: 'origin/feature/manual' + }) + ).toBe(false) + expect( + shouldResolveHostedReviewStartPoint({ + type: 'mr', + baseBranchOverride: 'origin/release' + }) + ).toBe(false) + }) + + it('never resolves start points for issues', () => { + expect(shouldResolveHostedReviewStartPoint({ type: 'issue' })).toBe(false) + }) +}) diff --git a/mobile/src/tasks/hosted-review-start-point.ts b/mobile/src/tasks/hosted-review-start-point.ts new file mode 100644 index 00000000000..fc3267a7057 --- /dev/null +++ b/mobile/src/tasks/hosted-review-start-point.ts @@ -0,0 +1,11 @@ +export type HostedReviewStartPointType = 'issue' | 'pr' | 'mr' + +export function shouldResolveHostedReviewStartPoint(args: { + type: HostedReviewStartPointType + baseBranchOverride?: string | null +}): boolean { + if (args.type !== 'pr' && args.type !== 'mr') { + return false + } + return !args.baseBranchOverride?.trim() +} diff --git a/mobile/src/tasks/mobile-agent-catalog.test.ts b/mobile/src/tasks/mobile-agent-catalog.test.ts new file mode 100644 index 00000000000..479cf3f6f88 --- /dev/null +++ b/mobile/src/tasks/mobile-agent-catalog.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { MOBILE_AGENT_CATALOG } from './mobile-agent-catalog' +import { MOBILE_TUI_AGENT_AUTO_PICK_ORDER } from './mobile-tui-agents' + +const currentDir = dirname(fileURLToPath(import.meta.url)) + +function readDesktopSharedFile(relativePath: string): string { + return readFileSync(resolve(currentDir, '../../../src/shared', relativePath), 'utf8') +} + +function parseDesktopAutoPickOrder(): string[] { + const source = readDesktopSharedFile('tui-agent-selection.ts') + const match = source.match(/TUI_AGENT_AUTO_PICK_ORDER = \[([\s\S]*?)\] as const/) + expect(match).not.toBeNull() + return Array.from(match?.[1].matchAll(/'([^']+)'/g) ?? [], (entry) => entry[1]) +} + +function parseDesktopConfiguredAgents(): string[] { + const source = readDesktopSharedFile('tui-agent-config.ts') + const match = source.match(/TUI_AGENT_CONFIG: Record = {([\s\S]*?)^}/m) + expect(match).not.toBeNull() + return Array.from( + match?.[1].matchAll(/^ (?:'([^']+)'|([a-z][a-z0-9-]*)): {/gm) ?? [], + (entry) => entry[1] ?? entry[2] + ) +} + +describe('mobile agent catalog', () => { + it('stays in the same order as desktop auto-pick and covers every configured TUI agent', () => { + const desktopAutoPickOrder = parseDesktopAutoPickOrder() + expect(MOBILE_TUI_AGENT_AUTO_PICK_ORDER).toEqual(desktopAutoPickOrder) + expect(MOBILE_AGENT_CATALOG.map((agent) => agent.id)).toEqual(desktopAutoPickOrder) + expect(new Set(MOBILE_AGENT_CATALOG.map((agent) => agent.id))).toEqual( + new Set(parseDesktopConfiguredAgents()) + ) + }) +}) diff --git a/mobile/src/tasks/mobile-agent-catalog.ts b/mobile/src/tasks/mobile-agent-catalog.ts new file mode 100644 index 00000000000..0f6e0e370a3 --- /dev/null +++ b/mobile/src/tasks/mobile-agent-catalog.ts @@ -0,0 +1,22 @@ +import type { TuiAgent } from '../../../src/shared/types' +import { + MOBILE_TUI_AGENT_AUTO_PICK_ORDER, + MOBILE_TUI_AGENT_FAVICON_DOMAINS, + MOBILE_TUI_AGENT_LABELS +} from './mobile-tui-agents' + +export type MobileAgentCatalogEntry = { + id: TuiAgent + label: string + faviconDomain?: string +} + +export const MOBILE_AGENT_CATALOG: MobileAgentCatalogEntry[] = MOBILE_TUI_AGENT_AUTO_PICK_ORDER.map( + (id) => ({ + id, + label: MOBILE_TUI_AGENT_LABELS[id], + ...(MOBILE_TUI_AGENT_FAVICON_DOMAINS[id] + ? { faviconDomain: MOBILE_TUI_AGENT_FAVICON_DOMAINS[id] } + : {}) + }) +) diff --git a/mobile/src/tasks/mobile-composer-branch-selection.ts b/mobile/src/tasks/mobile-composer-branch-selection.ts new file mode 100644 index 00000000000..042ab14ba17 --- /dev/null +++ b/mobile/src/tasks/mobile-composer-branch-selection.ts @@ -0,0 +1,32 @@ +export type ComposerBranchSelection = { + baseBranch: string + branchNameOverride: string | undefined + branchAutoName: string + name: string | undefined + lastAutoName: string | undefined +} + +export function resolveComposerBranchSelection(args: { + refName: string + localBranchName: string + currentName: string + lastAutoName: string +}): ComposerBranchSelection { + const shouldAutoName = !args.currentName.trim() || args.currentName === args.lastAutoName + if (!shouldAutoName) { + return { + baseBranch: args.refName, + branchNameOverride: undefined, + branchAutoName: '', + name: undefined, + lastAutoName: undefined + } + } + return { + baseBranch: args.refName, + branchNameOverride: args.localBranchName, + branchAutoName: args.localBranchName, + name: args.localBranchName, + lastAutoName: args.localBranchName + } +} diff --git a/mobile/src/tasks/mobile-github-project-group-sort.ts b/mobile/src/tasks/mobile-github-project-group-sort.ts new file mode 100644 index 00000000000..7b86d3c38c0 --- /dev/null +++ b/mobile/src/tasks/mobile-github-project-group-sort.ts @@ -0,0 +1,261 @@ +export type GitHubProjectSortDirection = 'ASC' | 'DESC' + +export type GitHubProjectFieldValue = + | { kind: 'text'; text: string } + | { kind: 'number'; number: number } + | { kind: 'date'; date: string } + | { kind: 'single-select'; optionId: string; name: string } + | { kind: 'iteration'; iterationId: string; title: string } + | { kind: 'labels'; labels: Array<{ name: string }> } + | { kind: 'users'; users: Array<{ login: string }> } + +export type GitHubProjectField = { + id: string + name: string + kind: string + options?: Array<{ id: string }> + iterations?: Array<{ + id: string + title?: string + startDate: string + duration: number + completed: boolean + }> +} + +export type GitHubProjectRow = { + position?: number | null + fieldValuesByFieldId: Record +} + +export type GitHubProjectSort = { + direction: GitHubProjectSortDirection + field: GitHubProjectField +} + +export type GitHubProjectTable = { + selectedView: { + groupByFields: GitHubProjectField[] + sortByFields: GitHubProjectSort[] + } + rows: GitHubProjectRow[] +} + +export type ProjectGroup = { + key: string + label: string + iteration: { + startDate: string + duration: number + completed: boolean + } | null + rows: GitHubProjectRow[] +} + +const EMPTY_GROUP_KEY = '__empty__' +const UNKNOWN_INDEX_SENTINEL = Number.MAX_SAFE_INTEGER + +function labelForEmpty(field: GitHubProjectField): string { + return `No ${field.name}` +} + +function deriveStringValue(value: GitHubProjectFieldValue): string { + switch (value.kind) { + case 'text': + return value.text + case 'number': + return String(value.number) + case 'date': + return value.date + case 'single-select': + return value.name + case 'iteration': + return value.title + case 'labels': + return value.labels.map((label) => label.name).join(', ') + case 'users': + return value.users.map((user) => user.login).join(', ') + } +} + +function getFieldValueForGrouping( + row: GitHubProjectRow, + field: GitHubProjectField +): { key: string; label: string; orderHint: number; iteration: ProjectGroup['iteration'] } { + const value = row.fieldValuesByFieldId[field.id] + if (!value) { + return { + key: EMPTY_GROUP_KEY, + label: labelForEmpty(field), + orderHint: UNKNOWN_INDEX_SENTINEL, + iteration: null + } + } + if (field.kind === 'iteration' && value.kind === 'iteration') { + const iterations = field.iterations ?? [] + const idx = iterations.findIndex((it) => it.id === value.iterationId) + const meta = iterations.find((it) => it.id === value.iterationId) + return { + key: value.iterationId, + label: value.title || meta?.title || 'Iteration', + orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx, + iteration: meta + ? { startDate: meta.startDate, duration: meta.duration, completed: meta.completed } + : null + } + } + if (field.kind === 'single-select' && value.kind === 'single-select') { + const idx = (field.options ?? []).findIndex((option) => option.id === value.optionId) + return { + key: value.optionId, + label: value.name, + orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx, + iteration: null + } + } + const label = deriveStringValue(value) + return { key: `raw:${label}`, label, orderHint: 0, iteration: null } +} + +export function groupRows( + table: GitHubProjectTable, + rowsInOrder: GitHubProjectRow[] +): ProjectGroup[] { + const groupField = table.selectedView.groupByFields[0] + if (!groupField) { + return [{ key: 'all', label: '', iteration: null, rows: rowsInOrder }] + } + const buckets = new Map< + string, + { + label: string + orderHint: number + iteration: ProjectGroup['iteration'] + rows: GitHubProjectRow[] + } + >() + for (const row of rowsInOrder) { + const { key, label, orderHint, iteration } = getFieldValueForGrouping(row, groupField) + let bucket = buckets.get(key) + if (!bucket) { + bucket = { label, orderHint, iteration, rows: [] } + buckets.set(key, bucket) + } + bucket.rows.push(row) + } + const entries = Array.from(buckets.entries()) + entries.sort((a, b) => { + if (a[0] === EMPTY_GROUP_KEY) { + return 1 + } + if (b[0] === EMPTY_GROUP_KEY) { + return -1 + } + if (groupField.kind === 'iteration' || groupField.kind === 'single-select') { + return a[1].orderHint - b[1].orderHint + } + return a[1].label.localeCompare(b[1].label) + }) + return entries.map(([key, value]) => ({ + key, + label: value.label, + iteration: value.iteration, + rows: value.rows + })) +} + +function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProjectSort): number { + const field = sort.field + const aValue = a.fieldValuesByFieldId[field.id] + const bValue = b.fieldValuesByFieldId[field.id] + if (!aValue && !bValue) { + return 0 + } + if (!aValue) { + return 1 + } + if (!bValue) { + return -1 + } + + let cmp = 0 + if ( + field.kind === 'single-select' && + aValue.kind === 'single-select' && + bValue.kind === 'single-select' + ) { + const options = field.options ?? [] + const aIdx = options.findIndex((option) => option.id === aValue.optionId) + const bIdx = options.findIndex((option) => option.id === bValue.optionId) + cmp = + (aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx) + } else if ( + field.kind === 'iteration' && + aValue.kind === 'iteration' && + bValue.kind === 'iteration' + ) { + const iterations = field.iterations ?? [] + const aIdx = iterations.findIndex((iteration) => iteration.id === aValue.iterationId) + const bIdx = iterations.findIndex((iteration) => iteration.id === bValue.iterationId) + cmp = + (aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx) + } else if (aValue.kind === 'number' && bValue.kind === 'number') { + cmp = aValue.number - bValue.number + } else if (aValue.kind === 'date' && bValue.kind === 'date') { + cmp = aValue.date.localeCompare(bValue.date) + } else if (aValue.kind === 'text' && bValue.kind === 'text') { + cmp = aValue.text.localeCompare(bValue.text) + } else if (aValue.kind === 'users' && bValue.kind === 'users') { + const aLogin = aValue.users[0]?.login ?? '' + const bLogin = bValue.users[0]?.login ?? '' + if (!aLogin && !bLogin) { + cmp = 0 + } else if (!aLogin) { + cmp = 1 + } else if (!bLogin) { + cmp = -1 + } else { + cmp = aLogin.localeCompare(bLogin) + } + } else if (aValue.kind === 'labels' && bValue.kind === 'labels') { + const aName = aValue.labels[0]?.name ?? '' + const bName = bValue.labels[0]?.name ?? '' + if (!aName && !bName) { + cmp = 0 + } else if (!aName) { + cmp = 1 + } else if (!bName) { + cmp = -1 + } else { + cmp = aName.localeCompare(bName) + } + } else { + return 0 + } + return sort.direction === 'DESC' ? -cmp : cmp +} + +export function sortRows(table: GitHubProjectTable, rows: GitHubProjectRow[]): GitHubProjectRow[] { + const sorts = table.selectedView.sortByFields + const out = [...rows] + out.sort((a, b) => { + for (const sort of sorts) { + const cmp = compareSort(a, b, sort) + if (cmp !== 0) { + return cmp + } + } + return (a.position ?? UNKNOWN_INDEX_SENTINEL) - (b.position ?? UNKNOWN_INDEX_SENTINEL) + }) + return out +} + +export function isIterationCurrent(iteration: { startDate: string; duration: number }): boolean { + const start = new Date(`${iteration.startDate}T00:00:00Z`).getTime() + if (Number.isNaN(start)) { + return false + } + const end = start + iteration.duration * 86_400_000 + const now = Date.now() + return now >= start && now < end +} diff --git a/mobile/src/tasks/mobile-task-providers.ts b/mobile/src/tasks/mobile-task-providers.ts new file mode 100644 index 00000000000..a5a28c385e8 --- /dev/null +++ b/mobile/src/tasks/mobile-task-providers.ts @@ -0,0 +1,57 @@ +export type TaskProvider = 'github' | 'gitlab' | 'linear' + +export const MOBILE_TASK_PROVIDERS: readonly TaskProvider[] = ['github', 'gitlab', 'linear'] + +const TASK_PROVIDER_SET = new Set(MOBILE_TASK_PROVIDERS) + +export function normalizeVisibleTaskProviders(value: unknown): TaskProvider[] { + if (!Array.isArray(value)) { + return [...MOBILE_TASK_PROVIDERS] + } + + const normalized: TaskProvider[] = [] + for (const provider of value) { + if (!TASK_PROVIDER_SET.has(provider as TaskProvider)) { + continue + } + if (!normalized.includes(provider as TaskProvider)) { + normalized.push(provider as TaskProvider) + } + } + + // Why: at least one provider must remain visible so the Tasks surface always + // has a valid source to select after settings hydration or manual edits. + return normalized.length > 0 ? normalized : [...MOBILE_TASK_PROVIDERS] +} + +export type TaskProviderAvailability = { + gitlabInstalled: boolean + linearConnected: boolean +} + +export function filterAvailableTaskProviders( + visibleProviders: readonly TaskProvider[], + availability: TaskProviderAvailability +): TaskProvider[] { + const available = visibleProviders.filter((provider) => { + if (provider === 'github') { + return true + } + if (provider === 'gitlab') { + return availability.gitlabInstalled + } + return availability.linearConnected + }) + + return available.length > 0 ? available : ['github'] +} + +export function resolveVisibleTaskProvider( + preferred: TaskProvider | null | undefined, + visibleProviders: readonly TaskProvider[] +): TaskProvider { + if (preferred && visibleProviders.includes(preferred)) { + return preferred + } + return visibleProviders[0] ?? 'github' +} diff --git a/mobile/src/tasks/mobile-tui-agents.ts b/mobile/src/tasks/mobile-tui-agents.ts new file mode 100644 index 00000000000..ca00fe5a47b --- /dev/null +++ b/mobile/src/tasks/mobile-tui-agents.ts @@ -0,0 +1,143 @@ +import type { TuiAgent } from '../../../src/shared/types' + +// Why: mobile tests run from the mobile package only, so runtime imports of +// desktop shared modules can break Vitest transforms in CI. Keep this list +// mirrored with src/shared/tui-agent-selection.ts and assert parity in tests. +export const MOBILE_TUI_AGENT_AUTO_PICK_ORDER = [ + 'claude', + 'codex', + 'grok', + 'copilot', + 'opencode', + 'pi', + 'gemini', + 'antigravity', + 'aider', + 'goose', + 'amp', + 'kilo', + 'kiro', + 'crush', + 'aug', + 'autohand', + 'cline', + 'codebuff', + 'continue', + 'cursor', + 'droid', + 'kimi', + 'mistral-vibe', + 'qwen-code', + 'rovo', + 'hermes', + 'openclaw' +] as const satisfies readonly TuiAgent[] + +export const MOBILE_TUI_AGENT_LABELS: Record = { + claude: 'Claude', + codex: 'Codex', + grok: 'Grok', + copilot: 'GitHub Copilot', + opencode: 'OpenCode', + pi: 'Pi', + gemini: 'Gemini', + antigravity: 'Antigravity', + aider: 'Aider', + goose: 'Goose', + amp: 'Amp', + kilo: 'Kilocode', + kiro: 'Kiro', + crush: 'Charm', + aug: 'Auggie', + autohand: 'Autohand Code', + cline: 'Cline', + codebuff: 'Codebuff', + continue: 'Continue', + cursor: 'Cursor', + droid: 'Droid', + kimi: 'Kimi', + 'mistral-vibe': 'Mistral Vibe', + 'qwen-code': 'Qwen Code', + rovo: 'Rovo Dev', + hermes: 'Hermes', + openclaw: 'OpenClaw' +} + +export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial> = { + grok: 'x.ai', + copilot: 'github.com', + opencode: 'opencode.ai', + gemini: 'gemini.google.com', + antigravity: 'antigravity.google', + goose: 'goose-docs.ai', + amp: 'ampcode.com', + kilo: 'kilo.ai', + kiro: 'kiro.dev', + crush: 'charm.sh', + aug: 'augmentcode.com', + autohand: 'autohand.ai', + cline: 'cline.bot', + codebuff: 'codebuff.com', + continue: 'continue.dev', + cursor: 'cursor.com', + droid: 'factory.ai', + kimi: 'moonshot.cn', + 'mistral-vibe': 'mistral.ai', + 'qwen-code': 'qwenlm.github.io', + rovo: 'atlassian.com', + hermes: 'nousresearch.com', + openclaw: 'openclaw.ai' +} + +export const MOBILE_TUI_AGENT_LAUNCH_COMMANDS: Record = { + claude: 'claude', + codex: 'codex', + grok: 'grok', + copilot: 'copilot', + opencode: 'opencode', + pi: 'pi', + gemini: 'gemini', + antigravity: 'agy', + aider: 'aider', + goose: 'goose', + amp: 'amp', + kilo: 'kilo', + kiro: 'kiro-cli', + crush: 'crush', + aug: 'auggie', + autohand: 'autohand', + cline: 'cline', + codebuff: 'codebuff', + continue: 'continue', + cursor: 'cursor-agent', + droid: 'droid', + kimi: 'kimi', + 'mistral-vibe': 'mistral-vibe', + 'qwen-code': 'qwen-code', + rovo: 'rovo', + hermes: 'hermes', + openclaw: 'openclaw' +} + +export function isMobileTuiAgent(value: unknown): value is TuiAgent { + return MOBILE_TUI_AGENT_AUTO_PICK_ORDER.includes(value as TuiAgent) +} + +export function pickMobileTuiAgent( + preferred: TuiAgent | 'blank' | null | undefined, + detected: Iterable +): TuiAgent | null { + if (preferred === 'blank') { + return null + } + const detectedSet = detected instanceof Set ? detected : new Set(detected) + if (preferred && detectedSet.has(preferred)) { + return preferred + } + for (const agent of MOBILE_TUI_AGENT_AUTO_PICK_ORDER) { + if (detectedSet.has(agent)) { + return agent + } + } + return null +} diff --git a/mobile/src/tasks/mobile-work-items.ts b/mobile/src/tasks/mobile-work-items.ts new file mode 100644 index 00000000000..02d8fbe989f --- /dev/null +++ b/mobile/src/tasks/mobile-work-items.ts @@ -0,0 +1,23 @@ +// Why: these limits must match desktop cache/fetch behavior, but mobile cannot +// import root shared modules at runtime because Metro resolves from mobile/. +export const PER_REPO_FETCH_LIMIT = 36 +export const CROSS_REPO_DISPLAY_LIMIT = 100 + +export const GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE = + 'GitHub work items require a GitHub remote for SSH repositories' + +export function isGitHubWorkItemsSshRemoteRequiredError(error: unknown): boolean { + const message = + error instanceof Error + ? error.message + : typeof error === 'object' && + error !== null && + 'message' in error && + typeof error.message === 'string' + ? error.message + : typeof error === 'string' + ? error + : '' + + return message.includes(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE) +} diff --git a/mobile/src/tasks/mobile-workspace-name.ts b/mobile/src/tasks/mobile-workspace-name.ts new file mode 100644 index 00000000000..ae4b31dcdf7 --- /dev/null +++ b/mobile/src/tasks/mobile-workspace-name.ts @@ -0,0 +1,32 @@ +export function resolveMobileWorkspaceCreateName(args: { + draft: string | undefined + fallback: string +}): string { + return args.draft?.trim() || args.fallback +} + +export function slugifyForWorkspaceName(input: string): string { + return input + .trim() + .toLowerCase() + .replace(/[\\/]+/g, '-') + .replace(/\s+/g, '-') + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/-+/g, '-') + .replace(/\.{2,}/g, '.') + .replace(/^[.-]+|[.-]+$/g, '') + .slice(0, 48) + .replace(/[-._]+$/g, '') +} + +export function getLinkedWorkItemSuggestedName(item: { title: string }): string { + const withoutLeadingNumber = item.title + .trim() + .replace(/^(?:issue|pr|pull request)\s*#?\d+\s*[:-]\s*/i, '') + .replace(/^#\d+\s*[:-]\s*/, '') + .replace(/\(#\d+\)/gi, '') + .replace(/\b#\d+\b/g, '') + .trim() + const seed = withoutLeadingNumber || item.title.trim() + return slugifyForWorkspaceName(seed) +} diff --git a/mobile/src/tasks/setup-hook-trust.test.ts b/mobile/src/tasks/setup-hook-trust.test.ts new file mode 100644 index 00000000000..b09bdd0d4b3 --- /dev/null +++ b/mobile/src/tasks/setup-hook-trust.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { + isSetupHookTrusted, + normalizeSetupHookTrust, + trustedOrcaHooksWithSetupApproval, + wasSetupHookPreviouslyApproved +} from './setup-hook-trust' +import type { PersistedTrustedOrcaHooks } from '../../../src/shared/types' + +describe('setup hook trust', () => { + it('trusts a setup script only when the approved hash matches', () => { + const trust: PersistedTrustedOrcaHooks = { + 'repo-1': { setup: { contentHash: 'hash-1', approvedAt: 1000 } } + } + + expect(isSetupHookTrusted(trust, 'repo-1', 'hash-1')).toBe(true) + expect(isSetupHookTrusted(trust, 'repo-1', 'hash-2')).toBe(false) + }) + + it('treats an always-trusted repo as trusted for changed setup scripts', () => { + const trust: PersistedTrustedOrcaHooks = { + 'repo-1': { all: { approvedAt: 1000 } } + } + + expect(isSetupHookTrusted(trust, 'repo-1', 'new-hash')).toBe(true) + }) + + it('preserves unrelated trust entries when approving setup', () => { + const trust: PersistedTrustedOrcaHooks = { + 'repo-1': { + archive: { contentHash: 'archive-hash', approvedAt: 1000 } + } + } + + expect( + trustedOrcaHooksWithSetupApproval({ + trust, + repoId: 'repo-1', + contentHash: 'setup-hash', + alwaysTrust: false, + approvedAt: 2000 + }) + ).toEqual({ + 'repo-1': { + archive: { contentHash: 'archive-hash', approvedAt: 1000 }, + setup: { contentHash: 'setup-hash', approvedAt: 2000 } + } + }) + }) + + it('records always-trust without dropping existing script approvals', () => { + const trust: PersistedTrustedOrcaHooks = { + 'repo-1': { + setup: { contentHash: 'setup-hash', approvedAt: 1000 } + } + } + + expect( + trustedOrcaHooksWithSetupApproval({ + trust, + repoId: 'repo-1', + contentHash: 'ignored-for-all', + alwaysTrust: true, + approvedAt: 2000 + }) + ).toEqual({ + 'repo-1': { + setup: { contentHash: 'setup-hash', approvedAt: 1000 }, + all: { approvedAt: 2000 } + } + }) + }) + + it('detects previous setup approval and ignores incomplete trust payloads', () => { + expect( + wasSetupHookPreviouslyApproved( + { 'repo-1': { setup: { contentHash: 'hash-1', approvedAt: 1000 } } }, + 'repo-1' + ) + ).toBe(true) + expect(normalizeSetupHookTrust({ contentHash: 'hash-1', scriptContent: '' })).toBe(null) + expect( + normalizeSetupHookTrust({ contentHash: 'hash-1', scriptContent: 'pnpm install' }) + ).toEqual({ + contentHash: 'hash-1', + scriptContent: 'pnpm install' + }) + }) +}) diff --git a/mobile/src/tasks/setup-hook-trust.ts b/mobile/src/tasks/setup-hook-trust.ts new file mode 100644 index 00000000000..847420f1e86 --- /dev/null +++ b/mobile/src/tasks/setup-hook-trust.ts @@ -0,0 +1,46 @@ +import type { PersistedTrustedOrcaHooks } from '../../../src/shared/types' + +export type SetupHookTrust = { + contentHash: string + scriptContent: string +} + +export function isSetupHookTrusted( + trust: PersistedTrustedOrcaHooks, + repoId: string, + contentHash: string +): boolean { + const repoTrust = trust[repoId] + return Boolean(repoTrust?.all || repoTrust?.setup?.contentHash === contentHash) +} + +export function wasSetupHookPreviouslyApproved( + trust: PersistedTrustedOrcaHooks, + repoId: string +): boolean { + return Boolean(trust[repoId]?.setup?.contentHash) +} + +export function trustedOrcaHooksWithSetupApproval(args: { + trust: PersistedTrustedOrcaHooks + repoId: string + contentHash: string + alwaysTrust: boolean + approvedAt?: number +}): PersistedTrustedOrcaHooks { + const approvedAt = args.approvedAt ?? Date.now() + const existing = args.trust[args.repoId] + const nextRepo = args.alwaysTrust + ? { ...existing, all: { approvedAt } } + : { ...existing, setup: { contentHash: args.contentHash, approvedAt } } + return { ...args.trust, [args.repoId]: nextRepo } +} + +export function normalizeSetupHookTrust( + setupTrust: SetupHookTrust | null | undefined +): SetupHookTrust | null { + if (!setupTrust?.contentHash || !setupTrust.scriptContent) { + return null + } + return setupTrust +} diff --git a/mobile/src/tasks/sparse-preset-draft.test.ts b/mobile/src/tasks/sparse-preset-draft.test.ts new file mode 100644 index 00000000000..852dd874f72 --- /dev/null +++ b/mobile/src/tasks/sparse-preset-draft.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { parseSparsePresetDirectories } from './sparse-preset-draft' + +describe('parseSparsePresetDirectories', () => { + it('normalizes textarea input into unique repo-relative directories', () => { + expect( + parseSparsePresetDirectories(` + src\\renderer + packages/ui/ + src/renderer + `) + ).toEqual({ + directories: ['src/renderer', 'packages/ui'], + error: null + }) + }) + + it('requires at least one directory', () => { + expect(parseSparsePresetDirectories(' \n ')).toEqual({ + directories: [], + error: 'Add at least one directory.' + }) + }) + + it('rejects root and parent path entries', () => { + expect(parseSparsePresetDirectories('.')).toEqual({ + directories: [], + error: 'Use repo-relative directories, not root, absolute paths, or parent segments.' + }) + expect(parseSparsePresetDirectories('src/../packages')).toEqual({ + directories: [], + error: 'Use repo-relative directories, not root, absolute paths, or parent segments.' + }) + expect(parseSparsePresetDirectories('/')).toEqual({ + directories: [], + error: 'Use repo-relative directories, not root, absolute paths, or parent segments.' + }) + }) + + it.each(['/Users/me/repo/packages/web', 'C:\\repo\\packages\\web', '\\\\server\\share\\repo'])( + 'rejects absolute directory input before normalization: %s', + (entry) => { + expect(parseSparsePresetDirectories(entry)).toEqual({ + directories: [], + error: 'Use repo-relative directories, not root, absolute paths, or parent segments.' + }) + } + ) +}) diff --git a/mobile/src/tasks/sparse-preset-draft.ts b/mobile/src/tasks/sparse-preset-draft.ts new file mode 100644 index 00000000000..cc358f21b68 --- /dev/null +++ b/mobile/src/tasks/sparse-preset-draft.ts @@ -0,0 +1,67 @@ +const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:/ + +export type SparsePresetDirectoryParseResult = { + directories: string[] + error: string | null +} + +function isAbsoluteSparseDirectoryPath(value: string): boolean { + const entry = value.trim() + return entry.startsWith('/') || entry.startsWith('\\') || WINDOWS_DRIVE_PATH_PATTERN.test(entry) +} + +function normalizeSparseDirectoryLines(value: string): string[] { + const seen = new Set() + return value + .split('\n') + .map((entry) => + entry + .trim() + .replace(/\\/g, '/') + .replace(/^\/+|\/+$/g, '') + ) + .filter((entry) => entry.length > 0) + .filter((entry) => { + if (seen.has(entry)) { + return false + } + seen.add(entry) + return true + }) +} + +export function parseSparsePresetDirectories(value: string): SparsePresetDirectoryParseResult { + const rawEntries = value + .split('\n') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + + // Why: absolute paths can look repo-relative after slash normalization. + if (rawEntries.some(isAbsoluteSparseDirectoryPath)) { + return { + directories: [], + error: 'Use repo-relative directories, not root, absolute paths, or parent segments.' + } + } + + const directories = normalizeSparseDirectoryLines(value) + + if (directories.length === 0) { + return { + directories, + error: 'Add at least one directory.' + } + } + + if (directories.some((entry) => entry === '.' || entry.split('/').includes('..'))) { + return { + directories: [], + error: 'Use repo-relative directories, not root, absolute paths, or parent segments.' + } + } + + return { + directories, + error: null + } +} diff --git a/mobile/src/tasks/workspace-agent-selection.test.ts b/mobile/src/tasks/workspace-agent-selection.test.ts new file mode 100644 index 00000000000..50558986d4c --- /dev/null +++ b/mobile/src/tasks/workspace-agent-selection.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' + +import { + normalizeWorkspaceAgent, + pickWorkspaceAgent, + workspaceAgentLabel +} from './workspace-agent-selection' + +describe('workspace agent selection', () => { + it('uses an installed explicit default agent', () => { + expect(pickWorkspaceAgent({ defaultTuiAgent: 'codex' }, new Set(['claude', 'codex']))).toBe( + 'codex' + ) + }) + + it('falls back by desktop auto-pick order when the default is unavailable on the target host', () => { + expect(pickWorkspaceAgent({ defaultTuiAgent: 'codex' }, new Set(['claude']))).toBe('claude') + }) + + it('honors blank terminal as an explicit no-agent preference', () => { + expect(pickWorkspaceAgent({ defaultTuiAgent: 'blank' }, new Set(['claude', 'codex']))).toBe( + 'blank' + ) + }) + + it('returns blank when detection completed and no known agent exists', () => { + expect(pickWorkspaceAgent({ defaultTuiAgent: null }, new Set(['unknown-agent']))).toBe('blank') + }) + + it('uses the preferred/default display value while detection is still pending', () => { + expect(pickWorkspaceAgent({ defaultTuiAgent: 'codex' }, null)).toBe('codex') + expect(pickWorkspaceAgent({ defaultTuiAgent: null }, null)).toBe('claude') + }) + + it('normalizes legacy blank sentinel and labels known choices', () => { + expect(normalizeWorkspaceAgent('__blank__')).toBe('blank') + expect(workspaceAgentLabel('codex')).toBe('Codex') + }) +}) diff --git a/mobile/src/tasks/workspace-agent-selection.ts b/mobile/src/tasks/workspace-agent-selection.ts new file mode 100644 index 00000000000..9be68b9b76d --- /dev/null +++ b/mobile/src/tasks/workspace-agent-selection.ts @@ -0,0 +1,37 @@ +import type { TuiAgent } from '../../../src/shared/types' +import { + isMobileTuiAgent, + MOBILE_TUI_AGENT_AUTO_PICK_ORDER, + MOBILE_TUI_AGENT_LABELS, + pickMobileTuiAgent +} from './mobile-tui-agents' + +export type WorkspaceAgentChoice = TuiAgent | 'blank' + +export function workspaceAgentLabel(agent: WorkspaceAgentChoice): string { + return agent === 'blank' ? 'Blank Terminal' : MOBILE_TUI_AGENT_LABELS[agent] +} + +export function normalizeWorkspaceAgent(value: unknown): WorkspaceAgentChoice | null { + if (value === 'blank' || value === '__blank__') { + return 'blank' + } + return isMobileTuiAgent(value) ? value : null +} + +export function pickWorkspaceAgent( + settings: { defaultTuiAgent?: TuiAgent | 'blank' | null }, + detectedAgentIds: Set | null +): WorkspaceAgentChoice { + const preferred = normalizeWorkspaceAgent(settings.defaultTuiAgent) + if (preferred === 'blank') { + return preferred + } + if (detectedAgentIds === null) { + return preferred ?? MOBILE_TUI_AGENT_AUTO_PICK_ORDER[0] ?? 'blank' + } + const detectedAgents = MOBILE_TUI_AGENT_AUTO_PICK_ORDER.filter((agent) => + detectedAgentIds.has(agent) + ) + return pickMobileTuiAgent(preferred, detectedAgents) ?? 'blank' +} diff --git a/mobile/src/tasks/workspace-create-params.test.ts b/mobile/src/tasks/workspace-create-params.test.ts new file mode 100644 index 00000000000..f15c7afce30 --- /dev/null +++ b/mobile/src/tasks/workspace-create-params.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest' +import { buildTaskWorkspaceCreateParams } from './workspace-create-params' + +describe('task workspace create params', () => { + it('passes a GitHub PR URL as an agent draft and links the PR', () => { + expect( + buildTaskWorkspaceCreateParams({ + item: { + provider: 'github', + source: { + type: 'pr', + repoId: 'repo-1', + number: 123, + title: 'Fix mobile tasks', + url: 'https://github.com/acme/app/pull/123' + } + }, + targetRepoId: 'ignored-for-github', + setupDecision: 'run', + agent: 'codex', + workspaceName: ' mobile-tasks ', + hostedStartPoint: { + baseBranch: 'origin/main', + pushTarget: { remoteName: 'origin', branchName: 'feature/mobile-tasks' } + } + }) + ).toMatchObject({ + repo: 'id:repo-1', + name: 'mobile-tasks', + displayName: 'Fix mobile tasks', + setupDecision: 'run', + activate: true, + startupDraft: 'https://github.com/acme/app/pull/123', + createdWithAgent: 'codex', + linkedPR: 123, + baseBranch: 'origin/main', + pushTarget: { remoteName: 'origin', branchName: 'feature/mobile-tasks' } + }) + }) + + it('omits startup draft and agent when blank terminal is selected', () => { + const params = buildTaskWorkspaceCreateParams({ + item: { + provider: 'github', + source: { + type: 'issue', + repoId: 'repo-1', + number: 88, + title: 'Investigate login', + url: 'https://github.com/acme/app/issues/88' + } + }, + targetRepoId: 'ignored-for-github', + setupDecision: 'skip', + agent: 'blank' + }) + + expect(params).toMatchObject({ + repo: 'id:repo-1', + name: 'issue-88', + displayName: 'Investigate login', + setupDecision: 'skip', + activate: true, + linkedIssue: 88 + }) + expect(params).not.toHaveProperty('startupDraft') + expect(params).not.toHaveProperty('createdWithAgent') + }) + + it('keeps the startup draft when no agent was provided so the host can auto-pick', () => { + const params = buildTaskWorkspaceCreateParams({ + item: { + provider: 'github', + source: { + type: 'issue', + repoId: 'repo-1', + number: 89, + title: 'Auto-pick agent', + url: 'https://github.com/acme/app/issues/89' + } + }, + targetRepoId: 'ignored-for-github', + setupDecision: 'inherit' + }) + + expect(params).toMatchObject({ + startupDraft: 'https://github.com/acme/app/issues/89', + linkedIssue: 89 + }) + expect(params).not.toHaveProperty('createdWithAgent') + }) + + it('links GitLab merge requests and carries explicit base branch overrides', () => { + expect( + buildTaskWorkspaceCreateParams({ + item: { + provider: 'gitlab', + source: { + type: 'mr', + repoId: 'repo-2', + number: 7, + title: 'Port drawer', + url: 'https://gitlab.com/acme/app/-/merge_requests/7' + } + }, + targetRepoId: 'ignored-for-gitlab', + setupDecision: 'inherit', + agent: 'claude', + baseBranch: 'origin/release', + hostedStartPoint: { baseBranch: 'origin/main' }, + branchNameOverride: 'port-drawer', + sparseCheckout: { directories: ['mobile'], presetId: 'preset-1' }, + note: ' keep mobile parity ' + }) + ).toMatchObject({ + repo: 'id:repo-2', + name: 'mr-7', + displayName: 'Port drawer', + startupDraft: 'https://gitlab.com/acme/app/-/merge_requests/7', + createdWithAgent: 'claude', + linkedGitLabMR: 7, + baseBranch: 'origin/release', + branchNameOverride: 'port-drawer', + sparseCheckout: { directories: ['mobile'], presetId: 'preset-1' }, + comment: 'keep mobile parity' + }) + }) + + it('creates Linear workspaces in the selected repo and links the identifier', () => { + expect( + buildTaskWorkspaceCreateParams({ + item: { + provider: 'linear', + source: { + identifier: 'ENG-42', + title: 'Ship Linear parity', + url: 'https://linear.app/acme/issue/ENG-42/ship-linear-parity' + } + }, + targetRepoId: 'repo-linear', + setupDecision: 'inherit', + agent: 'grok' + }) + ).toMatchObject({ + repo: 'id:repo-linear', + name: 'eng-42', + displayName: 'Ship Linear parity', + linkedLinearIssue: 'ENG-42', + startupDraft: 'https://linear.app/acme/issue/ENG-42/ship-linear-parity', + createdWithAgent: 'grok' + }) + }) +}) diff --git a/mobile/src/tasks/workspace-create-params.ts b/mobile/src/tasks/workspace-create-params.ts new file mode 100644 index 00000000000..ec945a11828 --- /dev/null +++ b/mobile/src/tasks/workspace-create-params.ts @@ -0,0 +1,137 @@ +import type { TuiAgent } from '../../../src/shared/types' +import { resolveMobileWorkspaceCreateName } from './mobile-workspace-name' +import type { WorkspaceAgentChoice } from './workspace-agent-selection' + +export type WorkspaceCreateSetupDecision = 'inherit' | 'run' | 'skip' + +export type WorkspaceCreateSparseCheckout = { + directories: string[] + presetId?: string +} + +export type WorkspaceCreateGitPushTarget = { + remoteName: string + branchName: string + remoteUrl?: string +} + +export type WorkspaceCreateHostedStartPoint = { + baseBranch: string + pushTarget?: WorkspaceCreateGitPushTarget +} + +type WorkspaceCreateGitHubItem = { + provider: 'github' + source: { + type: 'issue' | 'pr' + repoId: string + number: number + title: string + url: string + } +} + +type WorkspaceCreateGitLabItem = { + provider: 'gitlab' + source: { + type: 'issue' | 'mr' + repoId: string + number: number + title: string + url: string + } +} + +type WorkspaceCreateLinearItem = { + provider: 'linear' + source: { + identifier: string + title: string + url: string + } +} + +export type WorkspaceCreateTaskItem = + | WorkspaceCreateGitHubItem + | WorkspaceCreateGitLabItem + | WorkspaceCreateLinearItem + +export type WorkspaceCreateParams = Record + +export function buildTaskWorkspaceCreateParams(args: { + item: WorkspaceCreateTaskItem + targetRepoId: string + setupDecision: WorkspaceCreateSetupDecision + agent?: WorkspaceAgentChoice + workspaceName?: string + note?: string + baseBranch?: string + branchNameOverride?: string + sparseCheckout?: WorkspaceCreateSparseCheckout + hostedStartPoint?: WorkspaceCreateHostedStartPoint +}): WorkspaceCreateParams { + const { + item, + targetRepoId, + setupDecision, + agent, + workspaceName, + note, + baseBranch, + branchNameOverride, + sparseCheckout, + hostedStartPoint + } = args + const shouldLaunchAgent = agent !== 'blank' + const createdWithAgent = shouldLaunchAgent ? (agent as TuiAgent) : undefined + const comment = note?.trim() + const selectedBaseBranch = baseBranch || hostedStartPoint?.baseBranch + const common = { + setupDecision, + activate: true, + ...(shouldLaunchAgent ? { startupDraft: item.source.url } : {}), + ...(createdWithAgent ? { createdWithAgent } : {}), + ...(selectedBaseBranch ? { baseBranch: selectedBaseBranch } : {}), + ...(branchNameOverride ? { branchNameOverride } : {}), + ...(hostedStartPoint?.pushTarget ? { pushTarget: hostedStartPoint.pushTarget } : {}), + ...(sparseCheckout ? { sparseCheckout } : {}), + ...(comment ? { comment } : {}) + } + + if (item.provider === 'github') { + const fallback = `${item.source.type}-${item.source.number}` + return { + repo: `id:${item.source.repoId}`, + name: resolveMobileWorkspaceCreateName({ draft: workspaceName, fallback }), + displayName: item.source.title, + ...common, + ...(item.source.type === 'issue' + ? { linkedIssue: item.source.number } + : { linkedPR: item.source.number }) + } + } + + if (item.provider === 'gitlab') { + const fallback = `${item.source.type}-${item.source.number}` + return { + repo: `id:${item.source.repoId}`, + name: resolveMobileWorkspaceCreateName({ draft: workspaceName, fallback }), + displayName: item.source.title, + ...common, + ...(item.source.type === 'issue' + ? { linkedGitLabIssue: item.source.number } + : { linkedGitLabMR: item.source.number }) + } + } + + return { + repo: `id:${targetRepoId}`, + name: resolveMobileWorkspaceCreateName({ + draft: workspaceName, + fallback: item.source.identifier.toLowerCase() + }), + displayName: item.source.title, + linkedLinearIssue: item.source.identifier, + ...common + } +} diff --git a/mobile/src/tasks/workspace-create-timeout.ts b/mobile/src/tasks/workspace-create-timeout.ts new file mode 100644 index 00000000000..b3a5963d29e --- /dev/null +++ b/mobile/src/tasks/workspace-create-timeout.ts @@ -0,0 +1,3 @@ +// Why: desktop remote worktree creation uses the same 10-minute RPC budget. +// SSH clone/setup/startup can legitimately exceed the generic 30s mobile RPC timeout. +export const WORKTREE_CREATE_TIMEOUT_MS = 10 * 60_000 diff --git a/mobile/src/tasks/workspace-ssh-gate.test.ts b/mobile/src/tasks/workspace-ssh-gate.test.ts new file mode 100644 index 00000000000..c0bfce8c539 --- /dev/null +++ b/mobile/src/tasks/workspace-ssh-gate.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { deriveWorkspaceSshGate, workspaceSshStatusLabel } from './workspace-ssh-gate' + +describe('workspace SSH gate', () => { + it('does not gate local repositories', () => { + expect( + deriveWorkspaceSshGate({ + connectionId: null, + state: null, + connecting: false + }) + ).toEqual({ + status: null, + requiresConnection: false, + connectInProgress: false, + error: null + }) + }) + + it('requires connection for remote repos until the matching target is connected', () => { + expect( + deriveWorkspaceSshGate({ + connectionId: 'ssh-1', + state: null, + connecting: false + }) + ).toMatchObject({ status: null, requiresConnection: true }) + + expect( + deriveWorkspaceSshGate({ + connectionId: 'ssh-1', + state: { targetId: 'ssh-1', status: 'connected', error: null, reconnectAttempt: 0 }, + connecting: false + }) + ).toMatchObject({ status: 'connected', requiresConnection: false }) + }) + + it('ignores stale SSH state from a previously selected repository', () => { + expect( + deriveWorkspaceSshGate({ + connectionId: 'ssh-2', + state: { targetId: 'ssh-1', status: 'connected', error: null, reconnectAttempt: 0 }, + connecting: false + }) + ).toEqual({ + status: null, + requiresConnection: true, + connectInProgress: false, + error: null + }) + }) + + it('marks connecting, relay deploy, and reconnect states as in progress', () => { + for (const status of ['connecting', 'deploying-relay', 'reconnecting'] as const) { + expect( + deriveWorkspaceSshGate({ + connectionId: 'ssh-1', + state: { targetId: 'ssh-1', status, error: null, reconnectAttempt: 0 }, + connecting: false + }) + ).toMatchObject({ requiresConnection: true, connectInProgress: true }) + } + }) + + it('keeps auth and relay errors visible in the drawer', () => { + expect( + deriveWorkspaceSshGate({ + connectionId: 'ssh-1', + state: { + targetId: 'ssh-1', + status: 'auth-failed', + error: 'Permission denied', + reconnectAttempt: 0 + }, + connecting: false + }) + ).toMatchObject({ + status: 'auth-failed', + requiresConnection: true, + connectInProgress: false, + error: 'Permission denied' + }) + }) + + it('labels user-visible SSH states', () => { + expect(workspaceSshStatusLabel(null)).toBe('Disconnected') + expect(workspaceSshStatusLabel('deploying-relay')).toBe('Deploying relay') + expect(workspaceSshStatusLabel('auth-failed')).toBe('Authentication failed') + expect(workspaceSshStatusLabel('connected')).toBe('Connected') + }) +}) diff --git a/mobile/src/tasks/workspace-ssh-gate.ts b/mobile/src/tasks/workspace-ssh-gate.ts new file mode 100644 index 00000000000..567355cad2f --- /dev/null +++ b/mobile/src/tasks/workspace-ssh-gate.ts @@ -0,0 +1,39 @@ +import type { SshConnectionState, SshConnectionStatus } from '../../../src/shared/ssh-types' + +export type WorkspaceSshGate = { + status: SshConnectionStatus | null + requiresConnection: boolean + connectInProgress: boolean + error: string | null +} + +export function isWorkspaceSshConnectInProgress(status: SshConnectionStatus | null): boolean { + return status === 'connecting' || status === 'deploying-relay' || status === 'reconnecting' +} + +export function workspaceSshStatusLabel(status: SshConnectionStatus | null): string { + if (status === 'connected') return 'Connected' + if (status === 'connecting') return 'Connecting' + if (status === 'deploying-relay') return 'Deploying relay' + if (status === 'reconnecting') return 'Reconnecting' + if (status === 'auth-failed') return 'Authentication failed' + if (status === 'reconnection-failed') return 'Reconnect failed' + if (status === 'error') return 'Connection failed' + return 'Disconnected' +} + +export function deriveWorkspaceSshGate(args: { + connectionId: string | null + state: SshConnectionState | null + connecting: boolean +}): WorkspaceSshGate { + const matchingState = + args.connectionId && args.state?.targetId === args.connectionId ? args.state : null + const status = matchingState?.status ?? null + return { + status, + requiresConnection: args.connectionId !== null && status !== 'connected', + connectInProgress: args.connecting || isWorkspaceSshConnectInProgress(status), + error: matchingState?.error ?? null + } +} diff --git a/src/main/github/client-create-pr.test.ts b/src/main/github/client-create-pr.test.ts index 907ef905ff8..5fdc811c5bc 100644 --- a/src/main/github/client-create-pr.test.ts +++ b/src/main/github/client-create-pr.test.ts @@ -21,6 +21,11 @@ vi.mock('./gh-utils', () => ({ getOwnerRepo: getOwnerRepoMock, getIssueOwnerRepo: vi.fn(), getOwnerRepoForRemote: vi.fn(), + githubRepoContext: vi.fn((repoPath: string, connectionId?: string | null) => ({ + repoPath, + connectionId: connectionId ?? null + })), + ghRepoExecOptions: vi.fn((context: { repoPath: string }) => ({ cwd: context.repoPath })), gitExecFileAsync: vi.fn(), extractExecError: extractExecErrorMock, parseGitHubOwnerRepo: vi.fn(), diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 28241a18dbc..6e3b2a61ecb 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -1362,7 +1362,9 @@ async function findOpenPRByHeadBase(args: { ownerRepo: OwnerRepo head: string base: string + connectionId?: string | null }): Promise<{ number: number; url: string } | null> { + const context = githubRepoContext(args.repoPath, args.connectionId) const { stdout } = await ghExecFileAsync( [ 'pr', @@ -1380,7 +1382,7 @@ async function findOpenPRByHeadBase(args: { '--json', 'number,url' ], - { cwd: args.repoPath } + ghRepoExecOptions(context) ) const list = JSON.parse(stdout) as { number?: number; url?: string }[] if (list.length !== 1 || !list[0]?.number || !list[0]?.url) { @@ -1391,7 +1393,8 @@ async function findOpenPRByHeadBase(args: { export async function createGitHubPullRequest( repoPath: string, - input: CreateHostedReviewInput + input: CreateHostedReviewInput, + connectionId?: string | null ): Promise { if (input.provider !== 'github') { return { @@ -1401,7 +1404,7 @@ export async function createGitHubPullRequest( } } - const ownerRepo = await getOwnerRepo(repoPath) + const ownerRepo = await getOwnerRepo(repoPath, connectionId) if (!ownerRepo) { return { ok: false, @@ -1452,8 +1455,9 @@ export async function createGitHubPullRequest( createArgs.push('--draft') } try { + const context = githubRepoContext(repoPath, connectionId) const { stdout } = await ghExecFileAsync(createArgs, { - cwd: repoPath, + ...ghRepoExecOptions(context), timeout: 60_000, idempotent: false }) @@ -1462,7 +1466,9 @@ export async function createGitHubPullRequest( return { ok: true, ...created } } const found = head - ? await findOpenPRByHeadBase({ repoPath, ownerRepo, head, base }).catch(() => null) + ? await findOpenPRByHeadBase({ repoPath, ownerRepo, head, base, connectionId }).catch( + () => null + ) : null if (found) { return { ok: true, ...found } @@ -1479,9 +1485,13 @@ export async function createGitHubPullRequest( (classified.code === 'already_exists' || classified.code === 'unknown_completion') && head ) { - const existing = await findOpenPRByHeadBase({ repoPath, ownerRepo, head, base }).catch( - () => null - ) + const existing = await findOpenPRByHeadBase({ + repoPath, + ownerRepo, + head, + base, + connectionId + }).catch(() => null) if (existing) { return { ok: false, @@ -3006,3 +3016,53 @@ export async function updatePRTitle( release() } } + +export async function updatePRDetails( + repoPath: string, + prNumber: number, + updates: { title?: string; body?: string }, + connectionId?: string | null, + prRepo?: OwnerRepo | null +): Promise<{ ok: true } | { ok: false; error: string }> { + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) + const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId)) + if (!ownerRepo) { + return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } + } + + const fields: string[] = [] + if (updates.title !== undefined) { + const title = updates.title.trim() + if (!title) { + return { ok: false, error: 'Title is required' } + } + fields.push(`title=${title}`) + } + if (updates.body !== undefined) { + fields.push(`body=${updates.body}`) + } + if (fields.length === 0) { + return { ok: true } + } + + await acquire() + try { + await ghExecFileAsync( + [ + 'api', + '-X', + 'PATCH', + `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}`, + ...fields.flatMap((field) => ['--raw-field', field]) + ], + ghOptions + ) + return { ok: true } + } catch (err) { + const message = + err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error' + return { ok: false, error: classifyGhError(message).message } + } finally { + release() + } +} diff --git a/src/main/github/project-view/mutations.ts b/src/main/github/project-view/mutations.ts index 2c9e21dc2aa..d07db5e5efb 100644 --- a/src/main/github/project-view/mutations.ts +++ b/src/main/github/project-view/mutations.ts @@ -839,7 +839,7 @@ export async function getWorkItemDetailsBySlug( // Why: PR files/checks/review-thread tabs depend on a local repo path and // are out of Project-mode slug scope for v1. Omit them here; the dialog // branches on their absence and hides those tabs. - ...(args.type === 'issue' ? { assignees } : {}) + assignees } return { ok: true, details } } diff --git a/src/main/gitlab/client-mr.test.ts b/src/main/gitlab/client-mr.test.ts index fab59fcdfec..e9f87a15fef 100644 --- a/src/main/gitlab/client-mr.test.ts +++ b/src/main/gitlab/client-mr.test.ts @@ -35,7 +35,7 @@ vi.mock('./gl-utils', async () => { } }) -import { getMergeRequest, getMergeRequestForBranch, listMergeRequests } from './client' +import { getMergeRequest, getMergeRequestForBranch, listMergeRequests, updateMR } from './client' describe('gitlab client — MR operations', () => { beforeEach(() => { @@ -48,6 +48,10 @@ describe('gitlab client — MR operations', () => { releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) getGlabKnownHostsMock.mockResolvedValue(['gitlab.com']) + resolveIssueSourceMock.mockResolvedValue({ + source: { host: 'gitlab.com', path: 'g/p' }, + fellBack: false + }) }) describe('getMergeRequest', () => { @@ -193,15 +197,14 @@ describe('gitlab client — MR operations', () => { describe('listMergeRequests', () => { beforeEach(() => { resolveIssueSourceMock.mockImplementation(async () => ({ - source: await getProjectRefMock(), + source: { host: 'gitlab.com', path: 'g/p' }, fellBack: false })) }) - it('returns MRs via glab CLI', async () => { - getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify([ + it('returns MRs via the GitLab API', async () => { + glabApiWithHeadersMock.mockResolvedValueOnce({ + body: JSON.stringify([ { id: 100, iid: 1, @@ -215,7 +218,8 @@ describe('gitlab client — MR operations', () => { source_project_id: 5, target_project_id: 5 } - ]) + ]), + headers: { 'x-total': '1', 'x-total-pages': '1' } }) const result = await listMergeRequests('/repo', 'opened', 1, 20) @@ -231,58 +235,35 @@ describe('gitlab client — MR operations', () => { isCrossRepository: false, repoId: 'g/p' }) - expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + expect(glabApiWithHeadersMock).toHaveBeenCalledWith( [ - 'mr', - 'list', - '--output', - 'json', - '--per-page', - '20', - '--page', - '1', - '--order', - 'updated_at', - '--sort', - 'desc', - '--repo', - 'https://gitlab.com/g/p' + 'projects/g%2Fp/merge_requests?page=1&per_page=20&order_by=updated_at&sort=desc&with_merge_status_recheck=false&state=opened' ], { cwd: '/repo' } ) }) - it("passes --all when state='all'", async () => { - getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + it("omits state when state='all'", async () => { + glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} }) await listMergeRequests('/repo', 'all', 1, 20) - const callArgs = glabExecFileAsyncMock.mock.calls[0][0] as string[] - expect(callArgs).toContain('--all') - expect(callArgs).not.toContain('--opened') - expect(callArgs).not.toContain('--merged') - expect(callArgs).not.toContain('--closed') + const callArgs = glabApiWithHeadersMock.mock.calls[0][0] as string[] + expect(callArgs[0]).not.toContain('state=') }) - it('passes through Open / Merged / Closed states as flags', async () => { + it('passes through Open / Merged / Closed states as API params', async () => { for (const state of ['opened', 'merged', 'closed'] as const) { - glabExecFileAsyncMock.mockReset() - getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + glabApiWithHeadersMock.mockReset() + glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} }) await listMergeRequests('/repo', state, 1, 20) - const callArgs = glabExecFileAsyncMock.mock.calls[0][0] as string[] - if (state === 'opened') { - expect(callArgs).not.toContain('--opened') - } else { - expect(callArgs).toContain(`--${state}`) - } + const callArgs = glabApiWithHeadersMock.mock.calls[0][0] as string[] + expect(callArgs[0]).toContain(`state=${state}`) } }) it('flags fork MRs as cross-repository', async () => { - getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify([ + glabApiWithHeadersMock.mockResolvedValueOnce({ + body: JSON.stringify([ { id: 200, iid: 2, @@ -293,47 +274,78 @@ describe('gitlab client — MR operations', () => { source_project_id: 11, target_project_id: 5 } - ]) + ]), + headers: {} }) const result = await listMergeRequests('/repo', 'opened', 1, 20) expect(result.items[0].isCrossRepository).toBe(true) }) - it('falls back to CLI when project ref is unresolved', async () => { - getProjectRefMock.mockResolvedValueOnce(null) - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - id: 100, - iid: 1, - title: 'fallback mr', - state: 'opened', - web_url: 'https://gitlab.example.com/fallback/-/merge_requests/1', - updated_at: '2026-05-05', - source_branch: 'feat', - target_branch: 'main', - author: { username: 'alice' }, - source_project_id: 5, - target_project_id: 5 - } - ]) + it('returns a not_found envelope when project ref is unresolved', async () => { + resolveIssueSourceMock.mockResolvedValueOnce({ + source: null, + fellBack: false }) const result = await listMergeRequests('/repo', 'opened') - expect(result.items).toHaveLength(1) - expect(result.items[0].title).toBe('fallback mr') - const callArgs = glabExecFileAsyncMock.mock.calls[0][0] as string[] - expect(callArgs).toContain('--order') - expect(callArgs).toContain('updated_at') - expect(callArgs).not.toContain('--repo') + expect(result.items).toEqual([]) + expect(result.error?.type).toBe('not_found') + expect(glabApiWithHeadersMock).not.toHaveBeenCalled() }) - it('classifies CLI errors into the result envelope', async () => { - getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - glabExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden')) + it('classifies API errors into the result envelope', async () => { + glabApiWithHeadersMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden')) const result = await listMergeRequests('/repo', 'opened') expect(result.error?.type).toBe('permission_denied') expect(result.items).toEqual([]) }) }) + + describe('updateMR', () => { + beforeEach(() => { + resolveIssueSourceMock.mockImplementation(async () => ({ + source: { host: 'git.internal', path: 'g/p' }, + fellBack: false + })) + }) + + it('updates title, body, and labels through the selected SSH GitLab host', async () => { + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '{}' }) + + await expect( + updateMR( + '/repo', + 12, + { + title: 'Renamed', + body: 'Updated body', + addLabels: ['bug'], + removeLabels: ['stale'] + }, + 'upstream', + 'conn-1' + ) + ).resolves.toEqual({ ok: true }) + + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'api', + '--hostname', + 'git.internal', + '-X', + 'PUT', + 'projects/g%2Fp/merge_requests/12', + '-f', + 'title=Renamed', + '-f', + 'description=Updated body', + '-f', + 'add_labels=bug', + '-f', + 'remove_labels=stale' + ], + {} + ) + }) + }) }) diff --git a/src/main/gitlab/client-work-items.test.ts b/src/main/gitlab/client-work-items.test.ts index 65d97bf3475..8d87fa74f7d 100644 --- a/src/main/gitlab/client-work-items.test.ts +++ b/src/main/gitlab/client-work-items.test.ts @@ -53,22 +53,21 @@ describe('gitlab client — combined listWorkItems', () => { }) it('merges MRs + issues and sorts by updatedAt desc', async () => { - glabExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'mr') { - return { - stdout: JSON.stringify([ - { - id: 100, - iid: 1, - title: 'older mr', - state: 'opened', - updated_at: '2026-05-05T00:00:00Z', - source_project_id: 5, - target_project_id: 5 - } - ]) + glabApiWithHeadersMock.mockResolvedValueOnce({ + body: JSON.stringify([ + { + id: 100, + iid: 1, + title: 'older mr', + state: 'opened', + updated_at: '2026-05-05T00:00:00Z', + source_project_id: 5, + target_project_id: 5 } - } + ]), + headers: {} + }) + glabExecFileAsyncMock.mockImplementation(async () => { return { stdout: JSON.stringify([ { @@ -89,13 +88,13 @@ describe('gitlab client — combined listWorkItems', () => { }) it("skips the issues fetch when state === 'merged'", async () => { - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} }) await listWorkItems('/repo', 'merged', 1, 20) // Why: the merged-state filter doesn't apply to issues (issues // don't have a merged lifecycle), so the IPC must not even spawn // the issues read. Verifies the listIssues path was not taken. - expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(glabExecFileAsyncMock).not.toHaveBeenCalled() }) it('passes the closed state through to the issues fetch', async () => { @@ -104,11 +103,20 @@ describe('gitlab client — combined listWorkItems', () => { }) await listWorkItems('/repo', 'closed', 1, 20) - const issuesCalls = glabExecFileAsyncMock.mock.calls.filter( - (call) => (call[0] as string[])[0] === 'api' - ) - expect(issuesCalls).toHaveLength(1) - expect((issuesCalls[0][0] as string[])[1]).toContain('state=closed') + const issuesCallPath = glabExecFileAsyncMock.mock.calls[0][0] as string[] + expect(issuesCallPath.at(-1)).toContain('state=closed') + }) + + it('passes search queries through to merge request and issue fetches', async () => { + glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} }) + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + + await listWorkItems('/repo', 'opened', 1, 20, undefined, 'ambiguous selector') + + const mergeRequestCallPath = glabApiWithHeadersMock.mock.calls[0][0] as string[] + const issuesCallPath = glabExecFileAsyncMock.mock.calls[0][0] as string[] + expect(mergeRequestCallPath[0]).toContain('search=ambiguous%20selector') + expect(issuesCallPath.at(-1)).toContain('search=ambiguous%20selector') }) it("omits the state param when 'all'", async () => { @@ -117,11 +125,26 @@ describe('gitlab client — combined listWorkItems', () => { }) await listWorkItems('/repo', 'all', 1, 20) - const issuesCalls = glabExecFileAsyncMock.mock.calls.filter( - (call) => (call[0] as string[])[0] === 'api' - ) - expect(issuesCalls).toHaveLength(1) - expect((issuesCalls[0][0] as string[])[1]).not.toContain('state=') + const issuesCallPath = glabExecFileAsyncMock.mock.calls[0][0] as string[] + expect(issuesCallPath.at(-1)).not.toContain('state=') + }) + + it('routes issue list fetches through the selected SSH GitLab host', async () => { + resolveIssueSourceMock.mockResolvedValueOnce({ + source: { host: 'git.internal', path: 'g/p' }, + fellBack: false + }) + glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} }) + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + + await listWorkItems('/repo', 'opened', 1, 20, 'upstream', undefined, 'conn-1') + + expect(glabExecFileAsyncMock.mock.calls[0][0]).toEqual([ + 'api', + '--hostname', + 'git.internal', + 'projects/g%2Fp/issues?per_page=20&order_by=updated_at&sort=desc&state=opened' + ]) }) it('returns a not_found error envelope when project ref is unresolved', async () => { @@ -134,7 +157,7 @@ describe('gitlab client — combined listWorkItems', () => { }) it('surfaces the MR error envelope into the combined result', async () => { - glabExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden')) + glabApiWithHeadersMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden')) glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) const result = await listWorkItems('/repo', 'opened', 1, 20) @@ -142,7 +165,7 @@ describe('gitlab client — combined listWorkItems', () => { }) it('still returns issues when MRs error out', async () => { - glabExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 500')) + glabApiWithHeadersMock.mockRejectedValueOnce(new Error('HTTP 500')) glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([ { id: 200, iid: 9, title: 'live issue', state: 'opened', updated_at: '2026-05-08' } diff --git a/src/main/gitlab/client.ts b/src/main/gitlab/client.ts index 4e8699a4231..8b3b79cf0be 100644 --- a/src/main/gitlab/client.ts +++ b/src/main/gitlab/client.ts @@ -16,10 +16,14 @@ import type { import { derivePipelineStatus, mapIssueToWorkItem, mapMRInfo, mapMRToWorkItem } from './mappers' import { acquire, + classifyGlabError, classifyListIssuesError, getGlabKnownHosts, getProjectRef, getProjectRefForRemote, + glabHostnameArgs, + glabRepoExecOptions, + glabApiWithHeaders, glabExecFileAsync, release, resolveIssueSource, @@ -33,25 +37,6 @@ function encodedProject(projectPath: string): string { return encodeURIComponent(projectPath) } -function projectRefToGlabRepo(projectRef: ProjectRef): string { - // Why: `glab mr list` otherwise infers from cwd and can ignore an - // upstream/origin preference. A full URL also works for self-hosted hosts. - return `https://${projectRef.host}/${projectRef.path}` -} - -function mrListStateFlags(state: MRListState): string[] { - switch (state) { - case 'opened': - return [] - case 'merged': - return ['--merged'] - case 'closed': - return ['--closed'] - case 'all': - return ['--all'] - } -} - /** * Get the authenticated GitLab viewer. Mirrors getAuthenticatedViewer * from the GitHub client — returns null when glab is unavailable, the @@ -80,9 +65,12 @@ export async function getAuthenticatedViewer(): Promise { * Resolve a project's full GitLab project ref (host + path). Mirrors * github/getRepoSlug. Returns null for non-GitLab remotes. */ -export async function getProjectSlug(repoPath: string): Promise { +export async function getProjectSlug( + repoPath: string, + connectionId?: string | null +): Promise { const knownHosts = await getGlabKnownHosts() - return getProjectRef(repoPath, knownHosts) + return getProjectRef(repoPath, knownHosts, connectionId) } /** @@ -90,15 +78,23 @@ export async function getProjectSlug(repoPath: string): Promise { +export async function getMergeRequest( + repoPath: string, + iid: number, + connectionId?: string | null +): Promise { const knownHosts = await getGlabKnownHosts() - const projectRef = await getProjectRef(repoPath, knownHosts) + const projectRef = await getProjectRef(repoPath, knownHosts, connectionId) await acquire() try { const args = projectRef - ? ['api', `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`] + ? [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}` + ] : ['mr', 'view', String(iid), '--output', 'json'] - const { stdout } = await glabExecFileAsync(args, { cwd: repoPath }) + const { stdout } = await glabExecFileAsync(args, glabRepoExecOptions(repoPath, connectionId)) const data = JSON.parse(stdout) as Parameters[0] & { head_pipeline?: { status?: string } | null pipeline?: { status?: string } | null @@ -125,14 +121,15 @@ export async function getMergeRequest(repoPath: string, iid: number): Promise { const branchName = branch.replace(/^refs\/heads\//, '') if (!branchName && linkedMRIid == null) { return null } const knownHosts = await getGlabKnownHosts() - const projectRef = await getProjectRef(repoPath, knownHosts) + const projectRef = await getProjectRef(repoPath, knownHosts, connectionId) if (!projectRef) { return null } @@ -142,9 +139,10 @@ export async function getMergeRequestForBranch( const { stdout } = await glabExecFileAsync( [ 'api', + ...glabHostnameArgs(projectRef, connectionId), `projects/${encodedProject(projectRef.path)}/merge_requests?source_branch=${encodeURIComponent(branchName)}&order_by=updated_at&sort=desc&per_page=1` ], - { cwd: repoPath } + glabRepoExecOptions(repoPath, connectionId) ) const data = JSON.parse(stdout) as (Parameters[0] & { head_pipeline?: { status?: string } | null @@ -162,8 +160,12 @@ export async function getMergeRequestForBranch( // than the MR source branch. Fall back to the durable linked iid so the // core review status still follows the workspace. const { stdout } = await glabExecFileAsync( - ['api', `projects/${encodedProject(projectRef.path)}/merge_requests/${linkedMRIid}`], - { cwd: repoPath } + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + `projects/${encodedProject(projectRef.path)}/merge_requests/${linkedMRIid}` + ], + glabRepoExecOptions(repoPath, connectionId) ) const raw = JSON.parse(stdout) as Parameters[0] & { head_pipeline?: { status?: string } | null @@ -187,80 +189,60 @@ export async function listMergeRequests( state: MRListState = 'opened', page = 1, perPage = 20, - preference?: IssueSourcePreference + preference?: IssueSourcePreference, + query?: string, + connectionId?: string | null ): Promise { const knownHosts = await getGlabKnownHosts() - const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts) + // Why: MRs sit on `origin` in the fork model (the user's fork is where + // they push branches and submit MRs). Mirror github's `getOwnerRepo` + // call site by going through the upstream/origin preference resolver + // so cross-fork workflows reuse the same plumbing. + const { source: projectRef } = await resolveIssueSource( + repoPath, + preference, + knownHosts, + connectionId + ) + if (!projectRef) { + return { + items: [], + page, + perPage, + totalCount: 0, + totalPages: 0, + error: { + type: 'not_found', + message: 'No GitLab project found for this repository.' + } + } + } + // Why: 'all' is exposed as the picker filter but GitLab's API expects + // no state param to mean "any state". Drop the param when 'all'. + const stateParam = state === 'all' ? '' : `&state=${state}` + const searchParam = query?.trim() ? `&search=${encodeURIComponent(query.trim())}` : '' + const path = + `projects/${encodedProject(projectRef.path)}/merge_requests?` + + `page=${page}&per_page=${perPage}&order_by=updated_at&sort=desc&with_merge_status_recheck=false${stateParam}${searchParam}` + const repoId = projectRef.path await acquire() try { - if (projectRef) { - // Why: use `glab mr list` (CLI) instead of the REST API directly. - // The CLI respects the user's glab auth configuration; `--repo` - // keeps upstream/origin preference resolution explicit. - const stateFlag = mrListStateFlags(state) - const { stdout } = await glabExecFileAsync( - [ - 'mr', - 'list', - '--output', - 'json', - '--per-page', - String(perPage), - '--page', - String(page), - '--order', - 'updated_at', - '--sort', - 'desc', - '--repo', - projectRefToGlabRepo(projectRef), - ...stateFlag - ], - { cwd: repoPath } - ) - const data = JSON.parse(stdout) as Parameters[0][] - return { - items: data.map((d) => mapMRToWorkItem(d, projectRef.path)), - page, - perPage, - // Why: the CLI doesn't return x-total headers, so totals are - // approximate. For the Tasks UI this is acceptable — pagination - // still works via page+per_page. - totalCount: data.length, - totalPages: data.length < perPage ? page : page + 1 - } - } - // Fallback — let glab infer project from cwd. This path is taken when - // the repo's remote host is not in getGlabKnownHosts() (e.g. a fresh - // self-hosted instance), but glab itself can still resolve it from the - // local git config. - const stateFlag = mrListStateFlags(state) - const { stdout } = await glabExecFileAsync( - [ - 'mr', - 'list', - '--output', - 'json', - '--per-page', - String(perPage), - '--page', - String(page), - '--order', - 'updated_at', - '--sort', - 'desc', - ...stateFlag - ], - { cwd: repoPath } + const { body, headers } = await glabApiWithHeaders( + [...glabHostnameArgs(projectRef, connectionId), path], + glabRepoExecOptions(repoPath, connectionId) ) - const data = JSON.parse(stdout) as Parameters[0][] + const data = JSON.parse(body) as Parameters[0][] return { - items: data.map((d) => mapMRToWorkItem(d, 'unknown')), + items: data.map((d) => mapMRToWorkItem(d, repoId, projectRef)), page, perPage, - totalCount: data.length, - totalPages: data.length < perPage ? page : page + 1 + totalCount: parseHeaderInt(headers['x-total'], 0), + // Why: when 'all' state is requested or the per_page is large, + // GitLab may not include x-total-pages; fall back to ceil(total/perPage). + totalPages: + parseHeaderInt(headers['x-total-pages'], 0) || + Math.max(1, Math.ceil(parseHeaderInt(headers['x-total'], 0) / perPage)) } } catch (err) { const stderr = err instanceof Error ? err.message : String(err) @@ -277,6 +259,14 @@ export async function listMergeRequests( } } +function parseHeaderInt(value: string | undefined, fallback: number): number { + if (!value) { + return fallback + } + const parsed = Number.parseInt(value, 10) + return Number.isFinite(parsed) ? parsed : fallback +} + /** * Fetch a work item (MR or issue) given an explicit project ref + * iid + type. Mirrors github/getWorkItemByOwnerRepo — used by the @@ -287,20 +277,25 @@ export async function getWorkItemByProjectRef( repoPath: string, projectRef: ProjectRef, iid: number, - type: 'issue' | 'mr' + type: 'issue' | 'mr', + connectionId?: string | null ): Promise { await acquire() try { const resource = type === 'mr' ? 'merge_requests' : 'issues' const { stdout } = await glabExecFileAsync( - ['api', `projects/${encodedProject(projectRef.path)}/${resource}/${iid}`], - { cwd: repoPath } + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + `projects/${encodedProject(projectRef.path)}/${resource}/${iid}` + ], + glabRepoExecOptions(repoPath, connectionId) ) const data = JSON.parse(stdout) if (type === 'mr') { - return mapMRToWorkItem(data, projectRef.path) + return mapMRToWorkItem(data, projectRef.path, projectRef) } - return mapIssueToWorkItem(data, projectRef.path) + return mapIssueToWorkItem(data, projectRef.path, projectRef) } catch { return null } finally { @@ -337,11 +332,18 @@ export async function listWorkItems( state: MRListState = 'opened', page = 1, perPage = 20, - preference?: IssueSourcePreference + preference?: IssueSourcePreference, + query?: string, + connectionId?: string | null ): Promise> { const issueState = mrStateToIssueState(state) const knownHosts = await getGlabKnownHosts() - const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts) + const { source: projectRef } = await resolveIssueSource( + repoPath, + preference, + knownHosts, + connectionId + ) if (!projectRef) { return { items: [], @@ -366,13 +368,13 @@ export async function listWorkItems( // raw issues API directly and run mapIssueToWorkItem against the // raw payload instead. const [mrs, issues] = await Promise.all([ - listMergeRequests(repoPath, state, page, perPage, preference), + listMergeRequests(repoPath, state, page, perPage, preference, query, connectionId), issueState === null ? Promise.resolve({ items: [] as GitLabWorkItem[], error: undefined as ClassifiedError | undefined }) - : fetchIssuesAsWorkItems(repoPath, projectRef, issueState, perPage) + : fetchIssuesAsWorkItems(repoPath, projectRef, issueState, perPage, query, connectionId) ]) const merged = [...mrs.items, ...issues.items].sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '') @@ -401,21 +403,25 @@ export async function fetchIssuesAsWorkItems( repoPath: string, projectRef: ProjectRef, state: IssueListState, - perPage: number + perPage: number, + query?: string, + connectionId?: string | null ): Promise<{ items: GitLabWorkItem[]; error: ClassifiedError | undefined }> { await acquire() try { const stateParam = state === 'all' ? '' : `&state=${state}` + const searchParam = query?.trim() ? `&search=${encodeURIComponent(query.trim())}` : '' const { stdout } = await glabExecFileAsync( [ 'api', - `projects/${encodedProject(projectRef.path)}/issues?per_page=${perPage}&order_by=updated_at&sort=desc${stateParam}` + ...glabHostnameArgs(projectRef, connectionId), + `projects/${encodedProject(projectRef.path)}/issues?per_page=${perPage}&order_by=updated_at&sort=desc${stateParam}${searchParam}` ], - { cwd: repoPath } + glabRepoExecOptions(repoPath, connectionId) ) const data = JSON.parse(stdout) as Parameters[0][] return { - items: data.map((d) => mapIssueToWorkItem(d, projectRef.path)), + items: data.map((d) => mapIssueToWorkItem(d, projectRef.path, projectRef)), error: undefined } } catch (err) { @@ -439,15 +445,27 @@ export async function fetchIssuesAsWorkItems( * work directly from a mention/assignment without going to gitlab.com * first. */ -export async function listTodos(repoPath: string): Promise { +export async function listTodos( + repoPath: string, + connectionId?: string | null +): Promise { + const projectRef = await getProjectRef(repoPath, await getGlabKnownHosts(), connectionId) + if (connectionId && !projectRef) { + return [] + } await acquire() try { // Why: per_page=50 keeps the first-page round-trip small. Pagination // is left for a follow-up — most users have <50 pending todos in // practice and the UI shows the highest-priority ones first. const { stdout } = await glabExecFileAsync( - ['api', '--paginate', 'todos?state=pending&per_page=50'], - { cwd: repoPath } + [ + 'api', + ...(projectRef ? glabHostnameArgs(projectRef, connectionId) : []), + '--paginate', + 'todos?state=pending&per_page=50' + ], + glabRepoExecOptions(repoPath, connectionId) ) type RESTTodo = { id?: number @@ -498,11 +516,15 @@ export async function listTodos(repoPath: string): Promise { async function withProjectRef( repoPath: string, + preference: IssueSourcePreference | undefined, + connectionId: string | null | undefined, + explicitProjectRef: ProjectRef | null | undefined, fn: (projectRef: ProjectRef, repoFlag: string) => Promise, fallback: T ): Promise { - const knownHosts = await getGlabKnownHosts() - const projectRef = await getProjectRef(repoPath, knownHosts) + const projectRef = + explicitProjectRef ?? + (await resolveIssueSource(repoPath, preference, await getGlabKnownHosts(), connectionId)).source if (!projectRef) { return fallback } @@ -511,14 +533,30 @@ async function withProjectRef( export async function closeMR( repoPath: string, - iid: number + iid: number, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null ): Promise<{ ok: true } | { ok: false; error: string }> { return withProjectRef<{ ok: true } | { ok: false; error: string }>( repoPath, - async (_pr, repoFlag) => { + preference, + connectionId, + projectRef, + async (projectRef, repoFlag) => { await acquire() try { - await glabExecFileAsync(['mr', 'close', String(iid), '-R', repoFlag], { cwd: repoPath }) + await glabExecFileAsync( + [ + 'mr', + 'close', + String(iid), + '-R', + repoFlag, + ...glabHostnameArgs(projectRef, connectionId) + ], + glabRepoExecOptions(repoPath, connectionId) + ) return { ok: true } } catch (err) { const msg = err instanceof Error ? err.message : String(err) @@ -539,14 +577,30 @@ export async function closeMR( export async function reopenMR( repoPath: string, - iid: number + iid: number, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null ): Promise<{ ok: true } | { ok: false; error: string }> { return withProjectRef<{ ok: true } | { ok: false; error: string }>( repoPath, - async (_pr, repoFlag) => { + preference, + connectionId, + projectRef, + async (projectRef, repoFlag) => { await acquire() try { - await glabExecFileAsync(['mr', 'reopen', String(iid), '-R', repoFlag], { cwd: repoPath }) + await glabExecFileAsync( + [ + 'mr', + 'reopen', + String(iid), + '-R', + repoFlag, + ...glabHostnameArgs(projectRef, connectionId) + ], + glabRepoExecOptions(repoPath, connectionId) + ) return { ok: true } } catch (err) { const msg = err instanceof Error ? err.message : String(err) @@ -565,11 +619,17 @@ export async function reopenMR( export async function mergeMR( repoPath: string, iid: number, - method: 'merge' | 'squash' | 'rebase' = 'merge' + method: 'merge' | 'squash' | 'rebase' = 'merge', + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null ): Promise<{ ok: true } | { ok: false; error: string }> { return withProjectRef<{ ok: true } | { ok: false; error: string }>( repoPath, - async (_pr, repoFlag) => { + preference, + connectionId, + projectRef, + async (projectRef, repoFlag) => { await acquire() try { // Why: glab mr merge accepts --squash and --rebase flags; @@ -578,8 +638,17 @@ export async function mergeMR( const methodFlag = method === 'squash' ? ['--squash'] : method === 'rebase' ? ['--rebase'] : [] await glabExecFileAsync( - ['mr', 'merge', String(iid), '-R', repoFlag, '--yes', ...methodFlag], - { cwd: repoPath } + [ + 'mr', + 'merge', + String(iid), + '-R', + repoFlag, + '--yes', + ...methodFlag, + ...glabHostnameArgs(projectRef, connectionId) + ], + glabRepoExecOptions(repoPath, connectionId) ) return { ok: true } } catch (err) { @@ -595,23 +664,30 @@ export async function mergeMR( export async function addMRComment( repoPath: string, iid: number, - body: string + body: string, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null ): Promise<{ ok: true; comment: MRComment } | { ok: false; error: string }> { return withProjectRef<{ ok: true; comment: MRComment } | { ok: false; error: string }>( repoPath, + preference, + connectionId, + projectRef, async (projectRef) => { await acquire() try { const { stdout } = await glabExecFileAsync( [ 'api', + ...glabHostnameArgs(projectRef, connectionId), '-X', 'POST', `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/notes`, '-f', `body=${body}` ], - { cwd: repoPath } + glabRepoExecOptions(repoPath, connectionId) ) const data = JSON.parse(stdout) as { id?: number @@ -641,6 +717,73 @@ export async function addMRComment( ) } +export async function updateMR( + repoPath: string, + iid: number, + updates: { + title?: string + body?: string + addLabels?: string[] + removeLabels?: string[] + }, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null +): Promise<{ ok: true } | { ok: false; error: string }> { + return withProjectRef<{ ok: true } | { ok: false; error: string }>( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef) => { + const fields: string[] = [] + const title = updates.title?.trim() + if (updates.title !== undefined) { + if (!title) { + return { ok: false, error: 'Title is required' } + } + fields.push(`title=${title}`) + } + if (updates.body !== undefined) { + fields.push(`description=${updates.body}`) + } + const addLabels = (updates.addLabels ?? []).filter((label) => label.trim().length > 0) + const removeLabels = (updates.removeLabels ?? []).filter((label) => label.trim().length > 0) + if (addLabels.length > 0) { + fields.push(`add_labels=${addLabels.join(',')}`) + } + if (removeLabels.length > 0) { + fields.push(`remove_labels=${removeLabels.join(',')}`) + } + if (fields.length === 0) { + return { ok: true } + } + + await acquire() + try { + await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + '-X', + 'PUT', + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`, + ...fields.flatMap((field) => ['-f', field]) + ], + glabRepoExecOptions(repoPath, connectionId) + ) + return { ok: true } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { ok: false, error: classifyGlabError(msg).message } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' } + ) +} + /** Re-export so callers don't need to know the gl-utils module split. */ export { _resetProjectRefCache } from './gl-utils' export { diff --git a/src/main/gitlab/gl-utils.test.ts b/src/main/gitlab/gl-utils.test.ts index c4a54fba9d9..faf3f93068e 100644 --- a/src/main/gitlab/gl-utils.test.ts +++ b/src/main/gitlab/gl-utils.test.ts @@ -1,8 +1,10 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +/* eslint-disable max-lines -- Why: GitLab remote parsing coverage needs many URL/host fixtures against the same mocked git/glab helpers. */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock, glabExecFileAsyncMock } = vi.hoisted(() => ({ +const { gitExecFileAsyncMock, glabExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), - glabExecFileAsyncMock: vi.fn() + glabExecFileAsyncMock: vi.fn(), + sshExecMock: vi.fn() })) vi.mock('../git/runner', () => ({ @@ -18,11 +20,13 @@ import { getIssueProjectRef, getGlabKnownHosts, getProjectRef, + getProjectRefForRemote, parseGitLabProjectRef, parseGlabApiResponse, parseGlabAuthStatusHosts, resolveIssueSource } from './gl-utils' +import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' describe('gitlab project ref parsing', () => { it('parses HTTPS and SSH GitLab.com remotes', () => { @@ -99,9 +103,15 @@ describe('gitlab project ref parsing', () => { describe('gitlab project ref resolution', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() + sshExecMock.mockReset() + unregisterSshGitProvider('conn-1') _resetProjectRefCache() }) + afterEach(() => { + unregisterSshGitProvider('conn-1') + }) + it('keeps getProjectRef origin-based', async () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'git@gitlab.com:fork/orca.git\n' @@ -155,6 +165,47 @@ describe('gitlab project ref resolution', () => { path: 'stablyai/orca' }) }) + + it('resolves project refs through the SSH git provider for connected repos', async () => { + sshExecMock.mockResolvedValueOnce({ stdout: 'git@gitlab.com:remote/orca.git\n', stderr: '' }) + registerSshGitProvider('conn-1', { exec: sshExecMock } as never) + + await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({ + host: 'gitlab.com', + path: 'remote/orca' + }) + + expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo') + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('does not cache a missing SSH provider as a permanent null project ref', async () => { + await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toBeNull() + + sshExecMock.mockResolvedValueOnce({ + stdout: 'git@gitlab.com:remote/orca.git\n', + stderr: '' + }) + registerSshGitProvider('conn-1', { exec: sshExecMock } as never) + + await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({ + host: 'gitlab.com', + path: 'remote/orca' + }) + }) + + it('does not cache transient SSH exec failures as permanent null project refs', async () => { + sshExecMock + .mockRejectedValueOnce(new Error('ssh tunnel not ready')) + .mockResolvedValueOnce({ stdout: 'git@gitlab.com:remote/orca.git\n', stderr: '' }) + registerSshGitProvider('conn-1', { exec: sshExecMock } as never) + + await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toBeNull() + await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({ + host: 'gitlab.com', + path: 'remote/orca' + }) + }) }) describe('resolveIssueSource', () => { diff --git a/src/main/gitlab/gl-utils.ts b/src/main/gitlab/gl-utils.ts index 38fb75638b9..86c355e0015 100644 --- a/src/main/gitlab/gl-utils.ts +++ b/src/main/gitlab/gl-utils.ts @@ -2,6 +2,7 @@ import { execFile } from 'child_process' import { promisify } from 'util' import { gitExecFileAsync, glabExecFileAsync } from '../git/runner' import type { ClassifiedError, GitLabProjectRef, IssueSourcePreference } from '../../shared/types' +import { getSshGitProvider } from '../providers/ssh-git-dispatch' // Why: legacy generic execFile wrapper — only used by callers that don't need // WSL-aware routing. Repo-scoped callers should use glabExecFileAsync from @@ -167,22 +168,34 @@ export function parseGitLabProjectRef( export async function getProjectRefForRemote( repoPath: string, remoteName: string, - knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS + knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS, + connectionId?: string | null ): Promise { - const cacheKey = `${repoPath}\0${remoteName}\0${knownHosts.join(',')}` + const cacheKey = `${connectionId ?? 'local'}\0${repoPath}\0${remoteName}\0${knownHosts.join(',')}` if (projectRefCache.has(cacheKey)) { return projectRefCache.get(cacheKey)! } try { - const { stdout } = await gitExecFileAsync(['remote', 'get-url', remoteName], { - cwd: repoPath - }) + const sshGitProvider = connectionId ? getSshGitProvider(connectionId) : null + if (connectionId && !sshGitProvider) { + // Why: mobile can attempt GitLab loads before the SSH tunnel is ready. + // Caching that transient state would poison later loads after connect. + return null + } + const { stdout } = sshGitProvider + ? await sshGitProvider.exec(['remote', 'get-url', remoteName], repoPath) + : await gitExecFileAsync(['remote', 'get-url', remoteName], { cwd: repoPath }) const result = parseGitLabProjectRef(stdout, knownHosts) if (result) { projectRefCache.set(cacheKey, result) return result } } catch { + if (connectionId) { + // Why: remote SSH failures are often transient tunnel/process errors. + // Do not cache them as "not a GitLab repo" for the rest of the session. + return null + } // ignore — non-GitLab remote or no remote configured } projectRefCache.set(cacheKey, null) @@ -191,20 +204,22 @@ export async function getProjectRefForRemote( export async function getProjectRef( repoPath: string, - knownHosts?: readonly string[] + knownHosts?: readonly string[], + connectionId?: string | null ): Promise { - return getProjectRefForRemote(repoPath, 'origin', knownHosts) + return getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId) } export async function getIssueProjectRef( repoPath: string, - knownHosts?: readonly string[] + knownHosts?: readonly string[], + connectionId?: string | null ): Promise { - const upstream = await getProjectRefForRemote(repoPath, 'upstream', knownHosts) + const upstream = await getProjectRefForRemote(repoPath, 'upstream', knownHosts, connectionId) if (upstream) { return upstream } - return getProjectRefForRemote(repoPath, 'origin', knownHosts) + return getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId) } export type ResolvedIssueSource = { @@ -222,23 +237,40 @@ export type ResolvedIssueSource = { export async function resolveIssueSource( repoPath: string, preference: IssueSourcePreference | undefined, - knownHosts?: readonly string[] + knownHosts?: readonly string[], + connectionId?: string | null ): Promise { if (preference === 'upstream') { - const upstream = await getProjectRefForRemote(repoPath, 'upstream', knownHosts) + const upstream = await getProjectRefForRemote(repoPath, 'upstream', knownHosts, connectionId) if (upstream) { return { source: upstream, fellBack: false } } - const origin = await getProjectRefForRemote(repoPath, 'origin', knownHosts) + const origin = await getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId) return { source: origin, fellBack: origin !== null } } if (preference === 'origin') { return { - source: await getProjectRefForRemote(repoPath, 'origin', knownHosts), + source: await getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId), fellBack: false } } - return { source: await getIssueProjectRef(repoPath, knownHosts), fellBack: false } + return { source: await getIssueProjectRef(repoPath, knownHosts, connectionId), fellBack: false } +} + +export function glabRepoExecOptions( + repoPath: string, + connectionId?: string | null +): { cwd?: string } { + return connectionId ? {} : { cwd: repoPath } +} + +export function glabHostnameArgs( + projectRef: Pick | null | undefined, + connectionId?: string | null +): string[] { + // Why: local glab commands can infer host from cwd; SSH-backed calls have + // no local cwd, so self-hosted instances need an explicit hostname. + return connectionId && projectRef?.host ? ['--hostname', projectRef.host] : [] } // ── Known-hosts discovery via `glab auth status` ──────────────────── diff --git a/src/main/gitlab/issues.test.ts b/src/main/gitlab/issues.test.ts index 849b73e63b5..14712d8f6bc 100644 --- a/src/main/gitlab/issues.test.ts +++ b/src/main/gitlab/issues.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: GitLab issue mutation/list coverage shares glab mocks across related endpoint cases. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GlUtils from './gl-utils' @@ -30,7 +31,15 @@ vi.mock('./gl-utils', async () => { } }) -import { addIssueComment, createIssue, getIssue, listIssues, updateIssue } from './issues' +import { + addIssueComment, + createIssue, + getIssue, + listAssignableUsers, + listIssues, + listLabels, + updateIssue +} from './issues' describe('gitlab issue operations', () => { beforeEach(() => { @@ -226,6 +235,62 @@ describe('gitlab issue operations', () => { ) }) + it('updateIssue applies body edits via the issue API', async () => { + getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) + + await expect(updateIssue('/repo-root', 5, { body: 'Updated body' })).resolves.toEqual({ + ok: true + }) + + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + ['api', '-X', 'PUT', 'projects/stablyai%2Forca/issues/5', '-f', 'description=Updated body'], + { cwd: '/repo-root' } + ) + }) + + it('routes issue metadata reads through the selected SSH GitLab host', async () => { + getIssueProjectRefMock + .mockResolvedValueOnce({ host: 'git.internal', path: 'stablyai/orca' }) + .mockResolvedValueOnce({ host: 'git.internal', path: 'stablyai/orca' }) + glabExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'bug\nfeature\n' }) + .mockResolvedValueOnce({ + stdout: '{"username":"alice","name":"Alice","avatar_url":"https://example.com/a.png"}\n' + }) + + await expect(listLabels('/repo-root', 'upstream', 'conn-1')).resolves.toEqual([ + 'bug', + 'feature' + ]) + await expect(listAssignableUsers('/repo-root', 'upstream', 'conn-1')).resolves.toEqual([ + { + username: 'alice', + name: 'Alice', + avatarUrl: 'https://example.com/a.png' + } + ]) + + expect(glabExecFileAsyncMock.mock.calls[0][0]).toEqual([ + 'api', + '--hostname', + 'git.internal', + '--paginate', + 'projects/stablyai%2Forca/labels', + '--jq', + '.[].name' + ]) + expect(glabExecFileAsyncMock.mock.calls[1][0]).toEqual([ + 'api', + '--hostname', + 'git.internal', + '--paginate', + 'projects/stablyai%2Forca/members/all?per_page=100', + '--jq', + '.[] | {username, name, avatar_url}' + ]) + }) + it('addIssueComment posts to /notes and maps the response', async () => { getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) glabExecFileAsyncMock.mockResolvedValueOnce({ @@ -256,6 +321,32 @@ describe('gitlab issue operations', () => { ) }) + it('addIssueComment passes hostname for SSH-backed self-hosted repos', async () => { + getIssueProjectRefMock.mockResolvedValueOnce({ + host: 'gitlab.example.com', + path: 'stablyai/orca' + }) + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ id: 100, body: 'Hello' }) + }) + + await addIssueComment('/repo-root', 5, 'Hello', undefined, 'conn-1') + + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'api', + '--hostname', + 'gitlab.example.com', + '-X', + 'POST', + 'projects/stablyai%2Forca/issues/5/notes', + '-f', + 'body=Hello' + ], + {} + ) + }) + it('returns null from getIssue when project ref cannot be resolved', async () => { getIssueProjectRefMock.mockResolvedValueOnce(null) // Why: when there's no GitLab project ref the fallback path diff --git a/src/main/gitlab/issues.ts b/src/main/gitlab/issues.ts index 4ff529b6e03..8f9278bd760 100644 --- a/src/main/gitlab/issues.ts +++ b/src/main/gitlab/issues.ts @@ -13,7 +13,7 @@ import type { } from '../../shared/types' import { mapGitLabIssueInfo } from './mappers' // prettier-ignore -import { glabExecFileAsync, acquire, release, getIssueProjectRef, resolveIssueSource, classifyGlabError, classifyListIssuesError, getGlabKnownHosts } from './gl-utils' +import { glabExecFileAsync, acquire, release, getIssueProjectRef, resolveIssueSource, classifyGlabError, classifyListIssuesError, getGlabKnownHosts, glabRepoExecOptions, glabHostnameArgs, type ProjectRef } from './gl-utils' // Why: parallel to GitHub's IssueListResult — distinguishes a successful- // empty listing from a failed fetch. @@ -40,16 +40,21 @@ function encodedProject(projectPath: string): string { */ export async function getIssue( repoPath: string, - issueNumber: number + issueNumber: number, + connectionId?: string | null ): Promise { const knownHosts = await getGlabKnownHosts() - const projectRef = await getIssueProjectRef(repoPath, knownHosts) + const projectRef = await getIssueProjectRef(repoPath, knownHosts, connectionId) await acquire() try { if (projectRef) { const { stdout } = await glabExecFileAsync( - ['api', `projects/${encodedProject(projectRef.path)}/issues/${issueNumber}`], - { cwd: repoPath } + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + `projects/${encodedProject(projectRef.path)}/issues/${issueNumber}` + ], + glabRepoExecOptions(repoPath, connectionId) ) const data = JSON.parse(stdout) return mapGitLabIssueInfo(data) @@ -57,7 +62,7 @@ export async function getIssue( // Fallback for non-GitLab remotes — let glab infer the project from cwd. const { stdout } = await glabExecFileAsync( ['issue', 'view', String(issueNumber), '--output', 'json'], - { cwd: repoPath } + glabRepoExecOptions(repoPath, connectionId) ) const data = JSON.parse(stdout) return mapGitLabIssueInfo(data) @@ -83,10 +88,16 @@ export async function listIssues( limit = 20, preference?: IssueSourcePreference, state: IssueListState = 'opened', - assignee?: string + assignee?: string, + connectionId?: string | null ): Promise { const knownHosts = await getGlabKnownHosts() - const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts) + const { source: projectRef } = await resolveIssueSource( + repoPath, + preference, + knownHosts, + connectionId + ) await acquire() try { if (projectRef) { @@ -95,9 +106,10 @@ export async function listIssues( const { stdout } = await glabExecFileAsync( [ 'api', + ...glabHostnameArgs(projectRef, connectionId), `projects/${encodedProject(projectRef.path)}/issues?per_page=${limit}&order_by=updated_at&sort=desc${stateParam}${scopeParam}` ], - { cwd: repoPath } + glabRepoExecOptions(repoPath, connectionId) ) const data = JSON.parse(stdout) as Record[] // Why: GitLab's project issues endpoint returns true issues only @@ -126,7 +138,7 @@ export async function listIssues( ...stateFlag, ...assigneeFlag ], - { cwd: repoPath } + glabRepoExecOptions(repoPath, connectionId) ) const data = JSON.parse(stdout) as unknown[] return { @@ -151,14 +163,20 @@ export async function createIssue( repoPath: string, title: string, body: string, - preference?: IssueSourcePreference + preference?: IssueSourcePreference, + connectionId?: string | null ): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> { const trimmedTitle = title.trim() if (!trimmedTitle) { return { ok: false, error: 'Title is required' } } const knownHosts = await getGlabKnownHosts() - const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts) + const { source: projectRef } = await resolveIssueSource( + repoPath, + preference, + knownHosts, + connectionId + ) if (!projectRef) { return { ok: false, @@ -170,6 +188,7 @@ export async function createIssue( const { stdout } = await glabExecFileAsync( [ 'api', + ...glabHostnameArgs(projectRef, connectionId), '-X', 'POST', `projects/${encodedProject(projectRef.path)}/issues`, @@ -179,7 +198,7 @@ export async function createIssue( // Why: GitLab uses `description` (not `body`) for issue text. `description=${body}` ], - { cwd: repoPath } + glabRepoExecOptions(repoPath, connectionId) ) const data = JSON.parse(stdout) as { iid?: number; web_url?: string; url?: string } if (typeof data.iid !== 'number') { @@ -201,19 +220,21 @@ export async function createIssue( /** * Update an existing GitLab issue. * - * Why this path doesn't take a preference — mirrors github/updateIssue: - * mutations target an issue number already bound to a worktree / linked - * elsewhere. Routing through the live per-repo preference would let a - * user open upstream#N, toggle selector to origin, save, and silently - * write to a different project's issue with the same iid. + * Why: callers that list through a per-repo issue source preference must + * mutate the same GitLab project, or identical IIDs on origin/upstream can + * silently edit the wrong issue. */ export async function updateIssue( repoPath: string, issueNumber: number, - updates: GitLabIssueUpdate + updates: GitLabIssueUpdate, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRefOverride?: ProjectRef | null ): Promise<{ ok: true } | { ok: false; error: string }> { - const knownHosts = await getGlabKnownHosts() - const projectRef = await getIssueProjectRef(repoPath, knownHosts) + const projectRef = + projectRefOverride ?? + (await resolveIssueSource(repoPath, preference, await getGlabKnownHosts(), connectionId)).source if (!projectRef) { return { ok: false, @@ -229,9 +250,17 @@ export async function updateIssue( await acquire() try { const cmd = updates.state === 'closed' ? 'close' : 'reopen' - await glabExecFileAsync(['issue', cmd, String(issueNumber), '-R', repoFlag], { - cwd: repoPath - }) + await glabExecFileAsync( + [ + 'issue', + cmd, + String(issueNumber), + '-R', + repoFlag, + ...glabHostnameArgs(projectRef, connectionId) + ], + glabRepoExecOptions(repoPath, connectionId) + ) } catch (err) { const stderr = err instanceof Error ? err.message : String(err) // Treat "already closed/reopened" as a no-op (matches gh path). @@ -243,8 +272,38 @@ export async function updateIssue( } } + if (updates.body !== undefined) { + await acquire() + try { + await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + '-X', + 'PUT', + `projects/${encodedProject(repoFlag)}/issues/${issueNumber}`, + '-f', + `description=${updates.body}` + ], + glabRepoExecOptions(repoPath, connectionId) + ) + } catch (err) { + const stderr = err instanceof Error ? err.message : String(err) + errors.push(classifyGlabError(stderr).message) + } finally { + release() + } + } + // Field edits via `glab issue update`. - const editArgs: string[] = ['issue', 'update', String(issueNumber), '-R', repoFlag] + const editArgs: string[] = [ + 'issue', + 'update', + String(issueNumber), + '-R', + repoFlag, + ...glabHostnameArgs(projectRef, connectionId) + ] let hasEditArgs = false if (updates.title) { @@ -271,7 +330,7 @@ export async function updateIssue( if (hasEditArgs) { await acquire() try { - await glabExecFileAsync(editArgs, { cwd: repoPath }) + await glabExecFileAsync(editArgs, glabRepoExecOptions(repoPath, connectionId)) } catch (err) { const stderr = err instanceof Error ? err.message : String(err) errors.push(classifyGlabError(stderr).message) @@ -293,10 +352,14 @@ export async function updateIssue( export async function addIssueComment( repoPath: string, issueNumber: number, - body: string + body: string, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRefOverride?: ProjectRef | null ): Promise { - const knownHosts = await getGlabKnownHosts() - const projectRef = await getIssueProjectRef(repoPath, knownHosts) + const projectRef = + projectRefOverride ?? + (await resolveIssueSource(repoPath, preference, await getGlabKnownHosts(), connectionId)).source if (!projectRef) { return { ok: false, @@ -308,13 +371,14 @@ export async function addIssueComment( const { stdout } = await glabExecFileAsync( [ 'api', + ...glabHostnameArgs(projectRef, connectionId), '-X', 'POST', `projects/${encodedProject(projectRef.path)}/issues/${issueNumber}/notes`, '-f', `body=${body}` ], - { cwd: repoPath } + glabRepoExecOptions(repoPath, connectionId) ) const data = JSON.parse(stdout) as { id?: number @@ -345,10 +409,16 @@ export async function addIssueComment( export async function listLabels( repoPath: string, - preference?: IssueSourcePreference + preference?: IssueSourcePreference, + connectionId?: string | null ): Promise { const knownHosts = await getGlabKnownHosts() - const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts) + const { source: projectRef } = await resolveIssueSource( + repoPath, + preference, + knownHosts, + connectionId + ) if (!projectRef) { return [] } @@ -357,12 +427,13 @@ export async function listLabels( const { stdout } = await glabExecFileAsync( [ 'api', + ...glabHostnameArgs(projectRef, connectionId), '--paginate', `projects/${encodedProject(projectRef.path)}/labels`, '--jq', '.[].name' ], - { cwd: repoPath } + glabRepoExecOptions(repoPath, connectionId) ) return stdout .trim() @@ -377,10 +448,16 @@ export async function listLabels( export async function listAssignableUsers( repoPath: string, - preference?: IssueSourcePreference + preference?: IssueSourcePreference, + connectionId?: string | null ): Promise { const knownHosts = await getGlabKnownHosts() - const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts) + const { source: projectRef } = await resolveIssueSource( + repoPath, + preference, + knownHosts, + connectionId + ) if (!projectRef) { return [] } @@ -393,12 +470,13 @@ export async function listAssignableUsers( const { stdout } = await glabExecFileAsync( [ 'api', + ...glabHostnameArgs(projectRef, connectionId), '--paginate', `projects/${encodedProject(projectRef.path)}/members/all?per_page=100`, '--jq', '.[] | {username, name, avatar_url}' ], - { cwd: repoPath } + glabRepoExecOptions(repoPath, connectionId) ) type RESTMember = { username?: string; name?: string | null; avatar_url?: string | null } const users: GitLabAssignableUser[] = [] diff --git a/src/main/gitlab/mappers.ts b/src/main/gitlab/mappers.ts index 734b02b75fd..37a97e3a409 100644 --- a/src/main/gitlab/mappers.ts +++ b/src/main/gitlab/mappers.ts @@ -247,7 +247,11 @@ type GitLabMRRawForWorkItem = { target_project_id?: number } -export function mapMRToWorkItem(data: GitLabMRRawForWorkItem, repoId: string): GitLabWorkItem { +export function mapMRToWorkItem( + data: GitLabMRRawForWorkItem, + repoId: string, + projectRef?: GitLabWorkItem['projectRef'] +): GitLabWorkItem { const labels = (data.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name)) const number = data.iid ?? 0 return { @@ -268,7 +272,8 @@ export function mapMRToWorkItem(data: GitLabMRRawForWorkItem, repoId: string): G data.source_project_id !== undefined && data.target_project_id !== undefined && data.source_project_id !== data.target_project_id, - repoId + repoId, + ...(projectRef ? { projectRef } : {}) } } @@ -286,7 +291,8 @@ type GitLabIssueRawForWorkItem = { export function mapIssueToWorkItem( data: GitLabIssueRawForWorkItem, - repoId: string + repoId: string, + projectRef?: GitLabWorkItem['projectRef'] ): GitLabWorkItem { const labels = (data.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name)) const number = data.iid ?? 0 @@ -303,7 +309,8 @@ export function mapIssueToWorkItem( labels, updatedAt: data.updated_at ?? '', author: data.author?.username ?? null, - repoId + repoId, + ...(projectRef ? { projectRef } : {}) } } diff --git a/src/main/gitlab/work-item-details.ts b/src/main/gitlab/work-item-details.ts index 247c62283cf..ddec56a3d74 100644 --- a/src/main/gitlab/work-item-details.ts +++ b/src/main/gitlab/work-item-details.ts @@ -12,12 +12,14 @@ import { mapIssueToWorkItem, mapMRToWorkItem } from './mappers' import { acquire, getGlabKnownHosts, - getIssueProjectRef, - getProjectRef, + glabHostnameArgs, + glabRepoExecOptions, glabExecFileAsync, release, + resolveIssueSource, type ProjectRef } from './gl-utils' +import type { IssueSourcePreference } from '../../shared/types' function encodedProject(projectPath: string): string { return encodeURIComponent(projectPath) @@ -79,16 +81,18 @@ async function fetchDiscussions( repoPath: string, projectRef: ProjectRef, type: 'issue' | 'mr', - iid: number + iid: number, + connectionId?: string | null ): Promise { const resource = type === 'mr' ? 'merge_requests' : 'issues' const { stdout } = await glabExecFileAsync( [ 'api', + ...glabHostnameArgs(projectRef, connectionId), '--paginate', `projects/${encodedProject(projectRef.path)}/${resource}/${iid}/discussions?per_page=100` ], - { cwd: repoPath } + glabRepoExecOptions(repoPath, connectionId) ) return JSON.parse(stdout) as GitLabRawDiscussion[] } @@ -118,15 +122,17 @@ function mapPipelineJob(raw: GitLabRawJob): GitLabPipelineJob { async function fetchPipelineJobs( repoPath: string, projectRef: ProjectRef, - pipelineId: number + pipelineId: number, + connectionId?: string | null ): Promise { const { stdout } = await glabExecFileAsync( [ 'api', + ...glabHostnameArgs(projectRef, connectionId), '--paginate', `projects/${encodedProject(projectRef.path)}/pipelines/${pipelineId}/jobs?per_page=100` ], - { cwd: repoPath } + glabRepoExecOptions(repoPath, connectionId) ) const data = JSON.parse(stdout) as GitLabRawJob[] return data.map(mapPipelineJob) @@ -157,33 +163,26 @@ type GitLabRawMR = Parameters[0] & { export async function getWorkItemDetails( repoPath: string, iid: number, - type: 'issue' | 'mr' + type: 'issue' | 'mr', + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRefOverride?: ProjectRef | null ): Promise { - const knownHosts = await getGlabKnownHosts() - // Why: issues honor the upstream/origin preference (issues live on - // upstream when a fork is checked out). MRs always target origin — - // the fork model puts MRs against the project the user pushes to. + // Why: detail fetches must use the same project source as the list row + // that opened them, otherwise forked repos can show a row from one remote + // and a detail sheet from another. const projectRef = - type === 'issue' - ? await getIssueProjectRef(repoPath, knownHosts) - : await getProjectRef(repoPath, knownHosts) - + projectRefOverride ?? + (await resolveIssueSource(repoPath, preference, await getGlabKnownHosts(), connectionId)).source + if (!projectRef) { + return null + } await acquire() try { - if (projectRef) { - if (type === 'issue') { - return await fetchIssueDetails(repoPath, projectRef, iid) - } - return await fetchMRDetails(repoPath, projectRef, iid) - } - // Fallback — let glab infer project from cwd. This path is taken when - // the repo's remote host is not in getGlabKnownHosts() (e.g. a fresh - // self-hosted instance), but glab itself can still resolve it from the - // local git config. if (type === 'issue') { - return await fetchIssueDetailsFallback(repoPath, iid) + return await fetchIssueDetails(repoPath, projectRef, iid, connectionId) } - return await fetchMRDetailsFallback(repoPath, iid) + return await fetchMRDetails(repoPath, projectRef, iid, connectionId) } catch { return null } finally { @@ -194,19 +193,25 @@ export async function getWorkItemDetails( async function fetchIssueDetails( repoPath: string, projectRef: ProjectRef, - iid: number + iid: number, + connectionId?: string | null ): Promise { // Why: fan out the two reads. Issues don't have a pipeline so this // pair covers everything the dialog renders. const [issueRes, discussions] = await Promise.all([ - glabExecFileAsync(['api', `projects/${encodedProject(projectRef.path)}/issues/${iid}`], { - cwd: repoPath - }), - fetchDiscussions(repoPath, projectRef, 'issue', iid) + glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + `projects/${encodedProject(projectRef.path)}/issues/${iid}` + ], + glabRepoExecOptions(repoPath, connectionId) + ), + fetchDiscussions(repoPath, projectRef, 'issue', iid, connectionId) ]) const issueRaw = JSON.parse(issueRes.stdout) as GitLabRawIssue const item: Omit = (() => { - const full = mapIssueToWorkItem(issueRaw, projectRef.path) + const full = mapIssueToWorkItem(issueRaw, projectRef.path, projectRef) // Why: omit repoId from the returned shape — the renderer stamps // it from the dialog's caller (TaskPage / picker) so the main // process doesn't need to know Orca's Repo.id. @@ -223,54 +228,36 @@ async function fetchIssueDetails( } } -async function fetchIssueDetailsFallback( - repoPath: string, - iid: number -): Promise { - const { stdout } = await glabExecFileAsync(['issue', 'view', String(iid), '--output', 'json'], { - cwd: repoPath - }) - const issueRaw = JSON.parse(stdout) as GitLabRawIssue - const item: Omit = (() => { - const full = mapIssueToWorkItem(issueRaw, 'unknown') - const { repoId: _repoId, ...rest } = full - return rest - })() - return { - item, - body: issueRaw.description ?? '', - comments: [], - assignees: (issueRaw.assignees ?? []) - .map((a) => a?.username) - .filter((u): u is string => typeof u === 'string') - } -} - async function fetchMRDetails( repoPath: string, projectRef: ProjectRef, - iid: number + iid: number, + connectionId?: string | null ): Promise { // Why: MR detail + discussions in parallel. The pipeline jobs fetch // depends on `head_pipeline.id` from the MR payload, so it has to // wait — but it's a single follow-up call rather than a serial chain. const [mrRes, discussions] = await Promise.all([ glabExecFileAsync( - ['api', `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`], - { cwd: repoPath } + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}` + ], + glabRepoExecOptions(repoPath, connectionId) ), - fetchDiscussions(repoPath, projectRef, 'mr', iid) + fetchDiscussions(repoPath, projectRef, 'mr', iid, connectionId) ]) const mrRaw = JSON.parse(mrRes.stdout) as GitLabRawMR const item: Omit = (() => { - const full = mapMRToWorkItem(mrRaw, projectRef.path) + const full = mapMRToWorkItem(mrRaw, projectRef.path, projectRef) const { repoId: _repoId, ...rest } = full return rest })() const pipelineId = mrRaw.head_pipeline?.id const pipelineJobs = typeof pipelineId === 'number' - ? await fetchPipelineJobs(repoPath, projectRef, pipelineId).catch(() => []) + ? await fetchPipelineJobs(repoPath, projectRef, pipelineId, connectionId).catch(() => []) : undefined return { item, @@ -281,23 +268,3 @@ async function fetchMRDetails( ...(pipelineJobs !== undefined ? { pipelineJobs } : {}) } } - -async function fetchMRDetailsFallback( - repoPath: string, - iid: number -): Promise { - const { stdout } = await glabExecFileAsync(['mr', 'view', String(iid), '--output', 'json'], { - cwd: repoPath - }) - const mrRaw = JSON.parse(stdout) as GitLabRawMR - const item: Omit = (() => { - const full = mapMRToWorkItem(mrRaw, 'unknown') - const { repoId: _repoId, ...rest } = full - return rest - })() - return { - item, - body: mrRaw.description ?? '', - comments: [] - } -} diff --git a/src/main/ipc/gitlab.ts b/src/main/ipc/gitlab.ts index e3827f5c3b0..cdbdf8fee32 100644 --- a/src/main/ipc/gitlab.ts +++ b/src/main/ipc/gitlab.ts @@ -65,6 +65,10 @@ function normalizeIssueAssignee(value: unknown): '@me' | undefined { return value === '@me' ? '@me' : undefined } +function repoConnectionId(repo: Repo): string | null { + return repo.connectionId ?? null +} + export function registerGitLabHandlers(store: Store): void { ipcMain.handle('gitlab:viewer', async () => { return getAuthenticatedViewer() @@ -72,20 +76,25 @@ export function registerGitLabHandlers(store: Store): void { ipcMain.handle('gitlab:projectSlug', async (_event, args: { repoPath: string }) => { const repo = assertRegisteredRepo(args.repoPath, store) - return getProjectSlug(repo.path) + return getProjectSlug(repo.path, repoConnectionId(repo)) }) ipcMain.handle( 'gitlab:mrForBranch', async (_event, args: { repoPath: string; branch: string; linkedMRIid?: number | null }) => { const repo = assertRegisteredRepo(args.repoPath, store) - return getMergeRequestForBranch(repo.path, args.branch, args.linkedMRIid ?? null) + return getMergeRequestForBranch( + repo.path, + args.branch, + args.linkedMRIid ?? null, + repoConnectionId(repo) + ) } ) ipcMain.handle('gitlab:mr', async (_event, args: { repoPath: string; iid: number }) => { const repo = assertRegisteredRepo(args.repoPath, store) - return getMergeRequest(repo.path, args.iid) + return getMergeRequest(repo.path, args.iid, repoConnectionId(repo)) }) ipcMain.handle( @@ -103,14 +112,21 @@ export function registerGitLabHandlers(store: Store): void { const state = normalizeMRListState(args.state) const page = normalizePositiveInteger(args.page, 1, 10_000) const perPage = normalizePositiveInteger(args.perPage, 20, 100) - const result = await listMergeRequests(repo.path, state, page, perPage) - return result + return listMergeRequests( + repo.path, + state, + page, + perPage, + repo.issueSourcePreference, + undefined, + repoConnectionId(repo) + ) } ) ipcMain.handle('gitlab:issue', async (_event, args: { repoPath: string; number: number }) => { const repo = assertRegisteredRepo(args.repoPath, store) - return getIssue(repo.path, args.number) + return getIssue(repo.path, args.number, repoConnectionId(repo)) }) ipcMain.handle( @@ -128,7 +144,14 @@ export function registerGitLabHandlers(store: Store): void { const limit = normalizePositiveInteger(args.limit, 20, 100) const state = normalizeIssueListState(args.state) const assignee = normalizeIssueAssignee(args.assignee) - const result = await listIssues(repo.path, limit, undefined, state, assignee) + const result = await listIssues( + repo.path, + limit, + repo.issueSourcePreference, + state, + assignee, + repoConnectionId(repo) + ) // Why: Tasks page expects GitLabWorkItem[] so it can share row // rendering with MRs. Map IssueInfo → WorkItem here so the renderer // doesn't need a separate code path. @@ -152,7 +175,13 @@ export function registerGitLabHandlers(store: Store): void { 'gitlab:createIssue', async (_event, args: { repoPath: string; title: string; body: string }) => { const repo = assertRegisteredRepo(args.repoPath, store) - return createIssue(repo.path, args.title, args.body) + return createIssue( + repo.path, + args.title, + args.body, + repo.issueSourcePreference, + repoConnectionId(repo) + ) } ) @@ -160,7 +189,13 @@ export function registerGitLabHandlers(store: Store): void { 'gitlab:updateIssue', async (_event, args: { repoPath: string; number: number; updates: GitLabIssueUpdate }) => { const repo = assertRegisteredRepo(args.repoPath, store) - return updateIssue(repo.path, args.number, args.updates) + return updateIssue( + repo.path, + args.number, + args.updates, + repo.issueSourcePreference, + repoConnectionId(repo) + ) } ) @@ -168,18 +203,24 @@ export function registerGitLabHandlers(store: Store): void { 'gitlab:addIssueComment', async (_event, args: { repoPath: string; number: number; body: string }) => { const repo = assertRegisteredRepo(args.repoPath, store) - return addIssueComment(repo.path, args.number, args.body) + return addIssueComment( + repo.path, + args.number, + args.body, + repo.issueSourcePreference, + repoConnectionId(repo) + ) } ) ipcMain.handle('gitlab:listLabels', async (_event, args: { repoPath: string }) => { const repo = assertRegisteredRepo(args.repoPath, store) - return listLabels(repo.path) + return listLabels(repo.path, repo.issueSourcePreference, repoConnectionId(repo)) }) ipcMain.handle('gitlab:listAssignableUsers', async (_event, args: { repoPath: string }) => { const repo = assertRegisteredRepo(args.repoPath, store) - return listAssignableUsers(repo.path) + return listAssignableUsers(repo.path, repo.issueSourcePreference, repoConnectionId(repo)) }) // Why: combined MR + issue list — Tasks screen and any future picker @@ -201,7 +242,10 @@ export function registerGitLabHandlers(store: Store): void { repo.path, normalizeMRListState(args.state), normalizePositiveInteger(args.page, 1, 10_000), - normalizePositiveInteger(args.perPage, 20, 100) + normalizePositiveInteger(args.perPage, 20, 100), + repo.issueSourcePreference, + undefined, + repoConnectionId(repo) ) } ) @@ -212,18 +256,24 @@ export function registerGitLabHandlers(store: Store): void { 'gitlab:workItemDetails', async (_event, args: { repoPath: string; iid: number; type: 'issue' | 'mr' }) => { const repo = assertRegisteredRepo(args.repoPath, store) - return getWorkItemDetails(repo.path, args.iid, args.type) + return getWorkItemDetails( + repo.path, + args.iid, + args.type, + repo.issueSourcePreference, + repoConnectionId(repo) + ) } ) ipcMain.handle('gitlab:closeMR', async (_event, args: { repoPath: string; iid: number }) => { const repo = assertRegisteredRepo(args.repoPath, store) - return closeMR(repo.path, args.iid) + return closeMR(repo.path, args.iid, repo.issueSourcePreference, repoConnectionId(repo)) }) ipcMain.handle('gitlab:reopenMR', async (_event, args: { repoPath: string; iid: number }) => { const repo = assertRegisteredRepo(args.repoPath, store) - return reopenMR(repo.path, args.iid) + return reopenMR(repo.path, args.iid, repo.issueSourcePreference, repoConnectionId(repo)) }) ipcMain.handle( @@ -233,7 +283,13 @@ export function registerGitLabHandlers(store: Store): void { args: { repoPath: string; iid: number; method?: 'merge' | 'squash' | 'rebase' } ) => { const repo = assertRegisteredRepo(args.repoPath, store) - return mergeMR(repo.path, args.iid, args.method ?? 'merge') + return mergeMR( + repo.path, + args.iid, + args.method ?? 'merge', + repo.issueSourcePreference, + repoConnectionId(repo) + ) } ) @@ -241,7 +297,13 @@ export function registerGitLabHandlers(store: Store): void { 'gitlab:addMRComment', async (_event, args: { repoPath: string; iid: number; body: string }) => { const repo = assertRegisteredRepo(args.repoPath, store) - return addMRComment(repo.path, args.iid, args.body) + return addMRComment( + repo.path, + args.iid, + args.body, + repo.issueSourcePreference, + repoConnectionId(repo) + ) } ) @@ -250,7 +312,7 @@ export function registerGitLabHandlers(store: Store): void { // care about cwd because the endpoint is user-scoped. ipcMain.handle('gitlab:todos', async (_event, args: { repoPath: string }) => { const repo = assertRegisteredRepo(args.repoPath, store) - return listTodos(repo.path) + return listTodos(repo.path, repoConnectionId(repo)) }) // Why: paste-URL flow in the picker. The user pastes a GitLab URL that @@ -271,7 +333,13 @@ export function registerGitLabHandlers(store: Store): void { ) => { const repo = assertRegisteredRepo(args.repoPath, store) const projectRef: ProjectRef = { host: args.host, path: args.path } - const result = await getWorkItemByProjectRef(repo.path, projectRef, args.iid, args.type) + const result = await getWorkItemByProjectRef( + repo.path, + projectRef, + args.iid, + args.type, + repoConnectionId(repo) + ) // Why: only persist a recent entry when the lookup actually // produced an item. A 404 / auth failure shouldn't pollute the // user's recents list with project paths they can't read. diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts index 3b7cb60fd58..64e143aa8cd 100644 --- a/src/main/ipc/hosted-review.ts +++ b/src/main/ipc/hosted-review.ts @@ -77,42 +77,30 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector 'hostedReview:getCreationEligibility', async (_event, args: HostedReviewCreationEligibilityArgs) => { const repo = assertRegisteredRepo(args.repoPath, store) - if (repo.connectionId) { - return { - provider: 'unsupported' as const, - review: null, - canCreate: false, - blockedReason: 'unsupported_provider' as const, - nextAction: null, - defaultBaseRef: args.base ?? null, - head: args.branch, - title: null, - body: null - } - } const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath) - return getHostedReviewCreationEligibility({ ...args, repoPath: worktreePath }) + return getHostedReviewCreationEligibility({ + ...args, + repoPath: worktreePath, + connectionId: repo.connectionId ?? null + }) } ) ipcMain.handle('hostedReview:create', async (_event, args: CreateHostedReviewArgs) => { const repo = assertRegisteredRepo(args.repoPath, store) - if (repo.connectionId) { - return { - ok: false as const, - code: 'unsupported_provider' as const, - error: 'Creating pull requests from SSH worktrees is not supported yet.' - } - } const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath) - const result = await createHostedReview(worktreePath, { - provider: args.provider, - base: args.base, - head: args.head, - title: args.title, - body: args.body, - draft: args.draft - }) + const result = await createHostedReview( + worktreePath, + { + provider: args.provider, + base: args.base, + head: args.head, + title: args.title, + body: args.body, + draft: args.draft + }, + repo.connectionId ?? null + ) if (result.ok && !stats.hasCountedPR(result.url)) { stats.record({ type: 'pr_created', diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 0558b7c5609..63113f5e53a 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -32,6 +32,19 @@ import type { OrcaRuntimeService } from '../runtime/orca-runtime' let sshStore: SshConnectionStore | null = null let connectionManager: SshConnectionManager | null = null let portForwardManager: SshPortForwardManager | null = null +let registeredConnectSshTarget: ((targetId: string) => Promise) | null = null +let registeredGetSshState: ((targetId: string) => SshConnectionState | undefined) | null = null + +export async function connectRegisteredSshTarget(targetId: string): Promise { + if (!registeredConnectSshTarget) { + throw new Error('ssh_handlers_not_registered') + } + return registeredConnectSshTarget(targetId) +} + +export function getRegisteredSshState(targetId: string): SshConnectionState | undefined { + return registeredGetSshState?.(targetId) +} // Why: one session per SSH target encapsulates the entire relay lifecycle // (multiplexer, providers, abort controller, state machine). Eliminates the @@ -370,8 +383,8 @@ export function registerSshHandlers( // ── Connection lifecycle ─────────────────────────────────────────── - ipcMain.handle('ssh:connect', async (_event, args: { targetId: string }) => { - const reset = resetRelayInFlight.get(args.targetId) + async function connectTarget(targetId: string): Promise { + const reset = resetRelayInFlight.get(targetId) if (reset) { await reset } @@ -379,18 +392,25 @@ export function registerSshHandlers( // Why: serialize concurrent ssh:connect calls for the same target. // Multiple tabs can fire connect simultaneously; without this, they // interleave and the first session leaks. - const existing = connectInFlight.get(args.targetId) + const existing = connectInFlight.get(targetId) if (existing) { return existing } - const promise = doConnect(args.targetId) - connectInFlight.set(args.targetId, promise) + const promise = doConnect(targetId) + connectInFlight.set(targetId, promise) try { return await promise } finally { - connectInFlight.delete(args.targetId) + connectInFlight.delete(targetId) } + } + + registeredConnectSshTarget = connectTarget + registeredGetSshState = (targetId: string) => getPublicSshState(targetId) + + ipcMain.handle('ssh:connect', async (_event, args: { targetId: string }) => { + return connectTarget(args.targetId) }) async function doConnect(targetId: string): Promise { diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index d374eb80c23..c40c416761f 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -643,10 +643,6 @@ export async function createRemoteWorktree( store: Store, mainWindow: BrowserWindow ): Promise { - if (args.sparseCheckout) { - throw new Error('Sparse checkout is not supported for remote SSH repos yet.') - } - const provider = requireSshGitProvider(repo.connectionId!) const settings = store.getSettings() @@ -721,6 +717,31 @@ export async function createRemoteWorktree( } } + const sparseDirectories = args.sparseCheckout + ? normalizeSparseDirectories(args.sparseCheckout.directories) + : [] + if (args.sparseCheckout && sparseDirectories.length === 0) { + throw new Error('Sparse checkout requires at least one repo-relative directory.') + } + let sparsePresetId: string | undefined + if (args.sparseCheckout?.presetId) { + const preset = store + .getSparsePresets(repo.id) + .find((entry) => entry.id === args.sparseCheckout?.presetId) + if (preset?.repoId === repo.id) { + try { + const presetDirectories = normalizeSparseDirectories(preset.directories) + const presetSet = new Set(presetDirectories) + const directoriesMatch = + presetDirectories.length === sparseDirectories.length && + sparseDirectories.every((entry) => presetSet.has(entry)) + sparsePresetId = directoriesMatch ? preset.id : undefined + } catch { + // Why: corrupt preset data should not block creation or falsely label the new worktree. + } + } + } + const remoteTrackingBase = await resolveRemoteTrackingBaseSsh(provider, repo.path, baseBranch) if (remoteTrackingBase) { try { @@ -799,7 +820,9 @@ export async function createRemoteWorktree( repo.path, branchName, remotePath, - checkoutExistingBranch ? { checkoutExistingBranch } : { base: baseBranch } + checkoutExistingBranch + ? { checkoutExistingBranch } + : { base: baseBranch, ...(sparseDirectories.length > 0 ? { noCheckout: true } : {}) } ) } catch (err) { if ( @@ -819,6 +842,18 @@ export async function createRemoteWorktree( } throw err } + if (sparseDirectories.length > 0) { + try { + // Why: SSH providers expose generic git exec, so the remote sparse flow + // can mirror local addSparseWorktree without adding a relay method. + await provider.exec(['sparse-checkout', 'init', '--cone'], remotePath) + await provider.exec(['sparse-checkout', 'set', '--', ...sparseDirectories], remotePath) + await provider.exec(['checkout', branchName], remotePath) + } catch (err) { + await provider.removeWorktree(remotePath, true).catch(() => undefined) + throw err + } + } // Re-list to get the created worktree info const gitWorktrees = await provider.listWorktrees(repo.path) @@ -862,12 +897,19 @@ export async function createRemoteWorktree( ? { displayName: requestedName } : {}), ...(isTuiAgent(args.createdWithAgent) ? { createdWithAgent: args.createdWithAgent } : {}), + ...(sparseDirectories.length > 0 + ? { + sparseDirectories, + sparseBaseRef: baseBranch, + sparsePresetId + } + : {}), ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), ...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}), ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), - ...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}), ...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}), + ...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}), ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) } const meta = store.setWorktreeMeta(worktreeId, metaUpdates) @@ -1249,8 +1291,8 @@ export async function createLocalWorktree( ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), ...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}), ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), - ...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}), ...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}), + ...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}), ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) } const meta = store.setWorktreeMeta(worktreeId, metaUpdates) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index ecd2ed043a3..586f82d751f 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -204,6 +204,7 @@ describe('registerWorktreeHandlers', () => { recordOptimisticReconcileToken: ReturnType reconcileWorktreeBaseStatus: ReturnType clearOptimisticReconcileToken: ReturnType + resolveManagedMrBase: ReturnType } beforeEach(() => { @@ -367,11 +368,17 @@ describe('registerWorktreeHandlers', () => { emitWorktreeBaseStatus: vi.fn(), recordOptimisticReconcileToken: vi.fn().mockReturnValue('token-1'), reconcileWorktreeBaseStatus: vi.fn(), - clearOptimisticReconcileToken: vi.fn() + clearOptimisticReconcileToken: vi.fn(), + resolveManagedMrBase: vi.fn().mockResolvedValue({ baseBranch: 'origin/mr-branch' }) } registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never) }) + it('clears the GitLab MR base handler before re-registering IPC handlers', () => { + expect(removeHandlerMock).toHaveBeenCalledWith('worktrees:resolveMrBase') + expect(handlers['worktrees:resolveMrBase']).toBeDefined() + }) + function mockKnownFeatureWorktree(path = '/workspace/feature-wt'): void { listWorktreesMock.mockResolvedValue([ { @@ -961,6 +968,31 @@ describe('registerWorktreeHandlers', () => { }) }) + it('delegates GitLab MR base resolution through the runtime implementation', async () => { + runtimeStub.resolveManagedMrBase.mockResolvedValueOnce({ + baseBranch: 'fork-mr-sha', + pushTarget: { remoteName: 'origin', branchName: 'feature/mr' } + }) + + const result = await handlers['worktrees:resolveMrBase'](null, { + repoId: 'repo-1', + mrIid: 42, + sourceBranch: 'feature/mr', + isCrossRepository: true + }) + + expect(runtimeStub.resolveManagedMrBase).toHaveBeenCalledWith({ + repoSelector: 'id:repo-1', + mrIid: 42, + sourceBranch: 'feature/mr', + isCrossRepository: true + }) + expect(result).toEqual({ + baseBranch: 'fork-mr-sha', + pushTarget: { remoteName: 'origin', branchName: 'feature/mr' } + }) + }) + it('persists linked issue, PR, and selected agent metadata during remote create', async () => { const repo = { id: 'repo-ssh', @@ -1120,6 +1152,102 @@ describe('registerWorktreeHandlers', () => { ) }) + it('creates sparse checkout metadata and remote sparse config for SSH worktrees', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + removeWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/sparse-dashboard', + head: 'abc123', + branch: 'refs/heads/sparse-dashboard', + isBare: false, + isSparse: true, + isMainWorktree: false + } + ]) + } + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + store.getSparsePresets.mockReturnValue([ + { + id: 'preset-1', + repoId: 'repo-ssh', + name: 'App', + directories: ['apps/mobile', 'packages/shared'], + createdAt: 1, + updatedAt: 1 + } + ]) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue(mux) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'sparse-dashboard', + sparseCheckout: { + directories: [' apps/mobile ', 'packages/shared', 'apps/mobile'], + presetId: 'preset-1' + } + }) + + expect(provider.addWorktree).toHaveBeenCalledWith( + '/remote/repo', + 'sparse-dashboard', + '/remote/repo/../sparse-dashboard', + { base: 'origin/main', noCheckout: true } + ) + expect(provider.exec).toHaveBeenCalledWith( + ['sparse-checkout', 'init', '--cone'], + '/remote/repo/../sparse-dashboard' + ) + expect(provider.exec).toHaveBeenCalledWith( + ['sparse-checkout', 'set', '--', 'apps/mobile', 'packages/shared'], + '/remote/repo/../sparse-dashboard' + ) + expect(provider.exec).toHaveBeenCalledWith( + ['checkout', 'sparse-dashboard'], + '/remote/repo/../sparse-dashboard' + ) + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-ssh::/remote/sparse-dashboard', + expect.objectContaining({ + sparseDirectories: ['apps/mobile', 'packages/shared'], + sparseBaseRef: 'origin/main', + sparsePresetId: 'preset-1' + }) + ) + expect(result).toEqual({ + worktree: expect.objectContaining({ + isSparse: true, + sparseDirectories: ['apps/mobile', 'packages/shared'], + sparseBaseRef: 'origin/main', + sparsePresetId: 'preset-1' + }) + }) + }) + it('does not create an SSH worktree when remote-tracking base refresh fails', async () => { const repo = { id: 'repo-ssh', diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 75f5af4bae3..3befcf62519 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -22,10 +22,8 @@ import { } from '../git/worktree' import { gitExecFileAsync } from '../git/runner' import { withWorktreeSpan } from '../observability/instrumentation' -import { getDefaultRemote } from '../git/repo' import { resolveGitHubPrStartPoint } from '../github/pr-start-point' -import { getProjectRef as getGlabProjectRef, getGlabKnownHosts } from '../gitlab/gl-utils' -import { getWorkItemByProjectRef as getGitLabWorkItemByProjectRef } from '../gitlab/client' +import { getDefaultRemote } from '../git/repo' import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees' import { getSshGitProvider, requireSshGitProvider } from '../providers/ssh-git-dispatch' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' @@ -234,6 +232,7 @@ export function registerWorktreeHandlers( ipcMain.removeHandler('worktrees:list') ipcMain.removeHandler('worktrees:create') ipcMain.removeHandler('worktrees:resolvePrBase') + ipcMain.removeHandler('worktrees:resolveMrBase') ipcMain.removeHandler('worktrees:remove') ipcMain.removeHandler('worktrees:updateMeta') ipcMain.removeHandler('worktrees:listLineage') @@ -488,13 +487,8 @@ export function registerWorktreeHandlers( } ) - // Why: GitLab parallel of worktrees:resolvePrBase. Same shape, same - // semantics — caller passes mrIid (with optional source_branch + - // isCrossRepository hints from the picker) and we return either a - // `/` ref (same-project MRs) or a SHA fetched - // from refs/merge-requests//head (fork MRs). The returned value - // is the workspace's base ref; the new worktree branch derives from - // the workspace name, not from the source ref. + // Why: keep desktop IPC and mobile/runtime RPC on the same MR base + // resolution path so SSH repos do not regress differently by surface. ipcMain.handle( 'worktrees:resolveMrBase', async ( @@ -505,91 +499,13 @@ export function registerWorktreeHandlers( sourceBranch?: string isCrossRepository?: boolean } - ): Promise<{ baseBranch: string } | { error: string }> => { - const repo = store.getRepo(args.repoId) - if (!repo) { - return { error: 'Repo not found' } - } - // Why: parity with the gh-side guard above. Remote SSH repos are - // out of v1 scope; the picker disables the GitLab tab for them too. - if (repo.connectionId) { - return { error: 'MR start points are not supported for remote repos yet.' } - } - if (isFolderRepo(repo)) { - return { error: 'Folder mode does not support creating worktrees.' } - } - - let sourceBranch = args.sourceBranch?.trim() ?? '' - let isCrossRepository = args.isCrossRepository === true - - if (!sourceBranch) { - const knownHosts = await getGlabKnownHosts() - const projectRef = await getGlabProjectRef(repo.path, knownHosts) - if (!projectRef) { - return { error: 'No GitLab project found for this repository.' } - } - const item = await getGitLabWorkItemByProjectRef(repo.path, projectRef, args.mrIid, 'mr') - if (!item || item.type !== 'mr') { - return { error: `MR !${args.mrIid} not found.` } - } - sourceBranch = (item.branchName ?? '').trim() - if (!sourceBranch) { - return { error: `MR !${args.mrIid} has no source branch.` } - } - if (item.isCrossRepository === true) { - isCrossRepository = true - } - } - - let remote: string - try { - remote = await getDefaultRemote(repo.path) - } catch (error) { - return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' } - } - - // Why: GitLab exposes every MR head (fork or same-project) as - // refs/merge-requests//head on the target project. Using that - // ref lets us snapshot fork MRs without configuring the fork as a - // remote — same SHA-as-baseBranch shape as the gh-side branch above. - if (isCrossRepository) { - const mrRef = `refs/merge-requests/${args.mrIid}/head` - try { - await gitExecFileAsync(['fetch', remote, mrRef], { cwd: repo.path }) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return { error: `Failed to fetch ${mrRef}: ${message.split('\n')[0]}` } - } - let sha: string - try { - const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', 'FETCH_HEAD'], { - cwd: repo.path - }) - sha = stdout.trim() - } catch { - return { error: `Could not resolve fork MR !${args.mrIid} head after fetch.` } - } - if (!sha) { - return { error: `Empty SHA resolving fork MR !${args.mrIid} head.` } - } - return { baseBranch: sha } - } - - try { - await gitExecFileAsync(['fetch', remote, sourceBranch], { cwd: repo.path }) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return { error: `Failed to fetch ${remote}/${sourceBranch}: ${message.split('\n')[0]}` } - } - - const remoteRef = `${remote}/${sourceBranch}` - try { - await gitExecFileAsync(['rev-parse', '--verify', remoteRef], { cwd: repo.path }) - } catch { - return { error: `Remote ref ${remoteRef} does not exist after fetch.` } - } - - return { baseBranch: remoteRef } + ): Promise<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }> => { + return runtime.resolveManagedMrBase({ + repoSelector: `id:${args.repoId}`, + mrIid: args.mrIid, + sourceBranch: args.sourceBranch, + isCrossRepository: args.isCrossRepository + }) } ) diff --git a/src/main/linear/mappers.test.ts b/src/main/linear/mappers.test.ts new file mode 100644 index 00000000000..4c5a25c635d --- /dev/null +++ b/src/main/linear/mappers.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { mapLinearIssue } from './mappers' + +describe('mapLinearIssue', () => { + it('keeps core issue details when optional Linear relations fail', async () => { + const issue = { + id: 'issue-1', + identifier: 'LIN-1', + title: 'Investigate mobile detail', + description: 'Body', + url: 'https://linear.app/acme/issue/LIN-1', + estimate: 2, + priority: 1, + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + state: Promise.reject(new Error('state fetch failed')), + team: Promise.reject(new Error('team fetch failed')), + assignee: Promise.reject(new Error('assignee fetch failed')), + project: Promise.reject(new Error('project fetch failed')), + labels: async () => ({ + nodes: [{ id: 'label-1', name: 'Bug' }] + }), + children: async () => ({ + nodes: [ + { + id: 'child-1', + identifier: 'LIN-2', + title: 'Child', + url: 'https://linear.app/acme/issue/LIN-2' + } + ] + }) + } + + await expect( + mapLinearIssue(issue as never, { includeChildren: true, includeProject: true }) + ).resolves.toMatchObject({ + id: 'issue-1', + identifier: 'LIN-1', + title: 'Investigate mobile detail', + labels: ['Bug'], + subIssues: [{ id: 'child-1', identifier: 'LIN-2' }], + state: { name: '' }, + team: { id: '' }, + assignee: undefined, + project: undefined + }) + }) +}) diff --git a/src/main/linear/mappers.ts b/src/main/linear/mappers.ts index 752c60f470d..6f4d7d3cab2 100644 --- a/src/main/linear/mappers.ts +++ b/src/main/linear/mappers.ts @@ -10,6 +10,14 @@ type MapLinearIssueOptions = { includeProject?: boolean } +async function optionalRelation(value: Promise | T): Promise { + try { + return await value + } catch { + return undefined + } +} + function mapLinearIssueChild(issue: Issue): LinearIssueChildSummary { return { id: issue.id, @@ -28,8 +36,12 @@ export async function mapLinearIssue( issue: Issue | IssueSearchResult, options: MapLinearIssueOptions = {} ): Promise { - const [state, team, assignee] = await Promise.all([issue.state, issue.team, issue.assignee]) - const project = options.includeProject ? await issue.project : undefined + const [state, team, assignee, project] = await Promise.all([ + optionalRelation(issue.state), + optionalRelation(issue.team), + optionalRelation(issue.assignee), + options.includeProject ? optionalRelation(issue.project) : Promise.resolve(undefined) + ]) // Why: IssueSearchResult does not expose the labels() relation method — only // the raw labelIds array. For Issue instances we resolve actual label names; diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index c9aebff80fb..529e6cfd13c 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -547,12 +547,16 @@ describe('SshGitProvider', () => { }) it('addWorktree sends git.addWorktree request', async () => { - await provider.addWorktree('/home/user/repo', 'feature', '/home/user/feat', { base: 'main' }) + await provider.addWorktree('/home/user/repo', 'feature', '/home/user/feat', { + base: 'main', + noCheckout: true + }) expect(mux.request).toHaveBeenCalledWith('git.addWorktree', { repoPath: '/home/user/repo', branchName: 'feature', targetDir: '/home/user/feat', - base: 'main' + base: 'main', + noCheckout: true }) }) diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 3916ef62b81..19e00753ffd 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -373,7 +373,7 @@ export class SshGitProvider implements IGitProvider { repoPath: string, branchName: string, targetDir: string, - options?: { base?: string; checkoutExistingBranch?: boolean } + options?: { base?: string; checkoutExistingBranch?: boolean; noCheckout?: boolean } ): Promise { await this.mux.request('git.addWorktree', { repoPath, diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index cd9b11abfac..63c6c3d8c4b 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -191,7 +191,7 @@ export type IGitProvider = { repoPath: string, branchName: string, targetDir: string, - options?: { base?: string; checkoutExistingBranch?: boolean } + options?: { base?: string; checkoutExistingBranch?: boolean; noCheckout?: boolean } ): Promise removeWorktree( worktreePath: string, diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index e22c5bc2843..6bca4c9146f 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: Claude rate-limit fallback tests share account/keychain/PTY mocks that would be noisier split apart. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -269,7 +270,15 @@ describe('fetchClaudeRateLimits', () => { }) ) netFetchMock.mockResolvedValueOnce( - new Response(JSON.stringify({ error: { type: 'rate_limit_error' } }), { status: 429 }) + new Response( + JSON.stringify({ + error: { + type: 'rate_limit_error', + message: 'Rate limited. Please try again later.' + } + }), + { status: 429 } + ) ) await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ diff --git a/src/main/runtime/mobile-rpc-allowlist.test.ts b/src/main/runtime/mobile-rpc-allowlist.test.ts new file mode 100644 index 00000000000..9e438f9ca3b --- /dev/null +++ b/src/main/runtime/mobile-rpc-allowlist.test.ts @@ -0,0 +1,91 @@ +import { readdirSync, readFileSync, statSync } from 'fs' +import { join } from 'path' +import { describe, expect, it } from 'vitest' +import { ALL_RPC_METHODS } from './rpc/methods' + +const MOBILE_DYNAMIC_RPC_METHODS = [ + // Why: computed sendRequest method names do not appear as literals in the + // mobile source scan below, but still must stay mobile-authorized. + 'accounts.selectClaude', + 'accounts.selectCodex', + 'github.updateIssue', + 'github.updatePRState', + 'gitlab.updateIssue', + 'gitlab.updateMR' +] + +function listSourceFiles(root: string): string[] { + const entries = readdirSync(root) + const files: string[] = [] + for (const entry of entries) { + const path = join(root, entry) + const stat = statSync(path) + if (stat.isDirectory()) { + files.push(...listSourceFiles(path)) + continue + } + if (!/\.[cm]?[jt]sx?$/.test(entry) || /\.test\.[cm]?[jt]sx?$/.test(entry)) { + continue + } + files.push(path) + } + return files +} + +function mobileLiteralRpcMethods(): string[] { + const roots = [join(process.cwd(), 'mobile/app'), join(process.cwd(), 'mobile/src')] + const methods = new Set() + for (const file of roots.flatMap(listSourceFiles)) { + const source = readFileSync(file, 'utf8') + for (const match of source.matchAll(/sendRequest\(\s*['"]([^'"]+)/g)) { + methods.add(match[1]!) + } + for (const match of source.matchAll(/subscribe\(\s*['"]([^'"]+)/g)) { + methods.add(match[1]!) + } + for (const match of source.matchAll(/method:\s*['"]([^'"]+)/g)) { + const method = match[1]! + if (method.includes('.')) { + methods.add(method) + } + } + } + return [...methods].sort() +} + +function mobileRpcMethods(): string[] { + return [...new Set([...mobileLiteralRpcMethods(), ...MOBILE_DYNAMIC_RPC_METHODS])].sort() +} + +function mobileRpcAllowlist(): Set { + const source = readFileSync(join(process.cwd(), 'src/main/runtime/runtime-rpc.ts'), 'utf8') + const allowlist = source.match(/const MOBILE_RPC_METHOD_ALLOWLIST = new Set\(\[([\s\S]*?)\]\)/) + if (!allowlist) { + throw new Error('MOBILE_RPC_METHOD_ALLOWLIST not found') + } + return new Set([...allowlist[1]!.matchAll(/'([^']+)'/g)].map((match) => match[1]!)) +} + +function registeredRuntimeMethods(): Set { + return new Set(ALL_RPC_METHODS.map((method) => method.name)) +} + +describe('mobile RPC allowlist', () => { + it('allows every RPC method used by the mobile app', () => { + // Why: mobile-scoped runtime tokens are checked before dispatch. A mobile + // feature can compile and still fail at runtime if its method is missing here. + const allowed = mobileRpcAllowlist() + const missing = mobileRpcMethods().filter((method) => !allowed.has(method)) + + expect(missing).toEqual([]) + }) + + it('registers every RPC method used by the mobile app', () => { + // Why: the allowlist check runs before dispatch, but an allowlisted mobile + // method still fails at runtime if it was never added to ALL_RPC_METHODS. + const registered = registeredRuntimeMethods() + const missing = mobileRpcMethods().filter((method) => !registered.has(method)) + + expect(missing).toEqual([]) + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 5a143c2c6d4..2abadb47370 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -13,7 +13,9 @@ import * as gitRunner from '../git/runner' import { createSetupRunnerScript, getEffectiveHooks, + getEffectiveHooksFromConfig, hasHooksFile, + loadHooks, parseOrcaYaml, runHook, shouldRunSetupForCreate @@ -44,7 +46,23 @@ const { invalidateAuthorizedRootsCacheMock, createHostedReviewMock, getHostedReviewCreationEligibilityMock, - getPRForBranchMock + getPRForBranchMock, + detectInstalledAgentsMock, + detectRemoteAgentsMock, + listGitLabWorkItemsMock, + listGitLabTodosMock, + getGitLabProjectRefForRemoteMock, + getGitLabWorkItemByProjectRefMock, + createGitLabIssueMock, + updateGitLabIssueMock, + addGitLabIssueCommentMock, + addGitLabMRCommentMock, + mergeGitLabMRMock, + closeGitLabMRMock, + reopenGitLabMRMock, + getGlabKnownHostsMock, + getGitLabWorkItemDetailsMock, + getIssueMock } = vi.hoisted(() => { // Why: SSH runtime tests register providers through the public dispatcher API, // so the mock needs the same registry semantics as the real module. @@ -77,7 +95,23 @@ const { invalidateAuthorizedRootsCacheMock: vi.fn(), createHostedReviewMock: vi.fn(), getHostedReviewCreationEligibilityMock: vi.fn(), - getPRForBranchMock: vi.fn().mockResolvedValue(null) + getPRForBranchMock: vi.fn().mockResolvedValue(null), + detectInstalledAgentsMock: vi.fn(), + detectRemoteAgentsMock: vi.fn(), + listGitLabWorkItemsMock: vi.fn(), + listGitLabTodosMock: vi.fn(), + getGitLabProjectRefForRemoteMock: vi.fn(), + getGitLabWorkItemByProjectRefMock: vi.fn(), + createGitLabIssueMock: vi.fn(), + updateGitLabIssueMock: vi.fn(), + addGitLabIssueCommentMock: vi.fn(), + addGitLabMRCommentMock: vi.fn(), + mergeGitLabMRMock: vi.fn(), + closeGitLabMRMock: vi.fn(), + reopenGitLabMRMock: vi.fn(), + getGlabKnownHostsMock: vi.fn(), + getGitLabWorkItemDetailsMock: vi.fn(), + getIssueMock: vi.fn() } }) @@ -109,9 +143,22 @@ vi.mock('../ipc/ssh', () => ({ getActiveMultiplexer: getActiveMultiplexerMock })) +vi.mock('../ipc/preflight', () => ({ + detectInstalledAgents: detectInstalledAgentsMock, + detectRemoteAgents: detectRemoteAgentsMock +})) + vi.mock('../hooks', () => ({ + buildPosixRunnerScript: (script: string) => `#!/usr/bin/env bash\nset -e\n${script}\n`, + buildWindowsRunnerScript: (script: string) => `@echo off\r\n${script}\r\n`, createSetupRunnerScript: vi.fn(), getEffectiveHooks: vi.fn().mockReturnValue(null), + getEffectiveHooksFromConfig: vi.fn().mockReturnValue(null), + getSetupRunnerEnvVars: (_repo: never, worktreePath: string) => ({ + ORCA_ROOT_PATH: '/remote/repo', + ORCA_WORKTREE_PATH: worktreePath + }), + loadHooks: vi.fn().mockReturnValue(null), runHook: vi.fn().mockResolvedValue({ success: true, output: '' }), shouldRunSetupForCreate: vi .fn() @@ -149,6 +196,48 @@ vi.mock('../github/client', async (importOriginal) => { } }) +vi.mock('../gitlab/client', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + listWorkItems: listGitLabWorkItemsMock, + listTodos: listGitLabTodosMock, + getProjectRefForRemote: getGitLabProjectRefForRemoteMock, + getWorkItemByProjectRef: getGitLabWorkItemByProjectRefMock, + createIssue: createGitLabIssueMock, + updateIssue: updateGitLabIssueMock, + addIssueComment: addGitLabIssueCommentMock, + addMRComment: addGitLabMRCommentMock, + mergeMR: mergeGitLabMRMock, + closeMR: closeGitLabMRMock, + reopenMR: reopenGitLabMRMock + } +}) + +vi.mock('../gitlab/gl-utils', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + getGlabKnownHosts: getGlabKnownHostsMock + } +}) + +vi.mock('../gitlab/work-item-details', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + getWorkItemDetails: getGitLabWorkItemDetailsMock + } +}) + +vi.mock('../github/issues', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + getIssue: getIssueMock + } +}) + // Why: the CLI create-worktree path calls getDefaultBaseRef to resolve a // fallback base branch. Real resolution shells out to `git` against the // test's fabricated repo path, which has no refs, so we stub it to a @@ -189,12 +278,16 @@ afterEach(() => { getActiveMultiplexerMock.mockReturnValue({ request: muxRequestMock, notify: vi.fn() }) vi.mocked(createSetupRunnerScript).mockReset() vi.mocked(getEffectiveHooks).mockReset() + vi.mocked(getEffectiveHooksFromConfig).mockReset() + vi.mocked(loadHooks).mockReset() vi.mocked(hasHooksFile).mockReset() vi.mocked(parseOrcaYaml).mockReset() vi.mocked(runHook).mockReset() vi.mocked(shouldRunSetupForCreate).mockReset() vi.mocked(shouldRunSetupForCreate).mockImplementation((_repo, decision) => decision === 'run') vi.mocked(getEffectiveHooks).mockReturnValue(null) + vi.mocked(getEffectiveHooksFromConfig).mockReturnValue(null) + vi.mocked(loadHooks).mockReturnValue(null) vi.mocked(hasHooksFile).mockReturnValue(false) vi.mocked(parseOrcaYaml).mockReturnValue(null) computeWorktreePathMock.mockReset() @@ -221,6 +314,42 @@ afterEach(() => { }) getPRForBranchMock.mockReset() getPRForBranchMock.mockResolvedValue(null) + detectInstalledAgentsMock.mockReset() + detectInstalledAgentsMock.mockResolvedValue([]) + detectRemoteAgentsMock.mockReset() + detectRemoteAgentsMock.mockResolvedValue([]) + listGitLabWorkItemsMock.mockReset() + listGitLabWorkItemsMock.mockResolvedValue({ items: [] }) + listGitLabTodosMock.mockReset() + listGitLabTodosMock.mockResolvedValue([]) + getGitLabProjectRefForRemoteMock.mockReset() + getGitLabProjectRefForRemoteMock.mockResolvedValue({ host: 'gitlab.example', path: 'group/repo' }) + getGlabKnownHostsMock.mockReset() + getGlabKnownHostsMock.mockResolvedValue(['gitlab.com']) + getGitLabWorkItemByProjectRefMock.mockReset() + getGitLabWorkItemByProjectRefMock.mockResolvedValue(null) + createGitLabIssueMock.mockReset() + createGitLabIssueMock.mockResolvedValue({ + ok: true, + number: 1, + url: 'https://gitlab.example/i/1' + }) + updateGitLabIssueMock.mockReset() + updateGitLabIssueMock.mockResolvedValue({ ok: true }) + addGitLabIssueCommentMock.mockReset() + addGitLabIssueCommentMock.mockResolvedValue({ ok: true }) + addGitLabMRCommentMock.mockReset() + addGitLabMRCommentMock.mockResolvedValue({ ok: true }) + mergeGitLabMRMock.mockReset() + mergeGitLabMRMock.mockResolvedValue({ ok: true }) + closeGitLabMRMock.mockReset() + closeGitLabMRMock.mockResolvedValue({ ok: true }) + reopenGitLabMRMock.mockReset() + reopenGitLabMRMock.mockResolvedValue({ ok: true }) + getGitLabWorkItemDetailsMock.mockReset() + getGitLabWorkItemDetailsMock.mockResolvedValue({ body: 'Details' }) + getIssueMock.mockReset() + getIssueMock.mockResolvedValue(null) }) function syncSinglePty(runtime: OrcaRuntimeService, ptyId: string | null = 'pty-1'): void { @@ -410,6 +539,8 @@ const store = { ...meta }) as never, removeWorktreeMeta: () => {}, + getSparsePresets: () => [], + saveSparsePreset: (preset: unknown) => preset as never, getGitHubCache: () => undefined as never, getSettings: () => ({ workspaceDir: '/tmp/workspaces', @@ -461,6 +592,7 @@ describe('OrcaRuntimeService', () => { expect(status.minCompatibleRuntimeClientVersion).toBe(status.minCompatibleMobileVersion) expect(status.capabilities).toContain('terminal.binary-stream.v1') expect(status.capabilities).toContain('workspace-ports.v1') + expect(status.capabilities).toContain('mobile.tasks.v1') expect(typeof status.protocolVersion).toBe('number') expect(typeof status.minCompatibleMobileVersion).toBe('number') expect(status.protocolVersion).toBeGreaterThanOrEqual(1) @@ -1025,6 +1157,142 @@ describe('OrcaRuntimeService', () => { expect(listWorktrees).not.toHaveBeenCalled() }) + it('launches SSH setup terminals for runtime task-created worktrees', async () => { + vi.mocked(listWorktrees).mockClear() + vi.mocked(addWorktree).mockClear() + const created = { + path: '/remote/mobile-setup', + head: 'def', + branch: 'refs/heads/mobile-setup', + isBare: false, + isMainWorktree: false + } + const remoteRepo = { + id: TEST_REPO_ID, + path: '/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + const metaById: Record = {} + const remoteStore = { + ...store, + getRepos: () => [remoteRepo], + getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined), + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + } + } + const provider = { + exec: vi.fn(async (args: string[]) => { + if (args[0] === 'config') { + return { stdout: 'Remote User\n', stderr: '' } + } + if (args[0] === 'branch') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'origin/main\n', stderr: '' } + } + if (args[0] === 'fetch') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'rev-parse') { + return { + stdout: '/remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh\n', + stderr: '' + } + } + throw new Error(`unexpected git call: ${args.join(' ')}`) + }), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([created]) + } + const fsProvider = { + readFile: vi.fn().mockResolvedValue({ isBinary: false, content: 'hooks:\n' }), + createDir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined) + } + vi.mocked(getEffectiveHooksFromConfig).mockReturnValue({ + scripts: { setup: 'pnpm worktree:setup' } + }) + vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'pty-remote-agent' }) + .mockResolvedValueOnce({ id: 'pty-remote-setup' }) + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-remote' }) + registerSshGitProvider('ssh-1', provider as never) + registerSshFilesystemProvider('ssh-1', fsProvider as never) + getActiveMultiplexerMock.mockReturnValue({ request: muxRequestMock, notify: vi.fn() }) + const runtime = new OrcaRuntimeService(remoteStore as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + try { + const result = await runtime.createManagedWorktree({ + repoSelector: TEST_REPO_ID, + name: 'mobile-setup', + setupDecision: 'run', + startup: { command: 'claude' } + }) + + expect(result.setup).toMatchObject({ + runnerScriptPath: '/remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh' + }) + expect(spawn).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + cwd: '/remote/mobile-setup', + command: 'claude', + worktreeId: result.worktree.id + }) + ) + expect(spawn).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + cwd: '/remote/mobile-setup', + command: 'bash /remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh', + worktreeId: result.worktree.id + }) + ) + expect(revealTerminalSession).toHaveBeenLastCalledWith( + result.worktree.id, + expect.objectContaining({ + ptyId: 'pty-remote-setup', + title: 'Setup', + activate: false + }) + ) + } finally { + unregisterSshGitProvider('ssh-1') + unregisterSshFilesystemProvider('ssh-1') + } + }) + it('removes SSH-backed runtime worktrees through the SSH git provider', async () => { vi.mocked(listWorktrees).mockClear() const remoteStore = { @@ -1158,7 +1426,11 @@ describe('OrcaRuntimeService', () => { await expect(runtime.getRepoHooks('id:repo-1')).resolves.toMatchObject({ hasHooksFile: true, hooks: { scripts: { setup: 'pnpm install' } }, - source: 'orca.yaml' + source: 'orca.yaml', + setupTrust: { + contentHash: '005d0b7e5c261dcc5e2f8568e69a0b30e889a3275b55b18ec20a7deef0081e90', + scriptContent: 'pnpm install' + } }) } finally { unregisterSshFilesystemProvider('ssh-1') @@ -1169,6 +1441,39 @@ describe('OrcaRuntimeService', () => { expect(getEffectiveHooks).not.toHaveBeenCalled() }) + it('hashes only the shared orca.yaml setup script for local run-both hooks', async () => { + vi.mocked(hasHooksFile).mockReturnValue(true) + vi.mocked(loadHooks).mockReturnValue({ scripts: { setup: 'echo yaml setup' } }) + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { setup: 'echo yaml setup\necho local setup' } + }) + const runtimeStore = { + ...store, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: TEST_REPO_PATH, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + hookSettings: { + commandSourcePolicy: 'run-both' as const, + scripts: { setup: 'echo local setup' } + } + } + ] + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await expect(runtime.getRepoHooks('id:repo-1')).resolves.toMatchObject({ + hooks: { scripts: { setup: 'echo yaml setup\necho local setup' } }, + setupTrust: { + contentHash: '9bc9f57699fe0390d263cca1aec01235cccc8fa5fc87cd87fd51ba1c8483ec84', + scriptContent: 'echo yaml setup' + } + }) + }) + it('uses remote path joins for SSH hook checks and issue-command files', async () => { const remoteStore = { ...store, @@ -1287,7 +1592,8 @@ describe('OrcaRuntimeService', () => { ) }) - it('rejects host integration helpers for SSH repos instead of using remote paths locally', async () => { + it('allows host integration slug helpers for SSH repos through provider-aware GitHub clients', async () => { + getIssueMock.mockResolvedValueOnce({ number: 12, title: 'Remote issue' }) const remoteStore = { ...store, getRepos: () => [ @@ -1303,12 +1609,12 @@ describe('OrcaRuntimeService', () => { } const runtime = new OrcaRuntimeService(remoteStore as never) - await expect(runtime.getRepoSlug('id:repo-1')).rejects.toThrow( - 'repo_slug_unsupported_for_ssh_repo' - ) - await expect(runtime.listRepoWorkItems('id:repo-1')).rejects.toThrow( - 'repo_work_items_unsupported_for_ssh_repo' - ) + await expect(runtime.getRepoSlug('id:repo-1')).resolves.toBeNull() + await expect(runtime.getRepoIssue('id:repo-1', 12)).resolves.toEqual({ + number: 12, + title: 'Remote issue' + }) + expect(getIssueMock).toHaveBeenCalledWith('/remote/repo', 12, 'ssh-1') }) it('rejects hosted review worktree selectors outside the selected repo', async () => { @@ -1378,6 +1684,59 @@ describe('OrcaRuntimeService', () => { expect(createHostedReviewMock).not.toHaveBeenCalled() }) + it('passes SSH connection context through hosted review creation flows', async () => { + const remoteRepo = { + id: TEST_REPO_ID, + path: '/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + const remoteStore = { + ...store, + getRepos: () => [remoteRepo], + getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined) + } + const runtime = new OrcaRuntimeService(remoteStore as never) + + await runtime.getHostedReviewCreationEligibility({ + repoSelector: `id:${TEST_REPO_ID}`, + branch: 'feature/ssh', + base: 'main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + await runtime.createHostedReview({ + repoSelector: `id:${TEST_REPO_ID}`, + provider: 'github', + base: 'main', + head: 'feature/ssh', + title: 'Feature SSH', + body: '', + draft: false + }) + + expect(getHostedReviewCreationEligibilityMock).toHaveBeenCalledWith( + expect.objectContaining({ + repoPath: '/remote/repo', + connectionId: 'ssh-1', + branch: 'feature/ssh' + }) + ) + expect(createHostedReviewMock).toHaveBeenCalledWith( + '/remote/repo', + expect.objectContaining({ + provider: 'github', + head: 'feature/ssh', + title: 'Feature SSH' + }), + 'ssh-1' + ) + }) + it('treats SSH worktree drift as unknown without local git probes', async () => { vi.mocked(listWorktrees).mockClear() vi.mocked(getDefaultBaseRef).mockClear() @@ -6144,6 +6503,870 @@ describe('OrcaRuntimeService', () => { }) }) + it('uses desktop task agent selection and bracketed-pastes startup drafts for local worktrees', async () => { + detectInstalledAgentsMock.mockResolvedValue(['claude']) + const metaById: Record = {} + const runtimeStore = { + ...store, + getSettings: () => ({ + ...store.getSettings(), + defaultTuiAgent: 'codex' as const, + agentCmdOverrides: { codex: 'codex --profile work' } + }), + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + } + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-startup-draft' }) + const write = vi.fn().mockReturnValue(true) + runtime.setPtyController({ + spawn, + write, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn().mockResolvedValue({ tabId: 'tab-startup-draft' }), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-startup-draft') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-startup-draft') + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-startup-draft', + head: 'def', + branch: 'runtime-startup-draft', + isBare: false, + isMainWorktree: false + } + ]) + + const draftUrl = 'https://github.com/stablyai/orca/issues/123' + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-startup-draft', + startupDraft: draftUrl, + activate: true + }) + + expect(detectInstalledAgentsMock).not.toHaveBeenCalled() + expect(detectRemoteAgentsMock).not.toHaveBeenCalled() + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: '/tmp/workspaces/runtime-startup-draft', + command: 'codex --profile work', + worktreeId: result.worktree.id + }) + ) + expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'codex' }) + + runtime.onPtyData('pty-startup-draft', '\x1b[?2004h›', Date.now()) + await vi.waitFor(() => { + expect(write).toHaveBeenCalledWith('pty-startup-draft', `\x1b[200~${draftUrl}\x1b[201~`) + }) + }) + + it('honors split setup placement for local startup-draft worktrees', async () => { + const metaById: Record = {} + const runtimeStore = { + ...store, + getSettings: () => ({ + ...store.getSettings(), + defaultTuiAgent: 'codex' as const, + setupScriptLaunchMode: 'split-vertical' as const + }), + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + } + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'pty-startup-split-main' }) + .mockResolvedValueOnce({ id: 'pty-startup-split-setup' }) + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-startup-split' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-startup-setup-split') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-startup-setup-split') + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup' + } + }) + vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { + ORCA_ROOT_PATH: '/tmp/repo', + ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup-split' + } + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-startup-setup-split', + head: 'def', + branch: 'runtime-startup-setup-split', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-startup-setup-split', + startupDraft: 'https://github.com/stablyai/orca/issues/123', + setupDecision: 'run', + activate: true + }) + + expect(spawn).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + cwd: '/tmp/workspaces/runtime-startup-setup-split', + command: 'codex', + worktreeId: result.worktree.id + }) + ) + expect(spawn).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + cwd: '/tmp/workspaces/runtime-startup-setup-split', + command: 'bash /tmp/repo/.git/orca/setup-runner.sh', + env: expect.objectContaining({ + ORCA_ROOT_PATH: '/tmp/repo', + ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup-split', + ORCA_WORKTREE_ID: result.worktree.id + }), + worktreeId: result.worktree.id + }) + ) + const mainEnv = (spawn.mock.calls[0]![0] as { env?: Record }).env ?? {} + const setupEnv = (spawn.mock.calls[1]![0] as { env?: Record }).env ?? {} + expect(mainEnv.ORCA_TAB_ID).toBeDefined() + expect(mainEnv.ORCA_PANE_KEY).toBeDefined() + expect(setupEnv.ORCA_TAB_ID).toBe(mainEnv.ORCA_TAB_ID) + const mainLeafId = mainEnv.ORCA_PANE_KEY!.slice(`${mainEnv.ORCA_TAB_ID!}:`.length) + expect(revealTerminalSession).toHaveBeenLastCalledWith( + result.worktree.id, + expect.objectContaining({ + ptyId: 'pty-startup-split-setup', + tabId: mainEnv.ORCA_TAB_ID, + activate: false, + splitFromLeafId: mainLeafId, + splitDirection: 'vertical' + }) + ) + }) + + it('lets explicit startup draft agents override the desktop default', async () => { + detectInstalledAgentsMock.mockResolvedValue([]) + const metaById: Record = {} + const runtimeStore = { + ...store, + getSettings: () => ({ + ...store.getSettings(), + defaultTuiAgent: 'claude' as const, + agentCmdOverrides: {} + }), + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + } + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-explicit-draft' }) + const write = vi.fn().mockReturnValue(true) + runtime.setPtyController({ + spawn, + write, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn().mockResolvedValue({ tabId: 'tab-explicit-draft' }), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-explicit-draft') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-explicit-draft') + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-explicit-draft', + head: 'def', + branch: 'runtime-explicit-draft', + isBare: false, + isMainWorktree: false + } + ]) + + const draftUrl = 'https://github.com/stablyai/orca/issues/789' + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-explicit-draft', + startupDraft: draftUrl, + createdWithAgent: 'codex', + activate: true + }) + + expect(detectInstalledAgentsMock).not.toHaveBeenCalled() + expect(detectRemoteAgentsMock).not.toHaveBeenCalled() + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: '/tmp/workspaces/runtime-explicit-draft', + command: 'codex', + worktreeId: result.worktree.id + }) + ) + expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'codex' }) + + runtime.onPtyData('pty-explicit-draft', '\x1b[?2004h›', Date.now()) + await vi.waitFor(() => { + expect(write).toHaveBeenCalledWith('pty-explicit-draft', `\x1b[200~${draftUrl}\x1b[201~`) + }) + }) + + it('does not auto-launch an agent for startup drafts when the default is blank', async () => { + detectInstalledAgentsMock.mockResolvedValue(['claude', 'codex']) + const metaById: Record = {} + const runtimeStore = { + ...store, + getSettings: () => ({ + ...store.getSettings(), + defaultTuiAgent: 'blank' as const, + agentCmdOverrides: {} + }), + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + } + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-blank-draft' }) + const activateWorktree = vi.fn() + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + createTerminal: vi.fn(), + revealTerminalSession: vi.fn().mockResolvedValue({ tabId: 'tab-blank-draft' }), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-blank-draft') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-blank-draft') + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-blank-draft', + head: 'def', + branch: 'runtime-blank-draft', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-blank-draft', + startupDraft: 'https://github.com/stablyai/orca/issues/123', + activate: true + }) + + expect(detectInstalledAgentsMock).not.toHaveBeenCalled() + expect(detectRemoteAgentsMock).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() + expect(metaById[result.worktree.id]?.createdWithAgent).toBeUndefined() + expect(activateWorktree).toHaveBeenCalledWith('repo-1', result.worktree.id, undefined) + }) + + it('detects agents on the SSH host before launching remote startup drafts', async () => { + detectRemoteAgentsMock.mockResolvedValue(['claude']) + const created = { + path: '/remote/mobile-startup-draft', + head: 'def', + branch: 'refs/heads/mobile-startup-draft', + isBare: false, + isMainWorktree: false + } + const metaById: Record = {} + const remoteStore = { + ...store, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: '/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + ], + getRepo: () => ({ + id: TEST_REPO_ID, + path: '/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + }), + getSettings: () => ({ + ...store.getSettings(), + defaultTuiAgent: null, + agentCmdOverrides: {} + }), + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + } + } + const provider = { + exec: vi.fn(async (args: string[]) => { + if (args[0] === 'config') { + return { stdout: 'Remote User\n', stderr: '' } + } + if (args[0] === 'branch') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'origin/main\n', stderr: '' } + } + if (args[0] === 'fetch') { + return { stdout: '', stderr: '' } + } + throw new Error(`unexpected git call: ${args.join(' ')}`) + }), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([created]) + } + registerSshGitProvider('ssh-1', provider as never) + getActiveMultiplexerMock.mockReturnValue({ request: muxRequestMock, notify: vi.fn() }) + const runtime = new OrcaRuntimeService(remoteStore as never) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-remote-startup-draft' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + const draftUrl = 'https://github.com/stablyai/orca/pull/456' + const result = await runtime.createManagedWorktree({ + repoSelector: TEST_REPO_ID, + name: 'mobile-startup-draft', + startupDraft: draftUrl + }) + + expect(detectRemoteAgentsMock).toHaveBeenCalledWith({ connectionId: 'ssh-1' }) + expect(detectInstalledAgentsMock).not.toHaveBeenCalled() + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: '/remote/mobile-startup-draft', + command: `claude --prefill '${draftUrl}'`, + connectionId: 'ssh-1', + worktreeId: result.worktree.id + }) + ) + expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'claude' }) + }) + + it('pre-marks remote Codex workspaces trusted before pasting startup drafts', async () => { + detectRemoteAgentsMock.mockResolvedValue(['codex']) + muxRequestMock.mockResolvedValue({ resolvedPath: '/home/dev' }) + const created = { + path: '/remote/mobile-codex-draft', + head: 'def', + branch: 'refs/heads/mobile-codex-draft', + isBare: false, + isMainWorktree: false + } + const metaById: Record = {} + const remoteStore = { + ...store, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: '/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + ], + getRepo: () => ({ + id: TEST_REPO_ID, + path: '/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + }), + getSettings: () => ({ + ...store.getSettings(), + defaultTuiAgent: 'codex' as const, + agentCmdOverrides: {} + }), + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + } + } + const gitProvider = { + exec: vi.fn(async (args: string[]) => { + if (args[0] === 'config') { + return { stdout: 'Remote User\n', stderr: '' } + } + if (args[0] === 'branch') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'origin/main\n', stderr: '' } + } + if (args[0] === 'fetch') { + return { stdout: '', stderr: '' } + } + throw new Error(`unexpected git call: ${args.join(' ')}`) + }), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([created]) + } + const fsProvider = { + realpath: vi.fn().mockResolvedValue('/remote/mobile-codex-draft'), + readFile: vi.fn().mockRejectedValue(new Error('missing config')), + createDir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined) + } + registerSshGitProvider('ssh-1', gitProvider as never) + registerSshFilesystemProvider('ssh-1', fsProvider as never) + const runtime = new OrcaRuntimeService(remoteStore as never) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-remote-codex-draft' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + try { + const result = await runtime.createManagedWorktree({ + repoSelector: TEST_REPO_ID, + name: 'mobile-codex-draft', + startupDraft: 'https://github.com/stablyai/orca/issues/789' + }) + + expect(detectRemoteAgentsMock).not.toHaveBeenCalled() + expect(muxRequestMock).toHaveBeenCalledWith('session.resolveHome', { path: '~' }) + expect(fsProvider.createDir).toHaveBeenCalledWith('/home/dev/.codex') + expect(fsProvider.writeFile).toHaveBeenCalledWith( + '/home/dev/.codex/config.toml', + expect.stringContaining('[projects."/remote/mobile-codex-draft"]') + ) + expect(fsProvider.writeFile).toHaveBeenCalledWith( + '/home/dev/.codex/config.toml', + expect.stringContaining('trust_level = "trusted"') + ) + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: '/remote/mobile-codex-draft', + command: 'codex', + connectionId: 'ssh-1', + worktreeId: result.worktree.id + }) + ) + expect(fsProvider.writeFile.mock.invocationCallOrder[0]).toBeLessThan( + spawn.mock.invocationCallOrder[0]! + ) + expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'codex' }) + } finally { + unregisterSshFilesystemProvider('ssh-1') + unregisterSshGitProvider('ssh-1') + } + }) + + it('pre-marks remote Codex workspaces trusted before explicit startup commands', async () => { + muxRequestMock.mockResolvedValue({ resolvedPath: '/home/dev' }) + const created = { + path: '/remote/mobile-codex-command', + head: 'def', + branch: 'refs/heads/mobile-codex-command', + isBare: false, + isMainWorktree: false + } + const metaById: Record = {} + const remoteStore = { + ...store, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: '/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + } + ], + getRepo: () => ({ + id: TEST_REPO_ID, + path: '/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + }), + getSettings: () => store.getSettings(), + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + } + } + const gitProvider = { + exec: vi.fn(async (args: string[]) => { + if (args[0] === 'config') { + return { stdout: 'Remote User\n', stderr: '' } + } + if (args[0] === 'branch') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'origin/main\n', stderr: '' } + } + if (args[0] === 'fetch') { + return { stdout: '', stderr: '' } + } + throw new Error(`unexpected git call: ${args.join(' ')}`) + }), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([created]) + } + const fsProvider = { + realpath: vi.fn().mockResolvedValue('/remote/mobile-codex-command'), + readFile: vi.fn().mockRejectedValue(new Error('missing config')), + createDir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined) + } + registerSshGitProvider('ssh-1', gitProvider as never) + registerSshFilesystemProvider('ssh-1', fsProvider as never) + const runtime = new OrcaRuntimeService(remoteStore as never) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-remote-codex-command' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + try { + const result = await runtime.createManagedWorktree({ + repoSelector: TEST_REPO_ID, + name: 'mobile-codex-command', + startup: { command: 'codex' }, + createdWithAgent: 'codex' + }) + + expect(detectRemoteAgentsMock).not.toHaveBeenCalled() + expect(muxRequestMock).toHaveBeenCalledWith('session.resolveHome', { path: '~' }) + expect(fsProvider.writeFile).toHaveBeenCalledWith( + '/home/dev/.codex/config.toml', + expect.stringContaining('[projects."/remote/mobile-codex-command"]') + ) + expect(fsProvider.writeFile).toHaveBeenCalledWith( + '/home/dev/.codex/config.toml', + expect.stringContaining('trust_level = "trusted"') + ) + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: '/remote/mobile-codex-command', + command: 'codex', + connectionId: 'ssh-1', + worktreeId: result.worktree.id + }) + ) + expect(fsProvider.writeFile.mock.invocationCallOrder[0]).toBeLessThan( + spawn.mock.invocationCallOrder[0]! + ) + expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'codex' }) + } finally { + unregisterSshFilesystemProvider('ssh-1') + unregisterSshGitProvider('ssh-1') + } + }) + + it('passes SSH connection ids through GitLab task operations', async () => { + listGitLabWorkItemsMock.mockResolvedValue({ items: [] }) + listGitLabTodosMock.mockResolvedValue([]) + createGitLabIssueMock.mockResolvedValue({ + ok: true, + number: 1, + url: 'https://gitlab.example/issues/1' + }) + updateGitLabIssueMock.mockResolvedValue({ ok: true }) + addGitLabIssueCommentMock.mockResolvedValue({ ok: true }) + addGitLabMRCommentMock.mockResolvedValue({ ok: true }) + mergeGitLabMRMock.mockResolvedValue({ ok: true }) + closeGitLabMRMock.mockResolvedValue({ ok: true }) + reopenGitLabMRMock.mockResolvedValue({ ok: true }) + getGitLabWorkItemDetailsMock.mockResolvedValue({ body: 'Details' }) + + const remoteRepo = { + id: TEST_REPO_ID, + path: '/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1', + issueSourcePreference: 'origin' as const + } + const runtimeStore = { + ...store, + getRepos: () => [remoteRepo], + getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined) + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await runtime.listGitLabRepoWorkItems(TEST_REPO_ID, 'closed', 2, 25, 'ambiguous selector') + await runtime.listGitLabRepoTodos(TEST_REPO_ID) + await runtime.createGitLabRepoIssue(TEST_REPO_ID, 'New issue', 'Body') + await runtime.updateGitLabRepoIssue(TEST_REPO_ID, 7, { state: 'closed' }) + await runtime.addGitLabRepoIssueComment(TEST_REPO_ID, 7, 'Looks good') + await runtime.addGitLabRepoMRComment(TEST_REPO_ID, 8, 'Ship it') + await runtime.mergeGitLabRepoMR(TEST_REPO_ID, 8, 'squash') + await runtime.updateGitLabRepoMRState(TEST_REPO_ID, 8, 'closed') + await runtime.updateGitLabRepoMRState(TEST_REPO_ID, 8, 'opened') + await runtime.getGitLabRepoWorkItemDetails(TEST_REPO_ID, 8, 'mr') + + expect(listGitLabWorkItemsMock).toHaveBeenCalledWith( + '/remote/repo', + 'closed', + 2, + 25, + 'origin', + 'ambiguous selector', + 'ssh-1' + ) + expect(listGitLabTodosMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1') + expect(createGitLabIssueMock).toHaveBeenCalledWith( + '/remote/repo', + 'New issue', + 'Body', + 'origin', + 'ssh-1' + ) + expect(updateGitLabIssueMock).toHaveBeenCalledWith( + '/remote/repo', + 7, + { state: 'closed' }, + 'origin', + 'ssh-1', + undefined + ) + expect(addGitLabIssueCommentMock).toHaveBeenCalledWith( + '/remote/repo', + 7, + 'Looks good', + 'origin', + 'ssh-1', + undefined + ) + expect(addGitLabMRCommentMock).toHaveBeenCalledWith( + '/remote/repo', + 8, + 'Ship it', + 'origin', + 'ssh-1', + undefined + ) + expect(mergeGitLabMRMock).toHaveBeenCalledWith( + '/remote/repo', + 8, + 'squash', + 'origin', + 'ssh-1', + undefined + ) + expect(closeGitLabMRMock).toHaveBeenCalledWith('/remote/repo', 8, 'origin', 'ssh-1', undefined) + expect(reopenGitLabMRMock).toHaveBeenCalledWith('/remote/repo', 8, 'origin', 'ssh-1', undefined) + expect(getGitLabWorkItemDetailsMock).toHaveBeenCalledWith( + '/remote/repo', + 8, + 'mr', + 'origin', + 'ssh-1', + undefined + ) + }) + + it('resolves local GitLab fork MR bases from the target project MR head ref', async () => { + const localRepo = { + id: TEST_REPO_ID, + path: TEST_REPO_PATH, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + issueSourcePreference: 'origin' as const + } + const runtimeStore = { + ...store, + getRepos: () => [localRepo], + getRepo: (id: string) => (id === localRepo.id ? localRepo : undefined) + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { + if (args[0] === 'fetch') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'rev-parse' && args[1] === '--verify' && args[2] === 'FETCH_HEAD') { + return { stdout: 'fork-mr-sha\n', stderr: '' } + } + throw new Error(`unexpected git call: ${args.join(' ')}`) + }) + gitSpy.mockClear() + try { + const result = await runtime.resolveManagedMrBase({ + repoSelector: 'id:repo-1', + mrIid: 42, + sourceBranch: 'contrib/fix', + isCrossRepository: true + }) + + expect(result).toEqual({ baseBranch: 'fork-mr-sha' }) + expect(gitSpy).toHaveBeenCalledWith(['fetch', 'origin', 'refs/merge-requests/42/head'], { + cwd: TEST_REPO_PATH + }) + expect(gitSpy).toHaveBeenCalledWith(['rev-parse', '--verify', 'FETCH_HEAD'], { + cwd: TEST_REPO_PATH + }) + } finally { + gitSpy.mockRestore() + } + }) + + it('resolves SSH GitLab fork MR bases from the target project MR head ref', async () => { + const remoteRepo = { + id: TEST_REPO_ID, + path: '/remote/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1', + issueSourcePreference: 'origin' as const + } + const runtimeStore = { + ...store, + getRepos: () => [remoteRepo], + getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined) + } + const provider = { + exec: vi.fn(async (args: string[]) => { + if (args[0] === 'fetch') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'rev-parse' && args[1] === '--verify' && args[2] === 'FETCH_HEAD') { + return { stdout: 'remote-fork-mr-sha\n', stderr: '' } + } + throw new Error(`unexpected git call: ${args.join(' ')}`) + }) + } + registerSshGitProvider('ssh-1', provider as never) + getGlabKnownHostsMock.mockResolvedValue(['gitlab.com', 'git.internal']) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + const result = await runtime.resolveManagedMrBase({ + repoSelector: 'id:repo-1', + mrIid: 77, + sourceBranch: 'contrib/remote-fix', + isCrossRepository: true + }) + + expect(result).toEqual({ baseBranch: 'remote-fork-mr-sha' }) + expect(provider.exec).toHaveBeenCalledWith( + ['fetch', 'origin', 'refs/merge-requests/77/head'], + '/remote/repo' + ) + expect(provider.exec).toHaveBeenCalledWith( + ['rev-parse', '--verify', 'FETCH_HEAD'], + '/remote/repo' + ) + expect(getGitLabProjectRefForRemoteMock).toHaveBeenCalledWith( + '/remote/repo', + 'origin', + ['gitlab.com', 'git.internal'], + 'ssh-1' + ) + }) + it('creates the first terminal by id when duplicate repo entries expose the same path', async () => { const runtime = new OrcaRuntimeService(store) const spawn = vi.fn().mockResolvedValue({ id: 'pty-duplicate-path' }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index dfa8ee5ab11..6edf2bdcaa3 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -9,7 +9,7 @@ import { import type { AgentStatus } from '../../shared/agent-detection' import { gitExecFileAsync, wslAwareSpawn } from '../git/runner' import { isWslPath, parseWslPath, getWslHome } from '../wsl' -import { randomUUID } from 'crypto' +import { createHash, randomUUID } from 'crypto' import { basename, isAbsolute, join } from 'path' import { mkdir, readFile, readdir, rm, stat } from 'fs/promises' import { OrchestrationDb } from './orchestration/db' @@ -49,7 +49,18 @@ import { buildSetupRunnerCommand } from '../../shared/setup-runner-command' import { FIRST_PANE_ID } from '../../shared/pane-key' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' import { isValidHostTerminalTabId } from '../../shared/terminal-tab-id' +import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup' +import { pickTuiAgent } from '../../shared/tui-agent-selection' +import { TUI_AGENT_CONFIG, isTuiAgent } from '../../shared/tui-agent-config' +import { detectInstalledAgents, detectRemoteAgents } from '../ipc/preflight' import { + markCodexProjectTrusted, + markCopilotFolderTrusted, + markCursorWorkspaceTrusted +} from '../agent-trust-presets' +import { upsertProjectTrustLevelInContent } from '../codex/config-toml-trust' +import { + isWindowsAbsolutePathLike, isPathInsideOrEqual, normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' @@ -133,6 +144,7 @@ import { setPRFileViewed, getWorkItemByOwnerRepo, updatePRTitle, + updatePRDetails, mergePR, updatePRState, requestPRReviewers, @@ -148,11 +160,30 @@ import { import { resolveGitHubPrStartPoint } from '../github/pr-start-point' import { getWorkItemDetails, getPRFileContents } from '../github/work-item-details' import { getRateLimit } from '../github/rate-limit' +import { + closeMR as closeGitLabMR, + createIssue as createGitLabIssue, + getProjectRefForRemote as getGitLabProjectRefForRemote, + getWorkItemByProjectRef as getGitLabWorkItemByProjectRef, + addIssueComment as addGitLabIssueComment, + addMRComment as addGitLabMRComment, + listTodos as listGitLabTodos, + listWorkItems as listGitLabWorkItems, + mergeMR as mergeGitLabMR, + reopenMR as reopenGitLabMR, + updateMR as updateGitLabMR, + updateIssue as updateGitLabIssue +} from '../gitlab/client' +import { getGlabKnownHosts } from '../gitlab/gl-utils' +import { getWorkItemDetails as getGitLabWorkItemDetails } from '../gitlab/work-item-details' import type { GitHubIssueUpdate, GitHubPullRequestStateUpdate, GitHubPRFile, - GitHubPRReviewCommentInput + GitHubPRReviewCommentInput, + GitLabIssueUpdate, + GitLabProjectRef, + MRListState } from '../../shared/types' import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' import type { @@ -302,6 +333,7 @@ import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' import type { IFilesystemProvider, IPtyProvider } from '../providers/types' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { getSshGitProvider, requireSshGitProvider } from '../providers/ssh-git-dispatch' +import { getActiveMultiplexer } from '../ipc/ssh' import type { ClaudeAccountService } from '../claude-accounts/service' import type { CodexAccountService } from '../codex-accounts/service' import type { RateLimitService } from '../rate-limits/service' @@ -356,12 +388,22 @@ type RuntimeStore = { createAutomation?: Store['createAutomation'] updateAutomation?: Store['updateAutomation'] deleteAutomation?: Store['deleteAutomation'] + getSparsePresets?: Store['getSparsePresets'] + saveSparsePreset?: Store['saveSparsePreset'] getSettings(): { workspaceDir: string nestWorkspaces: boolean refreshLocalBaseRefOnWorktreeCreate: boolean branchPrefix: string branchPrefixCustom: string + defaultTuiAgent?: GlobalSettings['defaultTuiAgent'] + agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] + defaultTaskSource?: GlobalSettings['defaultTaskSource'] + defaultTaskViewPreset?: GlobalSettings['defaultTaskViewPreset'] + visibleTaskProviders?: GlobalSettings['visibleTaskProviders'] + defaultRepoSelection?: GlobalSettings['defaultRepoSelection'] + defaultLinearTeamSelection?: GlobalSettings['defaultLinearTeamSelection'] + githubProjects?: GlobalSettings['githubProjects'] experimentalWorktreeSymlinks?: boolean mobileAutoRestoreFitMs?: number | null voice?: VoiceSettings @@ -390,6 +432,36 @@ export type RuntimeAutomationUpdateInput = Omit< workspace?: string } +function normalizeSparsePresetName(name: string): string { + const trimmed = name.trim() + if (!trimmed) { + throw new Error('Preset name is required.') + } + if (trimmed.length > 80) { + throw new Error('Preset name is too long.') + } + return trimmed +} + +function normalizeSparsePresetDirectoriesForSave(directories: string[]): string[] { + let normalized: string[] + try { + normalized = normalizeSparseDirectories(directories) + } catch (err) { + if ( + err instanceof Error && + err.message === 'Sparse checkout directories must be repo-relative paths.' + ) { + throw new Error('Preset directories must be repo-relative paths.') + } + throw err + } + if (normalized.length === 0) { + throw new Error('Preset must have at least one directory.') + } + return normalized +} + function hasRuntimeAutomationUpdateValue( updates: RuntimeAutomationUpdateInput, key: K @@ -471,6 +543,19 @@ type RuntimePtyController = { getSize?(ptyId: string): { cols: number; rows: number } | null } +type WorktreeStartupDraftPaste = { + agent: TuiAgent + content: string +} + +const DECSET_BRACKETED_PASTE = '\x1b[?2004h' +const CODEX_COMPOSER_PROMPT = '›' +const BRACKETED_PASTE_BEGIN = '\x1b[200~' +const BRACKETED_PASTE_END = '\x1b[201~' +const BRACKETED_PASTE_QUIET_MS = 1500 +const DRAFT_PASTE_READY_TIMEOUT_MS = 8000 +const RECENT_PTY_OUTPUT_LIMIT = 4096 + type RuntimeNotifier = { worktreesChanged(repoId: string): void worktreeBaseStatus?(event: WorktreeBaseStatusEvent): void @@ -767,6 +852,9 @@ export class OrcaRuntimeService { // These listeners fire on every onPtyData call, enabling real-time streaming // without polling. Keyed by ptyId for O(1) lookup per data event. private dataListeners = new Map void>>() + // Why: startup draft paste can subscribe after the agent already emitted its + // ready marker. Keep a bounded raw buffer so fast startup output is replayed. + private recentPtyOutputById = new Map() // Why: mobile clients need to know when the desktop restores a terminal // from mobile-fit so they can update their UI. These listeners are // invoked from resizeForClient and onClientDisconnected/onPtyExit. @@ -1051,6 +1139,60 @@ export class OrcaRuntimeService { return this.store.getUI() } + getClientSettings(): Pick< + GlobalSettings, + | 'defaultTuiAgent' + | 'agentCmdOverrides' + | 'defaultTaskSource' + | 'defaultTaskViewPreset' + | 'visibleTaskProviders' + | 'defaultRepoSelection' + | 'defaultLinearTeamSelection' + | 'githubProjects' + > { + if (!this.store?.getSettings) { + throw new Error('runtime_unavailable') + } + const settings = this.store.getSettings() + return { + defaultTuiAgent: settings.defaultTuiAgent ?? null, + agentCmdOverrides: settings.agentCmdOverrides ?? {}, + defaultTaskSource: settings.defaultTaskSource ?? 'github', + defaultTaskViewPreset: settings.defaultTaskViewPreset ?? 'issues', + visibleTaskProviders: settings.visibleTaskProviders ?? ['github', 'gitlab', 'linear'], + defaultRepoSelection: settings.defaultRepoSelection ?? null, + defaultLinearTeamSelection: settings.defaultLinearTeamSelection ?? null, + githubProjects: settings.githubProjects + } + } + + updateClientSettings( + updates: Pick< + Partial, + | 'defaultTaskSource' + | 'defaultTaskViewPreset' + | 'defaultRepoSelection' + | 'defaultLinearTeamSelection' + | 'githubProjects' + > + ): Pick< + GlobalSettings, + | 'defaultTuiAgent' + | 'agentCmdOverrides' + | 'defaultTaskSource' + | 'defaultTaskViewPreset' + | 'visibleTaskProviders' + | 'defaultRepoSelection' + | 'defaultLinearTeamSelection' + | 'githubProjects' + > { + if (!this.store?.updateSettings) { + throw new Error('runtime_unavailable') + } + this.store.updateSettings(updates) + return this.getClientSettings() + } + listAutomations(): Automation[] { if (!this.store?.listAutomations) { throw new Error('runtime_unavailable') @@ -1843,6 +1985,10 @@ export class OrcaRuntimeService { } onPtyData(ptyId: string, data: string, at: number): void { + this.recentPtyOutputById.set( + ptyId, + `${this.recentPtyOutputById.get(ptyId) ?? ''}${data}`.slice(-RECENT_PTY_OUTPUT_LIMIT) + ) // Agent detection runs on raw data before leaf processing, since the // tail buffer logic normalizes away the OSC sequences we need. this.agentDetector?.onData(ptyId, data, at) @@ -2951,6 +3097,7 @@ export class OrcaRuntimeService { this.mobileDisplayModes.delete(ptyId) this.resizeListeners.delete(ptyId) this.lastRendererSizes.delete(ptyId) + this.recentPtyOutputById.delete(ptyId) // Layout state machine: clear `layouts` and `layoutQueues`. Any // already-queued applyLayout work for this ptyId will run, but every // applyLayout re-checks `layouts.has(ptyId)` (or fresh-subscribe) and @@ -4669,6 +4816,38 @@ export class OrcaRuntimeService { return this.store?.getRepos() ?? [] } + async listSparsePresets(repoSelector: string) { + if (!this.store?.getSparsePresets) { + throw new Error('runtime_unavailable') + } + const repo = await this.resolveRepoSelector(repoSelector) + return this.store.getSparsePresets(repo.id) + } + + async saveSparsePreset( + repoSelector: string, + args: { id?: string; name: string; directories: string[] } + ) { + if (!this.store?.getSparsePresets || !this.store.saveSparsePreset) { + throw new Error('runtime_unavailable') + } + const repo = await this.resolveRepoSelector(repoSelector) + const name = normalizeSparsePresetName(args.name) + const directories = normalizeSparsePresetDirectoriesForSave(args.directories) + const now = Date.now() + const existing = args.id + ? this.store.getSparsePresets(repo.id).find((preset) => preset.id === args.id) + : undefined + return this.store.saveSparsePreset({ + id: existing?.id ?? randomUUID(), + repoId: repo.id, + name, + directories, + createdAt: existing?.createdAt ?? now, + updatedAt: now + }) + } + async addRepo(path: string, kind: 'git' | 'folder' = 'git'): Promise { if (!this.store) { throw new Error('runtime_unavailable') @@ -5076,12 +5255,6 @@ export class OrcaRuntimeService { } } - private assertHostIntegrationRepoIsLocal(repo: Repo, operation: string): void { - if (repo.connectionId) { - throw new Error(`${operation}_unsupported_for_ssh_repo`) - } - } - private async resolveHostedReviewTarget(args: { repoSelector: string worktreeSelector?: string @@ -5100,8 +5273,7 @@ export class OrcaRuntimeService { async getRepoSlug(repoSelector: string): Promise<{ owner: string; repo: string } | null> { const repo = await this.resolveRepoSelector(repoSelector) - this.assertHostIntegrationRepoIsLocal(repo, 'repo_slug') - return getRepoSlug(repo.path) + return getRepoSlug(repo.path, repo.connectionId ?? null) } async listRepoWorkItems( @@ -5111,8 +5283,14 @@ export class OrcaRuntimeService { before?: string ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - this.assertHostIntegrationRepoIsLocal(repo, 'repo_work_items') - return listWorkItems(repo.path, limit, query, before, repo.issueSourcePreference) + return listWorkItems( + repo.path, + limit, + query, + before, + repo.issueSourcePreference, + repo.connectionId ?? null + ) } async getRepoWorkItem( @@ -5121,8 +5299,7 @@ export class OrcaRuntimeService { type?: 'issue' | 'pr' ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - this.assertHostIntegrationRepoIsLocal(repo, 'repo_work_item') - return getWorkItem(repo.path, number, type) + return getWorkItem(repo.path, number, type, repo.connectionId ?? null) } async getRepoWorkItemByOwnerRepo( @@ -5132,8 +5309,7 @@ export class OrcaRuntimeService { type: 'issue' | 'pr' ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - this.assertHostIntegrationRepoIsLocal(repo, 'repo_work_item') - return getWorkItemByOwnerRepo(repo.path, ownerRepo, number, type) + return getWorkItemByOwnerRepo(repo.path, ownerRepo, number, type, repo.connectionId ?? null) } async getRepoWorkItemDetails( @@ -5142,28 +5318,24 @@ export class OrcaRuntimeService { type?: 'issue' | 'pr' ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - this.assertHostIntegrationRepoIsLocal(repo, 'repo_work_item_details') - return getWorkItemDetails(repo.path, number, type) + return getWorkItemDetails(repo.path, number, type, repo.connectionId ?? null) } async countRepoWorkItems(repoSelector: string, query?: string): Promise { const repo = await this.resolveRepoSelector(repoSelector) - this.assertHostIntegrationRepoIsLocal(repo, 'repo_work_items') - return countWorkItems(repo.path, query, repo.issueSourcePreference) + return countWorkItems(repo.path, query, repo.issueSourcePreference, repo.connectionId ?? null) } async listRepoLabels(repoSelector: string): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - this.assertHostIntegrationRepoIsLocal(repo, 'repo_labels') - return listLabels(repo.path, repo.issueSourcePreference) + return listLabels(repo.path, repo.issueSourcePreference, repo.connectionId ?? null) } async listRepoAssignableUsers( repoSelector: string ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - this.assertHostIntegrationRepoIsLocal(repo, 'repo_assignable_users') - return listAssignableUsers(repo.path, repo.issueSourcePreference) + return listAssignableUsers(repo.path, repo.issueSourcePreference, repo.connectionId ?? null) } getGitHubRateLimit(options?: { @@ -5179,12 +5351,11 @@ export class OrcaRuntimeService { fallbackPRNumber?: number | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr') return getPRForBranch( repo.path, branch, linkedPRNumber ?? null, - null, + repo.connectionId ?? null, linkedPRNumber == null ? (fallbackPRNumber ?? null) : null ) } @@ -5200,9 +5371,9 @@ export class OrcaRuntimeService { linkedGiteaPR?: number | null }): Promise { const repo = await this.resolveRepoSelector(args.repoSelector) - this.assertHostIntegrationRepoIsLocal(repo, 'hosted_review') const review = await getHostedReviewForBranchFromRepo({ repoPath: repo.path, + connectionId: repo.connectionId ?? null, branch: args.branch, linkedGitHubPR: args.linkedGitHubPR ?? null, fallbackGitHubPR: args.linkedGitHubPR == null ? (args.fallbackGitHubPR ?? null) : null, @@ -5229,9 +5400,9 @@ export class OrcaRuntimeService { } ): Promise { const { repo, repoPath } = await this.resolveHostedReviewTarget(args) - this.assertHostIntegrationRepoIsLocal(repo, 'hosted_review') return getHostedReviewCreationEligibilityFromRepo({ repoPath, + connectionId: repo.connectionId ?? null, branch: args.branch, base: args.base ?? null, hasUncommittedChanges: args.hasUncommittedChanges, @@ -5251,15 +5422,18 @@ export class OrcaRuntimeService { args: CreateHostedReviewInput & { repoSelector: string; worktreeSelector?: string } ): Promise { const { repo, repoPath } = await this.resolveHostedReviewTarget(args) - this.assertHostIntegrationRepoIsLocal(repo, 'hosted_review') - const result = await createHostedReviewFromRepo(repoPath, { - provider: args.provider, - base: args.base, - head: args.head, - title: args.title, - body: args.body, - draft: args.draft - }) + const result = await createHostedReviewFromRepo( + repoPath, + { + provider: args.provider, + base: args.base, + head: args.head, + title: args.title, + body: args.body, + draft: args.draft + }, + repo.connectionId ?? null + ) if (result.ok && this.stats && !this.stats.hasCountedPR(result.url)) { this.stats.record({ type: 'pr_created', @@ -5271,13 +5445,179 @@ export class OrcaRuntimeService { return result } + async listGitLabRepoWorkItems( + repoSelector: string, + state?: MRListState, + page?: number, + perPage?: number, + query?: string + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return listGitLabWorkItems( + repo.path, + state ?? 'opened', + page ?? 1, + perPage ?? 20, + repo.issueSourcePreference, + query, + repo.connectionId ?? null + ) + } + + async listGitLabRepoTodos( + repoSelector: string + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return listGitLabTodos(repo.path, repo.connectionId ?? null) + } + + async createGitLabRepoIssue( + repoSelector: string, + title: string, + body: string + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return createGitLabIssue( + repo.path, + title, + body, + repo.issueSourcePreference, + repo.connectionId ?? null + ) + } + + async updateGitLabRepoIssue( + repoSelector: string, + number: number, + updates: GitLabIssueUpdate, + projectRef?: GitLabProjectRef | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return updateGitLabIssue( + repo.path, + number, + updates, + repo.issueSourcePreference, + repo.connectionId ?? null, + projectRef + ) + } + + async addGitLabRepoIssueComment( + repoSelector: string, + number: number, + body: string, + projectRef?: GitLabProjectRef | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return addGitLabIssueComment( + repo.path, + number, + body, + repo.issueSourcePreference, + repo.connectionId ?? null, + projectRef + ) + } + + async addGitLabRepoMRComment( + repoSelector: string, + iid: number, + body: string, + projectRef?: GitLabProjectRef | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return addGitLabMRComment( + repo.path, + iid, + body, + repo.issueSourcePreference, + repo.connectionId ?? null, + projectRef + ) + } + + async mergeGitLabRepoMR( + repoSelector: string, + iid: number, + method?: 'merge' | 'squash' | 'rebase', + projectRef?: GitLabProjectRef | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return mergeGitLabMR( + repo.path, + iid, + method ?? 'merge', + repo.issueSourcePreference, + repo.connectionId ?? null, + projectRef + ) + } + + async updateGitLabRepoMRState( + repoSelector: string, + iid: number, + state: 'opened' | 'closed', + projectRef?: GitLabProjectRef | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return state === 'closed' + ? closeGitLabMR( + repo.path, + iid, + repo.issueSourcePreference, + repo.connectionId ?? null, + projectRef + ) + : reopenGitLabMR( + repo.path, + iid, + repo.issueSourcePreference, + repo.connectionId ?? null, + projectRef + ) + } + + async updateGitLabRepoMR( + repoSelector: string, + iid: number, + updates: { title?: string; body?: string; addLabels?: string[]; removeLabels?: string[] }, + projectRef?: GitLabProjectRef | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return updateGitLabMR( + repo.path, + iid, + updates, + repo.issueSourcePreference, + repo.connectionId ?? null, + projectRef + ) + } + + async getGitLabRepoWorkItemDetails( + repoSelector: string, + iid: number, + type: 'issue' | 'mr', + projectRef?: GitLabProjectRef | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return getGitLabWorkItemDetails( + repo.path, + iid, + type, + repo.issueSourcePreference, + repo.connectionId ?? null, + projectRef + ) + } + async getRepoIssue( repoSelector: string, number: number ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - this.assertHostIntegrationRepoIsLocal(repo, 'repo_issue') - return getIssue(repo.path, number) + return getIssue(repo.path, number, repo.connectionId ?? null) } async getRepoPRChecks( @@ -5288,8 +5628,14 @@ export class OrcaRuntimeService { options?: { noCache?: boolean } ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_checks') - return getPRChecks(repo.path, prNumber, headSha, prRepo ?? null, options) + return getPRChecks( + repo.path, + prNumber, + headSha, + prRepo ?? null, + options, + repo.connectionId ?? null + ) } async rerunRepoPRChecks( @@ -5298,8 +5644,7 @@ export class OrcaRuntimeService { options?: { headSha?: string; failedOnly?: boolean } ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_checks_rerun') - return rerunPRChecks(repo.path, prNumber, options) + return rerunPRChecks(repo.path, prNumber, options, repo.connectionId ?? null) } async getRepoPRCheckDetails( @@ -5324,8 +5669,12 @@ export class OrcaRuntimeService { options?: { noCache?: boolean } ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_comments') - return getPRComments(repo.path, prNumber, { ...options, prRepo: prRepo ?? null }) + return getPRComments( + repo.path, + prNumber, + { ...options, prRepo: prRepo ?? null }, + repo.connectionId ?? null + ) } async getRepoPRFileContents( @@ -5340,8 +5689,11 @@ export class OrcaRuntimeService { } ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_file_contents') - return getPRFileContents({ repoPath: repo.path, ...args }) + return getPRFileContents({ + repoPath: repo.path, + connectionId: repo.connectionId ?? null, + ...args + }) } async resolveRepoReviewThread( @@ -5350,8 +5702,7 @@ export class OrcaRuntimeService { resolve: boolean ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_review_thread') - return resolveReviewThread(repo.path, threadId, resolve) + return resolveReviewThread(repo.path, threadId, resolve, repo.connectionId ?? null) } async setRepoPRFileViewed( @@ -5363,8 +5714,11 @@ export class OrcaRuntimeService { } ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_file_viewed') - return setPRFileViewed({ repoPath: repo.path, ...args }) + return setPRFileViewed({ + repoPath: repo.path, + connectionId: repo.connectionId ?? null, + ...args + }) } async updateRepoPRTitle( @@ -5374,8 +5728,17 @@ export class OrcaRuntimeService { prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_title') - return updatePRTitle(repo.path, prNumber, title, undefined, prRepo ?? null) + return updatePRTitle(repo.path, prNumber, title, repo.connectionId ?? null, prRepo ?? null) + } + + async updateRepoPRDetails( + repoSelector: string, + prNumber: number, + updates: { title?: string; body?: string }, + prRepo?: GitHubOwnerRepo | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return updatePRDetails(repo.path, prNumber, updates, repo.connectionId ?? null, prRepo ?? null) } async mergeRepoPR( @@ -5385,8 +5748,7 @@ export class OrcaRuntimeService { prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_merge') - return mergePR(repo.path, prNumber, method, undefined, prRepo ?? null) + return mergePR(repo.path, prNumber, method, repo.connectionId ?? null, prRepo ?? null) } async updateRepoPRState( @@ -5395,8 +5757,7 @@ export class OrcaRuntimeService { updates: GitHubPullRequestStateUpdate ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_state') - return updatePRState(repo.path, prNumber, updates) + return updatePRState(repo.path, prNumber, updates, repo.connectionId ?? null) } async requestRepoPRReviewers( @@ -5405,8 +5766,7 @@ export class OrcaRuntimeService { reviewers: string[] ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_reviewers') - return requestPRReviewers(repo.path, prNumber, reviewers) + return requestPRReviewers(repo.path, prNumber, reviewers, repo.connectionId ?? null) } async removeRepoPRReviewers( @@ -5425,8 +5785,13 @@ export class OrcaRuntimeService { body: string ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - this.assertHostIntegrationRepoIsLocal(repo, 'repo_issue_create') - return createIssue(repo.path, title, body, repo.issueSourcePreference) + return createIssue( + repo.path, + title, + body, + repo.issueSourcePreference, + repo.connectionId ?? null + ) } async updateRepoIssue( @@ -5435,8 +5800,7 @@ export class OrcaRuntimeService { updates: GitHubIssueUpdate ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - this.assertHostIntegrationRepoIsLocal(repo, 'repo_issue_update') - return updateIssue(repo.path, number, updates) + return updateIssue(repo.path, number, updates, repo.connectionId ?? null) } async addRepoIssueComment( @@ -5445,8 +5809,7 @@ export class OrcaRuntimeService { body: string ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - this.assertHostIntegrationRepoIsLocal(repo, 'repo_issue_comment') - return addIssueComment(repo.path, number, body) + return addIssueComment(repo.path, number, body, repo.connectionId ?? null) } async addRepoPRReviewComment( @@ -5454,8 +5817,11 @@ export class OrcaRuntimeService { args: Omit ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_review_comment') - return addPRReviewComment({ repoPath: repo.path, ...args }) + return addPRReviewComment({ + repoPath: repo.path, + connectionId: repo.connectionId ?? null, + ...args + }) } async addRepoPRReviewCommentReply( @@ -5470,7 +5836,6 @@ export class OrcaRuntimeService { } ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_review_comment_reply') return addPRReviewCommentReply( repo.path, args.prNumber, @@ -5478,7 +5843,8 @@ export class OrcaRuntimeService { args.body, args.threadId, args.path, - args.line + args.line, + repo.connectionId ?? null ) } @@ -5576,6 +5942,30 @@ export class OrcaRuntimeService { return deleteIssueCommentBySlug(args) } + private getSetupHookTrustPayload( + repo: Repo, + setupScript: string | undefined + ): { contentHash: string; scriptContent: string } | undefined { + const scriptContent = setupScript?.trim() + if (!scriptContent || repo.hookSettings?.commandSourcePolicy === 'local-only') { + return undefined + } + return { + contentHash: createHash('sha256').update(scriptContent).digest('hex'), + scriptContent + } + } + + private getSharedSetupHookTrustPayload( + repo: Repo, + sharedSetupScript: string | undefined + ): { contentHash: string; scriptContent: string } | undefined { + if (repo.hookSettings?.commandSourcePolicy === 'local-only') { + return undefined + } + return this.getSetupHookTrustPayload(repo, sharedSetupScript) + } + async getRepoHooks(repoSelector: string) { const repo = await this.resolveRepoSelector(repoSelector) if (repo.connectionId) { @@ -5595,7 +5985,8 @@ export class OrcaRuntimeService { hasHooksFile: Boolean(hooks), hooks, setupRunPolicy: getEffectiveSetupRunPolicy(repo), - source: hooks ? 'orca.yaml' : null + source: hooks ? 'orca.yaml' : null, + setupTrust: this.getSharedSetupHookTrustPayload(repo, hooks?.scripts?.setup) } } catch { return { @@ -5608,12 +5999,14 @@ export class OrcaRuntimeService { } const hasFile = hasHooksFile(repo.path) const hooks = getEffectiveHooks(repo) + const sharedHooks = hasFile ? loadHooks(repo.path) : null const setupRunPolicy = getEffectiveSetupRunPolicy(repo) return { hasHooksFile: hasFile, hooks, setupRunPolicy, - source: hasFile ? 'orca.yaml' : hooks ? 'legacy' : null + source: hasFile ? 'orca.yaml' : hooks ? 'legacy' : null, + setupTrust: this.getSharedSetupHookTrustPayload(repo, sharedHooks?.scripts?.setup) } } @@ -5883,6 +6276,341 @@ export class OrcaRuntimeService { return { repoId: repo.id, worktreeId: worktree.id, activated: true } } + private async buildStartupForDraft( + repo: Repo, + draft: string, + requestedAgent?: TuiAgent + ): Promise<{ + agent: TuiAgent + startup: WorktreeStartupLaunch + draftPaste?: WorktreeStartupDraftPaste + } | null> { + if (!this.store) { + return null + } + const content = draft.trim() + if (!content) { + return null + } + const settings = this.store.getSettings() + const preferredAgent = requestedAgent ?? settings.defaultTuiAgent + if (preferredAgent === 'blank') { + // Why: `blank` is an explicit user preference to create a shell-only + // workspace, so linked task drafts must not auto-pick a detected agent. + return null + } + let agent = isTuiAgent(preferredAgent) ? preferredAgent : null + if (!agent) { + let detected: string[] = [] + try { + detected = repo.connectionId + ? await detectRemoteAgents({ connectionId: repo.connectionId }) + : await detectInstalledAgents() + } catch { + detected = [] + } + const typedDetected = detected.filter(isTuiAgent) + agent = pickTuiAgent(null, typedDetected) + } + if (!agent) { + return null + } + + // Why: a mobile client can run on Windows while the workspace shell is + // Linux over SSH. Startup command quoting must target the shell that runs it. + const agentLaunchPlatform: NodeJS.Platform = repo.connectionId ? 'linux' : process.platform + const draftLaunchPlan = buildAgentDraftLaunchPlan({ + agent, + draft: content, + cmdOverrides: settings.agentCmdOverrides ?? {}, + platform: agentLaunchPlatform + }) + if (draftLaunchPlan) { + return { + agent, + startup: { + command: draftLaunchPlan.launchCommand, + ...(draftLaunchPlan.env ? { env: draftLaunchPlan.env } : {}) + } + } + } + + const startupPlan = buildAgentStartupPlan({ + agent, + prompt: '', + cmdOverrides: settings.agentCmdOverrides ?? {}, + platform: agentLaunchPlatform, + allowEmptyPromptLaunch: true + }) + if (!startupPlan) { + return null + } + return { + agent, + startup: { + command: startupPlan.launchCommand, + ...(startupPlan.env ? { env: startupPlan.env } : {}) + }, + draftPaste: { agent, content } + } + } + + private markLocalWorkspaceTrustedForAgent(agent: TuiAgent, workspacePath: string): void { + const preset = TUI_AGENT_CONFIG[agent].preflightTrust + if (!preset) { + return + } + try { + if (preset === 'cursor') { + markCursorWorkspaceTrusted(workspacePath) + } else if (preset === 'copilot') { + markCopilotFolderTrusted(workspacePath) + } else if (preset === 'codex') { + markCodexProjectTrusted(workspacePath) + } + } catch { + // Best-effort: the user can still accept the agent trust prompt manually. + } + } + + private async markRemoteWorkspaceTrustedForAgent( + agent: TuiAgent, + connectionId: string, + workspacePath: string + ): Promise { + const preset = TUI_AGENT_CONFIG[agent].preflightTrust + if (!preset) { + return + } + try { + const home = await this.resolveRemoteHome(connectionId) + const fsProvider = getSshFilesystemProvider(connectionId) + if (!home || !fsProvider) { + return + } + const absPath = await this.canonicalizeRemoteWorkspacePath(fsProvider, workspacePath) + if (preset === 'codex') { + await this.markRemoteCodexProjectTrusted(fsProvider, home, absPath) + } else if (preset === 'cursor') { + await this.markRemoteCursorWorkspaceTrusted(fsProvider, home, absPath) + } else if (preset === 'copilot') { + await this.markRemoteCopilotFolderTrusted(fsProvider, home, absPath) + } + } catch { + // Best-effort: the user can still accept the remote agent trust prompt manually. + } + } + + private async resolveRemoteHome(connectionId: string): Promise { + const mux = getActiveMultiplexer(connectionId) + if (!mux || mux.isDisposed?.()) { + return null + } + const result = (await mux.request('session.resolveHome', { path: '~' })) as { + resolvedPath?: unknown + } + const home = typeof result.resolvedPath === 'string' ? result.resolvedPath.trim() : '' + return home && home.startsWith('/') && !/[\u0000\r\n]/.test(home) + ? home.replace(/\/$/, '') + : null + } + + private async canonicalizeRemoteWorkspacePath( + fsProvider: IFilesystemProvider, + workspacePath: string + ): Promise { + try { + return await fsProvider.realpath(workspacePath) + } catch { + return workspacePath + } + } + + private async readRemoteTextFile( + fsProvider: IFilesystemProvider, + filePath: string + ): Promise { + try { + const result = await fsProvider.readFile(filePath) + return result.isBinary ? '' : result.content + } catch { + return '' + } + } + + private async markRemoteCodexProjectTrusted( + fsProvider: IFilesystemProvider, + remoteHome: string, + workspacePath: string + ): Promise { + const codexDir = `${remoteHome}/.codex` + const configPath = `${codexDir}/config.toml` + const existing = await this.readRemoteTextFile(fsProvider, configPath) + const updated = upsertProjectTrustLevelInContent(existing, workspacePath, 'trusted') + if (updated === existing) { + return + } + await fsProvider.createDir(codexDir) + await fsProvider.writeFile(configPath, updated) + } + + private async markRemoteCursorWorkspaceTrusted( + fsProvider: IFilesystemProvider, + remoteHome: string, + workspacePath: string + ): Promise { + const slug = workspacePath.replace(/^[\\/]+/, '').replace(/[\\/]+/g, '-') + if (!slug) { + return + } + const trustDir = `${remoteHome}/.cursor/projects/${slug}` + const trustFile = `${trustDir}/.workspace-trusted` + try { + await fsProvider.stat(trustFile) + return + } catch { + // Missing marker: write the same shape the local trust preset writes. + } + await fsProvider.createDir(trustDir) + await fsProvider.writeFile( + trustFile, + `${JSON.stringify({ trustedAt: new Date().toISOString(), workspacePath }, null, 2)}\n` + ) + } + + private async markRemoteCopilotFolderTrusted( + fsProvider: IFilesystemProvider, + remoteHome: string, + workspacePath: string + ): Promise { + const configDir = `${remoteHome}/.copilot` + const configPath = `${configDir}/config.json` + const raw = await this.readRemoteTextFile(fsProvider, configPath) + let config: Record = {} + if (raw.trim()) { + try { + const parsed = JSON.parse(raw) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + config = parsed as Record + } + } catch { + return + } + } + const existing = Array.isArray(config.trustedFolders) + ? (config.trustedFolders as unknown[]) + : [] + if (existing.includes(workspacePath)) { + return + } + config.trustedFolders = [ + ...existing.filter((entry) => typeof entry === 'string'), + workspacePath + ] + await fsProvider.createDir(configDir) + await fsProvider.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`) + } + + private pasteStartupDraftWhenReady(handle: string, draft: WorktreeStartupDraftPaste): void { + void this.waitForStartupDraftReady(handle, draft.agent) + .then((ptyId) => { + if (!ptyId) { + console.warn('[worktree-create] agent did not become ready for draft paste') + return + } + this.ptyController?.write( + ptyId, + `${BRACKETED_PASTE_BEGIN}${draft.content}${BRACKETED_PASTE_END}` + ) + }) + .catch((error) => { + console.warn('[worktree-create] failed to paste startup draft:', error) + }) + } + + private waitForStartupDraftReady(handle: string, agent: TuiAgent): Promise { + const livePty = this.getLivePtyForHandle(handle) + const ptyId = livePty?.pty.ptyId + if (!ptyId) { + return Promise.resolve(null) + } + const readySignal = + TUI_AGENT_CONFIG[agent].draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste' + return new Promise((resolve) => { + let settled = false + let recent = '' + let postHandshakeRecent = '' + let saw2004 = false + let quietTimer: NodeJS.Timeout | null = null + let hardTimer: NodeJS.Timeout | null = null + let unsubscribe: (() => void) | null = null + + const finish = (value: string | null): void => { + if (settled) { + return + } + settled = true + if (quietTimer) { + clearTimeout(quietTimer) + } + if (hardTimer) { + clearTimeout(hardTimer) + } + unsubscribe?.() + resolve(value) + } + + const armQuietTimer = (): void => { + if (quietTimer) { + clearTimeout(quietTimer) + } + quietTimer = setTimeout(() => finish(ptyId), BRACKETED_PASTE_QUIET_MS) + } + + const observeData = (data: string): void => { + const combined = recent + data + recent = combined.slice(-512) + if (!saw2004) { + const markerIndex = combined.indexOf(DECSET_BRACKETED_PASTE) + if (markerIndex === -1) { + return + } + saw2004 = true + const postHandshakeChunk = combined.slice(markerIndex + DECSET_BRACKETED_PASTE.length) + if (readySignal === 'codex-composer-prompt') { + if (postHandshakeChunk.includes(CODEX_COMPOSER_PROMPT)) { + finish(ptyId) + return + } + postHandshakeRecent = postHandshakeChunk.slice(-512) + return + } + postHandshakeRecent = postHandshakeChunk.slice(-512) + } else { + if ( + readySignal === 'codex-composer-prompt' && + (data.includes(CODEX_COMPOSER_PROMPT) || + (postHandshakeRecent + data).includes(CODEX_COMPOSER_PROMPT)) + ) { + finish(ptyId) + return + } + postHandshakeRecent = (postHandshakeRecent + data).slice(-512) + } + if (readySignal !== 'codex-composer-prompt' && saw2004) { + armQuietTimer() + } + } + + unsubscribe = this.subscribeToTerminalData(ptyId, observeData) + const replay = this.recentPtyOutputById.get(ptyId) + if (replay) { + observeData(replay) + } + hardTimer = setTimeout(() => finish(null), DRAFT_PASTE_READY_TIMEOUT_MS) + }) + } + async createManagedWorktree(args: { repoSelector: string name: string @@ -5904,6 +6632,8 @@ export class OrcaRuntimeService { setupDecision?: 'run' | 'skip' | 'inherit' createdWithAgent?: TuiAgent startup?: WorktreeStartupLaunch + startupDraft?: string + startupDraftPaste?: WorktreeStartupDraftPaste lineage?: WorktreeLineageInput }): Promise { if (!this.store) { @@ -5914,8 +6644,19 @@ export class OrcaRuntimeService { if (isFolderRepo(repo)) { throw new Error('Folder mode does not support creating worktrees.') } + const draftStartup = args.startupDraft + ? await this.buildStartupForDraft(repo, args.startupDraft, args.createdWithAgent) + : null + const effectiveStartup = args.startup ?? draftStartup?.startup + const effectiveCreatedWithAgent = args.createdWithAgent ?? draftStartup?.agent + const effectiveDraftPaste = args.startupDraftPaste ?? draftStartup?.draftPaste if (repo.connectionId) { - return await this.createManagedRemoteWorktree(repo, args) + return await this.createManagedRemoteWorktree(repo, { + ...args, + ...(effectiveStartup ? { startup: effectiveStartup } : {}), + ...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}), + ...(effectiveDraftPaste ? { startupDraftPaste: effectiveDraftPaste } : {}) + }) } const lineageInput = args.lineage || args.comment ? { ...args.lineage, comment: args.comment } : undefined @@ -6118,11 +6859,11 @@ export class OrcaRuntimeService { ...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}), - ...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}), ...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}), - ...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}), + ...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}), + ...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}), ...(args.comment !== undefined ? { comment: args.comment } : {}), ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) @@ -6226,16 +6967,25 @@ export class OrcaRuntimeService { const shouldActivate = args.activate === true || args.runHooks === true let didSpawnStartup = false let didSpawnSetup = false - if (args.startup && this.ptyController?.spawn) { + let startupTerminalHandle: string | null = null + if (effectiveStartup && this.ptyController?.spawn) { try { // Why: automation startup must not depend on a renderer TerminalPane // mounting. Runtime-spawned PTYs run immediately and the UI adopts the // session later, matching `orca terminal create` background semantics. - await this.createTerminal(`id:${worktree.id}`, { - command: args.startup.command, - env: args.startup.env + const startupTrustAgent = effectiveDraftPaste?.agent ?? effectiveCreatedWithAgent + if (startupTrustAgent) { + this.markLocalWorkspaceTrustedForAgent(startupTrustAgent, worktreePath) + } + const terminal = await this.createTerminal(`id:${worktree.id}`, { + command: effectiveStartup.command, + env: effectiveStartup.env }) + if (effectiveDraftPaste) { + this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste) + } didSpawnStartup = true + startupTerminalHandle = terminal.handle } catch (err) { const message = err instanceof Error ? err.message : String(err) warning = warning @@ -6247,16 +6997,32 @@ export class OrcaRuntimeService { if (didSpawnStartup && setup && this.ptyController?.spawn) { try { // Why: reveal-on-adopt can create the startup tab before renderer - // activation handles setup. Spawn setup in runtime too so startup+setup - // cannot be skipped by the renderer's "terminal already exists" guard. - await this.createTerminal(`id:${worktree.id}`, { - title: 'Setup', - command: buildSetupRunnerCommand( - setup.runnerScriptPath, - process.platform === 'win32' ? 'windows' : 'posix' - ), - env: setup.envVars - }) + // activation handles setup. Honor the same split-vs-tab setting here + // because renderer activation will skip setup once the startup tab exists. + const setupCommand = buildSetupRunnerCommand( + setup.runnerScriptPath, + process.platform === 'win32' ? 'windows' : 'posix' + ) + const setupLaunchMode = + (this.store.getSettings() as Partial>) + .setupScriptLaunchMode ?? 'new-tab' + if (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal') { + if (!startupTerminalHandle) { + throw new Error('startup_terminal_missing') + } + await this.splitTerminal(startupTerminalHandle, { + direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical', + command: setupCommand, + env: setup.envVars, + activate: false + }) + } else { + await this.createTerminal(`id:${worktree.id}`, { + title: 'Setup', + command: setupCommand, + env: setup.envVars + }) + } didSpawnSetup = true } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -6271,8 +7037,8 @@ export class OrcaRuntimeService { // Explicit activation and hook-running still use renderer activation so // the user can watch prompts/output in a visible pane. const activationSetup = didSpawnSetup ? undefined : setup - if (args.startup && !didSpawnStartup) { - this.notifier?.activateWorktree(repo.id, worktree.id, activationSetup, args.startup) + if (effectiveStartup && !didSpawnStartup) { + this.notifier?.activateWorktree(repo.id, worktree.id, activationSetup, effectiveStartup) } else { this.notifier?.activateWorktree(repo.id, worktree.id, activationSetup) } @@ -6334,6 +7100,7 @@ export class OrcaRuntimeService { setupDecision?: 'run' | 'skip' | 'inherit' createdWithAgent?: TuiAgent startup?: WorktreeStartupLaunch + startupDraftPaste?: WorktreeStartupDraftPaste } ): Promise { if (!this.store) { @@ -6381,22 +7148,97 @@ export class OrcaRuntimeService { this.invalidateResolvedWorktreeCache() this.notifier?.worktreesChanged(repo.id) + let warning = result.warning + let didSpawnStartup = false + let didSpawnSetup = false + let startupTerminalHandle: string | null = null if (args.startup && this.ptyController?.spawn) { try { - await this.createTerminal(`path:${result.worktree.path}`, { + const startupTrustAgent = args.startupDraftPaste?.agent ?? args.createdWithAgent + if (startupTrustAgent) { + await this.markRemoteWorkspaceTrustedForAgent( + startupTrustAgent, + repo.connectionId!, + result.worktree.path + ) + } + const terminal = await this.createTerminal(`path:${result.worktree.path}`, { command: args.startup.command, env: args.startup.env }) + if (args.startupDraftPaste) { + this.pasteStartupDraftWhenReady(terminal.handle, args.startupDraftPaste) + } + didSpawnStartup = true + startupTerminalHandle = terminal.handle } catch (err) { const message = err instanceof Error ? err.message : String(err) - return { - ...result, - warning: `Failed to create the startup terminal for ${result.worktree.path}: ${message}` - } + warning = warning + ? `${warning} Also failed to create the startup terminal for ${result.worktree.path}: ${message}` + : `Failed to create the startup terminal for ${result.worktree.path}: ${message}` } } - return result + if (didSpawnStartup && result.setup && this.ptyController?.spawn) { + try { + // Why: remote/mobile task creates spawn the agent terminal in runtime, + // so renderer activation never receives the setup payload. Runtime + // must apply the same user-selected split-vs-tab setup placement. + const setupCommand = buildSetupRunnerCommand( + result.setup.runnerScriptPath, + isWindowsAbsolutePathLike(result.setup.runnerScriptPath) ? 'windows' : 'posix' + ) + const setupLaunchMode = + (this.store.getSettings() as Partial>) + .setupScriptLaunchMode ?? 'new-tab' + if (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal') { + if (!startupTerminalHandle) { + throw new Error('startup_terminal_missing') + } + await this.splitTerminal(startupTerminalHandle, { + direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical', + command: setupCommand, + env: result.setup.envVars, + activate: false + }) + } else { + await this.createTerminal(`path:${result.worktree.path}`, { + title: 'Setup', + command: setupCommand, + env: result.setup.envVars + }) + } + didSpawnSetup = true + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + warning = warning + ? `${warning} Also failed to create the setup terminal for ${result.worktree.path}: ${message}` + : `Failed to create the setup terminal for ${result.worktree.path}: ${message}` + } + } + + if (!args.startup && this.ptyController?.spawn) { + try { + await this.createTerminal(`path:${result.worktree.path}`) + if (result.setup && !didSpawnSetup) { + await this.createTerminal(`path:${result.worktree.path}`, { + title: 'Setup', + command: buildSetupRunnerCommand( + result.setup.runnerScriptPath, + isWindowsAbsolutePathLike(result.setup.runnerScriptPath) ? 'windows' : 'posix' + ), + env: result.setup.envVars + }) + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + warning = warning + ? `${warning} Also failed to create the initial terminal for ${result.worktree.path}: ${message}` + : `Failed to create the initial terminal for ${result.worktree.path}: ${message}` + } + } + + return warning ? { ...result, warning } : result } /** @@ -6844,7 +7686,7 @@ export class OrcaRuntimeService { } async resolveManagedPrBase(args: { - repoId: string + repoSelector: string prNumber: number headRefName?: string isCrossRepository?: boolean @@ -6852,27 +7694,247 @@ export class OrcaRuntimeService { if (!this.store) { throw new Error('runtime_unavailable') } - const repo = this.store.getRepo(args.repoId) - if (!repo) { + let repo: Repo + try { + repo = await this.resolveRepoSelector(args.repoSelector) + } catch { return { error: 'Repo not found' } } - if (repo.connectionId) { - return { error: 'PR start points are not supported for remote repos yet.' } - } if (isFolderRepo(repo)) { return { error: 'Folder mode does not support creating worktrees.' } } + const sshGitProvider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null + const gitExec = sshGitProvider + ? (gitArgs: string[]) => sshGitProvider.exec(gitArgs, repo.path) + : (gitArgs: string[]) => gitExecFileAsync(gitArgs, { cwd: repo.path }) + const resolveRemote = sshGitProvider + ? async () => { + const { stdout } = await sshGitProvider.exec(['remote'], repo.path) + const remotes = stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + if (remotes.includes('origin')) { + return 'origin' + } + if (remotes.length === 1) { + return remotes[0]! + } + if (remotes.length === 0) { + throw new Error('Repo has no configured git remotes.') + } + throw new Error( + `Repo has multiple remotes (${remotes.join(', ')}) and no default is configured.` + ) + } + : () => getDefaultRemote(repo.path) return resolveGitHubPrStartPoint({ repoPath: repo.path, prNumber: args.prNumber, headRefName: args.headRefName, isCrossRepository: args.isCrossRepository, - gitExec: (gitArgs) => gitExecFileAsync(gitArgs, { cwd: repo.path }), - resolveRemote: () => getDefaultRemote(repo.path) + connectionId: repo.connectionId ?? null, + gitExec, + resolveRemote }) } + async resolveManagedMrBase(args: { + repoSelector: string + mrIid: number + sourceBranch?: string + isCrossRepository?: boolean + }): Promise<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }> { + if (!this.store) { + throw new Error('runtime_unavailable') + } + let repo: Repo + try { + repo = await this.resolveRepoSelector(args.repoSelector) + } catch { + return { error: 'Repo not found' } + } + if (isFolderRepo(repo)) { + return { error: 'Folder mode does not support creating worktrees.' } + } + const sshGitProvider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null + const gitExec = sshGitProvider + ? (gitArgs: string[]) => sshGitProvider.exec(gitArgs, repo.path) + : (gitArgs: string[]) => gitExecFileAsync(gitArgs, { cwd: repo.path }) + + let sourceBranch = args.sourceBranch?.trim() ?? '' + let isCrossRepository = args.isCrossRepository === true + + if (!sourceBranch) { + let remote: string + try { + remote = await this.resolveGitLabIssueSourceRemote( + repo.path, + repo.issueSourcePreference, + repo.connectionId ?? null + ) + } catch (error) { + return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' } + } + const knownHosts = await getGlabKnownHosts() + const projectRef = await getGitLabProjectRefForRemote( + repo.path, + remote, + knownHosts, + repo.connectionId ?? null + ) + if (!projectRef) { + return { error: 'No GitLab project found for this repository.' } + } + const item = await getGitLabWorkItemByProjectRef( + repo.path, + projectRef, + args.mrIid, + 'mr', + repo.connectionId ?? null + ) + if (!item || item.type !== 'mr') { + return { error: `MR !${args.mrIid} not found.` } + } + sourceBranch = (item.branchName ?? '').trim() + if (!sourceBranch) { + return { error: `MR !${args.mrIid} has no source branch.` } + } + if (item.isCrossRepository === true) { + isCrossRepository = true + } + } + + let remote: string + try { + remote = await this.resolveGitLabIssueSourceRemote( + repo.path, + repo.issueSourcePreference, + repo.connectionId ?? null + ) + } catch (error) { + return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' } + } + + if (isCrossRepository) { + const mrRef = `refs/merge-requests/${args.mrIid}/head` + // Why: GitLab exposes fork MR heads on the target project, so mobile/SSH + // can match desktop without adding the contributor fork as a remote. + try { + await gitExec(['fetch', remote, mrRef]) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { error: `Failed to fetch ${mrRef}: ${message.split('\n')[0]}` } + } + let sha: string + try { + const { stdout } = await gitExec(['rev-parse', '--verify', 'FETCH_HEAD']) + sha = stdout.trim() + } catch { + return { error: `Could not resolve fork MR !${args.mrIid} head after fetch.` } + } + if (!sha) { + return { error: `Empty SHA resolving fork MR !${args.mrIid} head.` } + } + return { baseBranch: sha } + } + + try { + await gitExec([ + 'fetch', + remote, + `+refs/heads/${sourceBranch}:refs/remotes/${remote}/${sourceBranch}` + ]) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { error: `Failed to fetch ${remote}/${sourceBranch}: ${message.split('\n')[0]}` } + } + + const remoteRef = `${remote}/${sourceBranch}` + try { + await gitExec(['rev-parse', '--verify', remoteRef]) + } catch { + return { error: `Remote ref ${remoteRef} does not exist after fetch.` } + } + return { baseBranch: remoteRef, pushTarget: { remoteName: remote, branchName: sourceBranch } } + } + + private async resolveGitLabIssueSourceRemote( + repoPath: string, + preference?: Repo['issueSourcePreference'], + connectionId?: string | null + ): Promise { + const knownHosts = await getGlabKnownHosts() + if (preference === 'origin') { + const origin = await getGitLabProjectRefForRemote( + repoPath, + 'origin', + knownHosts, + connectionId + ) + if (origin) { + return 'origin' + } + throw new Error('No GitLab project found for origin.') + } + if (preference === 'upstream') { + const upstream = await getGitLabProjectRefForRemote( + repoPath, + 'upstream', + knownHosts, + connectionId + ) + if (upstream) { + return 'upstream' + } + const origin = await getGitLabProjectRefForRemote( + repoPath, + 'origin', + knownHosts, + connectionId + ) + if (origin) { + return 'origin' + } + throw new Error('No GitLab project found for upstream or origin.') + } + const upstream = await getGitLabProjectRefForRemote( + repoPath, + 'upstream', + knownHosts, + connectionId + ) + if (upstream) { + return 'upstream' + } + const origin = await getGitLabProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId) + if (origin) { + return 'origin' + } + if (connectionId) { + const provider = requireSshGitProvider(connectionId) + const { stdout } = await provider.exec(['remote'], repoPath) + const remotes = stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + if (remotes.includes('origin')) { + return 'origin' + } + if (remotes.length === 1) { + return remotes[0]! + } + if (remotes.length === 0) { + throw new Error('Repo has no configured git remotes.') + } + throw new Error( + `Repo has multiple remotes (${remotes.join(', ')}) and no default is configured.` + ) + } + return getDefaultRemote(repoPath) + } + async removeManagedWorktree( worktreeSelector: string, force = false, @@ -7528,13 +8590,18 @@ export class OrcaRuntimeService { async splitTerminal( handle: string, - opts: { direction?: 'horizontal' | 'vertical'; command?: string } = {} + opts: { + direction?: 'horizontal' | 'vertical' + command?: string + env?: Record + activate?: boolean + } = {} ): Promise { - this.assertGraphReady() const livePty = this.getLivePtyForHandle(handle) if (livePty) { return await this.splitPtyBackedTerminal(livePty.pty, opts) } + this.assertGraphReady() const { leaf } = this.getLiveLeafForHandle(handle) const direction = opts.direction ?? 'horizontal' @@ -7558,7 +8625,12 @@ export class OrcaRuntimeService { private async splitPtyBackedTerminal( pty: RuntimePtyWorktreeRecord, - opts: { direction?: 'horizontal' | 'vertical'; command?: string } = {} + opts: { + direction?: 'horizontal' | 'vertical' + command?: string + env?: Record + activate?: boolean + } = {} ): Promise { if (!this.ptyController?.spawn) { throw new Error('runtime_unavailable') @@ -7583,6 +8655,7 @@ export class OrcaRuntimeService { cwd: worktree.path, command: opts.command, env: { + ...opts.env, ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: parentTabId, ORCA_WORKTREE_ID: worktree.id @@ -7603,7 +8676,7 @@ export class OrcaRuntimeService { await this.notifier?.revealTerminalSession?.(worktree.id, { ptyId: result.id, title: null, - activate: true, + activate: opts.activate !== false, tabId: parentTabId, leafId, splitFromLeafId: parsedPaneKey.leafId, @@ -8146,6 +9219,12 @@ export class OrcaRuntimeService { throw new Error('repo_not_found') } + private assertHostIntegrationRepoIsLocal(repo: Repo, operation: string): void { + if (repo.connectionId) { + throw new Error(`${operation}_unsupported_for_ssh_repo`) + } + } + private requireStore(): Store { if (!this.store) { throw new Error('runtime_unavailable') diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 29cce7eb827..7c591597c21 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -11,6 +11,80 @@ function makeRequest(method: string, params?: unknown): RpcRequest { } describe('client UI RPC methods', () => { + it('returns the runtime host agent settings needed by mobile create flows', async () => { + const settings = { + defaultTuiAgent: 'codex', + agentCmdOverrides: { codex: 'codex --profile work' }, + defaultTaskSource: 'gitlab', + defaultTaskViewPreset: 'my-prs', + visibleTaskProviders: ['github', 'gitlab'], + defaultRepoSelection: ['repo-1'], + defaultLinearTeamSelection: ['team-1'], + githubProjects: { + pinned: [], + recent: [], + lastViewByProject: {}, + activeProject: null + } + } + const runtime = { + getRuntimeId: () => 'test-runtime', + getClientSettings: vi.fn(() => settings) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('settings.get')) + + expect(runtime.getClientSettings).toHaveBeenCalledTimes(1) + expect(response).toMatchObject({ ok: true, result: { settings } }) + }) + + it('persists the runtime host task source setting for mobile Tasks', async () => { + const settings = { + defaultTuiAgent: null, + agentCmdOverrides: {}, + defaultTaskSource: 'linear', + defaultTaskViewPreset: 'issues', + visibleTaskProviders: ['github', 'linear'], + defaultRepoSelection: ['repo-1', 'repo-2'], + defaultLinearTeamSelection: ['team-1', 'team-2'], + githubProjects: { + pinned: [], + recent: [], + lastViewByProject: { + 'organization:stablyai:1': { viewId: 'view-1' } + }, + activeProject: { owner: 'stablyai', ownerType: 'organization', number: 1 } + } + } + const runtime = { + getRuntimeId: () => 'test-runtime', + updateClientSettings: vi.fn(() => settings) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('settings.update', { + defaultTuiAgent: 'codex', + defaultTaskSource: 'linear', + defaultTaskViewPreset: 'my-prs', + defaultRepoSelection: settings.defaultRepoSelection, + defaultLinearTeamSelection: ['team-1', 'team-2'], + githubProjects: settings.githubProjects + }) + ) + + expect(runtime.updateClientSettings).toHaveBeenCalledWith({ + defaultTuiAgent: 'codex', + defaultTaskSource: 'linear', + defaultTaskViewPreset: 'my-prs', + defaultRepoSelection: settings.defaultRepoSelection, + defaultLinearTeamSelection: ['team-1', 'team-2'], + githubProjects: settings.githubProjects + }) + expect(response).toMatchObject({ ok: true, result: { settings } }) + }) + it('returns the runtime host persisted UI state', async () => { const ui: PersistedUIState = { ...getDefaultUIState(), @@ -66,7 +140,10 @@ describe('client UI RPC methods', () => { statusBarItems: ['codex'], taskResumeState: { githubMode: 'items', - githubItemsQuery: 'is:open' + githubItemsQuery: 'is:open', + githubProjectHiddenFieldIdsByView: { + 'project-1:view-1': ['field-1'] + } }, workspaceCleanup: { dismissals: { @@ -88,7 +165,13 @@ describe('client UI RPC methods', () => { const payload = { worktreeCardProperties: ['status', 'inline-agents'], statusBarItems: ['codex'], - taskResumeState: { githubMode: 'items', githubItemsQuery: 'is:open' }, + taskResumeState: { + githubMode: 'items', + githubItemsQuery: 'is:open', + githubProjectHiddenFieldIdsByView: { + 'project-1:view-1': ['field-1'] + } + }, workspaceCleanup: { dismissals: { 'repo::/worktree': { diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 6aba227c7d5..f9c857a05d7 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { isTuiAgent } from '../../../../shared/tui-agent-config' import type { PersistedUIState } from '../../../../shared/types' import { defineMethod, type RpcMethod } from '../core' @@ -27,6 +28,7 @@ const TaskResumeState = z githubMode: z.enum(['items', 'project']).optional(), githubItemsPreset: z.string().nullable().optional(), githubItemsQuery: z.string().optional(), + githubProjectHiddenFieldIdsByView: z.record(z.string(), z.array(z.string())).optional(), linearPreset: z.enum(['assigned', 'created', 'all', 'completed']).optional(), linearQuery: z.string().optional() }) @@ -44,6 +46,44 @@ const WorkspaceCleanup = z dismissals: z.record(z.string(), WorkspaceCleanupDismissal) }) .strict() +const GitHubProjectRef = z + .object({ + owner: z.string(), + ownerType: z.enum(['organization', 'user']), + number: z.number().int() + }) + .strict() +const GitHubProjectSettings = z + .object({ + pinned: z.array(GitHubProjectRef), + recent: z.array( + GitHubProjectRef.extend({ + lastOpenedAt: z.string() + }).strict() + ), + lastViewByProject: z.record(z.string(), z.object({ viewId: z.string() }).strict()), + activeProject: GitHubProjectRef.nullable() + }) + .strict() + +const SettingsUpdate = z + .object({ + defaultTuiAgent: z + .unknown() + .transform((value) => + value === null || value === 'blank' || isTuiAgent(value) ? value : undefined + ) + .optional(), + defaultTaskSource: z.enum(['github', 'gitlab', 'linear']).optional(), + defaultTaskViewPreset: z + .enum(['issues', 'my-issues', 'prs', 'my-prs', 'review', 'all']) + .optional(), + defaultRepoSelection: z.array(z.string()).nullable().optional(), + defaultLinearTeamSelection: z.array(z.string()).nullable().optional(), + githubProjects: GitHubProjectSettings.optional() + }) + .strict() + .default({}) const UiUpdate = z .object({ @@ -120,6 +160,16 @@ const UiUpdate = z .default({}) export const CLIENT_UI_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'settings.get', + params: null, + handler: (_params, { runtime }) => ({ settings: runtime.getClientSettings() }) + }), + defineMethod({ + name: 'settings.update', + params: SettingsUpdate, + handler: (params, { runtime }) => ({ settings: runtime.updateClientSettings(params) }) + }), defineMethod({ name: 'ui.get', params: null, diff --git a/src/main/runtime/rpc/methods/github.test.ts b/src/main/runtime/rpc/methods/github.test.ts index 94d7c1ca90c..dc05051f970 100644 --- a/src/main/runtime/rpc/methods/github.test.ts +++ b/src/main/runtime/rpc/methods/github.test.ts @@ -300,6 +300,34 @@ describe('github RPC methods', () => { expect(response).toMatchObject({ ok: true, result: true }) }) + it('updates PR metadata on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateRepoPRDetails: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.updatePR', { + repo: 'repo-1', + prNumber: 7, + updates: { title: 'New title', body: 'Updated body' }, + prRepo: { owner: 'acme', repo: 'widgets' } + }) + ) + + expect(runtime.updateRepoPRDetails).toHaveBeenCalledWith( + 'repo-1', + 7, + { title: 'New title', body: 'Updated body' }, + { + owner: 'acme', + repo: 'widgets' + } + ) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + it('merges PRs on the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', @@ -323,6 +351,35 @@ describe('github RPC methods', () => { expect(response).toMatchObject({ ok: true, result: { ok: true } }) }) + it('routes PR reviewer mutations on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + requestRepoPRReviewers: vi.fn().mockResolvedValue({ ok: true }), + removeRepoPRReviewers: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const requestResponse = await dispatcher.dispatch( + makeRequest('github.requestPRReviewers', { + repo: 'repo-1', + prNumber: 7, + reviewers: ['octo'] + }) + ) + const removeResponse = await dispatcher.dispatch( + makeRequest('github.removePRReviewers', { + repo: 'repo-1', + prNumber: 7, + reviewers: ['octo'] + }) + ) + + expect(runtime.requestRepoPRReviewers).toHaveBeenCalledWith('repo-1', 7, ['octo']) + expect(runtime.removeRepoPRReviewers).toHaveBeenCalledWith('repo-1', 7, ['octo']) + expect(requestResponse).toMatchObject({ ok: true, result: { ok: true } }) + expect(removeResponse).toMatchObject({ ok: true, result: { ok: true } }) + }) + it('updates PR state on the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index 78ef65b036e..6384f8bdfdd 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -103,6 +103,15 @@ const UpdatePrTitle = RepoSelector.extend({ prRepo: SlugRepo.nullable().optional() }) +const UpdatePr = RepoSelector.extend({ + prNumber: z.number().int().positive(), + updates: z.object({ + title: OptionalString, + body: z.string().optional() + }), + prRepo: SlugRepo.nullable().optional() +}) + const MergePr = RepoSelector.extend({ prNumber: z.number().int().positive(), method: z.enum(['merge', 'squash', 'rebase']).optional(), @@ -398,6 +407,17 @@ export const GITHUB_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.updateRepoPRTitle(params.repo, params.prNumber, params.title, params.prRepo ?? null) }), + defineMethod({ + name: 'github.updatePR', + params: UpdatePr, + handler: async (params, { runtime }) => + runtime.updateRepoPRDetails( + params.repo, + params.prNumber, + params.updates, + params.prRepo ?? null + ) + }), defineMethod({ name: 'github.mergePR', params: MergePr, diff --git a/src/main/runtime/rpc/methods/gitlab.test.ts b/src/main/runtime/rpc/methods/gitlab.test.ts new file mode 100644 index 00000000000..fb2952467b3 --- /dev/null +++ b/src/main/runtime/rpc/methods/gitlab.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { GITLAB_METHODS } from './gitlab' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('gitlab RPC methods', () => { + it('routes GitLab task queries and mutations to the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + listGitLabRepoWorkItems: vi.fn().mockResolvedValue({ items: [] }), + listGitLabRepoTodos: vi.fn().mockResolvedValue([{ id: 1 }]), + createGitLabRepoIssue: vi.fn().mockResolvedValue({ ok: true, number: 7 }), + updateGitLabRepoIssue: vi.fn().mockResolvedValue({ ok: true }), + addGitLabRepoIssueComment: vi.fn().mockResolvedValue({ ok: true }), + addGitLabRepoMRComment: vi.fn().mockResolvedValue({ ok: true }), + mergeGitLabRepoMR: vi.fn().mockResolvedValue({ ok: true }), + updateGitLabRepoMRState: vi.fn().mockResolvedValue({ ok: true }), + updateGitLabRepoMR: vi.fn().mockResolvedValue({ ok: true }), + getGitLabRepoWorkItemDetails: vi.fn().mockResolvedValue({ body: 'Details' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITLAB_METHODS }) + const projectRef = { host: 'gitlab.example.com', path: 'group/project' } + + await dispatcher.dispatch( + makeRequest('gitlab.listWorkItems', { + repo: 'id:repo-1', + state: 'opened', + page: 1, + perPage: 25, + query: 'bug' + }) + ) + await dispatcher.dispatch( + makeRequest('gitlab.createIssue', { + repo: 'id:repo-1', + title: 'Fix bug', + body: 'Details' + }) + ) + await dispatcher.dispatch(makeRequest('gitlab.todos', { repo: 'id:repo-1' })) + await dispatcher.dispatch( + makeRequest('gitlab.updateIssue', { + repo: 'id:repo-1', + number: 7, + updates: { state: 'closed', title: 'Done', body: 'Updated body' }, + projectRef + }) + ) + await dispatcher.dispatch( + makeRequest('gitlab.addIssueComment', { + repo: 'id:repo-1', + number: 7, + body: 'looks good', + projectRef + }) + ) + await dispatcher.dispatch( + makeRequest('gitlab.addMRComment', { + repo: 'id:repo-1', + iid: 8, + body: 'ship it', + projectRef + }) + ) + await dispatcher.dispatch( + makeRequest('gitlab.mergeMR', { + repo: 'id:repo-1', + iid: 8, + method: 'squash', + projectRef + }) + ) + await dispatcher.dispatch( + makeRequest('gitlab.updateMRState', { + repo: 'id:repo-1', + iid: 8, + state: 'closed', + projectRef + }) + ) + await dispatcher.dispatch( + makeRequest('gitlab.updateMR', { + repo: 'id:repo-1', + iid: 8, + updates: { title: 'New title', body: 'New body', addLabels: ['bug'] }, + projectRef + }) + ) + await dispatcher.dispatch( + makeRequest('gitlab.workItemDetails', { + repo: 'id:repo-1', + iid: 8, + type: 'mr', + projectRef + }) + ) + + expect(runtime.listGitLabRepoWorkItems).toHaveBeenCalledWith( + 'id:repo-1', + 'opened', + 1, + 25, + 'bug' + ) + expect(runtime.createGitLabRepoIssue).toHaveBeenCalledWith('id:repo-1', 'Fix bug', 'Details') + expect(runtime.listGitLabRepoTodos).toHaveBeenCalledWith('id:repo-1') + expect(runtime.updateGitLabRepoIssue).toHaveBeenCalledWith( + 'id:repo-1', + 7, + { + state: 'closed', + title: 'Done', + body: 'Updated body' + }, + projectRef + ) + expect(runtime.addGitLabRepoIssueComment).toHaveBeenCalledWith( + 'id:repo-1', + 7, + 'looks good', + projectRef + ) + expect(runtime.addGitLabRepoMRComment).toHaveBeenCalledWith( + 'id:repo-1', + 8, + 'ship it', + projectRef + ) + expect(runtime.mergeGitLabRepoMR).toHaveBeenCalledWith('id:repo-1', 8, 'squash', projectRef) + expect(runtime.updateGitLabRepoMRState).toHaveBeenCalledWith( + 'id:repo-1', + 8, + 'closed', + projectRef + ) + expect(runtime.updateGitLabRepoMR).toHaveBeenCalledWith( + 'id:repo-1', + 8, + { + title: 'New title', + body: 'New body', + addLabels: ['bug'] + }, + projectRef + ) + expect(runtime.getGitLabRepoWorkItemDetails).toHaveBeenCalledWith( + 'id:repo-1', + 8, + 'mr', + projectRef + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/gitlab.ts b/src/main/runtime/rpc/methods/gitlab.ts new file mode 100644 index 00000000000..09a1c866cdd --- /dev/null +++ b/src/main/runtime/rpc/methods/gitlab.ts @@ -0,0 +1,151 @@ +import { z } from 'zod' +import { defineMethod, type RpcMethod } from '../core' +import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' + +const RepoSelector = z.object({ + repo: requiredString('Missing repo selector') +}) + +const GitLabProjectRef = z + .object({ + host: requiredString('Missing GitLab host'), + path: requiredString('Missing GitLab project path') + }) + .optional() + +const WorkItemsList = RepoSelector.extend({ + state: z.enum(['opened', 'merged', 'closed', 'all']).optional(), + page: OptionalFiniteNumber, + perPage: OptionalFiniteNumber, + query: OptionalString +}) + +const CreateIssue = RepoSelector.extend({ + title: requiredString('Missing title'), + body: z.string() +}) + +const IssueUpdate = z.object({ + state: z.enum(['opened', 'closed']).optional(), + title: z.string().optional(), + body: z.string().optional(), + addLabels: z.array(z.string()).optional(), + removeLabels: z.array(z.string()).optional(), + addAssignees: z.array(z.string()).optional(), + removeAssignees: z.array(z.string()).optional() +}) + +const UpdateIssue = RepoSelector.extend({ + number: z.number().int().positive(), + updates: IssueUpdate, + projectRef: GitLabProjectRef +}) + +const UpdateMrState = RepoSelector.extend({ + iid: z.number().int().positive(), + state: z.enum(['opened', 'closed']), + projectRef: GitLabProjectRef +}) + +const UpdateMr = RepoSelector.extend({ + iid: z.number().int().positive(), + updates: z.object({ + title: z.string().optional(), + body: z.string().optional(), + addLabels: z.array(z.string()).optional(), + removeLabels: z.array(z.string()).optional() + }), + projectRef: GitLabProjectRef +}) + +const MergeMr = RepoSelector.extend({ + iid: z.number().int().positive(), + method: z.enum(['merge', 'squash', 'rebase']).optional(), + projectRef: GitLabProjectRef +}) + +const AddIssueComment = RepoSelector.extend({ + number: z.number().int().positive(), + body: requiredString('Comment body is required'), + projectRef: GitLabProjectRef +}) + +const AddMRComment = RepoSelector.extend({ + iid: z.number().int().positive(), + body: requiredString('Comment body is required'), + projectRef: GitLabProjectRef +}) + +const WorkItemDetails = RepoSelector.extend({ + iid: z.number().int().positive(), + type: z.enum(['issue', 'mr']), + projectRef: GitLabProjectRef +}) + +export const GITLAB_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'gitlab.listWorkItems', + params: WorkItemsList, + handler: async (params, { runtime }) => + runtime.listGitLabRepoWorkItems( + params.repo, + params.state, + params.page, + params.perPage, + params.query + ) + }), + defineMethod({ + name: 'gitlab.todos', + params: RepoSelector, + handler: async (params, { runtime }) => runtime.listGitLabRepoTodos(params.repo) + }), + defineMethod({ + name: 'gitlab.createIssue', + params: CreateIssue, + handler: async (params, { runtime }) => + runtime.createGitLabRepoIssue(params.repo, params.title, params.body) + }), + defineMethod({ + name: 'gitlab.updateIssue', + params: UpdateIssue, + handler: async (params, { runtime }) => + runtime.updateGitLabRepoIssue(params.repo, params.number, params.updates, params.projectRef) + }), + defineMethod({ + name: 'gitlab.addIssueComment', + params: AddIssueComment, + handler: async (params, { runtime }) => + runtime.addGitLabRepoIssueComment(params.repo, params.number, params.body, params.projectRef) + }), + defineMethod({ + name: 'gitlab.addMRComment', + params: AddMRComment, + handler: async (params, { runtime }) => + runtime.addGitLabRepoMRComment(params.repo, params.iid, params.body, params.projectRef) + }), + defineMethod({ + name: 'gitlab.mergeMR', + params: MergeMr, + handler: async (params, { runtime }) => + runtime.mergeGitLabRepoMR(params.repo, params.iid, params.method, params.projectRef) + }), + defineMethod({ + name: 'gitlab.updateMRState', + params: UpdateMrState, + handler: async (params, { runtime }) => + runtime.updateGitLabRepoMRState(params.repo, params.iid, params.state, params.projectRef) + }), + defineMethod({ + name: 'gitlab.updateMR', + params: UpdateMr, + handler: async (params, { runtime }) => + runtime.updateGitLabRepoMR(params.repo, params.iid, params.updates, params.projectRef) + }), + defineMethod({ + name: 'gitlab.workItemDetails', + params: WorkItemDetails, + handler: async (params, { runtime }) => + runtime.getGitLabRepoWorkItemDetails(params.repo, params.iid, params.type, params.projectRef) + }) +] diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index b53c220fd38..2d4002e79c3 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -17,8 +17,10 @@ import { SESSION_TAB_METHODS } from './session-tabs' import { FILE_METHODS } from './files' import { GIT_METHODS } from './git' import { GITHUB_METHODS } from './github' +import { GITLAB_METHODS } from './gitlab' import { HOSTED_REVIEW_METHODS } from './hosted-review' import { LINEAR_METHODS } from './linear' +import { SSH_METHODS } from './ssh' import { SPEECH_METHODS } from './speech' import { CLIENT_UI_METHODS } from './client-ui' import { WORKSPACE_PORT_METHODS } from './workspace-ports' @@ -45,8 +47,10 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...FILE_METHODS, ...GIT_METHODS, ...GITHUB_METHODS, + ...GITLAB_METHODS, ...HOSTED_REVIEW_METHODS, ...LINEAR_METHODS, + ...SSH_METHODS, ...SPEECH_METHODS, ...WORKSPACE_PORT_METHODS, ...CLIENT_UI_METHODS diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index 7f05a2cb5ee..792484a5f45 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -61,9 +61,78 @@ describe('repo RPC methods', () => { }) }) + it('lists sparse checkout presets for a repo', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + listSparsePresets: vi.fn().mockResolvedValue([ + { + id: 'preset-1', + repoId: 'repo-1', + name: 'Frontend', + directories: ['src/renderer'], + createdAt: 1, + updatedAt: 2 + } + ]) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('repo.sparsePresets', { repo: 'repo-1' }) + ) + + expect(runtime.listSparsePresets).toHaveBeenCalledWith('repo-1') + expect(response).toMatchObject({ + ok: true, + result: { presets: [{ id: 'preset-1', directories: ['src/renderer'] }] } + }) + }) + + it('saves sparse checkout presets for a repo', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + saveSparsePreset: vi.fn().mockResolvedValue({ + id: 'preset-1', + repoId: 'repo-1', + name: 'Frontend', + directories: ['src/renderer'], + createdAt: 1, + updatedAt: 2 + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('repo.saveSparsePreset', { + repo: 'repo-1', + name: 'Frontend', + directories: ['src/renderer'] + }) + ) + + expect(runtime.saveSparsePreset).toHaveBeenCalledWith('repo-1', { + name: 'Frontend', + directories: ['src/renderer'] + }) + expect(response).toMatchObject({ + ok: true, + result: { preset: { id: 'preset-1', directories: ['src/renderer'] } } + }) + }) + it('routes repository hook operations to the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + getRepoHooks: vi.fn().mockResolvedValue({ + hasHooksFile: true, + hooks: { scripts: { setup: 'pnpm install' } }, + setupRunPolicy: 'run-by-default', + source: 'orca.yaml', + setupTrust: { + contentHash: 'hash-1', + scriptContent: 'pnpm install' + } + }), checkRepoHooks: vi.fn().mockResolvedValue({ hasHooks: true, hooks: { scripts: { setup: 'pnpm install' } }, @@ -88,6 +157,7 @@ describe('repo RPC methods', () => { } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + const hooksResponse = await dispatcher.dispatch(makeRequest('repo.hooks', { repo: 'repo-1' })) await dispatcher.dispatch(makeRequest('repo.hooksCheck', { repo: 'repo-1' })) await dispatcher.dispatch(makeRequest('repo.setupScriptImports', { repo: 'repo-1' })) await dispatcher.dispatch(makeRequest('repo.issueCommandRead', { repo: 'repo-1' })) @@ -98,9 +168,41 @@ describe('repo RPC methods', () => { }) ) + expect(runtime.getRepoHooks).toHaveBeenCalledWith('repo-1') + expect(hooksResponse).toMatchObject({ + ok: true, + result: { setupTrust: { contentHash: 'hash-1', scriptContent: 'pnpm install' } } + }) expect(runtime.checkRepoHooks).toHaveBeenCalledWith('repo-1') expect(runtime.inspectRepoSetupScriptImports).toHaveBeenCalledWith('repo-1') expect(runtime.readRepoIssueCommand).toHaveBeenCalledWith('repo-1') expect(runtime.writeRepoIssueCommand).toHaveBeenCalledWith('repo-1', 'Fix it') }) + + it('persists GitHub issue source preference updates', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateRepo: vi.fn().mockResolvedValue({ + id: 'repo-1', + path: '/srv/repo', + issueSourcePreference: 'origin' + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('repo.update', { + repo: 'repo-1', + updates: { issueSourcePreference: 'origin' } + }) + ) + + expect(runtime.updateRepo).toHaveBeenCalledWith('repo-1', { + issueSourcePreference: 'origin' + }) + expect(response).toMatchObject({ + ok: true, + result: { repo: { id: 'repo-1', issueSourcePreference: 'origin' } } + }) + }) }) diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 203ea979013..8803b669757 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -35,7 +35,7 @@ const RepoUpdate = RepoSelector.extend({ worktreeBaseRef: OptionalString, kind: z.enum(['git', 'folder']).optional(), symlinkPaths: z.array(z.string()).optional(), - issueSourcePreference: z.enum(['auto', 'github', 'linear']).optional() + issueSourcePreference: z.enum(['auto', 'upstream', 'origin']).optional() }) }) @@ -56,12 +56,36 @@ const RepoIssueCommandWrite = RepoSelector.extend({ content: z.string() }) +const RepoSparsePresetSave = RepoSelector.extend({ + id: OptionalString, + name: requiredString('Missing preset name'), + directories: z.array(z.string()) +}) + export const REPO_METHODS: RpcMethod[] = [ defineMethod({ name: 'repo.list', params: null, handler: (_params, { runtime }) => ({ repos: runtime.listRepos() }) }), + defineMethod({ + name: 'repo.sparsePresets', + params: RepoSelector, + handler: async (params, { runtime }) => ({ + presets: await runtime.listSparsePresets(params.repo) + }) + }), + defineMethod({ + name: 'repo.saveSparsePreset', + params: RepoSparsePresetSave, + handler: async (params, { runtime }) => ({ + preset: await runtime.saveSparsePreset(params.repo, { + ...(params.id ? { id: params.id } : {}), + name: params.name, + directories: params.directories + }) + }) + }), defineMethod({ name: 'repo.add', params: RepoPath, diff --git a/src/main/runtime/rpc/methods/ssh.test.ts b/src/main/runtime/rpc/methods/ssh.test.ts new file mode 100644 index 00000000000..94791a7fdd3 --- /dev/null +++ b/src/main/runtime/rpc/methods/ssh.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { SSH_METHODS } from './ssh' + +const { connectRegisteredSshTargetMock, getRegisteredSshStateMock } = vi.hoisted(() => ({ + connectRegisteredSshTargetMock: vi.fn(), + getRegisteredSshStateMock: vi.fn() +})) + +vi.mock('../../../ipc/ssh', () => ({ + connectRegisteredSshTarget: connectRegisteredSshTargetMock, + getRegisteredSshState: getRegisteredSshStateMock +})) + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('ssh RPC methods', () => { + it('returns the registered SSH target state', async () => { + const state = { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + } + getRegisteredSshStateMock.mockReturnValueOnce(state) + const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('ssh.getState', { targetId: 'ssh-1' })) + + expect(getRegisteredSshStateMock).toHaveBeenCalledWith('ssh-1') + expect(response).toMatchObject({ ok: true, result: { state } }) + }) + + it('connects through the registered desktop SSH lifecycle', async () => { + const state = { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + } + connectRegisteredSshTargetMock.mockResolvedValueOnce(state) + const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('ssh.connect', { targetId: 'ssh-1' })) + + expect(connectRegisteredSshTargetMock).toHaveBeenCalledWith('ssh-1') + expect(response).toMatchObject({ ok: true, result: { state } }) + }) + + it('returns null when the target has no registered state yet', async () => { + getRegisteredSshStateMock.mockReturnValueOnce(undefined) + const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('ssh.getState', { targetId: 'ssh-1' })) + + expect(response).toMatchObject({ ok: true, result: { state: null } }) + }) +}) diff --git a/src/main/runtime/rpc/methods/ssh.ts b/src/main/runtime/rpc/methods/ssh.ts new file mode 100644 index 00000000000..e4d836cedff --- /dev/null +++ b/src/main/runtime/rpc/methods/ssh.ts @@ -0,0 +1,20 @@ +import { z } from 'zod' +import { connectRegisteredSshTarget, getRegisteredSshState } from '../../../ipc/ssh' +import { defineMethod, type RpcMethod } from '../core' + +const SshTarget = z.object({ + targetId: z.string().min(1) +}) + +export const SSH_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'ssh.getState', + params: SshTarget, + handler: (params) => ({ state: getRegisteredSshState(params.targetId) ?? null }) + }), + defineMethod({ + name: 'ssh.connect', + params: SshTarget, + handler: async (params) => ({ state: await connectRegisteredSshTarget(params.targetId) }) + }) +] diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index ba4be3a9d13..0bcbb965af1 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -338,7 +338,8 @@ const TerminalSplit = TerminalHandle.extend({ .transform((v) => (v === 'vertical' || v === 'horizontal' ? v : undefined)) .pipe(z.union([z.enum(['vertical', 'horizontal']), z.undefined()])) .optional(), - command: OptionalString + command: OptionalString, + env: z.record(z.string(), z.string()).optional() }) const TerminalStop = z.object({ @@ -571,7 +572,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ handler: async (params, { runtime }) => ({ split: await runtime.splitTerminal(params.terminal, { direction: params.direction, - command: params.command + command: params.command, + env: params.env }) }) }), diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts new file mode 100644 index 00000000000..577465f3a42 --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -0,0 +1,173 @@ +import { z } from 'zod' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { + OptionalBoolean, + OptionalFiniteNumber, + OptionalPlainString, + OptionalString, + TriStateLinkedIssue +} from '../schemas' + +export const WorktreeListParams = z.object({ + repo: OptionalString, + limit: OptionalFiniteNumber +}) + +export const WorktreePsParams = z.object({ + limit: OptionalFiniteNumber +}) + +export const WorktreeSortOrder = z.object({ + orderedIds: z.array(z.string()) +}) + +export const WorktreeSelector = z.object({ + worktree: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing worktree selector')) +}) + +export const WorktreeCreate = z + .object({ + repo: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing repo selector')), + name: OptionalString, + baseBranch: OptionalString, + branchNameOverride: OptionalString, + linkedIssue: TriStateLinkedIssue, + linkedPR: TriStateLinkedIssue, + linkedLinearIssue: z.string().optional(), + linkedGitLabMR: TriStateLinkedIssue, + linkedGitLabIssue: TriStateLinkedIssue, + comment: OptionalString, + displayName: OptionalString, + workspaceStatus: OptionalString, + manualOrder: OptionalFiniteNumber, + sparseCheckout: z + .object({ + directories: z.array(z.string()), + presetId: OptionalString + }) + .optional(), + pushTarget: z + .object({ + remoteName: z.string(), + branchName: z.string(), + remoteUrl: OptionalString + }) + .optional(), + runHooks: OptionalBoolean, + activate: OptionalBoolean, + parentWorktree: OptionalString, + cwdParentWorktree: OptionalString, + noParent: OptionalBoolean, + callerTerminalHandle: OptionalString, + orchestrationContext: z + .object({ + parentWorktreeId: OptionalString, + orchestrationRunId: OptionalString, + taskId: OptionalString, + coordinatorHandle: OptionalString + }) + .optional(), + setupDecision: z + .unknown() + .transform((v) => + typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined + ) + .pipe(z.union([z.enum(['run', 'skip', 'inherit']), z.undefined()])) + .optional(), + // Why: mobile clients pass a startup command (e.g. 'claude') so the first + // terminal pane launches the selected agent instead of an idle shell. + startupCommand: OptionalString, + // Why: task-driven mobile creates need desktop parity: the host chooses + // the same default/detected agent and drafts the linked issue/PR URL into it. + startupDraft: OptionalString, + createdWithAgent: z + .unknown() + .transform((value) => (isTuiAgent(value) ? value : undefined)) + .optional() + }) + .superRefine((params, ctx) => { + if (params.parentWorktree && params.noParent === true) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose either --parent-worktree or --no-parent, not both.' + }) + } + }) + +export const WorktreeSet = WorktreeSelector.extend({ + displayName: OptionalString, + // Why: empty comments are meaningful metadata updates, so use the plain + // string parser instead of OptionalString's empty-as-undefined behavior. + comment: OptionalPlainString, + linkedIssue: TriStateLinkedIssue, + linkedPR: TriStateLinkedIssue, + linkedLinearIssue: z.union([z.string(), z.null()]).optional(), + linkedGitLabMR: TriStateLinkedIssue, + linkedGitLabIssue: TriStateLinkedIssue, + isArchived: OptionalBoolean, + isUnread: OptionalBoolean, + isPinned: OptionalBoolean, + sortOrder: OptionalFiniteNumber, + manualOrder: OptionalFiniteNumber, + lastActivityAt: OptionalFiniteNumber, + createdAt: OptionalFiniteNumber, + sparseDirectories: z.array(z.string()).optional(), + sparseBaseRef: OptionalString, + sparsePresetId: OptionalString, + baseRef: OptionalString, + workspaceStatus: OptionalString, + pushTarget: z + .object({ + remoteName: z.string(), + branchName: z.string(), + remoteUrl: OptionalString + }) + .optional(), + diffComments: z.array(z.unknown()).optional(), + parentWorktree: OptionalString, + noParent: OptionalBoolean +}).superRefine((params, ctx) => { + if (params.parentWorktree && params.noParent === true) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose either --parent-worktree or --no-parent, not both.' + }) + } +}) + +export const WorktreeRemove = WorktreeSelector.extend({ + force: OptionalBoolean, + runHooks: OptionalBoolean +}) + +export const WorktreeResolvePrBase = z.object({ + repo: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing repo selector')), + prNumber: z + .unknown() + .transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)) + .pipe(z.number().int().positive('Missing PR number')), + headRefName: OptionalString, + isCrossRepository: OptionalBoolean +}) + +export const WorktreeResolveMrBase = z.object({ + repo: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing repo selector')), + mrIid: z + .unknown() + .transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)) + .pipe(z.number().int().positive('Missing MR number')), + sourceBranch: OptionalString, + isCrossRepository: OptionalBoolean +}) diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index d0dce7cb0d9..4eb3d54e621 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -57,6 +57,7 @@ describe('worktree RPC methods', () => { setupDecision: 'skip', createdWithAgent: undefined, startup: undefined, + startupDraft: undefined, lineage: { parentWorktree: 'id:parent', noParent: false, @@ -66,6 +67,35 @@ describe('worktree RPC methods', () => { }) }) + it('forwards task startup drafts to runtime worktree creation', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + await dispatcher.dispatch( + makeRequest('worktree.create', { + repo: 'repo-1', + name: 'issue-123', + startupDraft: 'https://github.com/stablyai/orca/issues/123', + createdWithAgent: 'codex', + activate: true + }) + ) + + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + repoSelector: 'repo-1', + name: 'issue-123', + activate: true, + createdWithAgent: 'codex', + startup: undefined, + startupDraft: 'https://github.com/stablyai/orca/issues/123' + }) + ) + }) + it('rejects worktree.create when both parent and no-parent are supplied', async () => { const runtime = { getRuntimeId: () => 'test-runtime', @@ -87,6 +117,56 @@ describe('worktree RPC methods', () => { expect(runtime.createManagedWorktree).not.toHaveBeenCalled() }) + it('passes explicit repo selectors to PR base resolution', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveManagedPrBase: vi.fn().mockResolvedValue({ baseBranch: 'origin/pr-head' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.resolvePrBase', { + repo: 'id:repo-1', + prNumber: 42, + headRefName: 'feature/pr-head', + isCrossRepository: false + }) + ) + + expect(response).toMatchObject({ ok: true }) + expect(runtime.resolveManagedPrBase).toHaveBeenCalledWith({ + repoSelector: 'id:repo-1', + prNumber: 42, + headRefName: 'feature/pr-head', + isCrossRepository: false + }) + }) + + it('passes explicit repo selectors to MR base resolution', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveManagedMrBase: vi.fn().mockResolvedValue({ baseBranch: 'origin/mr-head' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.resolveMrBase', { + repo: 'id:repo-1', + mrIid: 42, + sourceBranch: 'feature/mr-head', + isCrossRepository: false + }) + ) + + expect(response).toMatchObject({ ok: true }) + expect(runtime.resolveManagedMrBase).toHaveBeenCalledWith({ + repoSelector: 'id:repo-1', + mrIid: 42, + sourceBranch: 'feature/mr-head', + isCrossRepository: false + }) + }) + it('rejects worktree.set when both parent and no-parent are supplied', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 18ebdd236e8..fbc28b2181b 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -1,161 +1,15 @@ -import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { - OptionalBoolean, - OptionalFiniteNumber, - OptionalPlainString, - OptionalString, - TriStateLinkedIssue -} from '../schemas' -import { isTuiAgent } from '../../../../shared/tui-agent-config' - -const WorktreeListParams = z.object({ - repo: OptionalString, - limit: OptionalFiniteNumber -}) - -const WorktreePsParams = z.object({ - limit: OptionalFiniteNumber -}) - -const WorktreeSortOrder = z.object({ - orderedIds: z.array(z.string()) -}) - -const WorktreeSelector = z.object({ - worktree: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing worktree selector')) -}) - -const WorktreeCreate = z - .object({ - repo: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing repo selector')), - name: OptionalString, - baseBranch: OptionalString, - branchNameOverride: OptionalString, - linkedIssue: TriStateLinkedIssue, - linkedPR: TriStateLinkedIssue, - linkedLinearIssue: z.string().optional(), - linkedGitLabMR: TriStateLinkedIssue, - linkedGitLabIssue: TriStateLinkedIssue, - comment: OptionalString, - displayName: OptionalString, - workspaceStatus: OptionalString, - manualOrder: OptionalFiniteNumber, - sparseCheckout: z - .object({ - directories: z.array(z.string()), - presetId: OptionalString - }) - .optional(), - pushTarget: z - .object({ - remoteName: z.string(), - branchName: z.string(), - remoteUrl: OptionalString - }) - .optional(), - runHooks: OptionalBoolean, - activate: OptionalBoolean, - parentWorktree: OptionalString, - cwdParentWorktree: OptionalString, - noParent: OptionalBoolean, - callerTerminalHandle: OptionalString, - orchestrationContext: z - .object({ - parentWorktreeId: OptionalString, - orchestrationRunId: OptionalString, - taskId: OptionalString, - coordinatorHandle: OptionalString - }) - .optional(), - setupDecision: z - .unknown() - .transform((v) => - typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined - ) - .pipe(z.union([z.enum(['run', 'skip', 'inherit']), z.undefined()])) - .optional(), - // Why: mobile clients pass a startup command (e.g. 'claude') so the first - // terminal pane launches the selected agent instead of an idle shell. - startupCommand: OptionalString, - createdWithAgent: z - .unknown() - .transform((value) => (isTuiAgent(value) ? value : undefined)) - .optional() - }) - .superRefine((params, ctx) => { - if (params.parentWorktree && params.noParent === true) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose either --parent-worktree or --no-parent, not both.' - }) - } - }) - -const WorktreeSet = WorktreeSelector.extend({ - displayName: OptionalString, - // Why: empty comments are meaningful metadata updates, so use the plain - // string parser instead of OptionalString's empty-as-undefined behavior. - comment: OptionalPlainString, - linkedIssue: TriStateLinkedIssue, - linkedPR: TriStateLinkedIssue, - linkedLinearIssue: z.union([z.string(), z.null()]).optional(), - linkedGitLabMR: TriStateLinkedIssue, - linkedGitLabIssue: TriStateLinkedIssue, - isArchived: OptionalBoolean, - isUnread: OptionalBoolean, - isPinned: OptionalBoolean, - sortOrder: OptionalFiniteNumber, - manualOrder: OptionalFiniteNumber, - lastActivityAt: OptionalFiniteNumber, - createdAt: OptionalFiniteNumber, - sparseDirectories: z.array(z.string()).optional(), - sparseBaseRef: OptionalString, - sparsePresetId: OptionalString, - baseRef: OptionalString, - workspaceStatus: OptionalString, - pushTarget: z - .object({ - remoteName: z.string(), - branchName: z.string(), - remoteUrl: OptionalString - }) - .optional(), - diffComments: z.array(z.unknown()).optional(), - parentWorktree: OptionalString, - noParent: OptionalBoolean -}).superRefine((params, ctx) => { - if (params.parentWorktree && params.noParent === true) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose either --parent-worktree or --no-parent, not both.' - }) - } -}) - -const WorktreeRemove = WorktreeSelector.extend({ - force: OptionalBoolean, - runHooks: OptionalBoolean -}) - -const WorktreeResolvePrBase = z.object({ - repo: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing repo selector')), - prNumber: z - .unknown() - .transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)) - .pipe(z.number().int().positive('Missing PR number')), - headRefName: OptionalString, - isCrossRepository: OptionalBoolean -}) + WorktreeCreate, + WorktreeListParams, + WorktreePsParams, + WorktreeRemove, + WorktreeResolveMrBase, + WorktreeResolvePrBase, + WorktreeSelector, + WorktreeSet, + WorktreeSortOrder +} from './worktree-schemas' export const WORKTREE_METHODS: RpcMethod[] = [ defineMethod({ @@ -215,6 +69,7 @@ export const WORKTREE_METHODS: RpcMethod[] = [ setupDecision: params.setupDecision, createdWithAgent: params.createdWithAgent, startup: params.startupCommand ? { command: params.startupCommand } : undefined, + startupDraft: params.startupDraft, lineage: { parentWorktree: params.parentWorktree, ...(params.cwdParentWorktree ? { cwdParentWorktree: params.cwdParentWorktree } : {}), @@ -271,12 +126,23 @@ export const WORKTREE_METHODS: RpcMethod[] = [ params: WorktreeResolvePrBase, handler: async (params, { runtime }) => runtime.resolveManagedPrBase({ - repoId: params.repo, + repoSelector: params.repo, prNumber: params.prNumber, headRefName: params.headRefName, isCrossRepository: params.isCrossRepository }) }), + defineMethod({ + name: 'worktree.resolveMrBase', + params: WorktreeResolveMrBase, + handler: async (params, { runtime }) => + runtime.resolveManagedMrBase({ + repoSelector: params.repo, + mrIid: params.mrIid, + sourceBranch: params.sourceBranch, + isCrossRepository: params.isCrossRepository + }) + }), defineMethod({ name: 'worktree.rm', params: WorktreeRemove, diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 9c367ea2134..b704a57269c 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -779,6 +779,57 @@ describe('OrcaRuntimeRpcServer', () => { const browserSetViewport = vi.fn().mockResolvedValue({ ok: true }) const browserDialogAccept = vi.fn().mockResolvedValue({ ok: true }) const browserDialogDismiss = vi.fn().mockResolvedValue({ ok: true }) + const listGitHubProjects = vi.fn().mockResolvedValue({ ok: true, projects: [] }) + const listGitHubLabelsBySlug = vi.fn().mockResolvedValue({ ok: true, labels: ['bug'] }) + const listGitHubAssignableUsersBySlug = vi + .fn() + .mockResolvedValue({ ok: true, users: [{ login: 'alex' }] }) + const listGitHubIssueTypesBySlug = vi.fn().mockResolvedValue({ + ok: true, + types: [{ id: 'type-1', name: 'Bug', color: 'RED', description: null }] + }) + const updateGitHubProjectItemField = vi.fn().mockResolvedValue({ ok: true }) + const clearGitHubProjectItemField = vi.fn().mockResolvedValue({ ok: true }) + const updateGitHubIssueBySlug = vi.fn().mockResolvedValue({ ok: true }) + const updateGitHubIssueTypeBySlug = vi.fn().mockResolvedValue({ ok: true }) + const updateGitHubPullRequestBySlug = vi.fn().mockResolvedValue({ ok: true }) + const updateRepoIssue = vi.fn().mockResolvedValue({ ok: true }) + const listRepoLabels = vi.fn().mockResolvedValue(['bug']) + const listRepoAssignableUsers = vi.fn().mockResolvedValue([{ login: 'alex' }]) + const addRepoIssueComment = vi.fn().mockResolvedValue({ ok: true, comment: { id: 2 } }) + const addRepoPRReviewComment = vi.fn().mockResolvedValue({ ok: true, comment: { id: 3 } }) + const addRepoPRReviewCommentReply = vi.fn().mockResolvedValue({ + ok: true, + comment: { id: 4 } + }) + const getRepoPRFileContents = vi.fn().mockResolvedValue({ + original: 'before', + modified: 'after', + originalIsBinary: false, + modifiedIsBinary: false + }) + const rerunRepoPRChecks = vi.fn().mockResolvedValue({ ok: true, count: 1 }) + const resolveRepoReviewThread = vi.fn().mockResolvedValue(true) + const setRepoPRFileViewed = vi.fn().mockResolvedValue(true) + const requestRepoPRReviewers = vi.fn().mockResolvedValue({ ok: true }) + const mergeRepoPR = vi.fn().mockResolvedValue({ ok: true }) + const addGitLabRepoIssueComment = vi.fn().mockResolvedValue({ ok: true }) + const addGitLabRepoMRComment = vi.fn().mockResolvedValue({ ok: true }) + const mergeGitLabRepoMR = vi.fn().mockResolvedValue({ ok: true }) + const addGitHubIssueCommentBySlug = vi.fn().mockResolvedValue({ + ok: true, + comment: { id: 1, author: 'me', body: 'done', createdAt: '2026-01-01T00:00:00Z', url: '' } + }) + const updateGitHubIssueCommentBySlug = vi.fn().mockResolvedValue({ ok: true }) + const deleteGitHubIssueCommentBySlug = vi.fn().mockResolvedValue({ ok: true }) + const linearSearchIssues = vi.fn().mockResolvedValue([]) + const linearSelectWorkspace = vi.fn().mockReturnValue({ + connected: true, + selectedWorkspaceId: 'workspace-1' + }) + const linearTeamLabels = vi.fn().mockResolvedValue([{ id: 'label-1', name: 'bug' }]) + const linearTeamMembers = vi.fn().mockResolvedValue([{ id: 'member-1', displayName: 'Alex' }]) + const linearAddIssueComment = vi.fn().mockResolvedValue({ ok: true, id: 'comment-1' }) const runtime = { getRuntimeId: () => 'test-runtime', getStatus: vi.fn().mockResolvedValue({ graphStatus: 'ok' }), @@ -796,7 +847,41 @@ describe('OrcaRuntimeRpcServer', () => { browserTabCreate, browserSetViewport, browserDialogAccept, - browserDialogDismiss + browserDialogDismiss, + listGitHubProjects, + listGitHubLabelsBySlug, + listGitHubAssignableUsersBySlug, + listGitHubIssueTypesBySlug, + updateGitHubProjectItemField, + clearGitHubProjectItemField, + updateGitHubIssueBySlug, + updateGitHubIssueTypeBySlug, + updateGitHubPullRequestBySlug, + updateRepoIssue, + listRepoLabels, + listRepoAssignableUsers, + addRepoIssueComment, + addRepoPRReviewComment, + addRepoPRReviewCommentReply, + getRepoPRFileContents, + rerunRepoPRChecks, + resolveRepoReviewThread, + setRepoPRFileViewed, + requestRepoPRReviewers, + mergeRepoPR, + addGitLabRepoIssueComment, + addGitLabRepoMRComment, + mergeGitLabRepoMR, + addGitHubIssueCommentBySlug, + updateGitHubIssueCommentBySlug, + deleteGitHubIssueCommentBySlug, + linearSearchIssues, + linearSelectWorkspace, + linearTeamLabels, + linearTeamMembers, + linearAddIssueComment, + getClientSettings: vi.fn(() => ({ defaultTuiAgent: 'codex', agentCmdOverrides: {} })), + updateClientSettings: vi.fn(() => ({ defaultTaskSource: 'linear' })) } as unknown as OrcaRuntimeService const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false }) server['deviceRegistry'] = new DeviceRegistry(userDataPath) @@ -822,6 +907,447 @@ describe('OrcaRuntimeRpcServer', () => { (response) => replies.push(JSON.parse(response) as Record), () => {} ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_settings_get', + method: 'settings.get', + deviceToken: mobile.token + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_settings_update', + method: 'settings.update', + deviceToken: mobile.token, + params: { defaultTaskSource: 'linear' } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_github_projects', + method: 'github.project.listAccessible', + deviceToken: mobile.token, + params: {} + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_project_issue_types', + method: 'github.project.listIssueTypesBySlug', + deviceToken: mobile.token, + params: { owner: 'stablyai', repo: 'orca' } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_project_labels', + method: 'github.project.listLabelsBySlug', + deviceToken: mobile.token, + params: { owner: 'stablyai', repo: 'orca' } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_project_assignees', + method: 'github.project.listAssignableUsersBySlug', + deviceToken: mobile.token, + params: { owner: 'stablyai', repo: 'orca', seedLogins: ['alex'] } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_project_update_issue', + method: 'github.project.updateIssueBySlug', + deviceToken: mobile.token, + params: { + owner: 'stablyai', + repo: 'orca', + number: 123, + updates: { title: 'New title' } + } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_project_update_issue_type', + method: 'github.project.updateIssueTypeBySlug', + deviceToken: mobile.token, + params: { + owner: 'stablyai', + repo: 'orca', + number: 123, + issueTypeId: 'type-1' + } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_project_update_field', + method: 'github.project.updateItemField', + deviceToken: mobile.token, + params: { + projectId: 'project-1', + itemId: 'item-1', + fieldId: 'field-1', + value: { kind: 'text', text: 'Ready' } + } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_project_clear_field', + method: 'github.project.clearItemField', + deviceToken: mobile.token, + params: { + projectId: 'project-1', + itemId: 'item-1', + fieldId: 'field-1' + } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_project_update_pr', + method: 'github.project.updatePullRequestBySlug', + deviceToken: mobile.token, + params: { + owner: 'stablyai', + repo: 'orca', + number: 456, + updates: { state: 'closed' } + } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_project_add_comment', + method: 'github.project.addIssueCommentBySlug', + deviceToken: mobile.token, + params: { + owner: 'stablyai', + repo: 'orca', + number: 123, + body: 'done' + } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_project_update_comment', + method: 'github.project.updateIssueCommentBySlug', + deviceToken: mobile.token, + params: { + owner: 'stablyai', + repo: 'orca', + commentId: 101, + body: 'edited' + } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_project_delete_comment', + method: 'github.project.deleteIssueCommentBySlug', + deviceToken: mobile.token, + params: { + owner: 'stablyai', + repo: 'orca', + commentId: 101 + } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_github_update_issue', + method: 'github.updateIssue', + deviceToken: mobile.token, + params: { + repo: 'id:repo-1', + number: 123, + updates: { title: 'New title', addLabels: ['bug'] } + } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_github_labels', + method: 'github.listLabels', + deviceToken: mobile.token, + params: { repo: 'id:repo-1' } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_github_assignees', + method: 'github.listAssignableUsers', + deviceToken: mobile.token, + params: { repo: 'id:repo-1' } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_github_add_comment', + method: 'github.addIssueComment', + deviceToken: mobile.token, + params: { + repo: 'id:repo-1', + number: 123, + body: 'done' + } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_github_add_review_comment', + method: 'github.addPRReviewComment', + deviceToken: mobile.token, + params: { + repo: 'id:repo-1', + prNumber: 456, + commitId: 'abc123', + path: 'src/app.ts', + line: 10, + body: 'please fix' + } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_github_reply_review_comment', + method: 'github.addPRReviewCommentReply', + deviceToken: mobile.token, + params: { + repo: 'id:repo-1', + prNumber: 456, + commentId: 99, + body: 'fixed', + threadId: 'thread-1', + path: 'src/app.ts', + line: 10 + } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_github_pr_file_contents', + method: 'github.prFileContents', + deviceToken: mobile.token, + params: { + repo: 'id:repo-1', + prNumber: 456, + path: 'src/app.ts', + status: 'modified', + headSha: 'abc123', + baseSha: 'def456' + } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_github_rerun_checks', + method: 'github.rerunPRChecks', + deviceToken: mobile.token, + params: { + repo: 'id:repo-1', + prNumber: 456, + headSha: 'abc123', + failedOnly: true + } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_github_resolve_thread', + method: 'github.resolveReviewThread', + deviceToken: mobile.token, + params: { + repo: 'id:repo-1', + threadId: 'thread-1', + resolve: true + } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_github_file_viewed', + method: 'github.setPRFileViewed', + deviceToken: mobile.token, + params: { + repo: 'id:repo-1', + pullRequestId: 'PR_kw', + path: 'src/app.ts', + viewed: true + } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_github_request_reviewers', + method: 'github.requestPRReviewers', + deviceToken: mobile.token, + params: { + repo: 'id:repo-1', + prNumber: 456, + reviewers: ['alex'] + } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_github_merge_pr', + method: 'github.mergePR', + deviceToken: mobile.token, + params: { + repo: 'id:repo-1', + prNumber: 456, + method: 'squash' + } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_gitlab_add_issue_comment', + method: 'gitlab.addIssueComment', + deviceToken: mobile.token, + params: { + repo: 'id:repo-1', + number: 123, + body: 'done' + } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_gitlab_add_mr_comment', + method: 'gitlab.addMRComment', + deviceToken: mobile.token, + params: { + repo: 'id:repo-1', + iid: 456, + body: 'ship it' + } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_gitlab_merge_mr', + method: 'gitlab.mergeMR', + deviceToken: mobile.token, + params: { + repo: 'id:repo-1', + iid: 456, + method: 'merge' + } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_linear_search', + method: 'linear.searchIssues', + deviceToken: mobile.token, + params: { query: 'auth', limit: 10, workspaceId: 'workspace-1' } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_linear_select_workspace', + method: 'linear.selectWorkspace', + deviceToken: mobile.token, + params: { workspaceId: 'workspace-1' } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_linear_team_labels', + method: 'linear.teamLabels', + deviceToken: mobile.token, + params: { teamId: 'team-1', workspaceId: 'workspace-1' } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_linear_team_members', + method: 'linear.teamMembers', + deviceToken: mobile.token, + params: { teamId: 'team-1', workspaceId: 'workspace-1' } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_linear_add_comment', + method: 'linear.addIssueComment', + deviceToken: mobile.token, + params: { issueId: 'issue-1', workspaceId: 'workspace-1', body: 'done' } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) await server['handleWebSocketMessage']( JSON.stringify({ id: 'req_git_status', @@ -981,6 +1507,92 @@ describe('OrcaRuntimeRpcServer', () => { }) ) expect(replies).toContainEqual(expect.objectContaining({ id: 'req_allowed', ok: true })) + expect(replies).toContainEqual(expect.objectContaining({ id: 'req_settings_get', ok: true })) + expect(replies).toContainEqual(expect.objectContaining({ id: 'req_settings_update', ok: true })) + expect(replies).toContainEqual(expect.objectContaining({ id: 'req_github_projects', ok: true })) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_project_issue_types', ok: true }) + ) + expect(replies).toContainEqual(expect.objectContaining({ id: 'req_project_labels', ok: true })) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_project_assignees', ok: true }) + ) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_project_update_issue', ok: true }) + ) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_project_update_issue_type', ok: true }) + ) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_project_update_field', ok: true }) + ) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_project_clear_field', ok: true }) + ) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_project_update_pr', ok: true }) + ) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_project_add_comment', ok: true }) + ) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_project_update_comment', ok: true }) + ) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_project_delete_comment', ok: true }) + ) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_github_update_issue', ok: true }) + ) + expect(replies).toContainEqual(expect.objectContaining({ id: 'req_github_labels', ok: true })) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_github_assignees', ok: true }) + ) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_github_add_comment', ok: true }) + ) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_github_add_review_comment', ok: true }) + ) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_github_reply_review_comment', ok: true }) + ) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_github_pr_file_contents', ok: true }) + ) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_github_rerun_checks', ok: true }) + ) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_github_resolve_thread', ok: true }) + ) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_github_file_viewed', ok: true }) + ) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_github_request_reviewers', ok: true }) + ) + expect(replies).toContainEqual(expect.objectContaining({ id: 'req_github_merge_pr', ok: true })) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_gitlab_add_issue_comment', ok: true }) + ) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_gitlab_add_mr_comment', ok: true }) + ) + expect(replies).toContainEqual(expect.objectContaining({ id: 'req_gitlab_merge_mr', ok: true })) + expect(replies).toContainEqual(expect.objectContaining({ id: 'req_linear_search', ok: true })) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_linear_select_workspace', ok: true }) + ) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_linear_team_labels', ok: true }) + ) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_linear_team_members', ok: true }) + ) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_linear_add_comment', ok: true }) + ) expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_status', ok: true })) expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_push', ok: true })) expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_upstream', ok: true })) @@ -1038,6 +1650,116 @@ describe('OrcaRuntimeRpcServer', () => { worktree: 'id:wt-1', page: 'page-1' }) + expect(listGitHubIssueTypesBySlug).toHaveBeenCalledWith({ + owner: 'stablyai', + repo: 'orca' + }) + expect(listGitHubLabelsBySlug).toHaveBeenCalledWith({ + owner: 'stablyai', + repo: 'orca' + }) + expect(listGitHubAssignableUsersBySlug).toHaveBeenCalledWith({ + owner: 'stablyai', + repo: 'orca', + seedLogins: ['alex'] + }) + expect(updateGitHubIssueBySlug).toHaveBeenCalledWith({ + owner: 'stablyai', + repo: 'orca', + number: 123, + updates: { title: 'New title' } + }) + expect(updateGitHubIssueTypeBySlug).toHaveBeenCalledWith({ + owner: 'stablyai', + repo: 'orca', + number: 123, + issueTypeId: 'type-1' + }) + expect(updateGitHubPullRequestBySlug).toHaveBeenCalledWith({ + owner: 'stablyai', + repo: 'orca', + number: 456, + updates: { state: 'closed' } + }) + expect(addGitHubIssueCommentBySlug).toHaveBeenCalledWith({ + owner: 'stablyai', + repo: 'orca', + number: 123, + body: 'done' + }) + expect(updateGitHubIssueCommentBySlug).toHaveBeenCalledWith({ + owner: 'stablyai', + repo: 'orca', + commentId: 101, + body: 'edited' + }) + expect(deleteGitHubIssueCommentBySlug).toHaveBeenCalledWith({ + owner: 'stablyai', + repo: 'orca', + commentId: 101 + }) + expect(updateRepoIssue).toHaveBeenCalledWith('id:repo-1', 123, { + title: 'New title', + addLabels: ['bug'] + }) + expect(listRepoLabels).toHaveBeenCalledWith('id:repo-1') + expect(listRepoAssignableUsers).toHaveBeenCalledWith('id:repo-1') + expect(addRepoIssueComment).toHaveBeenCalledWith('id:repo-1', 123, 'done') + expect(addRepoPRReviewComment).toHaveBeenCalledWith('id:repo-1', { + prNumber: 456, + commitId: 'abc123', + path: 'src/app.ts', + line: 10, + startLine: undefined, + body: 'please fix' + }) + expect(addRepoPRReviewCommentReply).toHaveBeenCalledWith('id:repo-1', { + prNumber: 456, + commentId: 99, + body: 'fixed', + threadId: 'thread-1', + path: 'src/app.ts', + line: 10 + }) + expect(getRepoPRFileContents).toHaveBeenCalledWith('id:repo-1', { + prNumber: 456, + path: 'src/app.ts', + oldPath: undefined, + status: 'modified', + headSha: 'abc123', + baseSha: 'def456' + }) + expect(rerunRepoPRChecks).toHaveBeenCalledWith('id:repo-1', 456, { + headSha: 'abc123', + failedOnly: true + }) + expect(resolveRepoReviewThread).toHaveBeenCalledWith('id:repo-1', 'thread-1', true) + expect(setRepoPRFileViewed).toHaveBeenCalledWith('id:repo-1', { + pullRequestId: 'PR_kw', + path: 'src/app.ts', + viewed: true + }) + expect(requestRepoPRReviewers).toHaveBeenCalledWith('id:repo-1', 456, ['alex']) + expect(mergeRepoPR).toHaveBeenCalledWith('id:repo-1', 456, 'squash', null) + expect(addGitLabRepoIssueComment).toHaveBeenCalledWith('id:repo-1', 123, 'done', undefined) + expect(addGitLabRepoMRComment).toHaveBeenCalledWith('id:repo-1', 456, 'ship it', undefined) + expect(mergeGitLabRepoMR).toHaveBeenCalledWith('id:repo-1', 456, 'merge', undefined) + expect(updateGitHubProjectItemField).toHaveBeenCalledWith({ + projectId: 'project-1', + itemId: 'item-1', + fieldId: 'field-1', + value: { kind: 'text', text: 'Ready' } + }) + expect(clearGitHubProjectItemField).toHaveBeenCalledWith({ + projectId: 'project-1', + itemId: 'item-1', + fieldId: 'field-1' + }) + expect(linearSearchIssues).toHaveBeenCalledWith('auth', 10, 'workspace-1') + expect(linearSelectWorkspace).toHaveBeenCalledWith('workspace-1') + expect(linearTeamLabels).toHaveBeenCalledWith('team-1', 'workspace-1') + expect(linearTeamMembers).toHaveBeenCalledWith('team-1', 'workspace-1') + expect(linearAddIssueComment).toHaveBeenCalledWith('issue-1', 'done', 'workspace-1') expect(removeClaudeAccount).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 44a7bd75038..4bbf960bcd3 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -156,12 +156,82 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'git.status', 'git.unstage', 'git.upstreamStatus', + 'github.createIssue', + 'github.addIssueComment', + 'github.addPRReviewComment', + 'github.addPRReviewCommentReply', + 'github.countWorkItems', + 'github.listAssignableUsers', + 'github.listLabels', + 'github.listWorkItems', + 'github.mergePR', + 'github.requestPRReviewers', + 'github.project.listAccessible', + 'github.project.listAssignableUsersBySlug', + 'github.project.listIssueTypesBySlug', + 'github.project.listLabelsBySlug', + 'github.project.listViews', + 'github.project.resolveRef', + 'github.project.addIssueCommentBySlug', + 'github.project.updateIssueCommentBySlug', + 'github.project.deleteIssueCommentBySlug', + 'github.project.clearItemField', + 'github.project.updateIssueBySlug', + 'github.project.updateIssueTypeBySlug', + 'github.project.updateItemField', + 'github.project.updatePullRequestBySlug', + 'github.project.viewTable', + 'github.project.workItemDetailsBySlug', + 'github.prFileContents', + 'github.prChecks', + 'github.rerunPRChecks', + 'github.resolveReviewThread', + 'github.setPRFileViewed', + 'github.updateIssue', + 'github.updatePR', + 'github.updatePRTitle', + 'github.updatePRState', + 'github.repoSlug', + 'github.workItem', + 'github.workItemDetails', + 'gitlab.createIssue', + 'gitlab.addIssueComment', + 'gitlab.addMRComment', + 'gitlab.listWorkItems', + 'gitlab.mergeMR', + 'gitlab.todos', + 'gitlab.updateIssue', + 'gitlab.updateMR', + 'gitlab.updateMRState', + 'gitlab.workItemDetails', + 'linear.getIssue', + 'linear.addIssueComment', + 'linear.connect', + 'linear.createIssue', + 'linear.issueComments', + 'linear.listIssues', + 'linear.listProjects', + 'linear.teamLabels', + 'linear.teamMembers', + 'linear.listTeams', + 'linear.searchIssues', + 'linear.selectWorkspace', + 'linear.status', + 'linear.teamStates', + 'linear.updateIssue', 'markdown.readTab', 'markdown.saveTab', 'notifications.subscribe', 'notifications.unsubscribe', + 'preflight.check', + 'preflight.detectAgents', + 'preflight.detectRemoteAgents', 'repo.hooks', 'repo.list', + 'repo.saveSparsePreset', + 'repo.searchRefs', + 'repo.sparsePresets', + 'repo.update', 'session.tabs.activate', 'session.tabs.close', 'session.tabs.createTerminal', @@ -171,6 +241,14 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'session.tabs.subscribe', 'session.tabs.subscribeAll', 'session.tabs.unsubscribe', + 'settings.get', + 'settings.update', + 'ssh.connect', + 'ssh.getState', + 'speech.dictation.cancel', + 'speech.dictation.chunk', + 'speech.dictation.finish', + 'speech.dictation.start', 'stats.summary', 'status.get', 'terminal.clearBuffer', @@ -193,6 +271,8 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'worktree.activate', 'worktree.create', 'worktree.ps', + 'worktree.resolveMrBase', + 'worktree.resolvePrBase', 'worktree.rm', 'worktree.set', 'worktree.sleep' diff --git a/src/main/source-control/hosted-review-creation.test.ts b/src/main/source-control/hosted-review-creation.test.ts index 2cdf3e46f6a..baf3e919da3 100644 --- a/src/main/source-control/hosted-review-creation.test.ts +++ b/src/main/source-control/hosted-review-creation.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: hosted review creation permutations share large mocks; splitting would hide branch-specific expectations. */ import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -10,7 +11,8 @@ const { getHostedReviewForBranchMock, ghExecFileAsyncMock, gitExecFileAsyncMock, - getUpstreamStatusMock + getUpstreamStatusMock, + getSshGitProviderMock } = vi.hoisted(() => ({ createGitHubPullRequestMock: vi.fn(), getRepoSlugMock: vi.fn(), @@ -21,7 +23,8 @@ const { getHostedReviewForBranchMock: vi.fn(), ghExecFileAsyncMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), - getUpstreamStatusMock: vi.fn() + getUpstreamStatusMock: vi.fn(), + getSshGitProviderMock: vi.fn() })) vi.mock('../github/client', () => ({ @@ -56,6 +59,10 @@ vi.mock('../git/upstream', () => ({ getUpstreamStatus: getUpstreamStatusMock })) +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock +})) + vi.mock('./hosted-review', () => ({ getHostedReviewForBranch: getHostedReviewForBranchMock })) @@ -73,7 +80,8 @@ function resetMocks(): void { getHostedReviewForBranchMock, ghExecFileAsyncMock, gitExecFileAsyncMock, - getUpstreamStatusMock + getUpstreamStatusMock, + getSshGitProviderMock ]) { mock.mockReset() } @@ -184,6 +192,71 @@ describe('createHostedReview', () => { expect(createGitHubPullRequestMock).toHaveBeenCalledOnce() }) + it('uses the SSH git provider for remote hosted-review preflight', async () => { + const remoteGit = { + exec: vi.fn(async (args: string[]) => { + if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref' && args[2] === 'HEAD') { + return { stdout: 'feature\n', stderr: '' } + } + if (args[0] === 'status') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'rev-parse' && args[2] === 'HEAD@{u}') { + return { stdout: 'origin/feature\n', stderr: '' } + } + if (args[0] === 'rev-list') { + return { stdout: '0 0\n', stderr: '' } + } + if (args[0] === 'log' && args.includes('--pretty=%s')) { + return { stdout: 'Feature title\n', stderr: '' } + } + if (args[0] === 'log') { + return { stdout: '- Feature title\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + } + getSshGitProviderMock.mockReturnValue(remoteGit) + + await expect( + createHostedReview( + '/remote/repo', + { + provider: 'github', + base: 'main', + head: 'feature', + title: 'Feature' + }, + 'ssh-1' + ) + ).resolves.toEqual({ + ok: true, + number: 12, + url: 'https://github.com/acme/orca/pull/12' + }) + + expect(remoteGit.exec).toHaveBeenCalledWith( + ['rev-parse', '--abbrev-ref', 'HEAD'], + '/remote/repo' + ) + expect(remoteGit.exec).toHaveBeenCalledWith(['status', '--porcelain'], '/remote/repo') + expect(getUpstreamStatusMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['auth', 'status', '--hostname', 'github.com'], + {} + ) + expect(createGitHubPullRequestMock).toHaveBeenCalledWith( + '/remote/repo', + { + provider: 'github', + base: 'main', + head: 'feature', + title: 'Feature' + }, + 'ssh-1' + ) + }) + it('returns the existing review instead of creating a duplicate', async () => { getHostedReviewForBranchMock.mockResolvedValue({ provider: 'github', @@ -287,6 +360,46 @@ describe('getHostedReviewCreationEligibility', () => { }) }) + it('resolves remote eligibility through SSH repo metadata', async () => { + const remoteGit = { + exec: vi.fn(async (args: string[]) => { + if (args[0] === 'log' && args.includes('--pretty=%s')) { + return { stdout: 'Remote title\n', stderr: '' } + } + if (args[0] === 'log') { + return { stdout: '- Remote title\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + } + getSshGitProviderMock.mockReturnValue(remoteGit) + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/remote/repo', + connectionId: 'ssh-1', + branch: 'feature/create-pr', + base: 'origin/main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'github', + canCreate: true, + title: 'Remote title', + body: '- Remote title' + }) + + expect(getProjectSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1') + expect(getRepoSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1') + expect(getHostedReviewForBranchMock).toHaveBeenCalledWith( + expect.objectContaining({ repoPath: '/remote/repo', connectionId: 'ssh-1' }) + ) + expect(remoteGit.exec).toHaveBeenCalledWith(['log', '-1', '--pretty=%s'], '/remote/repo') + }) + it('offers push as the next action for authenticated branches with local-only commits', async () => { await expect( getHostedReviewCreationEligibility({ diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index e5d12015b40..bb67ac768e4 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -17,12 +17,19 @@ import { getBitbucketRepoSlug } from '../bitbucket/client' import { getGiteaRepoSlug } from '../gitea/client' import { createGitHubPullRequest, getRepoSlug } from '../github/client' import { acquire, ghExecFileAsync, gitExecFileAsync, release } from '../github/gh-utils' +import { isNoUpstreamError, normalizeGitErrorMessage } from '../../shared/git-remote-error' +import type { GitUpstreamStatus } from '../../shared/types' import { gitOptionalLocksDisabledEnv } from '../git/runner' import { resolveDefaultBaseRefViaExec } from '../git/repo' import { getUpstreamStatus } from '../git/upstream' import { getProjectSlug } from '../gitlab/client' +import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { getHostedReviewForBranch } from './hosted-review' +type HostedReviewCreationEligibilityInput = HostedReviewCreationEligibilityArgs & { + connectionId?: string | null +} + function stripRefPrefix(ref: string): string { return normalizeHostedReviewHeadRef(ref) } @@ -36,11 +43,14 @@ function branchToTitle(branch: string): string { .replace(/\b\w/g, (char) => char.toUpperCase()) } -async function detectHostedReviewProvider(repoPath: string): Promise { - if (await getProjectSlug(repoPath)) { +async function detectHostedReviewProvider( + repoPath: string, + connectionId?: string | null +): Promise { + if (await getProjectSlug(repoPath, connectionId)) { return 'gitlab' } - if (await getRepoSlug(repoPath)) { + if (await getRepoSlug(repoPath, connectionId)) { return 'github' } if (await getBitbucketRepoSlug(repoPath)) { @@ -55,10 +65,16 @@ async function detectHostedReviewProvider(repoPath: string): Promise { +async function isGitHubAuthenticated( + repoPath: string, + connectionId?: string | null +): Promise { await acquire() try { - await ghExecFileAsync(['auth', 'status', '--hostname', 'github.com'], { cwd: repoPath }) + await ghExecFileAsync( + ['auth', 'status', '--hostname', 'github.com'], + connectionId ? {} : { cwd: repoPath } + ) return true } catch { return false @@ -67,9 +83,33 @@ async function isGitHubAuthenticated(repoPath: string): Promise { } } -async function getLatestCommitSubject(repoPath: string): Promise { +async function runGitForHostedReview( + repoPath: string, + args: string[], + connectionId?: string | null +): Promise<{ stdout: string; stderr?: string }> { + if (connectionId) { + const provider = getSshGitProvider(connectionId) + if (!provider) { + throw new Error( + 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' + ) + } + return provider.exec(args, repoPath) + } + return gitExecFileAsync(args, { cwd: repoPath }) +} + +async function getLatestCommitSubject( + repoPath: string, + connectionId?: string | null +): Promise { try { - const { stdout } = await gitExecFileAsync(['log', '-1', '--pretty=%s'], { cwd: repoPath }) + const { stdout } = await runGitForHostedReview( + repoPath, + ['log', '-1', '--pretty=%s'], + connectionId + ) const subject = stdout.trim() return subject || null } catch { @@ -77,14 +117,19 @@ async function getLatestCommitSubject(repoPath: string): Promise } } -async function getCommitSummaryBody(repoPath: string, base: string | null): Promise { +async function getCommitSummaryBody( + repoPath: string, + base: string | null, + connectionId?: string | null +): Promise { if (!base) { return null } try { - const { stdout } = await gitExecFileAsync( + const { stdout } = await runGitForHostedReview( + repoPath, ['log', '--pretty=format:- %s', '--max-count=20', `${base}..HEAD`], - { cwd: repoPath } + connectionId ) const body = stdout.trim() return body || null @@ -93,18 +138,34 @@ async function getCommitSummaryBody(repoPath: string, base: string | null): Prom } } -async function getDefaultBaseRef(repoPath: string): Promise { - return resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, { cwd: repoPath })) +async function getDefaultBaseRef( + repoPath: string, + connectionId?: string | null +): Promise { + return resolveDefaultBaseRefViaExec((argv) => runGitForHostedReview(repoPath, argv, connectionId)) } -async function getCurrentBranch(repoPath: string): Promise { - const { stdout } = await gitExecFileAsync(['rev-parse', '--abbrev-ref', 'HEAD'], { - cwd: repoPath - }) +async function getCurrentBranch(repoPath: string, connectionId?: string | null): Promise { + const { stdout } = await runGitForHostedReview( + repoPath, + ['rev-parse', '--abbrev-ref', 'HEAD'], + connectionId + ) return stripRefPrefix(stdout.trim()) } -async function hasUncommittedChanges(repoPath: string): Promise { +async function hasUncommittedChanges( + repoPath: string, + connectionId?: string | null +): Promise { + if (connectionId) { + const { stdout } = await runGitForHostedReview( + repoPath, + ['status', '--porcelain'], + connectionId + ) + return stdout.trim().length > 0 + } const { stdout } = await gitExecFileAsync(['status', '--porcelain'], { cwd: repoPath, // Why: create-PR validation should not take Git's optional index lock while @@ -114,6 +175,47 @@ async function hasUncommittedChanges(repoPath: string): Promise { return stdout.trim().length > 0 } +async function getHostedReviewUpstreamStatus( + repoPath: string, + connectionId?: string | null +): Promise { + if (!connectionId) { + return getUpstreamStatus(repoPath) + } + try { + const { stdout: upstreamStdout } = await runGitForHostedReview( + repoPath, + ['rev-parse', '--abbrev-ref', 'HEAD@{u}'], + connectionId + ) + const upstreamName = upstreamStdout.trim() + if (!upstreamName) { + return { hasUpstream: false, ahead: 0, behind: 0 } + } + + const { stdout: countsStdout } = await runGitForHostedReview( + repoPath, + ['rev-list', '--left-right', '--count', 'HEAD...@{u}'], + connectionId + ) + const tokens = countsStdout.trim().split(/\s+/) + if (tokens.length !== 2) { + throw new Error(`Unexpected git rev-list output: ${JSON.stringify(countsStdout)}`) + } + const ahead = Number.parseInt(tokens[0]!, 10) + const behind = Number.parseInt(tokens[1]!, 10) + if (!Number.isFinite(ahead) || !Number.isFinite(behind) || ahead < 0 || behind < 0) { + throw new Error(`Unparseable git rev-list counts: ${JSON.stringify(countsStdout)}`) + } + return { hasUpstream: true, upstreamName, ahead, behind } + } catch (error) { + if (isNoUpstreamError(error)) { + return { hasUpstream: false, ahead: 0, behind: 0 } + } + throw new Error(normalizeGitErrorMessage(error, 'upstream')) + } +} + const blockedCreateResultByReason = { auth_required: { ok: false, @@ -191,10 +293,11 @@ function blockedEligibilityToCreateResult( async function validateCurrentBranchCanCreateReview( repoPath: string, + connectionId: string | null | undefined, input: CreateHostedReviewInput ): Promise { const requestedHead = input.head ? stripRefPrefix(input.head).trim() : '' - const currentBranch = await getCurrentBranch(repoPath) + const currentBranch = await getCurrentBranch(repoPath, connectionId) if (requestedHead && requestedHead !== currentBranch) { return { ok: false, @@ -205,8 +308,8 @@ async function validateCurrentBranchCanCreateReview( try { const [dirty, upstreamStatus] = await Promise.all([ - hasUncommittedChanges(repoPath), - getUpstreamStatus(repoPath) + hasUncommittedChanges(repoPath, connectionId), + getHostedReviewUpstreamStatus(repoPath, connectionId) ]) const eligibility = await getHostedReviewCreationEligibility({ repoPath, @@ -215,7 +318,8 @@ async function validateCurrentBranchCanCreateReview( hasUncommittedChanges: dirty, hasUpstream: upstreamStatus.hasUpstream, ahead: upstreamStatus.ahead, - behind: upstreamStatus.behind + behind: upstreamStatus.behind, + connectionId }) // Why: renderer eligibility can be stale by submit time; the main process // is the last chance to avoid creating a PR from an out-of-date remote head. @@ -232,11 +336,12 @@ async function validateCurrentBranchCanCreateReview( } export async function getHostedReviewCreationEligibility( - args: HostedReviewCreationEligibilityArgs + args: HostedReviewCreationEligibilityInput ): Promise { const branch = stripRefPrefix(args.branch).trim() - const provider = await detectHostedReviewProvider(args.repoPath) - const defaultBaseRef = args.base?.trim() || (await getDefaultBaseRef(args.repoPath)) + const provider = await detectHostedReviewProvider(args.repoPath, args.connectionId) + const defaultBaseRef = + args.base?.trim() || (await getDefaultBaseRef(args.repoPath, args.connectionId)) const baseBranch = defaultBaseRef ? normalizeHostedReviewBaseRef(defaultBaseRef) : null const review = await getHostedReviewForBranch({ repoPath: args.repoPath, @@ -246,11 +351,13 @@ export async function getHostedReviewCreationEligibility( linkedGitLabMR: args.linkedGitLabMR ?? null, linkedBitbucketPR: args.linkedBitbucketPR ?? null, linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null, - linkedGiteaPR: args.linkedGiteaPR ?? null + linkedGiteaPR: args.linkedGiteaPR ?? null, + connectionId: args.connectionId ?? null }) - const title = (await getLatestCommitSubject(args.repoPath)) ?? branchToTitle(branch) - const body = await getCommitSummaryBody(args.repoPath, defaultBaseRef ?? null) + const title = + (await getLatestCommitSubject(args.repoPath, args.connectionId)) ?? branchToTitle(branch) + const body = await getCommitSummaryBody(args.repoPath, defaultBaseRef ?? null, args.connectionId) const baseResult = { provider, review: review ? { number: review.number, url: review.url } : null, @@ -294,7 +401,7 @@ export async function getHostedReviewCreationEligibility( if ((args.behind ?? 0) > 0) { return { ...baseResult, canCreate: false, blockedReason: 'needs_sync', nextAction: 'sync' } } - if (!(await isGitHubAuthenticated(args.repoPath))) { + if (!(await isGitHubAuthenticated(args.repoPath, args.connectionId))) { return { ...baseResult, canCreate: false, @@ -310,7 +417,8 @@ export async function getHostedReviewCreationEligibility( export async function createHostedReview( repoPath: string, - input: CreateHostedReviewInput + input: CreateHostedReviewInput, + connectionId?: string | null ): Promise { if (input.provider !== 'github') { return { @@ -319,7 +427,7 @@ export async function createHostedReview( error: 'Creating reviews for this provider is not supported yet.' } } - const provider = await detectHostedReviewProvider(repoPath) + const provider = await detectHostedReviewProvider(repoPath, connectionId) if (provider !== 'github') { return { ok: false, @@ -327,9 +435,9 @@ export async function createHostedReview( error: 'Creating pull requests requires a GitHub remote.' } } - const blocked = await validateCurrentBranchCanCreateReview(repoPath, input) + const blocked = await validateCurrentBranchCanCreateReview(repoPath, connectionId, input) if (blocked) { return blocked } - return createGitHubPullRequest(repoPath, input) + return createGitHubPullRequest(repoPath, input, connectionId) } diff --git a/src/main/source-control/hosted-review.test.ts b/src/main/source-control/hosted-review.test.ts index 3b62c0f4149..2c148e3b20a 100644 --- a/src/main/source-control/hosted-review.test.ts +++ b/src/main/source-control/hosted-review.test.ts @@ -82,7 +82,11 @@ describe('getHostedReviewForBranch', () => { }) await expect( - getHostedReviewForBranch({ repoPath: '/repo', branch: 'refs/heads/feature' }) + getHostedReviewForBranch({ + repoPath: '/repo', + connectionId: 'ssh-1', + branch: 'refs/heads/feature' + }) ).resolves.toEqual({ provider: 'gitlab', number: 7, @@ -93,6 +97,8 @@ describe('getHostedReviewForBranch', () => { updatedAt: '2026-05-10T00:00:00.000Z', mergeable: 'MERGEABLE' }) + expect(getProjectSlugMock).toHaveBeenCalledWith('/repo', 'ssh-1') + expect(getMergeRequestForBranchMock).toHaveBeenCalledWith('/repo', 'feature', null, 'ssh-1') expect(getPRForBranchMock).not.toHaveBeenCalled() }) diff --git a/src/main/source-control/hosted-review.ts b/src/main/source-control/hosted-review.ts index d2da4f8877c..01d37a02bf9 100644 --- a/src/main/source-control/hosted-review.ts +++ b/src/main/source-control/hosted-review.ts @@ -119,11 +119,15 @@ export async function getHostedReviewForBranch(input: { // Why: branch review status is tied to the branch publishing remote. // GitHub and GitLab task/project surfaces may use richer per-provider // source preferences, but this core status should follow origin. - const gitlabProject = await getProjectSlug(input.repoPath) + const gitlabProject = await getProjectSlug(input.repoPath, input.connectionId) if (gitlabProject) { const mr = - (await getMergeRequestForBranch(input.repoPath, branchName, input.linkedGitLabMR ?? null)) ?? - null + (await getMergeRequestForBranch( + input.repoPath, + branchName, + input.linkedGitLabMR ?? null, + input.connectionId + )) ?? null return mr ? mapGitLabReview(mr) : null } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index d09e332b4a0..093d428a7ed 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -683,7 +683,7 @@ export type PreloadApi = { mrIid: number sourceBranch?: string isCrossRepository?: boolean - }) => Promise<{ baseBranch: string } | { error: string }> + }) => Promise<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }> remove: (args: { worktreeId: string; force?: boolean; skipArchive?: boolean }) => Promise updateMeta: (args: { worktreeId: string; updates: Partial }) => Promise listLineage: () => Promise> diff --git a/src/preload/index.ts b/src/preload/index.ts index a29ca92f909..3f1bbc31b69 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -497,7 +497,7 @@ const api = { mrIid: number sourceBranch?: string isCrossRepository?: boolean - }): Promise<{ baseBranch: string } | { error: string }> => + }): Promise<{ baseBranch: string; pushTarget?: unknown } | { error: string }> => ipcRenderer.invoke('worktrees:resolveMrBase', args), remove: (args: { worktreeId: string; force?: boolean; skipArchive?: boolean }): Promise => diff --git a/src/relay/git-handler-worktree-ops.ts b/src/relay/git-handler-worktree-ops.ts index 00b8ce82320..7fcdc8a8574 100644 --- a/src/relay/git-handler-worktree-ops.ts +++ b/src/relay/git-handler-worktree-ops.ts @@ -17,6 +17,7 @@ export async function addWorktreeOp(git: GitExec, params: Record { ]) }) + it('passes --no-checkout when sparse setup will checkout after configuration', async () => { + const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) + gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // rev-parse refs/remotes/origin/main + gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add + gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get + gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --local set + + await localDispatcher.callRequest('git.addWorktree', { + repoPath: '/relay/repo', + branchName: 'feature/sparse', + targetDir: '/relay/wt', + base: 'origin/main', + noCheckout: true + }) + + expect(gitMock.mock.calls[1]?.[0]).toEqual([ + 'worktree', + 'add', + '--no-track', + '--no-checkout', + '-b', + 'feature/sparse', + '/relay/wt', + 'refs/remotes/origin/main' + ]) + }) + it('preserves an existing push.autoSetupRemote value (does not overwrite user-set false)', async () => { const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) gitMock.mockRejectedValueOnce(new Error('not a branch')) // rev-parse refs/heads/main^{commit} diff --git a/src/renderer/src/components/GitLabItemDialog.tsx b/src/renderer/src/components/GitLabItemDialog.tsx index 1cceb3aacb7..2f0ba574360 100644 --- a/src/renderer/src/components/GitLabItemDialog.tsx +++ b/src/renderer/src/components/GitLabItemDialog.tsx @@ -463,7 +463,7 @@ export default function GitLabItemDialog({ className="gap-1.5" > - Open in browser + Open in GitLab
{onCreateWorkspace ? ( diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index d28e7f6b5d7..69d48713807 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -1848,7 +1848,7 @@ function PaginationBar({ }): React.JSX.Element { const pageNumbers = getPageNumbers(currentPage, totalPages) const btnClass = - 'inline-flex items-center gap-0.5 rounded-md px-2 py-1 text-sm text-muted-foreground transition hover:bg-muted/60 hover:text-foreground disabled:pointer-events-none disabled:opacity-40' + 'inline-flex w-24 items-center justify-center gap-0.5 rounded-md px-2 py-1 text-sm text-muted-foreground transition hover:bg-muted/60 hover:text-foreground disabled:pointer-events-none disabled:opacity-40' const numClass = (page: number): string => cn( 'inline-flex size-8 items-center justify-center rounded-md text-sm transition', @@ -2673,9 +2673,9 @@ export default function TaskPage(): React.JSX.Element { ) { return } - // Why: GitLab queries don't work over SSH-relay (yet) and folder- - // mode repos have no remotes to derive a project from. Filter both. - const eligibleRepos = selectedRepos.filter((r) => !r.connectionId) + // Why: folder-mode repos have no remotes to derive a GitLab project from; + // SSH-backed Git repos go through the same provider-aware IPC path. + const eligibleRepos = selectedRepos if (eligibleRepos.length === 0) { setGitlabItems([]) setGitlabLoading(false) @@ -5343,7 +5343,7 @@ export default function TaskPage(): React.JSX.Element { e.stopPropagation() void window.api.shell.openUrl(item.url) }} - aria-label="Open in browser" + aria-label="Open in GitLab" className="text-muted-foreground hover:text-foreground" > diff --git a/src/renderer/src/components/github-project/ProjectGroupHeader.tsx b/src/renderer/src/components/github-project/ProjectGroupHeader.tsx index 5f77a8a094e..8dcc545b738 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 './group-sort' +import { isIterationCurrent, type ProjectGroup } from '../../../../shared/github-project-group-sort' type Props = { group: ProjectGroup diff --git a/src/renderer/src/components/github-project/ProjectViewList.tsx b/src/renderer/src/components/github-project/ProjectViewList.tsx index 0471b29f1c9..10c94da4744 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 './group-sort' +import { groupRows, sortRows } from '../../../../shared/github-project-group-sort' import { getAvailableColumns, loadHiddenColumns, saveHiddenColumns } from './columns' import { ACTION_COLUMN_WIDTH, 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 ef1059bff64..4d36f0125da 100644 --- a/src/renderer/src/components/github-project/group-sort.test.ts +++ b/src/renderer/src/components/github-project/group-sort.test.ts @@ -9,7 +9,7 @@ import type { GitHubProjectTable, GitHubProjectView } from '../../../../shared/github-project-types' -import { sortRows, groupRows } from './group-sort' +import { sortRows, groupRows } from '../../../../shared/github-project-group-sort' const singleSelectField: GitHubProjectField = { kind: 'single-select', @@ -205,6 +205,18 @@ describe('sortRows', () => { const sorted = sortRows(makeTable(view, rows), rows) expect(sorted.map((r) => r.id)).toEqual(['rHas', 'rEmpty']) }) + + it('keeps sort fallback finite when row positions are absent', () => { + const view = makeView(singleSelectField) + const rows = [ + { ...makeRow('rA', 0, {}), position: undefined as unknown as number }, + { ...makeRow('rB', 0, {}), position: undefined as unknown as number } + ] + + const sorted = sortRows(makeTable(view, rows), rows) + + expect(sorted.map((r) => r.id)).toEqual(['rA', 'rB']) + }) }) describe('groupRows', () => { diff --git a/src/renderer/src/hooks/composer-branch-selection.ts b/src/renderer/src/hooks/composer-branch-selection.ts index 92ec11c6be0..587d97154d2 100644 --- a/src/renderer/src/hooks/composer-branch-selection.ts +++ b/src/renderer/src/hooks/composer-branch-selection.ts @@ -1,37 +1,4 @@ -export type ComposerBranchSelection = { - baseBranch: string - branchNameOverride: string | undefined - branchAutoName: string - name: string | undefined - lastAutoName: string | undefined -} - -export function resolveComposerBranchSelection(args: { - refName: string - localBranchName: string - currentName: string - lastAutoName: string -}): ComposerBranchSelection { - const trimmedCurrentName = args.currentName.trim() - const shouldAutoName = - !trimmedCurrentName || - args.currentName === args.lastAutoName || - args.localBranchName.startsWith(trimmedCurrentName) || - args.refName.startsWith(trimmedCurrentName) - if (!shouldAutoName) { - return { - baseBranch: args.refName, - branchNameOverride: undefined, - branchAutoName: '', - name: undefined, - lastAutoName: undefined - } - } - return { - baseBranch: args.refName, - branchNameOverride: args.localBranchName, - branchAutoName: args.localBranchName, - name: args.localBranchName, - lastAutoName: args.localBranchName - } -} +export { + resolveComposerBranchSelection, + type ComposerBranchSelection +} from '../../../shared/composer-branch-selection' diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 07774f442d3..e22e907e1f4 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -124,7 +124,11 @@ export type ComposerCardProps = { onSmartBranchSelect: (refName: string, localBranchName: string) => void onSmartLinearIssueSelect: (issue: LinearIssue) => void /** GitLab parallel of onBaseBranchPrSelect. */ - onBaseBranchMrSelect?: (baseBranch: string, item: GitLabWorkItem) => void + onBaseBranchMrSelect?: ( + baseBranch: string, + item: GitLabWorkItem, + pushTarget?: GitPushTarget + ) => void smartNameSelection: SmartWorkspaceNameSelection | null onClearSmartNameSelection: () => void agentPrompt: string @@ -1450,8 +1454,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // semantics — except the note prefill uses GitLab's `!N` MR convention // so a glance at the worktree sidebar makes the provider obvious. const handleBaseBranchMrSelect = useCallback( - (nextBaseBranch: string, item: GitLabWorkItem): void => { + (nextBaseBranch: string, item: GitLabWorkItem, nextPushTarget?: GitPushTarget): void => { setBaseBranch(nextBaseBranch) + setPushTarget(nextPushTarget) setBranchNameOverride(undefined) branchAutoNameRef.current = '' setStartFromResetHint(null) @@ -1551,7 +1556,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS if ('error' in result) { return } - handleBaseBranchMrSelect(result.baseBranch, item) + handleBaseBranchMrSelect(result.baseBranch, item, result.pushTarget) }) }, [applyLinkedGitLabWorkItem, eligibleRepos, handleBaseBranchMrSelect, selectedRepo] diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 093b8fc8bb5..c1e435a7a68 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -984,6 +984,43 @@ describe('useIpcEvents updater integration', () => { title: 'Terminal 1' }) + storeState.terminalLayoutsByTabId = { + 'tab-existing': { + root: { type: 'leaf', leafId: 'leaf-source' }, + activeLeafId: 'leaf-source', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-source': 'pty-bg' } + } + } + updateTabPtyId.mockClear() + setTabLayout.mockClear() + createTerminalListenerRef.current({ + worktreeId: 'wt-2', + ptyId: 'pty-split-background', + tabId: 'tab-existing', + leafId: 'leaf-split-background', + splitFromLeafId: 'leaf-source', + splitDirection: 'vertical', + activate: false + }) + + expect(updateTabPtyId).toHaveBeenCalledWith('tab-existing', 'pty-split-background') + expect(setTabLayout).toHaveBeenCalledWith('tab-existing', { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'leaf-source' }, + second: { type: 'leaf', leafId: 'leaf-split-background' }, + ratio: 0.5 + }, + activeLeafId: 'leaf-source', + expandedLeafId: null, + ptyIdsByLeafId: { + 'leaf-source': 'pty-bg', + 'leaf-split-background': 'pty-split-background' + } + }) + const splitLayout = { root: { type: 'split', diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index ffd9cd9f885..e8cb49cd649 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -130,10 +130,15 @@ function addSplitLeafToLayout( newLeafId: string, ptyId: string, direction: TerminalSplitDirection, - title?: string | null + title?: string | null, + activateNewLeaf = true ): TerminalLayoutSnapshot { const root = layout?.root ?? { type: 'leaf', leafId: sourceLeafId } const existingLeafIds = collectLeafIdsInOrder(root) + const nextActiveLeafId = + activateNewLeaf || !layout?.activeLeafId || !existingLeafIds.includes(layout.activeLeafId) + ? newLeafId + : layout.activeLeafId const nextRoot = existingLeafIds.includes(newLeafId) ? root : (() => { @@ -152,7 +157,7 @@ function addSplitLeafToLayout( return { ...(layout ?? { root: null, activeLeafId: null, expandedLeafId: null }), root: nextRoot, - activeLeafId: newLeafId, + activeLeafId: nextActiveLeafId, expandedLeafId: null, ptyIdsByLeafId: { ...layout?.ptyIdsByLeafId, @@ -850,7 +855,8 @@ export function useIpcEvents(): void { leafId, ptyId, splitDirection ?? 'horizontal', - title + title, + shouldActivate ) ) window.dispatchEvent( diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 6eeeed13d27..2d85e8b84dc 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -1,9 +1,9 @@ import { toast } from 'sonner' import { useAppStore, type AppState } from '@/store' -import { AGENT_CATALOG } from '@/lib/agent-catalog' import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' +import { pickTuiAgent } from '../../../shared/tui-agent-selection' import { activateAndRevealWorktree, type AgentStartedTelemetry } from '@/lib/worktree-activation' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { @@ -69,26 +69,6 @@ export type LaunchWorkItemDirectArgs = { telemetrySource?: WorkspaceCreateTelemetrySource } -function pickAgent( - preferred: TuiAgent | 'blank' | null | undefined, - detected: Set -): TuiAgent | null { - // Why: honor the explicit default when the agent is actually installed. A - // stale preference (uninstalled binary) must not block the flow — fall - // through to the first matching detected agent in catalog order, which - // matches the quick-composer's auto-pick behavior and keeps the experience - // consistent regardless of where the user launches the workspace from. - if (preferred && preferred !== 'blank' && detected.has(preferred)) { - return preferred - } - for (const entry of AGENT_CATALOG) { - if (detected.has(entry.id)) { - return entry.id - } - } - return null -} - async function resolveDirectPrStartPoint( repoId: string, prNumber: number, @@ -272,7 +252,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom const worktreePath = result.worktree.path const detectedIds = new Set(await detectedAgentsPromise) - effectiveAgent = pickAgent(settings?.defaultTuiAgent, detectedIds) + effectiveAgent = pickTuiAgent(settings?.defaultTuiAgent, detectedIds) if (effectiveAgent) { // Why: direct task launch creates and starts the workspace in separate // steps so agent detection can overlap git worktree creation. Persist diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts index a2717c80056..4c32f0b395b 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -9,6 +9,8 @@ import { isShellProcess } from '@/lib/tui-agent-startup' import type { OrcaHooks, TaskViewPresetId } from '../../../shared/types' import { resolveHookCommandSourcePolicy } from '../../../shared/hook-command-source-policy' import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition' +import { slugifyForWorkspaceName } from '../../../shared/workspace-name' +export { getLinkedWorkItemSuggestedName } from '../../../shared/workspace-name' /** * Why: the TaskPage's preset buttons and the openTaskPage prefetcher both need @@ -158,43 +160,6 @@ export function getSetupConfig( return null } -// Why: branch names and on-disk worktree directories must be short, lowercase, -// and ASCII-safe. Free-form text (prompts, GitHub titles) often contains -// emoji, CJK, or hundreds of characters, which would otherwise make -// sanitizeWorktreeName either produce a ludicrously long name or throw -// "Invalid worktree name" when every character is stripped. -function slugifyForWorkspaceName(input: string): string { - return ( - input - .trim() - .toLowerCase() - .replace(/[\\/]+/g, '-') - .replace(/\s+/g, '-') - .replace(/[^a-z0-9._-]+/g, '-') - .replace(/-+/g, '-') - // Why: git check-ref-format rejects any ref containing `..`, so a prompt - // like "../../foo" must not turn into a branch seed with internal `..` - // sequences (the main-process sanitizer collapses these too, but we - // mirror the rule here so the renderer preview matches the real name). - .replace(/\.{2,}/g, '.') - .replace(/^[.-]+|[.-]+$/g, '') - .slice(0, 48) - .replace(/[-._]+$/g, '') - ) -} - -export function getLinkedWorkItemSuggestedName(item: { title: string }): string { - const withoutLeadingNumber = item.title - .trim() - .replace(/^(?:issue|pr|pull request)\s*#?\d+\s*[:-]\s*/i, '') - .replace(/^#\d+\s*[:-]\s*/, '') - .replace(/\(#\d+\)/gi, '') - .replace(/\b#\d+\b/g, '') - .trim() - const seed = withoutLeadingNumber || item.title.trim() - return slugifyForWorkspaceName(seed) -} - export function getWorkspaceSeedName(args: { explicitName: string prompt: string diff --git a/src/renderer/src/lib/tui-agent-startup.ts b/src/renderer/src/lib/tui-agent-startup.ts index 65964af5ee4..6427aa17dc0 100644 --- a/src/renderer/src/lib/tui-agent-startup.ts +++ b/src/renderer/src/lib/tui-agent-startup.ts @@ -1,173 +1,6 @@ -import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' -import type { TuiAgent } from '../../../shared/types' - -export type AgentStartupPlan = { - /** Why: surfaces the agent id so downstream paste-draft logic can resolve - * the per-agent draft injection strategy without re-deriving from the - * launch command string. */ - agent: TuiAgent - launchCommand: string - expectedProcess: string - followupPrompt: string | null - /** Why: text to type into the live agent input WITHOUT submitting it (no - * trailing \r). Used by the quick-create flow to pre-fill a linked work - * item URL so the user can edit/add to it before sending. Independent from - * `followupPrompt` so the call site can choose: type-and-submit (followup) - * or type-and-leave-pending (draft). */ - draftPrompt?: string | null - /** Why: env vars to apply at PTY spawn time. Currently used to deliver - * pi's `ORCA_PI_PREFILL` so the overlay's `orca-prefill` extension - * picks it up on session_start. Plumbed into `startup.env` by the - * activation site, NOT into the shell command, so the value isn't - * visibly prefixed onto the terminal. */ - env?: Record -} - -function quoteStartupArg(value: string, platform: NodeJS.Platform): string { - if (platform === 'win32') { - return `"${value.replace(/"/g, '""')}"` - } - - return `'${value.replace(/'/g, `'\\''`)}'` -} - -export function buildAgentStartupPlan(args: { - agent: TuiAgent - prompt: string - cmdOverrides: Partial> - platform: NodeJS.Platform - allowEmptyPromptLaunch?: boolean -}): AgentStartupPlan | null { - const { agent, prompt, cmdOverrides, platform, allowEmptyPromptLaunch = false } = args - const trimmedPrompt = prompt.trim() - const config = TUI_AGENT_CONFIG[agent] - const baseCommand = cmdOverrides[agent] ?? config.launchCmd - - if (!trimmedPrompt) { - if (!allowEmptyPromptLaunch) { - return null - } - return { - agent, - launchCommand: baseCommand, - expectedProcess: config.expectedProcess, - followupPrompt: null - } - } - - const quotedPrompt = quoteStartupArg(trimmedPrompt, platform) - - if (config.promptInjectionMode === 'argv') { - return { - agent, - launchCommand: `${baseCommand} ${quotedPrompt}`, - expectedProcess: config.expectedProcess, - followupPrompt: null - } - } - - if (config.promptInjectionMode === 'flag-prompt') { - return { - agent, - launchCommand: `${baseCommand} --prompt ${quotedPrompt}`, - expectedProcess: config.expectedProcess, - followupPrompt: null - } - } - - if (config.promptInjectionMode === 'flag-prompt-interactive') { - return { - agent, - launchCommand: `${baseCommand} --prompt-interactive ${quotedPrompt}`, - expectedProcess: config.expectedProcess, - followupPrompt: null - } - } - - if (config.promptInjectionMode === 'flag-interactive') { - return { - agent, - launchCommand: `${baseCommand} -i ${quotedPrompt}`, - expectedProcess: config.expectedProcess, - followupPrompt: null - } - } - - return { - agent, - launchCommand: baseCommand, - expectedProcess: config.expectedProcess, - // Why: several agent TUIs either lack a documented "start interactive - // session with this prompt" flag or vary too much across versions. For - // those agents Orca launches the TUI first, then types the composed prompt - // into the live session once the agent owns the terminal. - followupPrompt: trimmedPrompt - } -} - -export type AgentDraftLaunchPlan = { - agent: TuiAgent - launchCommand: string - expectedProcess: string - /** Why: env-var-based prefill (currently pi via the overlay-installed - * `orca-prefill` extension) ships the draft text in the PTY-spawn - * environment instead of via a CLI flag. Callers MUST plumb this into - * the queued `startup.env` so it reaches the shell that launches the - * agent. Empty/undefined when the agent uses a CLI flag (Claude). */ - env?: Record -} - -/** - * Why: when the agent's CLI exposes a documented "prefill but don't submit" - * flag (currently only `claude --prefill `), launch with that flag so - * the TUI mounts with the draft already in its input box. This is strictly - * better than the post-launch bracketed-paste fallback in agent-paste-draft.ts - * because it eliminates the empirical readiness wait entirely — the agent - * controls when its input is rendered. - * - * Returns `null` when the agent has no native prefill flag; callers fall - * back to the paste-after-ready path. - */ -export function buildAgentDraftLaunchPlan(args: { - agent: TuiAgent - draft: string - cmdOverrides: Partial> - platform: NodeJS.Platform -}): AgentDraftLaunchPlan | null { - const { agent, draft, cmdOverrides, platform } = args - const config = TUI_AGENT_CONFIG[agent] - const trimmed = draft.trim() - if (!trimmed) { - return null - } - const baseCommand = cmdOverrides[agent] ?? config.launchCmd - if (config.draftPromptFlag) { - const quoted = quoteStartupArg(trimmed, platform) - return { - agent, - launchCommand: `${baseCommand} ${config.draftPromptFlag} ${quoted}`, - expectedProcess: config.expectedProcess - } - } - if (config.draftPromptEnvVar) { - // Why: the env var is set on the PTY-spawn env (not embedded in the - // shell command) so the value never has to be shell-escaped and the - // user doesn't see a `FOO='...'` prefix typed into their terminal. - // Append a clear-var command so the var is unset from the shell env - // once the agent exits — otherwise re-running the agent in the same - // terminal would inherit the stale value and re-prefill with the old URL. - const clearVar = - platform === 'win32' - ? `set "${config.draftPromptEnvVar}="` - : `unset ${config.draftPromptEnvVar}` - return { - agent, - launchCommand: `${baseCommand}; ${clearVar}`, - expectedProcess: config.expectedProcess, - env: { [config.draftPromptEnvVar]: trimmed } - } - } - return null -} - -export { isShellProcess } from '../../../shared/agent-detection' +export { + buildAgentDraftLaunchPlan, + buildAgentStartupPlan, + isShellProcess +} from '../../../shared/tui-agent-startup' +export type { AgentDraftLaunchPlan, AgentStartupPlan } from '../../../shared/tui-agent-startup' diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 58b84bf6a54..71fdddd4c87 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -669,6 +669,8 @@ function createWorktreesApi(): NonNullable['worktrees']> { linkedIssue: args.linkedIssue, linkedPR: args.linkedPR, linkedLinearIssue: args.linkedLinearIssue, + linkedGitLabIssue: args.linkedGitLabIssue, + linkedGitLabMR: args.linkedGitLabMR, displayName: args.displayName, sparseCheckout: args.sparseCheckout, pushTarget: args.pushTarget, @@ -685,9 +687,13 @@ function createWorktreesApi(): NonNullable['worktrees']> { headRefName, isCrossRepository }), - resolveMrBase: async () => ({ - error: 'GitLab merge request base resolution is unavailable on web.' - }), + resolveMrBase: async ({ repoId, mrIid, sourceBranch, isCrossRepository }) => + callRuntimeResult('worktree.resolveMrBase', { + repo: repoId, + mrIid, + sourceBranch, + isCrossRepository + }), remove: async ({ worktreeId, force }) => { cachedWorktrees = null await callRuntimeResult('worktree.rm', { worktree: worktreeId, force }) @@ -1763,9 +1769,13 @@ function mapRepoPathArg(args: unknown): unknown { return args } const record = args as Record + const repoId = typeof record.repoId === 'string' && record.repoId.trim() ? record.repoId : null return { ...record, - repo: record.repoPath + // Why: runtime repo selectors accept loose path/name forms, but duplicate + // checked-out repos can make those ambiguous. The renderer already passes + // Orca's repo id on task calls, so prefer the explicit selector. + repo: repoId ? `id:${repoId}` : record.repoPath } } diff --git a/src/shared/composer-branch-selection.ts b/src/shared/composer-branch-selection.ts new file mode 100644 index 00000000000..92ec11c6be0 --- /dev/null +++ b/src/shared/composer-branch-selection.ts @@ -0,0 +1,37 @@ +export type ComposerBranchSelection = { + baseBranch: string + branchNameOverride: string | undefined + branchAutoName: string + name: string | undefined + lastAutoName: string | undefined +} + +export function resolveComposerBranchSelection(args: { + refName: string + localBranchName: string + currentName: string + lastAutoName: string +}): ComposerBranchSelection { + const trimmedCurrentName = args.currentName.trim() + const shouldAutoName = + !trimmedCurrentName || + args.currentName === args.lastAutoName || + args.localBranchName.startsWith(trimmedCurrentName) || + args.refName.startsWith(trimmedCurrentName) + if (!shouldAutoName) { + return { + baseBranch: args.refName, + branchNameOverride: undefined, + branchAutoName: '', + name: undefined, + lastAutoName: undefined + } + } + return { + baseBranch: args.refName, + branchNameOverride: args.localBranchName, + branchAutoName: args.localBranchName, + name: args.localBranchName, + lastAutoName: args.localBranchName + } +} diff --git a/src/renderer/src/components/github-project/group-sort.ts b/src/shared/github-project-group-sort.ts similarity index 90% rename from src/renderer/src/components/github-project/group-sort.ts rename to src/shared/github-project-group-sort.ts index 0a3b5137a61..561c1709ddc 100644 --- a/src/renderer/src/components/github-project/group-sort.ts +++ b/src/shared/github-project-group-sort.ts @@ -1,12 +1,12 @@ // Why: grouping and sorting of Project rows is deterministic shared logic // driven by `selectedView` — it must not depend on fetch ordering. Keeping it -// in a pure module makes the comparator easily fixture-testable. +// in a pure shared module lets desktop and mobile render Project views the same. import type { GitHubProjectField, GitHubProjectRow, GitHubProjectSort, GitHubProjectTable -} from '../../../../shared/github-project-types' +} from './github-project-types' export type ProjectGroup = { /** Stable key used for React reconciliation. */ @@ -63,7 +63,6 @@ function getFieldValueForGrouping( iteration: null } } - // Fallback: use stringified value as both key and label. const label = deriveStringValue(value) return { key: `raw:${label}`, label, orderHint: 0, iteration: null } } @@ -122,7 +121,6 @@ export function groupRows( const entries = Array.from(buckets.entries()) // Ordering rules per design doc §Grouping. entries.sort((a, b) => { - // Empty group always last. if (a[0] === EMPTY_GROUP_KEY) { return 1 } @@ -146,7 +144,6 @@ function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProje const field = sort.field const aValue = a.fieldValuesByFieldId[field.id] const bValue = b.fieldValuesByFieldId[field.id] - // Missing values sort last (regardless of direction). if (!aValue && !bValue) { return 0 } @@ -208,12 +205,7 @@ function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProje } } else { // Why: unknown sort-field kind — ignore this sort field and fall through - // to tie-breaks (and eventually row.position). Dev-time warning gated so - // production builds don't spam the console. - if (process.env.NODE_ENV !== 'production') { - // eslint-disable-next-line no-console - console.warn('[projectView] unknown sort-field kind', field) - } + // to tie-breaks (and eventually row.position). return 0 } return sort.direction === 'DESC' ? -cmp : cmp @@ -229,14 +221,12 @@ export function sortRows(table: GitHubProjectTable, rows: GitHubProjectRow[]): G return cmp } } - // Final tie-break: row.position preserves GitHub rank order. - return a.position - b.position + return (a.position ?? UNKNOWN_INDEX_SENTINEL) - (b.position ?? UNKNOWN_INDEX_SENTINEL) }) return out } export function isIterationCurrent(iteration: { startDate: string; duration: number }): boolean { - // Parse as YYYY-MM-DD in UTC to avoid TZ-shift false negatives near midnight. const start = new Date(`${iteration.startDate}T00:00:00Z`).getTime() if (Number.isNaN(start)) { return false diff --git a/src/shared/gitlab-types.ts b/src/shared/gitlab-types.ts index 92ae5b3f39e..5029bf92de1 100644 --- a/src/shared/gitlab-types.ts +++ b/src/shared/gitlab-types.ts @@ -146,6 +146,9 @@ export type GitLabWorkItem = { /** Stamped by the renderer fetcher / optimistic stubs so cross-project * views can attribute rows. Mirrors GitHubWorkItem.repoId. */ repoId: string + /** Exact GitLab project that produced this row. Mutations/details must use it + * instead of re-resolving the repo preference later. */ + projectRef?: GitLabProjectRef } export type GitLabMRFile = { @@ -245,9 +248,8 @@ export type GitLabWorkItemDetails = { export type GitLabIssueUpdate = { state?: 'opened' | 'closed' title?: string - /** Mirrors GitHubIssueUpdate.body — kept ignored on the repoPath-based - * flow for the same backward-compat reason. Slug-addressed write paths - * cover the body-edit case end-to-end. */ + /** Why: `glab issue update` handles title/labels/assignees, while body edits + * use the REST issue endpoint so mobile can save the markdown description. */ body?: string addLabels?: string[] removeLabels?: string[] diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 4fef2fbd4a2..3cb446b3b57 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -27,7 +27,8 @@ export const RUNTIME_CAPABILITIES = [ 'browser.screencast.v1', 'terminal.binary-stream.v1', 'terminal.multiplex.v1', - 'workspace-ports.v1' + 'workspace-ports.v1', + 'mobile.tasks.v1' ] as const export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {}) diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index b20eb958412..9aaf258ae95 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -16,6 +16,7 @@ import { z } from 'zod' import { FEATURE_WALL_MAX_DWELL_MS } from './feature-wall-telemetry' import { SETUP_SCRIPT_IMPORT_PROVIDERS } from './setup-script-import-providers' +import { WORKSPACE_SOURCE_VALUES, type WorkspaceSource } from './workspace-source' import { AGENT_HOOK_TARGETS } from './agent-hook-types' import { ONBOARDING_FINAL_STEP } from './constants' @@ -129,15 +130,8 @@ export const workspaceCreateErrorClassSchema = z.enum([ ]) export type WorkspaceCreateErrorClass = z.infer -export const workspaceSourceSchema = z.enum([ - 'command_palette', - 'sidebar', - 'shortcut', - 'drag_drop', - 'onboarding', - 'unknown' -]) -export type WorkspaceSource = z.infer +export const workspaceSourceSchema = z.enum(WORKSPACE_SOURCE_VALUES) +export type { WorkspaceSource } export const launchSourceSchema = z.enum([ 'command_palette', diff --git a/src/shared/tui-agent-selection.test.ts b/src/shared/tui-agent-selection.test.ts new file mode 100644 index 00000000000..70ec171082b --- /dev/null +++ b/src/shared/tui-agent-selection.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { pickTuiAgent } from './tui-agent-selection' + +describe('pickTuiAgent', () => { + it('uses an installed preferred agent', () => { + expect(pickTuiAgent('codex', ['claude', 'codex'])).toBe('codex') + }) + + it('falls back in desktop catalog order when the preference is absent or stale', () => { + expect(pickTuiAgent(null, ['cursor', 'codex'])).toBe('codex') + expect(pickTuiAgent('gemini', ['cursor', 'codex'])).toBe('codex') + }) + + it('respects the explicit blank terminal preference', () => { + expect(pickTuiAgent('blank', ['cursor', 'claude'])).toBeNull() + }) +}) diff --git a/src/shared/tui-agent-selection.ts b/src/shared/tui-agent-selection.ts new file mode 100644 index 00000000000..6f8b68b411e --- /dev/null +++ b/src/shared/tui-agent-selection.ts @@ -0,0 +1,52 @@ +import type { TuiAgent } from './types' + +// Keep this order in sync with the desktop agent catalog. It defines the +// automatic fallback priority when the user has not chosen a default agent. +export const TUI_AGENT_AUTO_PICK_ORDER = [ + 'claude', + 'codex', + 'grok', + 'copilot', + 'opencode', + 'pi', + 'gemini', + 'antigravity', + 'aider', + 'goose', + 'amp', + 'kilo', + 'kiro', + 'crush', + 'aug', + 'autohand', + 'cline', + 'codebuff', + 'continue', + 'cursor', + 'droid', + 'kimi', + 'mistral-vibe', + 'qwen-code', + 'rovo', + 'hermes', + 'openclaw' +] as const satisfies readonly TuiAgent[] + +export function pickTuiAgent( + preferred: TuiAgent | 'blank' | null | undefined, + detected: Iterable +): TuiAgent | null { + if (preferred === 'blank') { + return null + } + const detectedSet = detected instanceof Set ? detected : new Set(detected) + if (preferred && detectedSet.has(preferred)) { + return preferred + } + for (const agent of TUI_AGENT_AUTO_PICK_ORDER) { + if (detectedSet.has(agent)) { + return agent + } + } + return null +} diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts new file mode 100644 index 00000000000..5313a410310 --- /dev/null +++ b/src/shared/tui-agent-startup.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from './tui-agent-startup' + +describe('tui agent startup plans', () => { + it('uses POSIX quoting when the target shell is Linux', () => { + const plan = buildAgentStartupPlan({ + agent: 'claude', + prompt: "fix Bob's branch", + cmdOverrides: {}, + platform: 'linux' + }) + + expect(plan?.launchCommand).toBe("claude 'fix Bob'\\''s branch'") + }) + + it('uses PowerShell quoting by default when the target shell is Windows', () => { + const plan = buildAgentStartupPlan({ + agent: 'claude', + prompt: 'fix Bob\'s "quoted" branch', + cmdOverrides: {}, + platform: 'win32' + }) + + expect(plan?.launchCommand).toBe("claude 'fix Bob''s \"quoted\" branch'") + }) + + it('uses cmd escaping when requested explicitly', () => { + const plan = buildAgentStartupPlan({ + agent: 'claude', + prompt: 'fix "quoted" & %PATH%', + cmdOverrides: {}, + platform: 'win32', + shell: 'cmd' + }) + + expect(plan?.launchCommand).toBe('claude "fix ^"quoted^" ^& ^%PATH^%"') + }) + + it('clears draft environment variables with the target shell syntax', () => { + expect( + buildAgentDraftLaunchPlan({ + agent: 'pi', + draft: 'https://github.com/acme/repo/issues/42', + cmdOverrides: {}, + platform: 'win32' + })?.launchCommand + ).toBe('pi; Remove-Item Env:ORCA_PI_PREFILL -ErrorAction SilentlyContinue') + + expect( + buildAgentDraftLaunchPlan({ + agent: 'pi', + draft: 'https://github.com/acme/repo/issues/42', + cmdOverrides: {}, + platform: 'win32', + shell: 'cmd' + })?.launchCommand + ).toBe('pi & set "ORCA_PI_PREFILL="') + }) +}) diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts new file mode 100644 index 00000000000..31b8ebb5065 --- /dev/null +++ b/src/shared/tui-agent-startup.ts @@ -0,0 +1,161 @@ +import { isShellProcess } from './agent-detection' +import { TUI_AGENT_CONFIG } from './tui-agent-config' +import type { TuiAgent } from './types' + +export type AgentStartupPlan = { + agent: TuiAgent + launchCommand: string + expectedProcess: string + followupPrompt: string | null + draftPrompt?: string | null + env?: Record +} + +export type AgentStartupShell = 'posix' | 'powershell' | 'cmd' + +function resolveStartupShell( + platform: NodeJS.Platform, + shell?: AgentStartupShell +): AgentStartupShell { + return shell ?? (platform === 'win32' ? 'powershell' : 'posix') +} + +function quoteStartupArg(value: string, shell: AgentStartupShell): string { + if (shell === 'powershell') { + return `'${value.replace(/'/g, "''")}'` + } + if (shell === 'cmd') { + return `"${value.replace(/([\^&|<>()%!"])/g, '^$1')}"` + } + return `'${value.replace(/'/g, `'\\''`)}'` +} + +function clearEnvCommand(name: string, shell: AgentStartupShell): string { + if (shell === 'powershell') { + return `Remove-Item Env:${name} -ErrorAction SilentlyContinue` + } + if (shell === 'cmd') { + return `set "${name}="` + } + return `unset ${name}` +} + +function commandSeparator(shell: AgentStartupShell): string { + return shell === 'cmd' ? ' & ' : '; ' +} + +export function buildAgentStartupPlan(args: { + agent: TuiAgent + prompt: string + cmdOverrides: Partial> + platform: NodeJS.Platform + shell?: AgentStartupShell + allowEmptyPromptLaunch?: boolean +}): AgentStartupPlan | null { + const { agent, prompt, cmdOverrides, platform, allowEmptyPromptLaunch = false } = args + const shell = resolveStartupShell(platform, args.shell) + const trimmedPrompt = prompt.trim() + const config = TUI_AGENT_CONFIG[agent] + const baseCommand = cmdOverrides[agent] ?? config.launchCmd + + if (!trimmedPrompt) { + if (!allowEmptyPromptLaunch) { + return null + } + return { + agent, + launchCommand: baseCommand, + expectedProcess: config.expectedProcess, + followupPrompt: null + } + } + + const quotedPrompt = quoteStartupArg(trimmedPrompt, shell) + + if (config.promptInjectionMode === 'argv') { + return { + agent, + launchCommand: `${baseCommand} ${quotedPrompt}`, + expectedProcess: config.expectedProcess, + followupPrompt: null + } + } + + if (config.promptInjectionMode === 'flag-prompt') { + return { + agent, + launchCommand: `${baseCommand} --prompt ${quotedPrompt}`, + expectedProcess: config.expectedProcess, + followupPrompt: null + } + } + + if (config.promptInjectionMode === 'flag-prompt-interactive') { + return { + agent, + launchCommand: `${baseCommand} --prompt-interactive ${quotedPrompt}`, + expectedProcess: config.expectedProcess, + followupPrompt: null + } + } + + if (config.promptInjectionMode === 'flag-interactive') { + return { + agent, + launchCommand: `${baseCommand} -i ${quotedPrompt}`, + expectedProcess: config.expectedProcess, + followupPrompt: null + } + } + + return { + agent, + launchCommand: baseCommand, + expectedProcess: config.expectedProcess, + followupPrompt: trimmedPrompt + } +} + +export type AgentDraftLaunchPlan = { + agent: TuiAgent + launchCommand: string + expectedProcess: string + env?: Record +} + +export function buildAgentDraftLaunchPlan(args: { + agent: TuiAgent + draft: string + cmdOverrides: Partial> + platform: NodeJS.Platform + shell?: AgentStartupShell +}): AgentDraftLaunchPlan | null { + const { agent, draft, cmdOverrides, platform } = args + const shell = resolveStartupShell(platform, args.shell) + const config = TUI_AGENT_CONFIG[agent] + const trimmed = draft.trim() + if (!trimmed) { + return null + } + const baseCommand = cmdOverrides[agent] ?? config.launchCmd + if (config.draftPromptFlag) { + const quoted = quoteStartupArg(trimmed, shell) + return { + agent, + launchCommand: `${baseCommand} ${config.draftPromptFlag} ${quoted}`, + expectedProcess: config.expectedProcess + } + } + if (config.draftPromptEnvVar) { + const clearVar = clearEnvCommand(config.draftPromptEnvVar, shell) + return { + agent, + launchCommand: `${baseCommand}${commandSeparator(shell)}${clearVar}`, + expectedProcess: config.expectedProcess, + env: { [config.draftPromptEnvVar]: trimmed } + } + } + return null +} + +export { isShellProcess } diff --git a/src/shared/types.ts b/src/shared/types.ts index 8402fc1f18e..dba8844f028 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines */ import type { SshRemotePtyLease, SshTarget } from './ssh-types' import type { Automation, AutomationRun } from './automations-types' -import type { WorkspaceSource } from './telemetry-events' +import type { WorkspaceSource } from './workspace-source' import type { GitHubProjectSettings } from './github-project-types' import type { AgentStatusState, @@ -18,7 +18,7 @@ import type { KeybindingOverrides } from './keybindings' // Re-exported for backward compat with renderer call sites that import // `WorkspaceCreateTelemetrySource` from '../../../shared/types'. -export type { WorkspaceSource as WorkspaceCreateTelemetrySource } from './telemetry-events' +export type { WorkspaceSource as WorkspaceCreateTelemetrySource } from './workspace-source' export type { TaskProvider } from './task-providers' export type { GitBranchChangeStatus, @@ -1255,8 +1255,8 @@ export type CreateWorktreeArgs = { linkedIssue?: number linkedPR?: number linkedLinearIssue?: string - linkedGitLabMR?: number linkedGitLabIssue?: number + linkedGitLabMR?: number pushTarget?: GitPushTarget workspaceStatus?: WorkspaceStatus manualOrder?: number @@ -1997,6 +1997,7 @@ export type TaskResumeState = { githubMode?: 'items' | 'project' githubItemsPreset?: TaskViewPresetId | null githubItemsQuery?: string + githubProjectHiddenFieldIdsByView?: Record linearPreset?: 'assigned' | 'created' | 'all' | 'completed' linearQuery?: string } diff --git a/src/shared/workspace-name.test.ts b/src/shared/workspace-name.test.ts new file mode 100644 index 00000000000..2d57d2461a0 --- /dev/null +++ b/src/shared/workspace-name.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + getLinkedWorkItemSuggestedName, + resolveWorkspaceCreateName, + slugifyForWorkspaceName +} from './workspace-name' + +describe('slugifyForWorkspaceName', () => { + it('keeps workspace seed slugs short, ascii-safe, and git-ref-safe', () => { + expect(slugifyForWorkspaceName('../../Fix mobile Tasks 🚀')).toBe('fix-mobile-tasks') + expect(slugifyForWorkspaceName('feature/add issue drawer')).toBe('feature-add-issue-drawer') + expect(slugifyForWorkspaceName('a'.repeat(80))).toBe('a'.repeat(48)) + }) +}) + +describe('getLinkedWorkItemSuggestedName', () => { + it('removes duplicated issue and PR numbers from linked titles', () => { + expect(getLinkedWorkItemSuggestedName({ title: 'Issue #123: Fix mobile Tasks' })).toBe( + 'fix-mobile-tasks' + ) + expect(getLinkedWorkItemSuggestedName({ title: 'Add mobile drawer (#812)' })).toBe( + 'add-mobile-drawer' + ) + }) +}) + +describe('resolveWorkspaceCreateName', () => { + it('preserves explicit user-entered names for the host worktree sanitizer', () => { + expect( + resolveWorkspaceCreateName({ + draft: 'feature/something', + fallback: 'issue-123' + }) + ).toBe('feature/something') + expect( + resolveWorkspaceCreateName({ + draft: '日本語 テスト', + fallback: 'issue-123' + }) + ).toBe('日本語 テスト') + }) + + it('uses the stable fallback when the draft is blank', () => { + expect(resolveWorkspaceCreateName({ draft: ' ', fallback: 'pr-9' })).toBe('pr-9') + expect(resolveWorkspaceCreateName({ draft: undefined, fallback: 'issue-4' })).toBe('issue-4') + }) +}) diff --git a/src/shared/workspace-name.ts b/src/shared/workspace-name.ts new file mode 100644 index 00000000000..be013eca5d2 --- /dev/null +++ b/src/shared/workspace-name.ts @@ -0,0 +1,36 @@ +export function slugifyForWorkspaceName(input: string): string { + return ( + input + .trim() + .toLowerCase() + .replace(/[\\/]+/g, '-') + .replace(/\s+/g, '-') + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/-+/g, '-') + // Why: git check-ref-format rejects any ref containing `..`, so previews + // must match the main-process sanitizer before workspace creation. + .replace(/\.{2,}/g, '.') + .replace(/^[.-]+|[.-]+$/g, '') + .slice(0, 48) + .replace(/[-._]+$/g, '') + ) +} + +export function getLinkedWorkItemSuggestedName(item: { title: string }): string { + const withoutLeadingNumber = item.title + .trim() + .replace(/^(?:issue|pr|pull request)\s*#?\d+\s*[:-]\s*/i, '') + .replace(/^#\d+\s*[:-]\s*/, '') + .replace(/\(#\d+\)/gi, '') + .replace(/\b#\d+\b/g, '') + .trim() + const seed = withoutLeadingNumber || item.title.trim() + return slugifyForWorkspaceName(seed) +} + +export function resolveWorkspaceCreateName(args: { + draft: string | undefined + fallback: string +}): string { + return args.draft?.trim() || args.fallback +} diff --git a/src/shared/workspace-source.ts b/src/shared/workspace-source.ts new file mode 100644 index 00000000000..ef7272160d9 --- /dev/null +++ b/src/shared/workspace-source.ts @@ -0,0 +1,10 @@ +export const WORKSPACE_SOURCE_VALUES = [ + 'command_palette', + 'sidebar', + 'shortcut', + 'drag_drop', + 'onboarding', + 'unknown' +] as const + +export type WorkspaceSource = (typeof WORKSPACE_SOURCE_VALUES)[number]