Files
orca/src/renderer/src/hooks/useComposerState.ts
T
brennanb2025andOrca b75728f705 Fix review findings: draft delivery, scan churn, escaping, trust gates
- Deliver generated Linear drafts for native-prefill agents (claude/openclaude/
  pi/omp) in direct launches via forced sidecar paste; native prefill is
  deliberately avoided for untrusted context and the pane-owned startup paste
  skips those agents, so content was silently dropped
- Align usesGeneratedLinearContext with isLinearWorkItemReference so
  provider-only Linear references never ride argv or auto-submit
- Key skill-refresh identity on the discovery-target KEY, not object identity;
  Windows store churn no longer forces repeated skills.discover scans
- Resolve suppressed skill refreshes from the replacement scan broadcast
  instead of misreporting installed skills as missing
- Arm a one-shot focus rescan after install-command copies so externally-run
  installs are re-detected without scan-on-every-focus churn
- Restore escaping for Cf format controls the curated ranges missed
  (U+FFF9 etc.) — they could invisibly spoof the untrusted-block delimiters
- Submit pasted drafts when the composer carries a typed prompt (explicit run
  intent); context-only Linear drafts stay reviewable and unsubmitted
- Render Linear string identifiers into {{issue}} templates (was placeholder 0)
- Split installed-agent-skill-discovery out of useInstalledAgentSkills (max-lines)
- Fix merge-artifact indentation in folder-workspace-composer-submit.ts

Co-authored-by: Orca <help@stably.ai>
2026-07-02 18:32:36 -07:00

4309 lines
163 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable max-lines -- Why: this hook co-locates every piece of state
the NewWorkspaceComposerCard reads or mutates, so both the full-page composer
and the global quick-composer modal can consume a single unified source of
truth without duplicating effects, derivation, or the create side-effect. */
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: composer state synchronizes selected repo metadata, setup policy, issue-command hooks, and provider link lookups from async runtime IPC. */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '@/store'
import { getDefaultRepoHookSettings } from '../../../shared/constants'
import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform'
import { getAgentCatalog } from '@/lib/agent-catalog'
import { createBrowserUuid } from '@/lib/browser-uuid'
import {
parseGitHubIssueOrPRNumber,
parseGitHubIssueOrPRLink,
normalizeGitHubLinkQuery
} from '@/lib/github-links'
import { activateAndRevealWorktree, type AgentStartedTelemetry } from '@/lib/worktree-activation'
import { runBackgroundWorktreeCreation } from '@/lib/worktree-creation-flow'
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup'
import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../shared/tui-agent-selection'
import { repoIsRemote } from '../../../shared/agent-launch-remote'
import {
resolveTuiAgentLaunchArgs,
resolveTuiAgentLaunchEnv
} from '../../../shared/tui-agent-launch-defaults'
import { tuiAgentToAgentKind } from '@/lib/telemetry'
import { isGitRepoKind } from '../../../shared/repo-kind'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { getRuntimeRepoBaseRefDefault } from '@/runtime/runtime-repo-client'
import { resolveWorktreeCreateBaseBranch } from '@/runtime/worktree-create-base'
import {
buildTaskSourceContextFromRepo,
type TaskSourceContext
} from '../../../shared/task-source-context'
import type {
GitHubWorkItem,
GitHubPrStartPoint,
GitPushTarget,
GitLabWorkItem,
LinearIssue,
OrcaHooks,
RepoHookSettings,
SetupAgentStartupPolicy,
SetupDecision,
SetupRunPolicy,
SparsePreset,
TuiAgent,
WorktreeMeta,
WorkspaceStatus,
WorkspaceCreateTelemetrySource,
ProjectGroup
} from '../../../shared/types'
import { isWorkspaceStatusId } from '../../../shared/workspace-statuses'
import {
CLIENT_PLATFORM,
DEFAULT_ISSUE_COMMAND_TEMPLATE,
buildAgentPromptWithContext,
ensureAgentStartupInTerminal,
getAttachmentLabel,
getLinkedWorkItemProvider,
getLinkedWorkItemSuggestedName,
getLinkedWorkItemWorkspaceName,
getSetupConfig,
getWorkspaceSeedName,
isGitLabIssueUrl,
PER_REPO_FETCH_LIMIT,
renderIssueCommandTemplate,
type LinkedWorkItemSummary,
type SetupConfig
} from '@/lib/new-workspace'
import {
getLinkedWorkItemPromptContext,
resolveQuickCreateLinkedWorkItemPrompt
} from '@/lib/linked-work-item-context'
import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import {
buildLinearIssueLinkedWorkItem,
isLinearLinkedWorkItem
} from '@/lib/linear-linked-work-item'
import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name'
import {
getFullComposerCreateDisabled,
getQuickComposerCreateDisabled
} from '@/lib/new-workspace-create-gates'
import {
lookupSmartGitHubSubmitItem,
getSmartGitHubSubmitIntent,
getSmartGitHubSubmitResolution,
type SmartGitHubSubmitResolution
} from '@/lib/smart-github-submit'
import {
lookupGitHubWorkItemByOwnerRepoForSource,
lookupGitHubWorkItemForSource
} from '@/lib/github-work-item-source-lookup'
import { resolveGitHubPrStartPointForRepo } from '@/lib/github-pr-start-point'
import { isWorkItemLookupText } from '@/lib/work-item-lookup-text'
import {
canUseRepoBackedComposerSources,
getSelectedRepoSshGate,
isSshConnectInProgress
} from '@/lib/new-workspace-ssh-gate'
import {
getComposerEligibleRepos,
resolveComposerActiveRepoId
} from '@/lib/new-workspace-composer-repo'
import {
resolveWorkspaceCreationRepoId,
resolveWorkspaceCreationTarget
} from '@/lib/project-host-workspace-target'
import {
buildProjectHostSetupOptions,
type ProjectHostSetupOption
} from '@/lib/project-host-setup-options'
import {
buildNewWorkspaceCreateTargetOptions,
getProjectGroupIdFromNewWorkspaceOptionId,
type NewWorkspaceProjectOption
} from '@/lib/new-workspace-project-options'
import { useDetectedAgents } from '@/hooks/useDetectedAgents'
import {
getFolderSourceRepos,
getLinkedItemDisplayName,
getSmartNameSelection as getFolderSmartNameSelection,
toGitHubLinkedWorkItem,
toGitLabLinkedWorkItem,
toLinearLinkedWorkItem
} from '@/components/sidebar/folder-workspace-composer-helpers'
import { useFolderWorkspaceComposerPathStatus } from '@/components/sidebar/folder-workspace-composer-path-status'
import { submitFolderWorkspaceCreate } from '@/components/sidebar/folder-workspace-composer-submit'
import { buildExecutionHostRegistry } from '../../../shared/execution-host-registry'
import {
normalizeExecutionHostId,
parseExecutionHostId,
type ExecutionHostId
} from '../../../shared/execution-host'
import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides'
import { queueNewWorkspaceTerminalFocus } from '@/lib/new-workspace-terminal-focus'
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-suggestions'
import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField'
import { getForkPushWarning } from './fork-push-warning'
import { CONTEXTUAL_TOUR_ENABLE_AUTO_WORKSPACE_NAME_EVENT } from '@/components/contextual-tours/contextual-tour-composer-events'
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
import { normalizeSparseDirectoryLines, sparseDirectoriesMatch } from '@/lib/sparse-paths'
import { joinPath } from '@/lib/path'
import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client'
import {
checkRuntimeHooks,
readRuntimeIssueCommand,
type HookCheckResult
} from '@/runtime/runtime-hooks-client'
import {
formatWorkspaceCreateError,
getWorkspaceCreateErrorToastMessage,
type WorkspaceCreateErrorDisplay
} from '@/lib/workspace-create-error-format'
import type { SshConnectionStatus } from '../../../shared/ssh-types'
import {
isBranchCheckedOutInWorktrees,
resolveComposerBranchNameOverrideForCreate,
resolveComposerBranchReuse,
resolveComposerBranchSelection,
resolveComposerReuseOverride
} from './composer-branch-selection'
import { isCurrentComposerDropOwner } from './composer-drop-owner'
import {
collectComposerDropUploadResult,
shouldReportComposerDropUploadFailure
} from './composer-drop-upload-result'
import { translate } from '@/i18n/i18n'
export function canResolveFolderSmartGitHubSubmit({
hasFolderSourceRepos
}: {
hasFolderSourceRepos: boolean
}): boolean {
return hasFolderSourceRepos
}
type PendingSmartGitHubSubmitResolution =
| { kind: 'none' }
| (SmartGitHubSubmitResolution & { kind: 'metadata-only' })
| (SmartGitHubSubmitResolution & {
kind: 'pr-start-point'
baseBranch: string
compareBaseRef?: string
pushTarget?: GitPushTarget
branchNameOverride?: string
})
type SmartGitHubPrStartPointSelection = {
repoId: string
item: GitHubWorkItem
resolved?: GitHubPrStartPoint
}
export type UseComposerStateOptions = {
initialRepoId?: string
initialEphemeralVmRecipeId?: string
initialProjectGroupId?: string
initialName?: string
initialPrompt?: string
initialLinkedWorkItem?: LinkedWorkItemSummary | null
initialTaskSourceContext?: TaskSourceContext | null
initialWorkspaceStatus?: WorkspaceStatus
/** Seed the Start-from selection when the composer opens. Used by the
* Create-from → Quick fallback path so a PR pick that needs a setup
* decision still lands with the resolved PR head as the base branch. */
initialBaseBranch?: string
/** Why: the full-page composer persists drafts so users can navigate away
* without losing work; the quick-composer modal is transient and must not
* clobber or leak that long-running draft. */
persistDraft: boolean
/** Invoked after a successful createWorktree. The caller usually closes its
* surface here (palette modal, full page, etc.). */
onCreated?: () => void
/** Optional external repoId override — used by TaskPage's work-item list
* which drives repo selection from the page header, not the card. */
repoIdOverride?: string
onRepoIdOverrideChange?: (value: string) => void
/** Telemetry surface that opened this composer. Threaded into
* `createWorktree` so `workspace_created.source` reflects the actual
* entry point (Cmd+J palette → `command_palette`, sidebar buttons →
* `sidebar`, keyboard shortcut → `shortcut`). Omitted callers default
* to `unknown` at the IPC boundary. */
telemetrySource?: WorkspaceCreateTelemetrySource
/** Quick-create launches a blank/draft agent session and does not run
* issueCommand automation, so it can skip the issue-command probe that the
* full composer needs for linked-item prompt previews. */
enableIssueAutomation?: boolean
createGateMode?: 'full' | 'quick'
}
export type ComposerCardProps = {
eligibleRepos: ReturnType<typeof useAppStore.getState>['repos']
repoId: string
projectOptions: NewWorkspaceProjectOption[]
selectedProjectId: string | null
selectedRepoIsGit: boolean
onRepoChange: (value: string) => void
onProjectChange: (value: string) => void
projectHostSetupOptions: ProjectHostSetupOption[]
selectedProjectHostSetupId: string | null
onProjectHostSetupChange: (setupId: string) => void
ephemeralVmRecipes: NonNullable<OrcaHooks['environmentRecipes']>
selectedEphemeralVmRecipeId: string | null
onEphemeralVmRecipeChange: (recipeId: string | null) => void
ephemeralVmRecipeError: string | null
repoBackedSearchRepos?: ReturnType<typeof useAppStore.getState>['repos']
repoBackedSourcesDisabled?: boolean
allowSmartNameAddProject?: boolean
smartNameRepoSwitchTarget?: 'project' | 'task-source'
name: string
onNameValueChange: (value: string) => void
onSmartGitHubItemSelect: (item: GitHubWorkItem) => void
onSmartGitLabItemSelect: (item: GitLabWorkItem) => void
onSmartBranchSelect: (refName: string, localBranchName: string) => void
onSmartLinearIssueSelect: (issue: LinearIssue) => void
smartNameGitHubSourceContext?: TaskSourceContext | null
/** GitLab parallel of onBaseBranchPrSelect. */
onBaseBranchMrSelect?: (
baseBranch: string,
item: GitLabWorkItem,
pushTarget?: GitPushTarget,
compareBaseRef?: string
) => void
smartNameSelection: SmartWorkspaceNameSelection | null
onClearSmartNameSelection: () => void
/** True when the selected source is an existing LOCAL branch that can be
* reused (checked out) instead of branched off — gates the reuse checkbox. */
canReuseSelectedBranch: boolean
/** Whether the selected existing local branch will be reused (checked out)
* rather than used as the base for a new branch. */
reuseSelectedBranch: boolean
onReuseSelectedBranchChange: (next: boolean) => void
/** Whether the "create multiple" toggle is shown — worktree (git) targets
* only; folder-workspace targets create-and-close as before. */
showCreateMultiple: boolean
/** When on, the modal stays open after each create and resets identity fields
* so the user can create several worktrees in a row. */
createMultiple: boolean
onCreateMultipleChange: (next: boolean) => void
agentPrompt: string
onAgentPromptChange: (value: string) => void
/** Rendered issueCommand template to preview inside the empty prompt
* textarea when the user has linked a work item but not typed anything. */
linkedOnlyTemplatePreview: string | null
attachmentPaths: string[]
getAttachmentLabel: (pathValue: string) => string
onAddAttachment: () => void
onRemoveAttachment: (pathValue: string) => void
linkedWorkItem: LinkedWorkItemSummary | null
onRemoveLinkedWorkItem: () => void
linkPopoverOpen: boolean
onLinkPopoverOpenChange: (open: boolean) => void
linkQuery: string
onLinkQueryChange: (value: string) => void
filteredLinkItems: GitHubWorkItem[]
linkItemsLoading: boolean
linkDirectLoading: boolean
normalizedLinkQuery: { query: string }
onSelectLinkedItem: (item: GitHubWorkItem) => void
tuiAgent: TuiAgent
onTuiAgentChange: (value: TuiAgent) => void
detectedAgentIds: Set<TuiAgent> | null
onOpenAgentSettings: () => void
advancedOpen: boolean
onToggleAdvanced: () => void
createDisabled: boolean
projectError: string | null
creating: boolean
onCreate: () => void
note: string
onNoteChange: (value: string) => void
baseBranch: string | undefined
onBaseBranchChange: (next: string | undefined) => void
/** Called when a PR is selected in the Start-from picker. Updates both
* baseBranch and linkedWorkItem/linkedPR in one pass. */
onBaseBranchPrSelect: (
baseBranch: string,
item: GitHubWorkItem,
pushTarget?: GitPushTarget,
branchNameOverride?: string,
compareBaseRef?: string
) => void
/** PR number selected via the Start-from picker (when applicable). Used so the
* field can render "PR #N" copy. */
baseBranchLinkedPrNumber: number | null
/** Absolute path of the selected repo, used by Start-from picker for SWR. */
selectedRepoPath: string | null
/** True when the selected repo is a remote SSH repo. */
selectedRepoIsRemote: boolean
selectedRepoConnectionId: string | null
selectedRepoSshStatus: SshConnectionStatus | null
selectedRepoRequiresConnection: boolean
selectedRepoConnectInProgress: boolean
onConnectSelectedRepo: () => Promise<void>
branchesEnabled?: boolean
/** Transient inline hint shown next to the Start-from trigger after a repo
* switch resets a prior selection (e.g. "was PR #8778"). Null when none. */
startFromResetHint: string | null
/** Warning shown when a selected fork PR has "Allow edits from maintainers"
* off, so a push to the fork may be rejected. Null when none. */
forkPushWarning: string | null
setupConfig: SetupConfig | null
setupControlsEnabled?: boolean
requiresExplicitSetupChoice: boolean
setupDecision: 'run' | 'skip' | null
onSetupDecisionChange: (value: 'run' | 'skip') => void
setupAgentStartupPolicy: SetupAgentStartupPolicy
onSetupAgentStartupPolicyChange: (value: SetupAgentStartupPolicy) => void
shouldWaitForSetupCheck: boolean
resolvedSetupDecision: 'run' | 'skip' | null
createError: WorkspaceCreateErrorDisplay | null
canUseSparseCheckout: boolean
/** Saved presets for the currently-selected repo. Empty array when no
* presets exist or when the repo is remote. */
sparsePresets: SparsePreset[]
/** ID of the selected sparse preset. Null means sparse checkout is off. */
sparseSelectedPresetId: string | null
onSparseSelectPreset: (preset: SparsePreset | null) => void
sparseControlsEnabled?: boolean
}
export type UseComposerStateResult = {
cardProps: ComposerCardProps
/** Ref the consumer should attach to the composer wrapper so the global
* Enter-to-submit handler can scope its behavior to the visible composer. */
composerRef: React.RefObject<HTMLDivElement | null>
onComposerNodeChange: (node: HTMLDivElement | null) => void
promptTextareaRef: React.RefObject<HTMLTextAreaElement | null>
nameInputRef: React.RefObject<HTMLInputElement | null>
submit: () => Promise<void>
submitQuick: (agent: TuiAgent | null) => Promise<void>
/** Invoked by the Enter handler to re-check whether submission should fire. */
createDisabled: boolean
}
export type InitialWorkspaceRunSeedInput = {
draftProjectId?: string | null
draftHostId?: string | null
draftProjectHostSetupId?: string | null
initialTaskSourceContext?: Pick<
TaskSourceContext,
'projectId' | 'hostId' | 'projectHostSetupId'
> | null
}
function getRepoSetupAgentStartupPolicy(repo?: {
hookSettings?: Pick<RepoHookSettings, 'setupAgentStartupPolicy'>
}): SetupAgentStartupPolicy {
return repo?.hookSettings?.setupAgentStartupPolicy ?? 'start-immediately'
}
function buildSetupAgentStartupHookSettings(
current: RepoHookSettings | undefined,
setupAgentStartupPolicy: SetupAgentStartupPolicy
): RepoHookSettings {
const defaults = getDefaultRepoHookSettings()
return {
...defaults,
...current,
setupRunPolicy: current?.setupRunPolicy ?? defaults.setupRunPolicy,
setupAgentStartupPolicy,
commandSourcePolicy: current?.commandSourcePolicy ?? defaults.commandSourcePolicy,
scripts: {
...defaults.scripts,
...current?.scripts
}
}
}
export function resolveInitialWorkspaceRunSeed({
draftProjectId,
draftHostId,
draftProjectHostSetupId,
initialTaskSourceContext
}: InitialWorkspaceRunSeedInput): {
projectId: string | null
hostId: ExecutionHostId | null
projectHostSetupId: string | null
} {
return {
projectId: draftProjectId ?? initialTaskSourceContext?.projectId ?? null,
hostId: normalizeExecutionHostId(draftHostId ?? initialTaskSourceContext?.hostId),
projectHostSetupId:
draftProjectHostSetupId ?? initialTaskSourceContext?.projectHostSetupId ?? null
}
}
export function isExplicitWorkspaceNameInput({
name,
lastAutoName
}: {
name: string
lastAutoName: string
}): boolean {
// Why: a user-authored name must win over linked-item and first-message AI naming.
return Boolean(name.trim()) && name !== lastAutoName && !isWorkItemLookupText(name)
}
export function resolveSmartGitHubCreateNames({
resolutionKind,
smartWorkspaceName,
smartDisplayName,
fallbackWorkspaceName,
nameIsAutoManaged
}: {
resolutionKind: Exclude<PendingSmartGitHubSubmitResolution['kind'], 'none'>
smartWorkspaceName: string
smartDisplayName: string
fallbackWorkspaceName: string
nameIsAutoManaged: boolean
}): { workspaceName: string; displayName: string | undefined } {
if (resolutionKind === 'pr-start-point' && !nameIsAutoManaged && fallbackWorkspaceName) {
// Why: submit-time PR start-point resolution augments an already-linked PR;
// it must not reclaim a workspace name the user edited after selection.
return { workspaceName: fallbackWorkspaceName, displayName: undefined }
}
return { workspaceName: smartWorkspaceName, displayName: smartDisplayName }
}
function getLinkedWorkItemSeedName(item: LinkedWorkItemSummary | null | undefined): string {
if (!item) {
return ''
}
return getLinkedWorkItemWorkspaceName(item)?.seedName ?? getLinkedWorkItemSuggestedName(item)
}
export function getInitialAutoManagedWorkspaceName({
draftName,
draftLinkedWorkItem,
initialName,
initialLinkedWorkItem
}: {
draftName?: string | null
draftLinkedWorkItem?: LinkedWorkItemSummary | null
initialName: string
initialLinkedWorkItem?: LinkedWorkItemSummary | null
}): string {
// Why: command-palette prefilled names are user input unless they exactly
// match the linked item seed Orca generated for a source selection.
const candidateName = draftName ?? initialName
const seedName = getLinkedWorkItemSeedName(draftLinkedWorkItem ?? initialLinkedWorkItem)
return candidateName && seedName && candidateName === seedName ? candidateName : ''
}
// Why: both the full-page TaskPage composer and the Cmd+J modal can be
// mounted simultaneously. Without instance scoping, a single native file
// drop fires every subscriber and duplicates attachments/prompt edits across
// the background draft and the visible modal. Route drops to the
// most-recently-mounted composer only — the modal stacks on top, so the
// modal wins when both are present, and the page takes over once the modal
// closes.
const composerDropStack: symbol[] = []
const EMPTY_SPARSE_PRESETS: SparsePreset[] = []
export function useComposerState(options: UseComposerStateOptions): UseComposerStateResult {
const {
initialRepoId,
initialEphemeralVmRecipeId,
initialName = '',
initialPrompt = '',
initialLinkedWorkItem = null,
initialTaskSourceContext = null,
initialWorkspaceStatus,
initialBaseBranch,
persistDraft,
onCreated,
repoIdOverride,
onRepoIdOverrideChange,
telemetrySource,
enableIssueAutomation = true,
createGateMode = 'full',
initialProjectGroupId
} = options
// Why: each `useAppStore(s => s.someAction)` registers its own equality
// check that React has to re-run on every store mutation. Consolidating
// all stable actions into a single useShallow subscription turns 11 checks
// per store update into one.
const actions = useAppStore(
useShallow((s) => ({
setNewWorkspaceDraft: s.setNewWorkspaceDraft,
clearNewWorkspaceDraft: s.clearNewWorkspaceDraft,
createWorktree: s.createWorktree,
updateRepo: s.updateRepo,
updateWorktreeMeta: s.updateWorktreeMeta,
createFolderWorkspace: s.createFolderWorkspace,
setSidebarOpen: s.setSidebarOpen,
closeModal: s.closeModal,
openSettingsPage: s.openSettingsPage,
openSettingsTarget: s.openSettingsTarget,
prefetchWorktreeCreateBase: s.prefetchWorktreeCreateBase,
prefetchWorkItems: s.prefetchWorkItems,
fetchSparsePresets: s.fetchSparsePresets
}))
)
const {
setNewWorkspaceDraft,
clearNewWorkspaceDraft,
createWorktree,
updateRepo,
updateWorktreeMeta,
createFolderWorkspace,
setSidebarOpen,
closeModal,
openSettingsPage,
openSettingsTarget,
prefetchWorktreeCreateBase,
prefetchWorkItems,
fetchSparsePresets
} = actions
const repos = useAppStore((s) => s.repos)
const projects = useAppStore((s) => s.projects)
const projectGroups = useAppStore((s) => s.projectGroups)
const projectHostSetups = useAppStore((s) => s.projectHostSetups)
const activeRepoId = useAppStore((s) => s.activeRepoId)
const settings = useAppStore((s) => s.settings)
const newWorkspaceDraft = useAppStore((s) => s.newWorkspaceDraft)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const sparsePresetsByRepo = useAppStore((s) => s.sparsePresetsByRepo)
const workspaceStatuses = useAppStore((s) => s.workspaceStatuses)
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const sshTargetLabels = useAppStore((s) => s.sshTargetLabels)
const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration)
const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId)
const workspaceHostScope = useAppStore((s) => s.workspaceHostScope)
const eligibleRepos = useMemo(() => getComposerEligibleRepos(repos), [repos])
// Why: a runtime-owned SSH repo (the active repo right after creating a per-workspace-env) is not
// eligible, so seed from its local same-project sibling instead of falling back to another project.
const seedActiveRepoId = useMemo(
() => resolveComposerActiveRepoId(repos, eligibleRepos, activeRepoId),
[repos, eligibleRepos, activeRepoId]
)
const draftRepoId = persistDraft ? (newWorkspaceDraft?.repoId ?? null) : null
const draftProjectId = persistDraft ? (newWorkspaceDraft?.projectId ?? null) : null
const draftProjectGroupId = persistDraft ? (newWorkspaceDraft?.projectGroupId ?? null) : null
const draftHostId = persistDraft ? (newWorkspaceDraft?.hostId ?? null) : null
const draftProjectHostSetupId = persistDraft
? (newWorkspaceDraft?.projectHostSetupId ?? null)
: null
// Why: Tasks can start work from Linear/Jira source contexts that are not
// repo-backed. Seed the run target from the logical project/source host so
// the modal does not silently fall back to the ambient active repo.
const initialRunSeed = resolveInitialWorkspaceRunSeed({
draftProjectId,
draftHostId,
draftProjectHostSetupId,
initialTaskSourceContext
})
const resolvedInitialWorkspaceStatus = useMemo(
() =>
initialWorkspaceStatus && isWorkspaceStatusId(initialWorkspaceStatus, workspaceStatuses)
? initialWorkspaceStatus
: undefined,
[initialWorkspaceStatus, workspaceStatuses]
)
const resolvedInitialRepoId = resolveWorkspaceCreationRepoId({
eligibleRepos,
projects,
projectHostSetups,
draftRepoId,
initialRepoId,
activeRepoId: seedActiveRepoId,
projectId: initialRunSeed.projectId,
hostId: initialRunSeed.hostId,
projectHostSetupId: initialRunSeed.projectHostSetupId,
focusedHostScope: workspaceHostScope
})
const [internalRepoId, setInternalRepoId] = useState<string>(resolvedInitialRepoId)
const initialFolderProjectGroupId = initialProjectGroupId ?? draftProjectGroupId
const initialFolderProjectGroup = projectGroups.find(
(group) => group.id === initialFolderProjectGroupId && Boolean(group.parentPath?.trim())
)
const [selectedProjectGroupId, setSelectedProjectGroupId] = useState<string | null>(
initialFolderProjectGroup?.id ?? null
)
const initialProjectGroupAppliedRef = useRef(Boolean(initialFolderProjectGroup))
const [projectError, setProjectError] = useState<string | null>(null)
const repoId = repoIdOverride ?? internalRepoId
const selectedProjectGroup = useMemo<ProjectGroup | null>(
() =>
selectedProjectGroupId
? (projectGroups.find(
(group) => group.id === selectedProjectGroupId && Boolean(group.parentPath?.trim())
) ?? null)
: null,
[projectGroups, selectedProjectGroupId]
)
useEffect(() => {
if (selectedProjectGroupId && !selectedProjectGroup) {
setSelectedProjectGroupId(null)
}
}, [selectedProjectGroup, selectedProjectGroupId])
useEffect(() => {
if (
selectedProjectGroupId ||
!initialFolderProjectGroupId ||
initialProjectGroupAppliedRef.current
) {
return
}
const nextGroup = projectGroups.find(
(group) => group.id === initialFolderProjectGroupId && Boolean(group.parentPath?.trim())
)
if (nextGroup) {
initialProjectGroupAppliedRef.current = true
setSelectedProjectGroupId(nextGroup.id)
}
}, [initialFolderProjectGroupId, projectGroups, selectedProjectGroupId])
const isProjectGroupTarget = selectedProjectGroup !== null
const folderSourceRepos = useMemo(
() => getFolderSourceRepos(repos, projectGroups, selectedProjectGroup),
[projectGroups, repos, selectedProjectGroup]
)
const parsedFolderTargetHost = parseExecutionHostId(selectedProjectGroup?.executionHostId)
const folderTargetRuntimeEnvironmentId =
parsedFolderTargetHost?.kind === 'runtime' ? parsedFolderTargetHost.environmentId : null
const folderTargetConnectionId =
parsedFolderTargetHost?.kind === 'runtime' ? null : (selectedProjectGroup?.connectionId ?? null)
const folderTargetIsRemote =
folderTargetConnectionId !== null || folderTargetRuntimeEnvironmentId !== null
const folderTargetAgentDetectionTarget = folderTargetRuntimeEnvironmentId
? { kind: 'runtime' as const, environmentId: folderTargetRuntimeEnvironmentId }
: folderTargetConnectionId
? { kind: 'ssh' as const, connectionId: folderTargetConnectionId }
: selectedProjectGroup
? { kind: 'local' as const }
: undefined
const folderTargetSshState = folderTargetConnectionId
? (sshConnectionStates.get(folderTargetConnectionId) ?? null)
: null
const {
selectedRepoSshStatus: folderTargetSshStatus,
selectedRepoRequiresConnection: folderTargetRequiresConnection,
selectedRepoConnectInProgress: folderTargetConnectInProgress
} = getSelectedRepoSshGate({
connectionId: folderTargetConnectionId,
status: folderTargetSshState?.status ?? null
})
const { pathStatusBlocksCreate: folderPathStatusBlocksCreate, pathStatusProjectError } =
useFolderWorkspaceComposerPathStatus(
selectedProjectGroup,
true,
folderTargetRuntimeEnvironmentId
)
const { detectedIds: folderDetectedIds } = useDetectedAgents(folderTargetAgentDetectionTarget)
const folderDetectedAgentIds = useMemo<Set<TuiAgent> | null>(
() => (folderDetectedIds ? new Set(folderDetectedIds) : null),
[folderDetectedIds]
)
const selectedWorkspaceTarget = useMemo(
() =>
resolveWorkspaceCreationTarget({
eligibleRepos,
projects,
projectHostSetups,
draftRepoId: repoId,
focusedHostScope: workspaceHostScope
}),
[eligibleRepos, projectHostSetups, projects, repoId, workspaceHostScope]
)
const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId)
const selectedRepoIsGit = selectedRepo ? isGitRepoKind(selectedRepo) : false
const [ephemeralVmRecipes, setEphemeralVmRecipes] = useState<
NonNullable<OrcaHooks['environmentRecipes']>
>([])
const [selectedEphemeralVmRecipeId, setSelectedEphemeralVmRecipeId] = useState<string | null>(
null
)
const [ephemeralVmRecipeError, setEphemeralVmRecipeError] = useState<string | null>(null)
const selectedRepoAgentLaunchPlatform = useMemo(() => {
if (!selectedRepo) {
return CLIENT_PLATFORM
}
const projectRuntime = selectedRepo.connectionId
? undefined
: getLocalRepoProjectExecutionRuntimeContext(
{
activeRepoId,
activeWorktreeId: null,
projects,
repos,
settings,
worktreesByRepo
},
selectedRepo.id,
CLIENT_PLATFORM
)
return getAgentLaunchPlatformForRepo(selectedRepo, projectRuntime)
}, [activeRepoId, projects, repos, selectedRepo, settings, worktreesByRepo])
// Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only
// `orca-ide` rename must not be applied to remote launch commands.
const selectedRepoIsRemote = selectedRepo ? repoIsRemote(selectedRepo) : false
const selectedRepoProjectId =
selectedWorkspaceTarget.status === 'ready' ? selectedWorkspaceTarget.target.projectId : null
const selectedProjectId = selectedProjectGroup
? `project-group:${selectedProjectGroup.id}`
: selectedRepoProjectId
const selectedProjectHostSetupId =
!selectedProjectGroup && selectedWorkspaceTarget.status === 'ready'
? selectedWorkspaceTarget.target.projectHostSetupId
: null
const hostOptions = useMemo(
() =>
buildExecutionHostRegistry({
repos,
settings,
sshTargetLabels,
sshConnectionStates,
runtimeEnvironments,
runtimeStatusByEnvironmentId,
hostLabelOverrides: getHostDisplayLabelOverrides(settings)
}),
[
repos,
settings,
sshConnectionStates,
sshTargetLabels,
runtimeEnvironments,
runtimeStatusByEnvironmentId
]
)
const projectHostSetupOptions = useMemo(
() =>
buildProjectHostSetupOptions({
projectId: selectedRepoProjectId,
projectHostSetups,
eligibleRepos,
hosts: hostOptions
}),
[eligibleRepos, hostOptions, projectHostSetups, selectedRepoProjectId]
)
const projectOptions = useMemo(
() =>
buildNewWorkspaceCreateTargetOptions({
projects,
projectHostSetups,
eligibleRepos,
projectGroups
}),
[eligibleRepos, projectGroups, projectHostSetups, projects]
)
const selectedRepoSettings = useMemo(() => {
if (!settings) {
return settings
}
// Why: composer probes and attachment uploads inspect the selected repo,
// even though workspace creation defaults still follow host scope.
return getSettingsForRepoRuntimeOwner(
{ repos: selectedRepo ? [selectedRepo] : [], settings },
selectedRepo?.id ?? null
)
}, [selectedRepo, settings])
// Why: key the recipe load on the repo's stable identity, not the whole repo
// object. `updateRepo` (e.g. saving the setup-startup policy from this very
// composer, or a GitHub upstream backfill) replaces the selected repo object
// by reference; depending on the object would re-run this effect and silently
// reset the user's manually-chosen recipe via setSelectedEphemeralVmRecipeId(null).
const selectedRecipeRepoId = selectedRepo?.id ?? null
const selectedRecipeRepoConnectionId = selectedRepo?.connectionId ?? null
// Why: the experimental toggle must hide the composer target and avoid probing
// repo recipes, since recipe discovery can surface setup errors for a hidden feature.
const ephemeralVmsEnabled = settings?.experimentalEphemeralVms === true
useEffect(() => {
let cancelled = false
setEphemeralVmRecipes([])
setSelectedEphemeralVmRecipeId(null)
setEphemeralVmRecipeError(null)
if (
!ephemeralVmsEnabled ||
!selectedRecipeRepoId ||
!selectedRepoIsGit ||
selectedRecipeRepoConnectionId ||
isProjectGroupTarget
) {
return () => {
cancelled = true
}
}
void window.api.ephemeralVm
.listRecipes({ repoId: selectedRecipeRepoId })
.then((result) => {
if (cancelled) {
return
}
setEphemeralVmRecipes(result.recipes ?? [])
setSelectedEphemeralVmRecipeId(
initialEphemeralVmRecipeId &&
result.recipes?.some((recipe) => recipe.id === initialEphemeralVmRecipeId)
? initialEphemeralVmRecipeId
: null
)
const diagnosticMessages = (result.diagnostics ?? []).map((diagnostic) => {
const recipeLabel = `environmentRecipes[${diagnostic.index}]`
const fieldLabel = diagnostic.field ? `.${diagnostic.field}` : ''
return `${recipeLabel}${fieldLabel}: ${diagnostic.message}`
})
setEphemeralVmRecipeError(
[result.status === 'error' ? result.message : null, ...diagnosticMessages]
.filter((message): message is string => Boolean(message))
.join('\n') || null
)
})
.catch((error) => {
if (cancelled) {
return
}
setEphemeralVmRecipes([])
setEphemeralVmRecipeError(error instanceof Error ? error.message : String(error))
})
return () => {
cancelled = true
}
}, [
ephemeralVmsEnabled,
initialEphemeralVmRecipeId,
isProjectGroupTarget,
selectedRecipeRepoConnectionId,
selectedRecipeRepoId,
selectedRepoIsGit
])
const selectedRepoConnectionId = selectedRepo?.connectionId ?? null
const selectedRepoSshState = selectedRepoConnectionId
? (sshConnectionStates.get(selectedRepoConnectionId) ?? null)
: null
const { selectedRepoSshStatus, selectedRepoRequiresConnection, selectedRepoConnectInProgress } =
getSelectedRepoSshGate({
connectionId: selectedRepoConnectionId,
status: selectedRepoSshState?.status ?? null
})
const repoIdRef = useRef(repoId)
repoIdRef.current = repoId
const setRepoId = useCallback(
(value: string) => {
if (onRepoIdOverrideChange) {
onRepoIdOverrideChange(value)
} else {
setInternalRepoId(value)
}
},
[onRepoIdOverrideChange]
)
const [name, setName] = useState<string>(
persistDraft ? (newWorkspaceDraft?.name ?? initialName) : initialName
)
const [agentPrompt, setAgentPrompt] = useState<string>(
persistDraft ? (newWorkspaceDraft?.prompt ?? initialPrompt) : initialPrompt
)
const [note, setNote] = useState<string>(persistDraft ? (newWorkspaceDraft?.note ?? '') : '')
const [attachmentPaths, setAttachmentPaths] = useState<string[]>(
persistDraft ? (newWorkspaceDraft?.attachments ?? []) : []
)
const [linkedWorkItem, setLinkedWorkItem] = useState<LinkedWorkItemSummary | null>(
persistDraft
? (newWorkspaceDraft?.linkedWorkItem ?? initialLinkedWorkItem)
: initialLinkedWorkItem
)
const taskSourceContext = useMemo(() => {
if (
persistDraft &&
newWorkspaceDraft?.taskSourceContext &&
newWorkspaceDraft.linkedWorkItem?.url === linkedWorkItem?.url
) {
return newWorkspaceDraft.taskSourceContext
}
if (initialTaskSourceContext && initialLinkedWorkItem?.url === linkedWorkItem?.url) {
return initialTaskSourceContext
}
if (
!linkedWorkItem ||
getLinkedWorkItemProvider(linkedWorkItem) !== 'github' ||
!selectedRepo ||
selectedWorkspaceTarget.status !== 'ready'
) {
return null
}
const selectedProject = projects.find(
(project) => project.id === selectedWorkspaceTarget.target.projectId
)
if (selectedProject?.providerIdentity?.provider !== 'github') {
return null
}
return buildTaskSourceContextFromRepo({
provider: 'github',
projectId: selectedWorkspaceTarget.target.projectId,
repo: selectedRepo,
projectHostSetupId: selectedWorkspaceTarget.target.projectHostSetupId,
providerIdentity: selectedProject.providerIdentity
})
}, [
initialLinkedWorkItem,
initialTaskSourceContext,
linkedWorkItem,
newWorkspaceDraft?.linkedWorkItem?.url,
newWorkspaceDraft?.taskSourceContext,
persistDraft,
projects,
selectedRepo,
selectedWorkspaceTarget
])
const selectedRepoGitHubSourceContext = useMemo(() => {
if (!selectedRepo || !selectedRepoIsGit) {
return null
}
if (taskSourceContext?.provider === 'github') {
return taskSourceContext
}
if (selectedWorkspaceTarget.status === 'ready') {
const selectedProject = projects.find(
(project) => project.id === selectedWorkspaceTarget.target.projectId
)
return buildTaskSourceContextFromRepo({
provider: 'github',
projectId: selectedWorkspaceTarget.target.projectId,
repo: selectedRepo,
projectHostSetupId: selectedWorkspaceTarget.target.projectHostSetupId,
providerIdentity:
selectedProject?.providerIdentity?.provider === 'github'
? selectedProject.providerIdentity
: null
})
}
return buildTaskSourceContextFromRepo({
provider: 'github',
projectId: selectedRepo.id,
repo: selectedRepo
})
}, [projects, selectedRepo, selectedRepoIsGit, selectedWorkspaceTarget, taskSourceContext])
const [linkedIssue, setLinkedIssue] = useState<string>(() => {
if (persistDraft && newWorkspaceDraft?.linkedIssue) {
return newWorkspaceDraft.linkedIssue
}
if (
initialLinkedWorkItem?.type === 'issue' &&
getLinkedWorkItemProvider(initialLinkedWorkItem) === 'github'
) {
return String(initialLinkedWorkItem.number)
}
return ''
})
const [linkedPR, setLinkedPR] = useState<number | null>(() => {
if (persistDraft && newWorkspaceDraft?.linkedPR !== undefined) {
return newWorkspaceDraft.linkedPR
}
return initialLinkedWorkItem?.type === 'pr' ? initialLinkedWorkItem.number : null
})
// Why: GitLab parallels of linkedIssue/linkedPR. Kept as separate state
// (rather than reusing the GitHub slots with a provider discriminator) so
// the existing GitHub auto-name / linked-badge / persistence code paths
// stay untouched.
const [linkedGitLabIssue, setLinkedGitLabIssue] = useState<number | null>(() => {
if (persistDraft && newWorkspaceDraft?.linkedGitLabIssue !== undefined) {
return newWorkspaceDraft.linkedGitLabIssue
}
return initialLinkedWorkItem?.type === 'issue' && isGitLabIssueUrl(initialLinkedWorkItem.url)
? initialLinkedWorkItem.number
: null
})
const [linkedGitLabMR, setLinkedGitLabMR] = useState<number | null>(() => {
if (persistDraft && newWorkspaceDraft?.linkedGitLabMR !== undefined) {
return newWorkspaceDraft.linkedGitLabMR
}
return initialLinkedWorkItem?.type === 'mr' ? initialLinkedWorkItem.number : null
})
const [baseBranch, setBaseBranch] = useState<string | undefined>(
persistDraft ? newWorkspaceDraft?.baseBranch : initialBaseBranch
)
const [compareBaseRef, setCompareBaseRef] = useState<string | undefined>(
persistDraft ? newWorkspaceDraft?.compareBaseRef : undefined
)
const [branchNameOverride, setBranchNameOverride] = useState<string | undefined>(undefined)
const [branchNameOverridePreservesNameEdits, setBranchNameOverridePreservesNameEdits] =
useState(false)
// Why (#5181): when the user picks an existing LOCAL branch, let them reuse it
// (check it out) instead of creating a new branch from it. `reuseEligibleBranch`
// is the local branch name eligible for reuse (null = not a reusable local
// branch, e.g. a remote-only ref or non-branch source); `reuseSelectedBranch`
// is the explicit checkbox value driving whether reuse actually happens.
const [reuseEligibleBranch, setReuseEligibleBranch] = useState<string | null>(null)
const [reuseSelectedBranch, setReuseSelectedBranch] = useState(false)
const [pushTarget, setPushTarget] = useState<GitPushTarget | undefined>(undefined)
// Why: when a repo switch wipes a prior Start-from selection, surface the
// reset inline (e.g. "was PR #8778") so the change is recoverable visually
// instead of slipping past the user. Cleared on any subsequent selection.
const [startFromResetHint, setStartFromResetHint] = useState<string | null>(null)
// Why: a fork PR with "Allow edits from maintainers" off can't be pushed to;
// warn (but don't block) so the maintainer isn't surprised by a rejected push.
const [forkPushWarning, setForkPushWarning] = useState<string | null>(null)
const disabledTuiAgentKey = (settings?.disabledTuiAgents ?? []).join('\u0000')
const disabledTuiAgents = useMemo<TuiAgent[]>(
() => settings?.disabledTuiAgents ?? [],
// Why: settings IPC round-trips clone arrays; agent availability only
// changes when the disabled-agent content changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
[disabledTuiAgentKey]
)
// Why: the long-form composer's agent selection is a required TuiAgent (not
// null/blank), so 'blank' preferences from global settings must collapse to
// the Claude default here — the blank-terminal affordance only lives in the
// quick-create flow.
const enabledCatalogAgents = useMemo(
() =>
filterEnabledTuiAgents(
getAgentCatalog().map((agent) => agent.id),
disabledTuiAgents
),
[disabledTuiAgents]
)
const fallbackDefaultAgent: TuiAgent =
settings?.defaultTuiAgent &&
settings.defaultTuiAgent !== 'blank' &&
isTuiAgentEnabled(settings.defaultTuiAgent, disabledTuiAgents)
? settings.defaultTuiAgent
: (enabledCatalogAgents[0] ?? 'claude')
const [tuiAgent, setTuiAgent] = useState<TuiAgent>(
persistDraft ? (newWorkspaceDraft?.agent ?? fallbackDefaultAgent) : fallbackDefaultAgent
)
// Why: when the selected repo is remote (has a connectionId), read the
// per-connection agent list instead of the local one. This ensures the
// Create Workspace dialog shows agents installed on the SSH host, not the
// local machine.
const connectionId = selectedRepoConnectionId
const isRemote = typeof connectionId === 'string'
const detectedAgentList = useAppStore((s) => {
if (isRemote) {
return s.remoteDetectedAgentIds[connectionId] ?? null
}
return s.detectedAgentIds
})
const ensureDetectedAgents = useAppStore((s) => s.ensureDetectedAgents)
const ensureRemoteDetectedAgents = useAppStore((s) => s.ensureRemoteDetectedAgents)
const detectedAgentIds = useMemo<Set<TuiAgent> | null>(
() => (detectedAgentList ? new Set(detectedAgentList) : null),
[detectedAgentList]
)
const [yamlHooks, setYamlHooks] = useState<OrcaHooks | null>(null)
const [checkedHooksRepoId, setCheckedHooksRepoId] = useState<string | null>(null)
const [issueCommandTemplate, setIssueCommandTemplate] = useState('')
const [hasLoadedIssueCommand, setHasLoadedIssueCommand] = useState(false)
const [setupDecision, setSetupDecision] = useState<'run' | 'skip' | null>(null)
const [setupAgentStartupPolicy, setSetupAgentStartupPolicy] = useState<SetupAgentStartupPolicy>(
() => getRepoSetupAgentStartupPolicy(selectedRepo)
)
const setupAgentStartupPolicyRef = useRef(setupAgentStartupPolicy)
setupAgentStartupPolicyRef.current = setupAgentStartupPolicy
const setupAgentStartupPolicySaveRef = useRef<{
repoId: string
policy: SetupAgentStartupPolicy
promise: Promise<boolean>
} | null>(null)
const setupAgentStartupPolicyDraftRef = useRef<{
repoId: string
policy: SetupAgentStartupPolicy
} | null>(null)
const [creating, setCreating] = useState(false)
const [createError, setCreateError] = useState<WorkspaceCreateErrorDisplay | null>(null)
// Why: when checked, a successful worktree create keeps the modal open and
// resets identity fields so the user can queue another worktree without
// reopening. Defaults off; the modal unmounts on close, so reopening always
// starts unchecked.
const [createMultiple, setCreateMultiple] = useState(false)
const [advancedOpen, setAdvancedOpen] = useState(
persistDraft ? Boolean((newWorkspaceDraft?.note ?? '').trim()) : false
)
const [sparseEnabled, setSparseEnabled] = useState(false)
const [sparseDirectories, setSparseDirectories] = useState('')
const [sparseSelectedPresetId, setSparseSelectedPresetId] = useState<string | null>(null)
const [linkPopoverOpen, setLinkPopoverOpen] = useState(false)
const [linkQuery, setLinkQuery] = useState('')
const [linkDebouncedQuery, setLinkDebouncedQuery] = useState('')
const [linkItems, setLinkItems] = useState<GitHubWorkItem[]>([])
const [linkItemsLoading, setLinkItemsLoading] = useState(false)
const [linkDirectItem, setLinkDirectItem] = useState<GitHubWorkItem | null>(null)
const [linkDirectLoading, setLinkDirectLoading] = useState(false)
const lastAutoNameRef = useRef<string>(
getInitialAutoManagedWorkspaceName({
draftName: persistDraft ? newWorkspaceDraft?.name : null,
draftLinkedWorkItem: persistDraft ? newWorkspaceDraft?.linkedWorkItem : null,
initialName,
initialLinkedWorkItem
})
)
const nameRef = useRef<string>(name)
nameRef.current = name
const branchAutoNameRef = useRef<string>('')
// Why: tracks the note value we auto-prefilled from a Start-from PR pick, so
// a subsequent PR change can replace it without clobbering user-typed text.
const lastAutoNoteRef = useRef<string>('')
// Why: read the latest note inside handleBaseBranchPrSelect without adding
// `note` to its deps (which would rebuild the callback on every keystroke).
const noteRef = useRef<string>(note)
noteRef.current = note
// Why: PR selections in the Create From field resolve their checkout ref
// asynchronously. Submit must still know that the linked PR is a checkout
// source if Create is clicked before that resolver settles.
const smartGitHubPrStartPointSelectionRef = useRef<SmartGitHubPrStartPointSelection | null>(null)
useEffect(() => {
const clearAutoManagedName = (): void => {
if (nameRef.current === lastAutoNameRef.current) {
setName('')
lastAutoNameRef.current = ''
setCreateError(null)
}
}
window.addEventListener(CONTEXTUAL_TOUR_ENABLE_AUTO_WORKSPACE_NAME_EVENT, clearAutoManagedName)
return () => {
window.removeEventListener(
CONTEXTUAL_TOUR_ENABLE_AUTO_WORKSPACE_NAME_EVENT,
clearAutoManagedName
)
}
}, [])
const composerRef = useRef<HTMLDivElement | null>(null)
const promptTextareaRef = useRef<HTMLTextAreaElement | null>(null)
const promptCaretFrameRef = useRef<number | null>(null)
const nameInputRef = useRef<HTMLInputElement | null>(null)
// Why: the native-file-drop effect below subscribes once on mount and must
// read the latest agentPrompt when computing the caret-scoped insertion.
// Mirror the value into a ref so the listener sees fresh state without
// re-subscribing (which would reorder the composerDropStack and break
// multi-instance routing).
const agentPromptRef = useRef(agentPrompt)
agentPromptRef.current = agentPrompt
const connectionIdRef = useRef(connectionId)
connectionIdRef.current = connectionId
const selectedRepoConnectionIdRef = useRef(selectedRepoConnectionId)
selectedRepoConnectionIdRef.current = selectedRepoConnectionId
// Why: resolves the selected repo's owner/repo slug so a PR URL pasted
// into the workspace name field can be matched against the current repo.
// Pasting a PR URL from a different repo would otherwise recover only the
// PR number, mislinking the worktree to an unrelated PR with the same
// number in the selected repo.
const [selectedRepoSlug, setSelectedRepoSlug] = useState<{ owner: string; repo: string } | null>(
null
)
const selectedRepoPath = selectedRepo?.path
const selectedRepoPathRef = useRef<string | undefined>(selectedRepoPath)
selectedRepoPathRef.current = selectedRepoPath
const selectedRepoSettingsRef = useRef(selectedRepoSettings)
selectedRepoSettingsRef.current = selectedRepoSettings
useEffect(() => {
const nextPolicy = getRepoSetupAgentStartupPolicy(selectedRepo)
const draft = setupAgentStartupPolicyDraftRef.current
if (draft?.repoId === repoId && draft.policy !== nextPolicy) {
return
}
setupAgentStartupPolicyRef.current = nextPolicy
setSetupAgentStartupPolicy(nextPolicy)
}, [repoId, selectedRepo])
const persistSetupAgentStartupPolicy = useCallback(
async (
policy: SetupAgentStartupPolicy = setupAgentStartupPolicyRef.current
): Promise<boolean> => {
while (true) {
const currentRepo = useAppStore.getState().repos.find((repo) => repo.id === repoId)
if (!currentRepo || !isGitRepoKind(currentRepo)) {
return true
}
const pendingSave = setupAgentStartupPolicySaveRef.current
if (pendingSave?.repoId === currentRepo.id) {
if (pendingSave.policy === policy) {
const saved = await pendingSave.promise
if (
saved &&
setupAgentStartupPolicyDraftRef.current?.repoId === currentRepo.id &&
setupAgentStartupPolicyDraftRef.current.policy === policy
) {
setupAgentStartupPolicyDraftRef.current = null
}
return saved
}
await pendingSave.promise
continue
}
if (getRepoSetupAgentStartupPolicy(currentRepo) === policy) {
if (
setupAgentStartupPolicyDraftRef.current?.repoId === currentRepo.id &&
setupAgentStartupPolicyDraftRef.current.policy === policy
) {
setupAgentStartupPolicyDraftRef.current = null
}
return true
}
const promise = updateRepo(currentRepo.id, {
hookSettings: buildSetupAgentStartupHookSettings(currentRepo.hookSettings, policy)
}).finally(() => {
if (setupAgentStartupPolicySaveRef.current?.promise === promise) {
setupAgentStartupPolicySaveRef.current = null
}
})
setupAgentStartupPolicySaveRef.current = { repoId: currentRepo.id, policy, promise }
const saved = await promise
if (
saved &&
setupAgentStartupPolicyDraftRef.current?.repoId === currentRepo.id &&
setupAgentStartupPolicyDraftRef.current.policy === policy
) {
setupAgentStartupPolicyDraftRef.current = null
}
return saved
}
},
[repoId, updateRepo]
)
const handleSetupAgentStartupPolicyChange = useCallback(
(policy: SetupAgentStartupPolicy) => {
setupAgentStartupPolicyRef.current = policy
if (repoId) {
setupAgentStartupPolicyDraftRef.current = { repoId, policy }
}
setSetupAgentStartupPolicy(policy)
void persistSetupAgentStartupPolicy(policy).then((saved) => {
if (!saved) {
toast.error(
translate(
'auto.hooks.useComposerState.setupAgentStartupPolicySaveFailed',
'Failed to save setup startup behavior.'
)
)
}
})
},
[persistSetupAgentStartupPolicy, repoId]
)
const cancelPromptCaretFrame = useCallback((): void => {
if (promptCaretFrameRef.current === null) {
return
}
cancelAnimationFrame(promptCaretFrameRef.current)
promptCaretFrameRef.current = null
}, [])
const handleComposerNodeChange = useCallback(
(node: HTMLDivElement | null): void => {
// Why: the queued caret restoration targets composer descendants and
// must be canceled as soon as the composer root leaves the DOM.
if (!node) {
cancelPromptCaretFrame()
}
},
[cancelPromptCaretFrame]
)
const hookCheckRef = useRef<{
key: string
promise: Promise<HookCheckResult>
} | null>(null)
const loadHookCheckForRepo = useCallback((targetRepoId: string): Promise<HookCheckResult> => {
const key = `${selectedRepoSettingsRef.current?.activeRuntimeEnvironmentId ?? 'local'}:${targetRepoId}`
const existing = hookCheckRef.current
if (existing?.key === key) {
return existing.promise
}
const promise = checkRuntimeHooks(selectedRepoSettingsRef.current, targetRepoId)
hookCheckRef.current = { key, promise }
return promise
}, [])
const commitHookCheckIfCurrent = useCallback(
(targetRepoId: string, hooks: OrcaHooks | null): boolean => {
if (repoIdRef.current !== targetRepoId) {
return false
}
setYamlHooks(hooks)
setCheckedHooksRepoId(targetRepoId)
return true
},
[]
)
useEffect(() => {
if (!selectedRepo || !selectedRepoPath || !selectedRepoIsGit) {
setSelectedRepoSlug(null)
return
}
let cancelled = false
const target = getActiveRuntimeTarget(selectedRepoSettings)
const slugRequest =
target.kind === 'environment'
? callRuntimeRpc<{ owner: string; repo: string } | null>(
target,
'github.repoSlug',
{ repo: repoId },
{ timeoutMs: 30_000 }
)
: (window.api.gh.repoSlug({ repoPath: selectedRepoPath, repoId }) as Promise<{
owner: string
repo: string
} | null>)
void slugRequest
.then((result) => {
if (cancelled) {
return
}
setSelectedRepoSlug(result)
})
.catch(() => {
if (!cancelled) {
setSelectedRepoSlug(null)
}
})
return () => {
cancelled = true
}
}, [repoId, selectedRepo, selectedRepoIsGit, selectedRepoPath, selectedRepoSettings])
const sparsePresetsForRepo = sparsePresetsByRepo[repoId]
const sparsePresets = sparsePresetsForRepo ?? EMPTY_SPARSE_PRESETS
const normalizedSparseDirectories = useMemo(
() => normalizeSparseDirectoryLines(sparseDirectories),
[sparseDirectories]
)
// Why: a preset attribution should only ride along if what's about to be
// created actually equals the saved preset. If the user picked a preset and
// then edited the textarea, we want the worktree to be a "Custom" sparse
// checkout — not falsely tagged as the original preset.
const effectivePresetId = useMemo(() => {
if (!sparseSelectedPresetId) {
return null
}
const selected = sparsePresets.find((preset) => preset.id === sparseSelectedPresetId)
if (!selected) {
return null
}
return sparseDirectoriesMatch(selected.directories, normalizedSparseDirectories)
? selected.id
: null
}, [normalizedSparseDirectories, sparsePresets, sparseSelectedPresetId])
const sparseError = useMemo(() => {
if (!sparseEnabled) {
return null
}
if (!selectedRepoIsGit) {
return null
}
if (selectedRepo?.connectionId) {
return 'Sparse checkout is only supported for local repos right now.'
}
if (normalizedSparseDirectories.length === 0) {
return 'Enter at least one repo-relative directory.'
}
if (
normalizedSparseDirectories.some((entry) => entry === '.' || entry.split('/').includes('..'))
) {
return 'Use repo-relative directories, not root or parent paths.'
}
return null
}, [normalizedSparseDirectories, selectedRepo?.connectionId, selectedRepoIsGit, sparseEnabled])
const parsedLinkedIssueNumber = useMemo(
() => (linkedIssue.trim() ? parseGitHubIssueOrPRNumber(linkedIssue) : null),
[linkedIssue]
)
// Why: when the user pastes a PR URL straight into the workspace name field
// (without picking from the source picker), `linkedPR` stays null and the
// worktree card has no PR strip. Recover the PR number from the name on
// submit so create-from-PR worktrees always link back to their PR.
const effectiveLinkedPR = useMemo<number | null>(() => {
if (linkedPR !== null) {
return linkedPR
}
const fromName = parseGitHubIssueOrPRLink(name)
if (fromName && fromName.type === 'pr') {
// Why: only adopt a number when the URL's owner/repo matches the
// selected repo. Pasting `github.com/other/repo/pull/1234` must not
// mislink the worktree to an unrelated PR #1234 in the current repo.
// If the slug hasn't resolved yet, suppress recovery rather than
// risking a cross-repo mislink.
if (
selectedRepoSlug &&
fromName.slug.owner.toLowerCase() === selectedRepoSlug.owner.toLowerCase() &&
fromName.slug.repo.toLowerCase() === selectedRepoSlug.repo.toLowerCase()
) {
return fromName.number
}
}
return null
}, [linkedPR, name, selectedRepoSlug])
const setupConfig = useMemo(
() => (selectedRepoIsGit ? getSetupConfig(selectedRepo, yamlHooks) : null),
[selectedRepo, selectedRepoIsGit, yamlHooks]
)
const setupPolicy: SetupRunPolicy = selectedRepo?.hookSettings?.setupRunPolicy ?? 'run-by-default'
const linkedWorkItemProvider = linkedWorkItem ? getLinkedWorkItemProvider(linkedWorkItem) : null
// Why: Linear starts skip the generic default template, but must wait long
// enough to honor a repo-authored issueCommand if one exists.
const willApplyIssueCommandAsPrompt =
enableIssueAutomation &&
!agentPrompt.trim() &&
Boolean(linkedWorkItem) &&
(linkedWorkItemProvider !== 'linear' ||
!hasLoadedIssueCommand ||
issueCommandTemplate.trim().length > 0)
const shouldWaitForIssueAutomationCheck =
enableIssueAutomation &&
(parsedLinkedIssueNumber !== null || willApplyIssueCommandAsPrompt) &&
!hasLoadedIssueCommand
const requiresExplicitSetupChoice = Boolean(setupConfig) && setupPolicy === 'ask'
const resolvedSetupDecision =
setupDecision ??
(!setupConfig || setupPolicy === 'ask'
? null
: setupPolicy === 'run-by-default'
? 'run'
: 'skip')
const isSetupCheckPending = Boolean(repoId) && checkedHooksRepoId !== repoId
const shouldWaitForSetupCheck = Boolean(selectedRepo) && selectedRepoIsGit && isSetupCheckPending
// Why: when the user leaves the workspace name blank and provides no other
// seed source (prompt, linked issue/PR), pick a globally-unique marine
// creature name so the workspace gets a distinct, readable identifier
// instead of colliding on a literal "workspace" default — or on the same
// creature already used in another repo.
const fallbackCreatureName = useMemo(
() => getSuggestedCreatureName(worktreesByRepo),
[worktreesByRepo]
)
const workspaceSeedName = useMemo(
() =>
getWorkspaceSeedName({
explicitName: name,
prompt: agentPrompt,
linkedIssueNumber: parsedLinkedIssueNumber,
linkedPR,
fallbackName: fallbackCreatureName
}),
[agentPrompt, fallbackCreatureName, linkedPR, name, parsedLinkedIssueNumber]
)
// Why: Linear blank launches are context-only unless a repo explicitly
// configures issueCommand; avoid turning third-party ticket text into a task.
const canApplyDefaultLinkedOnlyTemplate =
linkedWorkItemProvider !== 'linear' || issueCommandTemplate.trim().length > 0
const shouldApplyLinkedOnlyTemplate =
enableIssueAutomation &&
!agentPrompt.trim() &&
Boolean(linkedWorkItem) &&
hasLoadedIssueCommand &&
canApplyDefaultLinkedOnlyTemplate
const linkedOnlyTemplatePrompt = useMemo(() => {
if (!shouldApplyLinkedOnlyTemplate || !linkedWorkItem) {
return ''
}
const template = issueCommandTemplate.trim() || DEFAULT_ISSUE_COMMAND_TEMPLATE
return renderIssueCommandTemplate(template, {
// Why: Linear issues carry a placeholder number 0; {{issue}} must render
// the string identifier (e.g. "ENG-123") instead.
issueNumber:
linkedWorkItem.linearIdentifier?.trim() ||
(linkedWorkItem.type === 'issue' ? linkedWorkItem.number : null),
artifactUrl: linkedWorkItem.url
})
}, [issueCommandTemplate, linkedWorkItem, shouldApplyLinkedOnlyTemplate])
const normalizedLinkQuery = useMemo(
() => normalizeGitHubLinkQuery(linkDebouncedQuery),
[linkDebouncedQuery]
)
const filteredLinkItems = useMemo(() => {
if (normalizedLinkQuery.tooLarge) {
return []
}
if (normalizedLinkQuery.directNumber !== null) {
return linkDirectItem ? [linkDirectItem] : []
}
const query = normalizedLinkQuery.query.trim().toLowerCase()
if (!query) {
return linkItems
}
return linkItems.filter((item) => {
const text = [
item.type,
item.number,
item.title,
item.author ?? '',
item.labels.join(' '),
item.branchName ?? '',
item.baseRefName ?? ''
]
.join(' ')
.toLowerCase()
return text.includes(query)
})
}, [
linkDirectItem,
linkItems,
normalizedLinkQuery.directNumber,
normalizedLinkQuery.query,
normalizedLinkQuery.tooLarge
])
// Persist draft whenever relevant fields change (full-page only).
useEffect(() => {
if (!persistDraft) {
return
}
setNewWorkspaceDraft({
repoId: repoId || null,
projectId:
selectedProjectGroup !== null
? null
: selectedWorkspaceTarget.status === 'ready'
? selectedWorkspaceTarget.target.projectId
: null,
projectGroupId: selectedProjectGroup?.id ?? null,
hostId:
selectedProjectGroup !== null
? null
: selectedWorkspaceTarget.status === 'ready'
? selectedWorkspaceTarget.target.hostId
: null,
projectHostSetupId:
selectedProjectGroup !== null
? null
: selectedWorkspaceTarget.status === 'ready'
? selectedWorkspaceTarget.target.projectHostSetupId
: null,
name,
prompt: agentPrompt,
note,
attachments: attachmentPaths,
linkedWorkItem,
taskSourceContext,
agent: tuiAgent,
linkedIssue,
linkedPR,
linkedGitLabIssue,
linkedGitLabMR,
...(baseBranch !== undefined ? { baseBranch } : {}),
...(compareBaseRef !== undefined ? { compareBaseRef } : {})
})
}, [
persistDraft,
agentPrompt,
attachmentPaths,
baseBranch,
compareBaseRef,
linkedIssue,
linkedPR,
linkedGitLabIssue,
linkedGitLabMR,
linkedWorkItem,
note,
name,
repoId,
selectedProjectGroup,
selectedWorkspaceTarget,
setNewWorkspaceDraft,
taskSourceContext,
tuiAgent
])
// Auto-pick the first eligible repo if we somehow start with none selected.
useEffect(() => {
if (isProjectGroupTarget) {
return
}
if (!repoId && eligibleRepos[0]?.id) {
setRepoId(eligibleRepos[0].id)
}
}, [eligibleRepos, isProjectGroupTarget, repoId, setRepoId])
useEffect(() => {
if (!selectedProjectGroup) {
return
}
if (repoId && folderSourceRepos.some((repo) => repo.id === repoId)) {
return
}
setRepoId(folderSourceRepos[0]?.id ?? '')
}, [folderSourceRepos, repoId, selectedProjectGroup, setRepoId])
// Why: the compact sparse dropdown is always visible under Advanced, so
// presets must load before sparse mode is enabled.
useEffect(() => {
if (!repoId || !selectedRepoIsGit || selectedRepo?.connectionId) {
return
}
if (sparsePresetsByRepo[repoId] !== undefined) {
return
}
void fetchSparsePresets(repoId)
}, [
fetchSparsePresets,
repoId,
selectedRepo?.connectionId,
selectedRepoIsGit,
sparsePresetsByRepo
])
// Why: detect agents for the selected repo. For local repos this runs once
// on mount (deduped by the store). For remote repos it re-runs when the
// selected repo changes so the agent list matches the SSH host.
useEffect(() => {
if (isRemote && selectedRepoSshStatus !== 'connected') {
return
}
let cancelled = false
const detect = isRemote ? ensureRemoteDetectedAgents(connectionId) : ensureDetectedAgents()
void detect.then((ids) => {
if (cancelled) {
return
}
const enabledIds = filterEnabledTuiAgents(ids, disabledTuiAgents)
if (!newWorkspaceDraft?.agent && !settings?.defaultTuiAgent && enabledIds.length > 0) {
const firstInCatalogOrder = getAgentCatalog().find((a) => enabledIds.includes(a.id))
if (firstInCatalogOrder) {
setTuiAgent(firstInCatalogOrder.id)
}
} else if (!isTuiAgentEnabled(tuiAgent, disabledTuiAgents)) {
const firstEnabledDetected = getAgentCatalog().find((a) => enabledIds.includes(a.id))
setTuiAgent(firstEnabledDetected?.id ?? fallbackDefaultAgent)
}
})
return () => {
cancelled = true
}
// Why: re-run when connectionId changes (user picks a different repo) so
// detection targets the correct host. Draft/settings deps are intentionally
// excluded — detection is a best-effort PATH snapshot.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connectionId, isRemote, selectedRepoSshStatus, disabledTuiAgents])
// Per-repo: load yaml hooks + issue command template.
useEffect(() => {
if (!repoId) {
return
}
let cancelled = false
setHasLoadedIssueCommand(false)
setIssueCommandTemplate('')
setYamlHooks(null)
setCheckedHooksRepoId(null)
if (!selectedRepoIsGit) {
setHasLoadedIssueCommand(true)
setCheckedHooksRepoId(repoId)
return () => {
cancelled = true
}
}
void loadHookCheckForRepo(repoId)
.then((result) => {
if (!cancelled) {
commitHookCheckIfCurrent(repoId, result.hooks)
}
})
.catch(() => {
if (!cancelled) {
commitHookCheckIfCurrent(repoId, null)
}
})
if (!enableIssueAutomation) {
setHasLoadedIssueCommand(true)
return () => {
cancelled = true
}
}
void readRuntimeIssueCommand(selectedRepoSettings, repoId)
.then((result) => {
if (!cancelled) {
setIssueCommandTemplate(result.effectiveContent ?? '')
setHasLoadedIssueCommand(true)
}
})
.catch(() => {
if (!cancelled) {
setIssueCommandTemplate('')
setHasLoadedIssueCommand(true)
}
})
return () => {
cancelled = true
}
}, [
commitHookCheckIfCurrent,
enableIssueAutomation,
loadHookCheckForRepo,
repoId,
selectedRepoIsGit,
selectedRepoSettings
])
const onConnectSelectedRepo = useCallback(async (): Promise<void> => {
const targetId = selectedRepoConnectionIdRef.current
if (!targetId) {
return
}
const liveState = useAppStore.getState()
const liveRepo = liveState.repos.find((repo) => repo.id === repoIdRef.current)
if (liveRepo?.connectionId !== targetId) {
return
}
const liveStatus = liveState.sshConnectionStates.get(targetId)?.status ?? null
if (liveStatus === 'connected' || isSshConnectInProgress(liveStatus)) {
return
}
try {
await window.api.ssh.connect({ targetId })
} catch (error) {
toast.error(
error instanceof Error
? error.message
: translate('auto.hooks.useComposerState.ba6cb77082', 'Failed to connect to project.')
)
}
}, [])
const onConnectSelectedProjectGroup = useCallback(async (): Promise<void> => {
if (!folderTargetConnectionId) {
return
}
const liveStatus = useAppStore
.getState()
.sshConnectionStates.get(folderTargetConnectionId)?.status
if (liveStatus === 'connected' || isSshConnectInProgress(liveStatus ?? null)) {
return
}
try {
await window.api.ssh.connect({ targetId: folderTargetConnectionId })
} catch (error) {
toast.error(
error instanceof Error
? error.message
: translate('auto.hooks.useComposerState.ba6cb77082', 'Failed to connect to project.')
)
}
}, [folderTargetConnectionId])
// Why: warm the Start-from picker's PR cache on composer mount and whenever
// the selected repo changes so opening the picker paints instantly from
// cache.
const canPrefetchSelectedRepoWorkItems = canUseRepoBackedComposerSources({
connectionId: selectedRepoConnectionId,
status: selectedRepoSshStatus
})
const prefetchSshConnectedGeneration =
selectedRepoConnectionId && selectedRepoSshStatus === 'connected' ? sshConnectedGeneration : 0
useEffect(() => {
if (!repoId || !selectedRepoIsGit || !canPrefetchSelectedRepoWorkItems) {
return
}
void prefetchWorktreeCreateBase(repoId, baseBranch)
}, [
baseBranch,
canPrefetchSelectedRepoWorkItems,
prefetchSshConnectedGeneration,
prefetchWorktreeCreateBase,
repoId,
selectedRepoIsGit
])
useEffect(() => {
if (!selectedRepoIsGit || !selectedRepo?.path || !canPrefetchSelectedRepoWorkItems) {
return
}
prefetchWorkItems(selectedRepo.id, selectedRepo.path, PER_REPO_FETCH_LIMIT, 'is:pr is:open')
}, [
canPrefetchSelectedRepoWorkItems,
prefetchSshConnectedGeneration,
prefetchWorkItems,
selectedRepo?.id,
selectedRepo?.path,
selectedRepoIsGit
])
// Reset setup decision when config / policy changes.
useEffect(() => {
if (shouldWaitForSetupCheck) {
setSetupDecision(null)
return
}
if (!setupConfig) {
setSetupDecision(null)
return
}
if (setupPolicy === 'ask') {
setSetupDecision(null)
return
}
setSetupDecision(setupPolicy === 'run-by-default' ? 'run' : 'skip')
}, [setupConfig, setupPolicy, shouldWaitForSetupCheck])
// Link popover: debounce + load recent items + resolve direct number.
useEffect(() => {
const timeout = window.setTimeout(() => setLinkDebouncedQuery(linkQuery), 250)
return () => window.clearTimeout(timeout)
}, [linkQuery])
useEffect(() => {
if (!linkPopoverOpen || !selectedRepo || !selectedRepoIsGit) {
return
}
let cancelled = false
setLinkItemsLoading(true)
const lookupRepoId = selectedRepo.id
void window.api.gh
.listWorkItems({ repoPath: selectedRepo.path, repoId: selectedRepo.id, limit: 100 })
.then((envelope) => {
if (!cancelled) {
// Why: IPC payload omits repoId — stamp it here from the repo we
// queried so downstream consumers typed against GitHubWorkItem work.
// Cast through unknown: spreading a discriminated union loses the
// discriminant, so the union-preserving shape must be asserted.
// Why: the link popover intentionally does NOT surface
// `envelope.errors?.issues`. Per-surface error copy lives in the
// Tasks view (TaskPage) and the smart workspace-name field — a
// partial-failure banner inside the small
// @-mention popover would crowd the input and the user would
// already see the same error on the originating Tasks page. If a
// future UX decision flips this, add an error row to the popover's
// render output.
// Why: surface partial issues-side failures via devtools even though the
// popover intentionally omits a UI banner (see rationale above). A user
// hitting a 403 on a private upstream would otherwise see an empty popover
// and no diagnostic trail.
if (envelope.errors?.issues) {
console.warn(
'[composer/link] issues-side partial failure in @-mention popover:',
envelope.errors.issues
)
}
setLinkItems(
envelope.items.map((it) => ({
...it,
repoId: lookupRepoId
})) as unknown as GitHubWorkItem[]
)
}
})
.catch(() => {
if (!cancelled) {
setLinkItems([])
}
})
.finally(() => {
if (!cancelled) {
setLinkItemsLoading(false)
}
})
return () => {
cancelled = true
}
}, [linkPopoverOpen, selectedRepo, selectedRepoIsGit])
useEffect(() => {
if (
!linkPopoverOpen ||
!selectedRepo ||
!selectedRepoIsGit ||
normalizedLinkQuery.directNumber === null
) {
setLinkDirectItem(null)
setLinkDirectLoading(false)
return
}
let cancelled = false
setLinkDirectLoading(true)
// Why: Superset lets users paste a full GitHub URL or type a raw issue/PR
// number and still get a concrete selectable result. Orca mirrors that by
// resolving direct lookups against the selected repo instead of requiring a
// text match in the recent-items list.
const lookupRepoId = selectedRepo.id
void lookupGitHubWorkItemForSource({
repoPath: selectedRepo.path,
repoId: selectedRepo.id,
sourceContext: selectedRepoGitHubSourceContext,
number: normalizedLinkQuery.directNumber
})
.then((item) => {
if (!cancelled) {
setLinkDirectItem(
item ? ({ ...item, repoId: lookupRepoId } as unknown as GitHubWorkItem) : null
)
}
})
.catch(() => {
if (!cancelled) {
setLinkDirectItem(null)
}
})
.finally(() => {
if (!cancelled) {
setLinkDirectLoading(false)
}
})
return () => {
cancelled = true
}
}, [
linkPopoverOpen,
normalizedLinkQuery.directNumber,
selectedRepo,
selectedRepoGitHubSourceContext,
selectedRepoIsGit
])
const applyLinkedWorkItem = useCallback(
(item: GitHubWorkItem, options: { preserveBranchNameOverride?: boolean } = {}): void => {
if (item.type === 'issue') {
setLinkedIssue(String(item.number))
setLinkedPR(null)
} else {
setLinkedIssue('')
setLinkedPR(item.number)
}
setLinkedGitLabIssue(null)
setLinkedGitLabMR(null)
setLinkedWorkItem({
type: item.type,
provider: 'github',
number: item.number,
title: item.title,
url: item.url
})
const suggestedName =
getLinkedWorkItemWorkspaceName(item)?.seedName ?? getLinkedWorkItemSuggestedName(item)
// Why: a pasted URL/#123 in the field is the lookup query that found
// this item, not a deliberate name — replace it with the title-derived
// name or it silently becomes a slugified-URL workspace name.
if (
suggestedName &&
(!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name))
) {
setName(suggestedName)
lastAutoNameRef.current = suggestedName
}
if (!options.preserveBranchNameOverride) {
setBranchNameOverride(undefined)
}
},
[name]
)
const resolvePendingSmartGitHubSubmit =
useCallback(async (): Promise<PendingSmartGitHubSubmitResolution> => {
if (linkedWorkItem) {
const startPointSelection = smartGitHubPrStartPointSelectionRef.current
if (
!isProjectGroupTarget &&
linkedWorkItem.type === 'pr' &&
getLinkedWorkItemProvider(linkedWorkItem) === 'github' &&
selectedRepo &&
selectedRepoIsGit &&
startPointSelection?.repoId === selectedRepo.id &&
startPointSelection.item.number === linkedWorkItem.number
) {
const selectedPrStartPoint =
startPointSelection.resolved ??
(await resolveGitHubPrStartPointForRepo({
repoId: selectedRepo.id,
prNumber: startPointSelection.item.number,
settings: getSettingsForRepoRuntimeOwner(
{ repos: [selectedRepo], settings },
selectedRepo.id
),
...(startPointSelection.item.branchName
? { headRefName: startPointSelection.item.branchName }
: {}),
...(startPointSelection.item.baseRefName
? { baseRefName: startPointSelection.item.baseRefName }
: {}),
...(startPointSelection.item.isCrossRepository !== undefined
? { isCrossRepository: startPointSelection.item.isCrossRepository }
: {})
}))
startPointSelection.resolved = selectedPrStartPoint
const smartGitHubMetadata = getSmartGitHubSubmitResolution(startPointSelection.item)
const resolution: Exclude<PendingSmartGitHubSubmitResolution, { kind: 'none' }> = {
...smartGitHubMetadata,
kind: 'pr-start-point',
baseBranch: selectedPrStartPoint.baseBranch,
...(selectedPrStartPoint.compareBaseRef
? { compareBaseRef: selectedPrStartPoint.compareBaseRef }
: {}),
...(selectedPrStartPoint.pushTarget
? { pushTarget: selectedPrStartPoint.pushTarget }
: {}),
...(selectedPrStartPoint.branchNameOverride
? { branchNameOverride: selectedPrStartPoint.branchNameOverride }
: {})
}
setBaseBranch(selectedPrStartPoint.baseBranch)
setCompareBaseRef(selectedPrStartPoint.compareBaseRef)
setPushTarget(selectedPrStartPoint.pushTarget)
if (selectedPrStartPoint.branchNameOverride) {
setBranchNameOverride(selectedPrStartPoint.branchNameOverride)
setBranchNameOverridePreservesNameEdits(true)
} else {
setBranchNameOverride(undefined)
setBranchNameOverridePreservesNameEdits(false)
}
setForkPushWarning(getForkPushWarning(selectedPrStartPoint))
return resolution
}
return { kind: 'none' }
}
const intent = getSmartGitHubSubmitIntent(name)
if (!intent) {
return { kind: 'none' }
}
const item = isProjectGroupTarget
? (
await Promise.all(
folderSourceRepos.filter(isGitRepoKind).map((repo) =>
lookupSmartGitHubSubmitItem({
repoPath: repo.path,
repoId: repo.id,
sourceContext: buildTaskSourceContextFromRepo({
provider: 'github',
projectId: repo.id,
repo
}),
intent,
workItem: lookupGitHubWorkItemForSource,
workItemByOwnerRepo: lookupGitHubWorkItemByOwnerRepoForSource
}).catch(() => null)
)
)
)
.filter((candidate): candidate is GitHubWorkItem => candidate !== null)
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))[0]
: selectedRepo && selectedRepoIsGit
? await lookupSmartGitHubSubmitItem({
repoPath: selectedRepo.path,
repoId: selectedRepo.id,
sourceContext: selectedRepoGitHubSourceContext,
intent,
workItem: lookupGitHubWorkItemForSource,
workItemByOwnerRepo: lookupGitHubWorkItemByOwnerRepoForSource
})
: null
if (!item) {
throw new Error('Could not resolve the GitHub item before creating the workspace.')
}
const prStartPoint =
!isProjectGroupTarget && item.type === 'pr' && selectedRepo && selectedRepoIsGit
? await resolveGitHubPrStartPointForRepo({
repoId: selectedRepo.id,
prNumber: item.number,
settings: getSettingsForRepoRuntimeOwner(
{ repos: [selectedRepo], settings },
selectedRepo.id
),
...(item.branchName ? { headRefName: item.branchName } : {}),
...(item.baseRefName ? { baseRefName: item.baseRefName } : {}),
...(item.isCrossRepository !== undefined
? { isCrossRepository: item.isCrossRepository }
: {})
})
: null
const smartGitHubMetadata = getSmartGitHubSubmitResolution(item)
const resolution: Exclude<PendingSmartGitHubSubmitResolution, { kind: 'none' }> = prStartPoint
? {
...smartGitHubMetadata,
kind: 'pr-start-point',
baseBranch: prStartPoint.baseBranch,
...(prStartPoint.compareBaseRef ? { compareBaseRef: prStartPoint.compareBaseRef } : {}),
...(prStartPoint.pushTarget ? { pushTarget: prStartPoint.pushTarget } : {}),
...(prStartPoint.branchNameOverride
? { branchNameOverride: prStartPoint.branchNameOverride }
: {})
}
: {
...smartGitHubMetadata,
kind: 'metadata-only'
}
// Why: Create can be clicked before the debounced smart field commits
// its selected source. Commit the resolved item here so failures leave
// the form showing the title instead of the raw URL.
setLinkedIssue(
resolution.linkedIssueNumber !== null ? String(resolution.linkedIssueNumber) : ''
)
setLinkedPR(resolution.linkedPR)
setLinkedGitLabIssue(null)
setLinkedGitLabMR(null)
setLinkedWorkItem(resolution.linkedWorkItem)
setName(resolution.workspaceName)
lastAutoNameRef.current = resolution.workspaceName
if (prStartPoint) {
setBaseBranch(prStartPoint.baseBranch)
setCompareBaseRef(prStartPoint.compareBaseRef)
setPushTarget(prStartPoint.pushTarget)
if (prStartPoint.branchNameOverride) {
setBranchNameOverride(prStartPoint.branchNameOverride)
setBranchNameOverridePreservesNameEdits(true)
} else {
setBranchNameOverride(undefined)
setBranchNameOverridePreservesNameEdits(false)
}
setForkPushWarning(getForkPushWarning(prStartPoint))
} else {
setBranchNameOverride(undefined)
setBranchNameOverridePreservesNameEdits(false)
}
branchAutoNameRef.current = ''
setStartFromResetHint(null)
return resolution
}, [
folderSourceRepos,
isProjectGroupTarget,
linkedWorkItem,
name,
selectedRepo,
selectedRepoGitHubSourceContext,
selectedRepoIsGit,
settings
])
// Why: GitHub/GitLab review routing prefers one provider identity. Clear
// the opposite provider slots so stale hidden fields cannot win later.
const applyLinkedGitLabWorkItem = useCallback(
(item: GitLabWorkItem): void => {
smartGitHubPrStartPointSelectionRef.current = null
if (item.type === 'issue') {
setLinkedGitLabIssue(item.number)
setLinkedGitLabMR(null)
} else {
setLinkedGitLabIssue(null)
setLinkedGitLabMR(item.number)
}
setLinkedIssue('')
setLinkedPR(null)
setLinkedWorkItem({
type: item.type,
provider: 'gitlab',
number: item.number,
title: item.title,
url: item.url
})
// Why: GitLabWorkItem.branchName lines up with GitHubWorkItem.branchName
// structurally; cast to the suggested-name helper's input shape so we
// reuse the existing naming heuristic without forking it.
const suggestedName = getLinkedWorkItemSuggestedName({
type: item.type === 'mr' ? 'pr' : 'issue',
number: item.number,
title: item.title,
branchName: item.branchName
} as unknown as GitHubWorkItem)
const titleName = getLinkedWorkItemWorkspaceName({
type: item.type,
provider: 'gitlab',
number: item.number,
title: item.title
})
const nextName = titleName?.seedName ?? suggestedName
if (
nextName &&
(!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name))
) {
setName(nextName)
lastAutoNameRef.current = nextName
}
setBranchNameOverride(undefined)
},
[name]
)
const handleSelectLinkedItem = useCallback(
(item: GitHubWorkItem): void => {
smartGitHubPrStartPointSelectionRef.current = null
applyLinkedWorkItem(item)
setLinkPopoverOpen(false)
setLinkQuery('')
setLinkDebouncedQuery('')
setLinkDirectItem(null)
},
[applyLinkedWorkItem]
)
const handleLinkPopoverChange = useCallback((open: boolean): void => {
setLinkPopoverOpen(open)
if (!open) {
setLinkQuery('')
setLinkDebouncedQuery('')
setLinkDirectItem(null)
}
}, [])
const handleRemoveLinkedWorkItem = useCallback((): void => {
smartGitHubPrStartPointSelectionRef.current = null
setLinkedWorkItem(null)
setLinkedIssue('')
setLinkedPR(null)
setForkPushWarning(null)
if (name === lastAutoNameRef.current) {
lastAutoNameRef.current = ''
}
}, [name])
const handleNameValueChange = useCallback(
(nextName: string): void => {
// Why: linked GitHub items should keep refreshing the suggested workspace
// name only while the current value is still auto-managed. As soon as the
// user edits the field by hand, later issue/PR selections must stop
// clobbering it until they clear the field again.
if (!nextName.trim()) {
lastAutoNameRef.current = ''
} else if (name !== lastAutoNameRef.current) {
lastAutoNameRef.current = ''
}
if (
branchNameOverride &&
!branchNameOverridePreservesNameEdits &&
nextName !== branchAutoNameRef.current
) {
setBranchNameOverride(undefined)
branchAutoNameRef.current = ''
}
setName(nextName)
setCreateError(null)
},
[branchNameOverride, branchNameOverridePreservesNameEdits, name]
)
const addComposerAttachments = useCallback((paths: string[]): void => {
if (paths.length === 0) {
return
}
setAttachmentPaths((current) => {
const next = [...current]
for (const pathValue of paths) {
if (!next.includes(pathValue)) {
next.push(pathValue)
}
}
return next
})
}, [])
const insertComposerFolderPaths = useCallback(
(folderPaths: string[]): void => {
if (folderPaths.length === 0) {
return
}
// Why: de-dup within a single drop — the OS occasionally delivers the
// same folder twice when a user drags from a selection that includes both
// the item and its parent, and we don't want to insert it multiple times.
const uniqueFolderPaths = Array.from(new Set(folderPaths))
// Why: wrap paths containing shell metacharacters in double quotes (and
// escape embedded quotes) so inserted folder refs stay a single token if
// pasted into a terminal. Simple paths stay unadorned to match OS drops.
const formatPath = (p: string): string => {
if (/[\s"'$`\\()[\]{}*?!;&|<>#~]/.test(p)) {
return `"${p.replace(/(["\\$`])/g, '\\$1')}"`
}
return p
}
const insertion = uniqueFolderPaths.map(formatPath).join(' ')
const textarea = promptTextareaRef.current
// Why: compute selection, insertion, and caret target OUTSIDE the
// setAgentPrompt updater so the updater stays pure. React Strict Mode
// double-invokes updaters in dev, and batching can delay execution.
const current = agentPromptRef.current
const selStart = textarea?.selectionStart ?? current.length
const selEnd = textarea?.selectionEnd ?? current.length
const before = current.slice(0, selStart)
const after = current.slice(selEnd)
// Why: pad with single spaces when the caret sits directly against other
// text so the folder path doesn't merge into an adjacent word.
const needsLeadingSpace = before.length > 0 && !/\s$/.test(before)
const needsTrailingSpace = after.length > 0 && !/^\s/.test(after)
const padded = `${needsLeadingSpace ? ' ' : ''}${insertion}${needsTrailingSpace ? ' ' : ''}`
const caret = before.length + padded.length
if (textarea) {
cancelPromptCaretFrame()
promptCaretFrameRef.current = requestAnimationFrame(() => {
promptCaretFrameRef.current = null
if (promptTextareaRef.current !== textarea || !textarea.isConnected) {
return
}
textarea.focus()
textarea.setSelectionRange(caret, caret)
})
}
// Why: pass a plain value (not an updater) since `before`/`after` were
// already resolved from `agentPromptRef.current`; this keeps the state
// write side-effect-free under Strict-Mode double-render.
setAgentPrompt(before + padded + after)
},
[cancelPromptCaretFrame]
)
const uploadComposerPaths = useCallback(
async (
sourcePaths: string[],
targetSettings = selectedRepoSettings,
targetConnectionId = connectionId,
targetRepoPath = selectedRepoPath,
canReportFailure: () => boolean = () => true
): Promise<{ filePaths: string[]; folderPaths: string[] } | null> => {
if (!targetSettings?.activeRuntimeEnvironmentId?.trim() && !targetConnectionId) {
return null
}
if (!targetRepoPath) {
if (canReportFailure()) {
toast.error(
translate(
'auto.hooks.useComposerState.3db83fc58a',
'No project path is available on this host for attachments.'
)
)
}
return { filePaths: [], folderPaths: [] }
}
const destinationDir = joinPath(targetRepoPath, '.orca/drops')
const { results } = await importExternalPathsToRuntime(
{
settings: targetSettings,
worktreeId: targetRepoPath,
worktreePath: targetRepoPath,
connectionId: targetConnectionId ?? undefined
},
sourcePaths,
destinationDir,
{ ensureDestinationDir: true }
)
const uploadResult = collectComposerDropUploadResult(results)
if (shouldReportComposerDropUploadFailure(uploadResult, canReportFailure)) {
toast.error(
translate(
'auto.hooks.useComposerState.a9ff236145',
'Some attachments could not be uploaded.'
)
)
}
return { filePaths: uploadResult.filePaths, folderPaths: uploadResult.folderPaths }
},
[connectionId, selectedRepoPath, selectedRepoSettings]
)
const handleAddAttachment = useCallback(async (): Promise<void> => {
try {
const selectedPath = await window.api.shell.pickAttachment()
if (!selectedPath) {
return
}
const uploaded = await uploadComposerPaths([selectedPath])
if (uploaded) {
addComposerAttachments(uploaded.filePaths)
insertComposerFolderPaths(uploaded.folderPaths)
return
}
addComposerAttachments([selectedPath])
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to add attachment.'
toast.error(message)
}
}, [addComposerAttachments, insertComposerFolderPaths, uploadComposerPaths])
const applyLocalComposerDrop = useCallback(
async (paths: string[], canApply: () => boolean = () => true): Promise<void> => {
const fileAttachments: string[] = []
const folderPaths: string[] = []
for (const filePath of paths) {
try {
await window.api.fs.authorizeExternalPath({ targetPath: filePath })
const stat = await window.api.fs.stat({ filePath })
if (stat.isDirectory) {
folderPaths.push(filePath)
} else {
fileAttachments.push(filePath)
}
} catch {
// Skip paths we cannot authorize or stat.
}
}
if (!canApply()) {
return
}
addComposerAttachments(fileAttachments)
insertComposerFolderPaths(folderPaths)
},
[addComposerAttachments, insertComposerFolderPaths]
)
const addComposerAttachmentsRef = useRef(addComposerAttachments)
addComposerAttachmentsRef.current = addComposerAttachments
const insertComposerFolderPathsRef = useRef(insertComposerFolderPaths)
insertComposerFolderPathsRef.current = insertComposerFolderPaths
const uploadComposerPathsRef = useRef(uploadComposerPaths)
uploadComposerPathsRef.current = uploadComposerPaths
const applyLocalComposerDropRef = useRef(applyLocalComposerDrop)
applyLocalComposerDropRef.current = applyLocalComposerDrop
// Why: native OS file drops onto the composer are captured by the preload
// bridge (see `data-native-file-drop-target="composer"` markers) and relayed
// as a gesture-scoped IPC event. Files become attachments (matching the
// manual picker behavior); folders are pasted inline at the textarea caret
// so the user can reference them as working directories in their prompt
// without attaching a path we can't embed as file content.
const instanceIdRef = useRef<symbol>(Symbol('composer'))
useEffect(() => {
const instanceId = instanceIdRef.current
composerDropStack.push(instanceId)
const unsubscribe = window.api.ui.onFileDrop((data) => {
if (data.target !== 'composer') {
return
}
// Why: only the top-of-stack composer (most recently mounted) owns the
// drop. Earlier subscribers stay bound to keep their own cleanup tidy
// but short-circuit so the event doesn't double-apply when page+modal
// are both alive.
if (!isCurrentComposerDropOwner(composerDropStack, instanceId)) {
return
}
void (async () => {
const isStillDropOwner = (): boolean =>
isCurrentComposerDropOwner(composerDropStack, instanceId)
const uploaded = await uploadComposerPathsRef.current(
data.paths,
selectedRepoSettingsRef.current,
connectionIdRef.current,
selectedRepoPathRef.current,
isStillDropOwner
)
if (!isStillDropOwner()) {
return
}
if (uploaded) {
addComposerAttachmentsRef.current(uploaded.filePaths)
insertComposerFolderPathsRef.current(uploaded.folderPaths)
return
}
await applyLocalComposerDropRef.current(data.paths, isStillDropOwner)
})()
})
return () => {
unsubscribe()
const idx = composerDropStack.lastIndexOf(instanceId)
if (idx !== -1) {
composerDropStack.splice(idx, 1)
}
}
}, [])
const handleRepoChange = useCallback(
(
value: string,
options: { preserveStartFrom?: boolean; forceResetStartFrom?: boolean } = {}
): void => {
setProjectError(null)
if (value === repoId && !options.forceResetStartFrom) {
setRepoId(value)
return
}
// Why: capture a short descriptor of the prior Start-from selection so
// the field can render an inline reset (e.g. "was PR #8778") after the
// repo changes and the selection is wiped.
let hint: string | null = null
if (!options.preserveStartFrom) {
if (linkedWorkItem?.type === 'pr' && baseBranch) {
hint = `was PR #${linkedWorkItem.number}`
} else if (linkedWorkItem?.type === 'mr' && baseBranch) {
// Why: GitLab MR convention is `!N`, not `#N` — match the
// upstream UI so the reset hint is recognizable.
hint = `was MR !${linkedWorkItem.number}`
} else if (baseBranch) {
hint = `was ${baseBranch}`
}
}
const preserveLinearLinkedWorkItem = isLinearLinkedWorkItem(linkedWorkItem)
setRepoId(value)
if (!options.preserveStartFrom) {
smartGitHubPrStartPointSelectionRef.current = null
setLinkedIssue('')
setLinkedPR(null)
setLinkedGitLabIssue(null)
setLinkedGitLabMR(null)
// Why: repo changes invalidate repo-scoped sources (GitHub/GitLab/branch),
// but a selected Linear issue is workspace-scoped source context and
// must survive choosing the implementation project.
if (!preserveLinearLinkedWorkItem) {
setLinkedWorkItem(null)
}
}
setSparseEnabled(false)
setSparseDirectories('')
// Why: presets are repo-scoped, so a stale selection from the prior
// repo would be meaningless after a repo switch.
setSparseSelectedPresetId(null)
// Why: the Start-from picker is repo-scoped, so any prior branch/PR
// selection is meaningless in the new repo. Resetting to undefined
// makes the field fall back to the new repo's effective base ref.
if (!options.preserveStartFrom) {
setBaseBranch(undefined)
setCompareBaseRef(undefined)
setPushTarget(undefined)
setBranchNameOverride(undefined)
// Why (#5181): reuse state is branch-scoped, so a repo switch must clear
// it alongside the branch override (matches the other reset paths).
setBranchNameOverridePreservesNameEdits(false)
setReuseEligibleBranch(null)
setReuseSelectedBranch(false)
setForkPushWarning(null)
setStartFromResetHint(hint)
}
},
[baseBranch, linkedWorkItem, repoId, setRepoId]
)
const handleFolderSourceRepoChange = useCallback(
(value: string): void => {
if (!folderSourceRepos.some((repo) => repo.id === value)) {
return
}
setRepoId(value)
smartGitHubPrStartPointSelectionRef.current = null
setLinkedWorkItem((current) => {
const provider = current ? getLinkedWorkItemProvider(current) : null
return provider === 'github' || provider === 'gitlab' ? null : current
})
setLinkedIssue('')
setLinkedPR(null)
setLinkedGitLabIssue(null)
setLinkedGitLabMR(null)
},
[folderSourceRepos, setRepoId]
)
const handleProjectHostSetupChange = useCallback(
(setupId: string): void => {
const option = projectHostSetupOptions.find((candidate) => candidate.id === setupId)
if (!option || option.kind !== 'ready') {
return
}
// Why: switching the run host for the same logical project must not
// erase the task/PR source the user is starting from.
handleRepoChange(option.repoId, { preserveStartFrom: true })
},
[handleRepoChange, projectHostSetupOptions]
)
const handleProjectChange = useCallback(
(projectId: string): void => {
initialProjectGroupAppliedRef.current = true
const projectGroupId = getProjectGroupIdFromNewWorkspaceOptionId(projectId)
if (projectGroupId) {
const nextProjectGroup = projectGroups.find(
(group) => group.id === projectGroupId && Boolean(group.parentPath?.trim())
)
if (!nextProjectGroup) {
setSelectedProjectGroupId(null)
setProjectError(
translate(
'auto.hooks.useComposerState.chooseOrAddProjectBeforeWorkspace',
'Choose or add a project before creating a workspace.'
)
)
return
}
const nextSourceRepo = getFolderSourceRepos(repos, projectGroups, nextProjectGroup)[0]
setSelectedProjectGroupId(nextProjectGroup.id)
setProjectError(null)
setRepoId(nextSourceRepo?.id ?? '')
setLinkedIssue('')
setLinkedPR(null)
setLinkedGitLabIssue(null)
setLinkedGitLabMR(null)
const linkedProvider = linkedWorkItem ? getLinkedWorkItemProvider(linkedWorkItem) : null
if (linkedWorkItem && linkedProvider !== 'linear' && linkedProvider !== 'jira') {
setLinkedWorkItem(null)
}
setSparseEnabled(false)
setSparseDirectories('')
setSparseSelectedPresetId(null)
setBaseBranch(undefined)
setPushTarget(undefined)
setBranchNameOverride(undefined)
// Why (#5181): clear branch-scoped reuse state on a project switch too.
setBranchNameOverridePreservesNameEdits(false)
setReuseEligibleBranch(null)
setReuseSelectedBranch(false)
setForkPushWarning(null)
setStartFromResetHint(null)
return
}
setSelectedProjectGroupId(null)
const preferredHostId =
selectedWorkspaceTarget.status === 'ready' ? selectedWorkspaceTarget.target.hostId : null
// Why: prefer the host the user is currently on, but treat it as a
// preference (focusedHostScope) rather than a hard hostId match. Pinning
// hostId made selecting a project that is only set up on a different host
// a silent no-op — the resolver returned '' (project-not-set-up-on-host)
// and the early return below swallowed the click. Falling back to any
// host the project is ready on lets cross-host selection work.
const nextRepoId = resolveWorkspaceCreationRepoId({
eligibleRepos,
projects,
projectHostSetups,
projectId,
focusedHostScope: preferredHostId ?? workspaceHostScope
})
if (!nextRepoId) {
return
}
handleRepoChange(nextRepoId, { forceResetStartFrom: isProjectGroupTarget })
},
[
eligibleRepos,
handleRepoChange,
isProjectGroupTarget,
linkedWorkItem,
projectGroups,
projectHostSetups,
projects,
repos,
setRepoId,
selectedWorkspaceTarget,
workspaceHostScope
]
)
const showProjectRequiredError = useCallback((): void => {
setProjectError('Choose or add a project before creating a workspace.')
requestAnimationFrame(() => {
document
.querySelector<HTMLElement>(
'[data-contextual-tour-target="workspace-creation-project"] [data-project-combobox-root="true"][role="combobox"]'
)
?.focus()
})
}, [])
const handleSparseSelectPreset = useCallback((preset: SparsePreset | null): void => {
if (preset) {
setSparseEnabled(true)
setSparseDirectories(preset.directories.join('\n'))
setSparseSelectedPresetId(preset.id)
} else {
setSparseEnabled(false)
setSparseDirectories('')
setSparseSelectedPresetId(null)
}
}, [])
const handleBaseBranchChange = useCallback((next: string | undefined): void => {
smartGitHubPrStartPointSelectionRef.current = null
setBaseBranch(next)
setCompareBaseRef(undefined)
setPushTarget(undefined)
setBranchNameOverride(undefined)
// Why (#5181): the Start-from picker means "create a new branch from this
// base", so it never offers branch reuse — clear any reuse state left over
// from a prior smart-field branch pick.
setBranchNameOverridePreservesNameEdits(false)
setReuseEligibleBranch(null)
setReuseSelectedBranch(false)
setForkPushWarning(null)
branchAutoNameRef.current = ''
setStartFromResetHint(null)
}, [])
const handleBaseBranchPrSelect = useCallback(
(
nextBaseBranch: string,
item: GitHubWorkItem,
nextPushTarget?: GitPushTarget,
nextBranchNameOverride?: string,
nextCompareBaseRef?: string
): void => {
setBaseBranch(nextBaseBranch)
setCompareBaseRef(nextCompareBaseRef)
setPushTarget(nextPushTarget)
setBranchNameOverride(nextBranchNameOverride)
setBranchNameOverridePreservesNameEdits(Boolean(nextBranchNameOverride))
branchAutoNameRef.current = ''
setStartFromResetHint(null)
// Why: per spec, a PR selection in the Start-from picker is also a
// linkedWorkItem assignment. Reuse applyLinkedWorkItem so auto-name and
// linkedPR state stay in a single code path.
applyLinkedWorkItem(item, { preserveBranchNameOverride: Boolean(nextBranchNameOverride) })
// Why: starting a worktree from a PR is a strong hint for what the
// worktree's comment should surface (`orca worktree current`, sidebar).
// Prefill the note if it's empty or still equal to a prior auto-fill, so
// we don't overwrite anything the user has typed.
if (item.type === 'pr') {
const suggestedNote = `PR #${item.number} — ${item.title}`
const currentNote = noteRef.current
if (!currentNote.trim() || currentNote === lastAutoNoteRef.current) {
setNote(suggestedNote)
lastAutoNoteRef.current = suggestedNote
}
}
},
[applyLinkedWorkItem]
)
// Why: GitLab parallel of handleBaseBranchPrSelect. Same shape, same
// 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,
nextPushTarget?: GitPushTarget,
nextCompareBaseRef?: string
): void => {
setBaseBranch(nextBaseBranch)
setCompareBaseRef(nextCompareBaseRef)
setPushTarget(nextPushTarget)
setBranchNameOverride(undefined)
branchAutoNameRef.current = ''
setStartFromResetHint(null)
applyLinkedGitLabWorkItem(item)
if (item.type === 'mr') {
const suggestedNote = `MR !${item.number} — ${item.title}`
const currentNote = noteRef.current
if (!currentNote.trim() || currentNote === lastAutoNoteRef.current) {
setNote(suggestedNote)
lastAutoNoteRef.current = suggestedNote
}
}
},
[applyLinkedGitLabWorkItem]
)
const handleSmartGitHubItemSelect = useCallback(
(item: GitHubWorkItem): void => {
if (isProjectGroupTarget) {
const linkedItem = toGitHubLinkedWorkItem(item)
setLinkedIssue(String(item.number))
setLinkedPR(item.type === 'pr' ? item.number : null)
setLinkedGitLabIssue(null)
setLinkedGitLabMR(null)
setLinkedWorkItem(linkedItem)
const nextName = getLinkedItemDisplayName(linkedItem)
if (
nextName &&
(!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name))
) {
setName(nextName)
lastAutoNameRef.current = nextName
}
return
}
setStartFromResetHint(null)
setBranchNameOverride(undefined)
setForkPushWarning(null)
branchAutoNameRef.current = ''
smartGitHubPrStartPointSelectionRef.current = null
// Why: provider items can come from a different source host than the
// selected run host. Resolve git refs against the run repo; keep item
// metadata/source context separate for provider identity.
const runRepo = selectedRepo ?? eligibleRepos.find((repo) => repo.id === item.repoId)
applyLinkedWorkItem(item)
if (item.type !== 'pr' || !runRepo) {
setBaseBranch(undefined)
setCompareBaseRef(undefined)
setPushTarget(undefined)
return
}
setBaseBranch(undefined)
setCompareBaseRef(undefined)
setPushTarget(undefined)
const startPointSelection: SmartGitHubPrStartPointSelection = {
repoId: runRepo.id,
item
}
smartGitHubPrStartPointSelectionRef.current = startPointSelection
const itemRepoSettings = getSettingsForRepoRuntimeOwner(
{ repos: [runRepo], settings },
runRepo.id
)
const resolvePrBase = resolveGitHubPrStartPointForRepo({
repoId: runRepo.id,
prNumber: item.number,
settings: itemRepoSettings,
...(item.branchName ? { headRefName: item.branchName } : {}),
...(item.baseRefName ? { baseRefName: item.baseRefName } : {}),
...(item.isCrossRepository !== undefined
? { isCrossRepository: item.isCrossRepository }
: {})
})
void resolvePrBase
.then((result) => {
if (smartGitHubPrStartPointSelectionRef.current !== startPointSelection) {
return
}
startPointSelection.resolved = result
handleBaseBranchPrSelect(
result.baseBranch,
item,
result.pushTarget,
result.branchNameOverride,
result.compareBaseRef
)
// Why: a fork PR push lands on the contributor's fork; if they didn't
// allow maintainer edits, GitHub will reject it. Warn up front.
setForkPushWarning(getForkPushWarning(result))
})
.catch((error: unknown) => {
if (smartGitHubPrStartPointSelectionRef.current !== startPointSelection) {
return
}
setBaseBranch(undefined)
setCompareBaseRef(undefined)
setPushTarget(undefined)
toast.error(
error instanceof Error
? error.message
: translate('auto.hooks.useComposerState.b2ead86962', 'Failed to resolve PR base.')
)
})
},
[
applyLinkedWorkItem,
eligibleRepos,
handleBaseBranchPrSelect,
isProjectGroupTarget,
name,
selectedRepo,
settings
]
)
// Why: GitLab parallel of handleSmartGitHubItemSelect. For a picked
// MR, resolves the base branch via worktrees:resolveMrBase (which uses
// refs/merge-requests/<iid>/head for fork MRs the same way the gh side
// uses refs/pull/<N>/head). Issue selections short-circuit since
// there's no branch-resolution step to run.
const handleSmartGitLabItemSelect = useCallback(
(item: GitLabWorkItem): void => {
if (isProjectGroupTarget) {
const linkedItem = toGitLabLinkedWorkItem(item)
setLinkedGitLabIssue(item.type === 'issue' ? item.number : null)
setLinkedGitLabMR(item.type === 'mr' ? item.number : null)
setLinkedIssue('')
setLinkedPR(null)
setLinkedWorkItem(linkedItem)
const nextName = getLinkedItemDisplayName(linkedItem)
if (
nextName &&
(!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name))
) {
setName(nextName)
lastAutoNameRef.current = nextName
}
return
}
applyLinkedGitLabWorkItem(item)
setStartFromResetHint(null)
setBranchNameOverride(undefined)
setForkPushWarning(null)
branchAutoNameRef.current = ''
// Why: MR metadata can be sourced from one host/account while the
// workspace is created on another host for the same logical project.
const runRepo = selectedRepo ?? eligibleRepos.find((repo) => repo.id === item.repoId)
if (item.type !== 'mr' || !runRepo) {
setCompareBaseRef(undefined)
return
}
setCompareBaseRef(undefined)
const itemRepoSettings = getSettingsForRepoRuntimeOwner(
{ repos: [runRepo], settings },
runRepo.id
)
const target = getActiveRuntimeTarget(itemRepoSettings)
const resolveMrBase =
target.kind === 'local'
? window.api.worktrees.resolveMrBase({
repoId: runRepo.id,
mrIid: item.number,
...(item.branchName ? { sourceBranch: item.branchName } : {}),
...(item.baseRefName ? { targetBranch: item.baseRefName } : {}),
...(item.isCrossRepository !== undefined
? { isCrossRepository: item.isCrossRepository }
: {})
})
: callRuntimeRpc<
| { baseBranch: string; compareBaseRef?: string; pushTarget?: GitPushTarget }
| { error: string }
>(
target,
'worktree.resolveMrBase',
{
repo: runRepo.id,
mrIid: item.number,
...(item.branchName ? { sourceBranch: item.branchName } : {}),
...(item.baseRefName ? { targetBranch: item.baseRefName } : {}),
...(item.isCrossRepository !== undefined
? { isCrossRepository: item.isCrossRepository }
: {})
},
{ timeoutMs: 30_000 }
)
void resolveMrBase
.then((result) => {
if ('error' in result) {
// Why: without surfacing the failure the worktree silently falls
// back to the repo default branch (origin/master), so clear stale
// base state and tell the user — mirrors the GitHub PR path.
setBaseBranch(undefined)
setCompareBaseRef(undefined)
setPushTarget(undefined)
toast.error(result.error)
return
}
handleBaseBranchMrSelect(
result.baseBranch,
item,
result.pushTarget,
result.compareBaseRef
)
})
.catch((error: unknown) => {
setBaseBranch(undefined)
setCompareBaseRef(undefined)
setPushTarget(undefined)
toast.error(
error instanceof Error
? error.message
: translate('auto.hooks.useComposerState.5f3d2c8a1b', 'Failed to resolve MR base.')
)
})
},
[
applyLinkedGitLabWorkItem,
eligibleRepos,
handleBaseBranchMrSelect,
isProjectGroupTarget,
name,
selectedRepo,
settings
]
)
const handleSmartBranchSelect = useCallback(
(refName: string, localBranchName: string): void => {
smartGitHubPrStartPointSelectionRef.current = null
const selection = resolveComposerBranchSelection({
refName,
localBranchName,
currentName: name,
lastAutoName: lastAutoNameRef.current
})
setBaseBranch(selection.baseBranch)
setCompareBaseRef(undefined)
setPushTarget(undefined)
setStartFromResetHint(null)
setForkPushWarning(null)
// Why (#5181): reuse an existing local branch (check it out) instead of
// branching off it. Default reuse ON when the worktree name was
// auto-derived from the branch, and preserve name edits so reuse survives
// renaming the worktree folder. Reuse is impossible when the branch is
// already checked out in another worktree (git allows it in only one), so
// gate eligibility on that and don't pin the override to a busy branch.
// Note: worktreesByRepo only covers visible worktrees; a branch busy only
// in a hidden external worktree falls through to the backend conflict
// check, which rejects it with a clear "already exists locally" error.
const branchCheckedOutElsewhere = isBranchCheckedOutInWorktrees(
localBranchName,
(worktreesByRepo[repoId] ?? []).map((worktree) => worktree.branch)
)
const { reuseEligibleBranch: nextReuseEligibleBranch, defaultReuse } =
resolveComposerBranchReuse({
refName,
localBranchName,
selectionProducedOverride: selection.branchNameOverride !== undefined,
branchCheckedOutElsewhere
})
setReuseEligibleBranch(nextReuseEligibleBranch)
setReuseSelectedBranch(defaultReuse)
setBranchNameOverridePreservesNameEdits(defaultReuse)
const effectiveOverride = resolveComposerReuseOverride({
refName,
localBranchName,
branchNameOverride: selection.branchNameOverride,
branchCheckedOutElsewhere
})
if (selection.name !== undefined && selection.lastAutoName !== undefined) {
setName(selection.name)
lastAutoNameRef.current = selection.lastAutoName
branchAutoNameRef.current = effectiveOverride ? selection.branchAutoName : ''
setBranchNameOverride(effectiveOverride)
} else {
setBranchNameOverride(effectiveOverride)
branchAutoNameRef.current = effectiveOverride ? selection.branchAutoName : ''
}
},
[name, worktreesByRepo, repoId]
)
const handleReuseSelectedBranchChange = useCallback(
(next: boolean): void => {
if (!reuseEligibleBranch) {
return
}
setReuseSelectedBranch(next)
// Why (#5181): reuse pins the exact existing branch as the override and
// preserves it across worktree-name edits, so the folder can be named
// independently while the branch is checked out. Opting out drops the
// override so a fresh branch is created from the selected ref as base.
setBranchNameOverridePreservesNameEdits(next)
setBranchNameOverride(next ? reuseEligibleBranch : undefined)
if (next) {
branchAutoNameRef.current = reuseEligibleBranch
}
},
[reuseEligibleBranch]
)
const handleSmartLinearIssueSelect = useCallback(
(issue: LinearIssue): void => {
if (isProjectGroupTarget) {
const linkedItem = toLinearLinkedWorkItem(issue)
setLinkedIssue('')
setLinkedPR(null)
setLinkedGitLabIssue(null)
setLinkedGitLabMR(null)
setLinkedWorkItem(linkedItem)
const suggestedName =
getLinkedItemDisplayName(linkedItem) ?? getLinearIssueWorkspaceName(issue)
if (
!name.trim() ||
name === lastAutoNameRef.current ||
isWorkItemLookupText(name) ||
name.trim().toLowerCase() === issue.identifier.toLowerCase()
) {
setName(suggestedName)
lastAutoNameRef.current = suggestedName
}
return
}
setLinkedIssue('')
setLinkedPR(null)
setLinkedGitLabIssue(null)
setLinkedGitLabMR(null)
setLinkedWorkItem(buildLinearIssueLinkedWorkItem(issue))
const suggestedName = getLinearIssueWorkspaceName(issue)
// Why: same lookup-text rule as applyLinkedWorkItem, plus the typed
// Linear identifier ("STA-123") that matched this issue.
if (
!name.trim() ||
name === lastAutoNameRef.current ||
isWorkItemLookupText(name) ||
name.trim().toLowerCase() === issue.identifier.toLowerCase()
) {
setName(suggestedName)
lastAutoNameRef.current = suggestedName
}
setBranchNameOverride(undefined)
setForkPushWarning(null)
branchAutoNameRef.current = ''
// Why: match the GitHub issue/PR flow by drafting linked context for
// review instead of auto-submitting. Auto-filling the note here would
// turn a source selection into user-authored instructions.
},
[isProjectGroupTarget, name]
)
const handleClearSmartNameSelection = useCallback((): void => {
smartGitHubPrStartPointSelectionRef.current = null
setLinkedIssue('')
setLinkedPR(null)
setLinkedGitLabIssue(null)
setLinkedGitLabMR(null)
setLinkedWorkItem(null)
setBaseBranch(undefined)
setCompareBaseRef(undefined)
setPushTarget(undefined)
setBranchNameOverride(undefined)
setBranchNameOverridePreservesNameEdits(false)
setReuseEligibleBranch(null)
setReuseSelectedBranch(false)
setForkPushWarning(null)
branchAutoNameRef.current = ''
setStartFromResetHint(null)
if (name === lastAutoNameRef.current) {
setName('')
lastAutoNameRef.current = ''
}
if (noteRef.current === lastAutoNoteRef.current) {
setNote('')
lastAutoNoteRef.current = ''
}
}, [name])
const smartNameSelection = useMemo<SmartWorkspaceNameSelection | null>(() => {
if (isProjectGroupTarget) {
return getFolderSmartNameSelection(linkedWorkItem)
}
if (linkedWorkItem) {
const provider = getLinkedWorkItemProvider(linkedWorkItem)
const isLinear = provider === 'linear'
const kind: SmartWorkspaceNameSelection['kind'] = isLinear
? 'linear'
: provider === 'jira'
? 'jira'
: provider === 'gitlab'
? linkedWorkItem.type === 'mr'
? 'gitlab-mr'
: 'gitlab-issue'
: linkedWorkItem.type === 'pr'
? 'github-pr'
: 'github-issue'
return {
kind,
label:
isLinear || provider === 'jira' || linkedWorkItem.number === 0
? linkedWorkItem.title
: `#${linkedWorkItem.number} ${linkedWorkItem.title}`,
url: linkedWorkItem.url
}
}
if (baseBranch) {
return { kind: 'branch', label: baseBranch }
}
return null
}, [baseBranch, isProjectGroupTarget, linkedWorkItem])
const handleOpenAgentSettings = useCallback((): void => {
openSettingsTarget({ pane: 'agents', repoId: null })
openSettingsPage()
closeModal()
}, [closeModal, openSettingsPage, openSettingsTarget])
const applyWorktreeMeta = useCallback(
async (worktreeId: string, meta: Partial<WorktreeMeta>): Promise<void> => {
if (Object.keys(meta).length === 0) {
return
}
try {
await updateWorktreeMeta(worktreeId, meta)
} catch {
console.error('Failed to update worktree meta after creation')
}
},
[updateWorktreeMeta]
)
const folderCreateDisabled =
creating ||
!selectedProjectGroup?.parentPath ||
folderPathStatusBlocksCreate ||
folderTargetRequiresConnection
const submitFolderTarget = useCallback(
async (requestedAgent: TuiAgent | null): Promise<void> => {
if (!selectedProjectGroup?.parentPath || folderCreateDisabled) {
return
}
setCreateError(null)
setCreating(true)
try {
const shouldResolveSmartGitHubSubmit = canResolveFolderSmartGitHubSubmit({
hasFolderSourceRepos: folderSourceRepos.length > 0
})
const smartGitHubResolution = shouldResolveSmartGitHubSubmit
? await resolvePendingSmartGitHubSubmit()
: ({ kind: 'none' } as const)
const smartGitHubMetadata =
smartGitHubResolution.kind === 'none' ? null : smartGitHubResolution
const agent =
requestedAgent && isTuiAgentEnabled(requestedAgent, disabledTuiAgents)
? requestedAgent
: null
const folderWorkspaceCreated = await submitFolderWorkspaceCreate({
projectGroup: selectedProjectGroup,
name: smartGitHubMetadata?.workspaceName ?? name,
lastAutoName: lastAutoNameRef.current,
linkedWorkItem: smartGitHubMetadata?.linkedWorkItem ?? linkedWorkItem,
note,
quickAgent: agent,
autoRenameBranchFromWork: settings?.autoRenameBranchFromWork,
agentCmdOverrides: settings?.agentCmdOverrides,
agentArgs: agent
? resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs)
: undefined,
agentEnv: agent ? resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv) : undefined,
isRemote: folderTargetIsRemote,
launchSource: telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer',
runtimeEnvironmentId: folderTargetRuntimeEnvironmentId,
createFolderWorkspace: (input) =>
createFolderWorkspace(input, {
runtimeEnvironmentId: folderTargetRuntimeEnvironmentId
}),
onOpenChange: (open) => {
if (!open) {
if (persistDraft) {
clearNewWorkspaceDraft()
}
onCreated?.()
}
}
})
if (!folderWorkspaceCreated) {
setCreateError({
title: translate(
'auto.hooks.useComposerState.folderWorkspaceCreateFailedTitle',
'Folder workspace creation failed'
),
message: translate(
'auto.hooks.useComposerState.folderWorkspaceCreateFailedMessage',
'The folder workspace could not be created. Check the error details above, then try again.'
)
})
}
} catch (error) {
const formattedError = formatWorkspaceCreateError(error)
setCreateError(formattedError)
toast.error(getWorkspaceCreateErrorToastMessage(formattedError))
} finally {
setCreating(false)
}
},
[
clearNewWorkspaceDraft,
createFolderWorkspace,
disabledTuiAgents,
folderCreateDisabled,
folderTargetIsRemote,
folderTargetRuntimeEnvironmentId,
folderSourceRepos.length,
linkedWorkItem,
name,
note,
onCreated,
persistDraft,
resolvePendingSmartGitHubSubmit,
selectedProjectGroup,
settings?.agentCmdOverrides,
settings?.agentDefaultArgs,
settings?.agentDefaultEnv,
settings?.autoRenameBranchFromWork,
telemetrySource
]
)
const submit = useCallback(async (): Promise<void> => {
if (isProjectGroupTarget) {
await submitFolderTarget(tuiAgent)
return
}
if (!repoId || !selectedRepo) {
showProjectRequiredError()
return
}
if (
!workspaceSeedName ||
selectedRepoRequiresConnection ||
shouldWaitForSetupCheck ||
shouldWaitForIssueAutomationCheck ||
(requiresExplicitSetupChoice && !setupDecision) ||
sparseError !== null
) {
return
}
if (!isTuiAgentEnabled(tuiAgent, disabledTuiAgents)) {
setTuiAgent(fallbackDefaultAgent)
toast.error(
translate(
'auto.hooks.useComposerState.7eb3f44ff7',
'Selected agent is disabled. Choose an enabled agent before creating.'
)
)
return
}
setCreateError(null)
setCreating(true)
try {
const smartGitHubResolution = await resolvePendingSmartGitHubSubmit()
const submitLinkedWorkItem =
smartGitHubResolution.kind === 'none'
? linkedWorkItem
: smartGitHubResolution.linkedWorkItem
const submitLinkedIssueNumber =
smartGitHubResolution.kind === 'none'
? parsedLinkedIssueNumber
: smartGitHubResolution.linkedIssueNumber
const submitLinkedPR =
smartGitHubResolution.kind === 'none' ? effectiveLinkedPR : smartGitHubResolution.linkedPR
const submitTitleName = submitLinkedWorkItem
? getLinkedWorkItemWorkspaceName(submitLinkedWorkItem)
: null
const nameIsAutoManaged = !isExplicitWorkspaceNameInput({
name,
lastAutoName: lastAutoNameRef.current
})
const smartGitHubCreateNames =
smartGitHubResolution.kind === 'none'
? { workspaceName: workspaceSeedName, displayName: undefined }
: resolveSmartGitHubCreateNames({
resolutionKind: smartGitHubResolution.kind,
smartWorkspaceName: smartGitHubResolution.workspaceName,
smartDisplayName: smartGitHubResolution.displayName,
fallbackWorkspaceName: workspaceSeedName,
nameIsAutoManaged
})
const workspaceName =
smartGitHubResolution.kind === 'none'
? nameIsAutoManaged && submitTitleName
? submitTitleName.seedName
: workspaceSeedName
: smartGitHubCreateNames.workspaceName
if (!workspaceName) {
return
}
const submitBaseBranch =
smartGitHubResolution.kind === 'pr-start-point'
? smartGitHubResolution.baseBranch
: smartGitHubResolution.kind === 'metadata-only' &&
(effectiveLinkedPR !== null || linkedGitLabMR !== null)
? undefined
: baseBranch
const submitCompareBaseRef =
smartGitHubResolution.kind === 'pr-start-point'
? smartGitHubResolution.compareBaseRef
: smartGitHubResolution.kind === 'none'
? compareBaseRef
: undefined
const submitPushTarget =
smartGitHubResolution.kind === 'pr-start-point'
? smartGitHubResolution.pushTarget
: smartGitHubResolution.kind === 'none'
? pushTarget
: undefined
const submitBranchNameOverride =
smartGitHubResolution.kind === 'pr-start-point'
? smartGitHubResolution.branchNameOverride
: smartGitHubResolution.kind === 'none'
? branchNameOverride
: undefined
const submitLinkedWorkItemProvider = submitLinkedWorkItem
? getLinkedWorkItemProvider(submitLinkedWorkItem)
: null
const submitShouldApplyLinkedOnlyTemplate =
enableIssueAutomation &&
!agentPrompt.trim() &&
Boolean(submitLinkedWorkItem) &&
hasLoadedIssueCommand &&
(submitLinkedWorkItemProvider !== 'linear' || issueCommandTemplate.trim().length > 0)
const submitLinkedOnlyTemplatePrompt =
submitShouldApplyLinkedOnlyTemplate && submitLinkedWorkItem
? renderIssueCommandTemplate(
issueCommandTemplate.trim() || DEFAULT_ISSUE_COMMAND_TEMPLATE,
{
// Why: Linear issues carry a placeholder number 0; {{issue}}
// must render the string identifier (e.g. "ENG-123") instead.
issueNumber:
submitLinkedWorkItem.linearIdentifier?.trim() ||
(submitLinkedWorkItem.type === 'issue' ? submitLinkedWorkItem.number : null),
artifactUrl: submitLinkedWorkItem.url
}
)
: ''
const linkedPromptContext = getLinkedWorkItemPromptContext(submitLinkedWorkItem)
const submitStartupPrompt = submitShouldApplyLinkedOnlyTemplate
? buildAgentPromptWithContext(
submitLinkedOnlyTemplatePrompt,
attachmentPaths,
[],
linkedPromptContext.linkedContextBlocks
)
: buildAgentPromptWithContext(
agentPrompt,
attachmentPaths,
linkedPromptContext.linkedUrls,
linkedPromptContext.linkedContextBlocks
)
const submitShouldRunIssueAutomation =
enableIssueAutomation &&
submitLinkedWorkItemProvider !== 'linear' &&
submitLinkedIssueNumber !== null &&
issueCommandTemplate.length > 0 &&
!submitShouldApplyLinkedOnlyTemplate
const setupTrustDecision = selectedRepoIsGit
? await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup')
: 'skip'
const effectiveSetupDecision: SetupDecision =
setupTrustDecision === 'skip'
? 'skip'
: ((resolvedSetupDecision ?? 'inherit') as SetupDecision)
let issueCommandTrustDecision: 'run' | 'skip' = 'run'
if (selectedRepoIsGit && submitShouldRunIssueAutomation) {
issueCommandTrustDecision =
setupTrustDecision === 'skip'
? 'skip'
: await ensureHooksConfirmed(useAppStore.getState(), repoId, 'issueCommand')
}
const linkedLinearIssue =
submitLinkedWorkItem && submitLinkedWorkItemProvider === 'linear'
? submitLinkedWorkItem.linearIdentifier
: undefined
const linkedLinearIssueWorkspaceId =
submitLinkedWorkItem && submitLinkedWorkItemProvider === 'linear'
? submitLinkedWorkItem.linearWorkspaceId
: undefined
const linkedLinearIssueOrganizationUrlKey =
submitLinkedWorkItem && submitLinkedWorkItemProvider === 'linear'
? submitLinkedWorkItem.linearOrganizationUrlKey
: undefined
const effectiveBranchNameOverride = resolveComposerBranchNameOverrideForCreate({
branchNameOverride: submitBranchNameOverride,
branchAutoName: branchAutoNameRef.current,
workspaceName,
preserveWorkspaceNameEdits:
smartGitHubResolution.kind === 'pr-start-point' || branchNameOverridePreservesNameEdits
})
const createDisplayName =
smartGitHubResolution.kind === 'none'
? nameIsAutoManaged
? submitTitleName?.displayName
: undefined
: smartGitHubCreateNames.displayName
// Why: the first-work hook only renames blank, auto-generated git workspaces
// that actually launch an agent. Persist that known-pending state for the card.
const pendingFirstAgentMessageRename =
selectedRepoIsGit &&
settings?.autoRenameBranchFromWork === true &&
!name.trim() &&
Boolean(tuiAgent) &&
!effectiveBranchNameOverride &&
!createDisplayName
const submitAgentArgs = resolveTuiAgentLaunchArgs(tuiAgent, settings?.agentDefaultArgs)
const submitAgentEnv = resolveTuiAgentLaunchEnv(tuiAgent, settings?.agentDefaultEnv)
// Why: linked source blocks should stay reviewable as drafts; do not send
// third-party Linear text as an already-submitted startup prompt.
const submitUsesDraftDelivery = linkedPromptContext.linkedContextBlocks.length > 0
let startupPlan: ReturnType<typeof buildAgentStartupPlan> = null
startupPlan = buildAgentStartupPlan({
agent: tuiAgent,
prompt: submitUsesDraftDelivery ? '' : submitStartupPrompt,
cmdOverrides: settings?.agentCmdOverrides ?? {},
agentArgs: submitAgentArgs,
agentEnv: submitAgentEnv,
platform: selectedRepoAgentLaunchPlatform,
isRemote: selectedRepoIsRemote,
allowEmptyPromptLaunch: submitUsesDraftDelivery
})
// Why: a user-typed prompt is explicit run intent — deliver as a paste
// (never argv, which would carry the untrusted block) and submit it.
// Context-only submits stay unsubmitted for review.
const submitDraftPromptSubmit = submitUsesDraftDelivery && agentPrompt.trim().length > 0
if (startupPlan && submitUsesDraftDelivery) {
startupPlan.draftPrompt = submitStartupPrompt
if (submitDraftPromptSubmit) {
startupPlan.draftPromptSubmit = true
}
}
const shouldSeedInitialAgentStatus =
tuiAgent === 'command-code' &&
(!submitUsesDraftDelivery || submitDraftPromptSubmit) &&
submitStartupPrompt.trim().length > 0
// Why: backend startup is safe only when the launch command is
// self-contained. Agents that need post-ready paste/follow-up stay on
// the renderer path so prompt delivery is not skipped.
const composerTelemetry: AgentStartedTelemetry = {
agent_kind: tuiAgentToAgentKind(tuiAgent),
launch_source: telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer',
request_kind: 'new'
}
const backendStartup =
startupPlan && !startupPlan.draftPrompt && !startupPlan.followupPrompt
? {
command: startupPlan.launchCommand,
...(startupPlan.env ? { env: startupPlan.env } : {}),
launchConfig: startupPlan.launchConfig,
launchAgent: tuiAgent,
...(startupPlan.startupCommandDelivery
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
: {}),
telemetry: composerTelemetry
}
: undefined
if (!(await persistSetupAgentStartupPolicy())) {
throw new Error(
translate(
'auto.hooks.useComposerState.setupAgentStartupPolicySaveFailed',
'Failed to save setup startup behavior.'
)
)
}
const result = await createWorktree(
repoId,
workspaceName,
selectedRepoIsGit ? submitBaseBranch : undefined,
effectiveSetupDecision,
selectedRepoIsGit && sparseEnabled
? {
directories: normalizedSparseDirectories,
...(effectivePresetId ? { presetId: effectivePresetId } : {})
}
: undefined,
telemetrySource,
createDisplayName,
submitLinkedIssueNumber ?? undefined,
submitLinkedPR ?? undefined,
submitPushTarget,
tuiAgent,
linkedLinearIssue,
effectiveBranchNameOverride,
resolvedInitialWorkspaceStatus,
smartGitHubResolution.kind === 'none' ? (linkedGitLabMR ?? undefined) : undefined,
smartGitHubResolution.kind === 'none' ? (linkedGitLabIssue ?? undefined) : undefined,
backendStartup,
pendingFirstAgentMessageRename,
undefined,
linkedLinearIssueWorkspaceId,
linkedLinearIssueOrganizationUrlKey,
undefined,
undefined,
undefined,
submitCompareBaseRef
)
const worktree = result.worktree
const trimmedNote = note.trim()
// Why: linked source metadata is already included in createWorktree.
// Re-saving it here can trigger slow post-create PR push-target lookups.
await applyWorktreeMeta(worktree.id, trimmedNote ? { comment: trimmedNote } : {})
const issueCommand =
submitShouldRunIssueAutomation && issueCommandTrustDecision === 'run'
? {
command: renderIssueCommandTemplate(issueCommandTemplate, {
issueNumber: submitLinkedIssueNumber,
artifactUrl: submitLinkedWorkItem?.url ?? null
})
}
: undefined
const backendSpawnedStartup = result.startupTerminal?.spawned === true
if (startupPlan && !backendSpawnedStartup && !startupPlan.launchToken) {
// Why: delayed delivery must target the exact pane spawned from this
// queued startup, so both halves share one renderer-session token.
startupPlan.launchToken = createBrowserUuid()
}
const activation = activateAndRevealWorktree(worktree.id, {
sidebarRevealBehavior: 'auto',
setup: result.setup,
defaultTabs: result.defaultTabs,
issueCommand,
...(startupPlan && !backendSpawnedStartup
? {
startup: {
command: startupPlan.launchCommand,
...(startupPlan.env ? { env: startupPlan.env } : {}),
launchConfig: startupPlan.launchConfig,
...(startupPlan.launchToken ? { launchToken: startupPlan.launchToken } : {}),
launchAgent: tuiAgent,
...(startupPlan.draftPrompt ? { draftPrompt: startupPlan.draftPrompt } : {}),
...(startupPlan.draftPromptSubmit ? { draftPromptSubmit: true } : {}),
...(startupPlan.startupCommandDelivery
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
: {}),
...(shouldSeedInitialAgentStatus
? {
initialAgentStatus: {
agent: tuiAgent,
prompt: submitStartupPrompt.trim()
}
}
: {}),
telemetry: composerTelemetry
}
}
: {})
})
if (startupPlan && !backendSpawnedStartup) {
void ensureAgentStartupInTerminal({
worktreeId: worktree.id,
primaryTabId: activation === false ? null : activation.primaryTabId,
startup: startupPlan
})
}
setSidebarOpen(true)
if (persistDraft) {
clearNewWorkspaceDraft()
}
onCreated?.()
queueNewWorkspaceTerminalFocus(worktree.id, activation)
} catch (error) {
const formattedError = formatWorkspaceCreateError(error)
setCreateError(formattedError)
toast.error(getWorkspaceCreateErrorToastMessage(formattedError))
} finally {
setCreating(false)
}
}, [
agentPrompt,
attachmentPaths,
baseBranch,
branchNameOverride,
branchNameOverridePreservesNameEdits,
clearNewWorkspaceDraft,
compareBaseRef,
createWorktree,
applyWorktreeMeta,
enableIssueAutomation,
issueCommandTemplate,
effectiveLinkedPR,
hasLoadedIssueCommand,
linkedGitLabIssue,
linkedGitLabMR,
linkedWorkItem,
name,
normalizedSparseDirectories,
note,
onCreated,
parsedLinkedIssueNumber,
persistSetupAgentStartupPolicy,
persistDraft,
pushTarget,
repoId,
requiresExplicitSetupChoice,
resolvePendingSmartGitHubSubmit,
resolvedSetupDecision,
resolvedInitialWorkspaceStatus,
selectedRepo,
selectedRepoAgentLaunchPlatform,
selectedRepoIsRemote,
selectedRepoIsGit,
selectedRepoRequiresConnection,
showProjectRequiredError,
settings?.agentCmdOverrides,
settings?.agentDefaultArgs,
settings?.agentDefaultEnv,
settings?.autoRenameBranchFromWork,
setSidebarOpen,
setupDecision,
sparseEnabled,
sparseError,
effectivePresetId,
telemetrySource,
fallbackDefaultAgent,
disabledTuiAgents,
tuiAgent,
shouldWaitForIssueAutomationCheck,
shouldWaitForSetupCheck,
workspaceSeedName,
isProjectGroupTarget,
submitFolderTarget
])
const resetForNextCreate = useCallback(() => {
// Why: with "create multiple" on, clear identity fields after each create so
// the next worktree starts clean. Context (repo, base branch, agent, project
// group) is intentionally retained for fast sequential creation. The
// PR-pick-derived refs (compare base, push target, branch override) are
// identity, not durable context, so they reset too — leaving them while the
// linked item is cleared would carry a half-set Start-from state (e.g. a
// silent fork push target) into the next worktree.
setName('')
lastAutoNameRef.current = ''
setAgentPrompt('')
setNote('')
setAttachmentPaths([])
setLinkedWorkItem(null)
setLinkedIssue('')
setLinkedPR(null)
setLinkedGitLabIssue(null)
setLinkedGitLabMR(null)
setBranchNameOverride(undefined)
setBranchNameOverridePreservesNameEdits(false)
setCompareBaseRef(undefined)
setPushTarget(undefined)
setReuseSelectedBranch(false)
setStartFromResetHint(null)
setForkPushWarning(null)
setCreateError(null)
// Refocus the name field on the next frame (after the reset re-render) so the
// user can immediately type the next worktree name.
requestAnimationFrame(() => nameInputRef.current?.focus())
}, [])
const submitQuick = useCallback(
async (requestedAgent: TuiAgent | null): Promise<void> => {
if (isProjectGroupTarget) {
await submitFolderTarget(requestedAgent)
return
}
const workspaceNameSeed = getWorkspaceSeedName({
explicitName: name,
prompt: '',
linkedIssueNumber: parsedLinkedIssueNumber,
linkedPR,
fallbackName: fallbackCreatureName
})
if (!repoId || !selectedRepo) {
showProjectRequiredError()
return
}
if (
!workspaceNameSeed ||
selectedRepoRequiresConnection ||
(requiresExplicitSetupChoice && !setupDecision) ||
sparseError !== null
) {
return
}
setCreateError(null)
setCreating(true)
try {
const smartGitHubResolution = await resolvePendingSmartGitHubSubmit()
const submitLinkedWorkItem =
smartGitHubResolution.kind === 'none'
? linkedWorkItem
: smartGitHubResolution.linkedWorkItem
const agent =
requestedAgent && isTuiAgentEnabled(requestedAgent, disabledTuiAgents)
? requestedAgent
: null
const submitLinkedIssueNumber =
smartGitHubResolution.kind === 'none'
? parsedLinkedIssueNumber
: smartGitHubResolution.linkedIssueNumber
const submitLinkedPR =
smartGitHubResolution.kind === 'none' ? effectiveLinkedPR : smartGitHubResolution.linkedPR
const submitTitleName = submitLinkedWorkItem
? getLinkedWorkItemWorkspaceName(submitLinkedWorkItem)
: null
const nameIsAutoManaged = !isExplicitWorkspaceNameInput({
name,
lastAutoName: lastAutoNameRef.current
})
const smartGitHubCreateNames =
smartGitHubResolution.kind === 'none'
? { workspaceName: workspaceNameSeed, displayName: undefined }
: resolveSmartGitHubCreateNames({
resolutionKind: smartGitHubResolution.kind,
smartWorkspaceName: smartGitHubResolution.workspaceName,
smartDisplayName: smartGitHubResolution.displayName,
fallbackWorkspaceName: workspaceNameSeed,
nameIsAutoManaged
})
const workspaceName =
smartGitHubResolution.kind === 'none'
? nameIsAutoManaged && submitTitleName
? submitTitleName.seedName
: workspaceNameSeed
: smartGitHubCreateNames.workspaceName
if (!workspaceName) {
return
}
const smartSubmitBaseBranch =
smartGitHubResolution.kind === 'pr-start-point'
? smartGitHubResolution.baseBranch
: smartGitHubResolution.kind === 'metadata-only' &&
(effectiveLinkedPR !== null || linkedGitLabMR !== null)
? undefined
: baseBranch
const submitCompareBaseRef =
smartGitHubResolution.kind === 'pr-start-point'
? smartGitHubResolution.compareBaseRef
: smartGitHubResolution.kind === 'none'
? compareBaseRef
: undefined
const submitPushTarget =
smartGitHubResolution.kind === 'pr-start-point'
? smartGitHubResolution.pushTarget
: smartGitHubResolution.kind === 'none'
? pushTarget
: undefined
const submitBranchNameOverride =
smartGitHubResolution.kind === 'pr-start-point'
? smartGitHubResolution.branchNameOverride
: smartGitHubResolution.kind === 'none'
? branchNameOverride
: undefined
let submitSetupConfig = setupConfig
let submitResolvedSetupDecision = resolvedSetupDecision
if (selectedRepoIsGit && checkedHooksRepoId !== repoId) {
let hookCheck: HookCheckResult
try {
hookCheck = await loadHookCheckForRepo(repoId)
} catch {
hookCheck = { hasHooks: false, hooks: null, mayNeedUpdate: false }
}
if (!commitHookCheckIfCurrent(repoId, hookCheck.hooks)) {
return
}
submitSetupConfig = getSetupConfig(selectedRepo, hookCheck.hooks)
submitResolvedSetupDecision =
setupDecision ??
(!submitSetupConfig || setupPolicy === 'ask'
? null
: setupPolicy === 'run-by-default'
? 'run'
: 'skip')
}
if (selectedRepoIsGit && submitSetupConfig && setupPolicy === 'ask' && !setupDecision) {
setAdvancedOpen(true)
return
}
const trustDecision = selectedRepoIsGit
? await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup')
: 'skip'
const effectiveSetupDecision: SetupDecision =
trustDecision === 'skip'
? 'skip'
: ((submitResolvedSetupDecision ?? 'inherit') as SetupDecision)
const submitLinkedWorkItemProvider = submitLinkedWorkItem
? getLinkedWorkItemProvider(submitLinkedWorkItem)
: null
const linkedLinearIssue =
submitLinkedWorkItem && submitLinkedWorkItemProvider === 'linear'
? submitLinkedWorkItem.linearIdentifier
: undefined
const linkedLinearIssueWorkspaceId =
submitLinkedWorkItem && submitLinkedWorkItemProvider === 'linear'
? submitLinkedWorkItem.linearWorkspaceId
: undefined
const linkedLinearIssueOrganizationUrlKey =
submitLinkedWorkItem && submitLinkedWorkItemProvider === 'linear'
? submitLinkedWorkItem.linearOrganizationUrlKey
: undefined
const effectiveBranchNameOverride = resolveComposerBranchNameOverrideForCreate({
branchNameOverride: submitBranchNameOverride,
branchAutoName: branchAutoNameRef.current,
workspaceName,
preserveWorkspaceNameEdits:
smartGitHubResolution.kind === 'pr-start-point' || branchNameOverridePreservesNameEdits
})
const submitBaseBranch = selectedRepoIsGit
? await resolveWorktreeCreateBaseBranch({
explicitBaseBranch: smartSubmitBaseBranch,
repoWorktreeBaseRef: selectedRepo.worktreeBaseRef,
loadDefaultBaseRef: async () =>
(await getRuntimeRepoBaseRefDefault(selectedRepoSettings, repoId).catch(() => null))
?.defaultBaseRef
})
: undefined
const createDisplayName =
smartGitHubResolution.kind === 'none'
? nameIsAutoManaged
? submitTitleName?.displayName
: undefined
: smartGitHubCreateNames.displayName
// Why: quick create uses the same blank-name creature branch flow; the card
// needs an explicit marker rather than guessing from the generated title.
const pendingFirstAgentMessageRename =
selectedRepoIsGit &&
settings?.autoRenameBranchFromWork === true &&
!name.trim() &&
Boolean(agent) &&
!effectiveBranchNameOverride &&
!createDisplayName
const trimmedNote = note.trim()
// Why: backend startup is safe only when the launch command is
// self-contained. Agents that need post-ready paste/follow-up stay on
// the renderer path so prompt delivery is not skipped.
const promptLinkedWorkItem = agent === null ? null : submitLinkedWorkItem
const { prompt: quickPrompt, draftPrompt: quickDraftPrompt } =
resolveQuickCreateLinkedWorkItemPrompt(promptLinkedWorkItem, trimmedNote)
const quickUsesLinearSourceDraft = promptLinkedWorkItem
? getLinkedWorkItemProvider(promptLinkedWorkItem) === 'linear'
: false
const draftLaunchPlan =
agent === null || !quickDraftPrompt || quickUsesLinearSourceDraft
? null
: buildAgentDraftLaunchPlan({
agent,
draft: quickDraftPrompt,
cmdOverrides: settings?.agentCmdOverrides ?? {},
agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs),
agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv),
platform: selectedRepoAgentLaunchPlatform,
isRemote: selectedRepoIsRemote
})
let startupPlan: ReturnType<typeof buildAgentStartupPlan> = null
if (draftLaunchPlan) {
startupPlan = {
agent: draftLaunchPlan.agent,
launchCommand: draftLaunchPlan.launchCommand,
expectedProcess: draftLaunchPlan.expectedProcess,
followupPrompt: null,
launchConfig: draftLaunchPlan.launchConfig,
...(draftLaunchPlan.startupCommandDelivery
? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery }
: {}),
...(draftLaunchPlan.env ? { env: draftLaunchPlan.env } : {})
}
} else if (agent !== null) {
startupPlan = buildAgentStartupPlan({
agent,
prompt: quickPrompt,
cmdOverrides: settings?.agentCmdOverrides ?? {},
agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs),
agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv),
platform: selectedRepoAgentLaunchPlatform,
isRemote: selectedRepoIsRemote,
allowEmptyPromptLaunch: true
})
if (startupPlan && quickDraftPrompt) {
startupPlan.draftPrompt = quickDraftPrompt
}
}
const quickTelemetry: AgentStartedTelemetry | null =
agent === null
? null
: {
agent_kind: tuiAgentToAgentKind(agent),
launch_source:
telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer',
request_kind: 'new'
}
const backendStartup =
startupPlan && !startupPlan.draftPrompt && !startupPlan.followupPrompt
? {
command: startupPlan.launchCommand,
...(startupPlan.env ? { env: startupPlan.env } : {}),
launchConfig: startupPlan.launchConfig,
...(agent ? { launchAgent: agent } : {}),
...(startupPlan.startupCommandDelivery
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
: {}),
...(quickTelemetry ? { telemetry: quickTelemetry } : {})
}
: undefined
if (!(await persistSetupAgentStartupPolicy())) {
throw new Error(
translate(
'auto.hooks.useComposerState.setupAgentStartupPolicySaveFailed',
'Failed to save setup startup behavior.'
)
)
}
let creationWorkspaceRunContext: WorktreeCreationRequest['workspaceRunContext'] =
selectedWorkspaceTarget.status === 'ready'
? {
kind: 'workspace-run',
projectId: selectedWorkspaceTarget.target.projectId,
hostId: selectedWorkspaceTarget.target.hostId,
projectHostSetupId: selectedWorkspaceTarget.target.projectHostSetupId,
repoId: selectedWorkspaceTarget.target.repoId,
path: selectedWorkspaceTarget.target.repo.path
}
: null
let ephemeralVmRecipe: WorktreeCreationRequest['ephemeralVmRecipe']
const activeEphemeralVmRecipeId = ephemeralVmsEnabled ? selectedEphemeralVmRecipeId : null
if (activeEphemeralVmRecipeId && selectedWorkspaceTarget.status === 'ready') {
const vmRecipeTrustDecision = await ensureHooksConfirmed(
useAppStore.getState(),
repoId,
'vmRecipe'
)
if (vmRecipeTrustDecision === 'skip') {
return
}
ephemeralVmRecipe = {
sourceRepoId: repoId,
recipeId: activeEphemeralVmRecipeId,
projectId: selectedWorkspaceTarget.target.projectId
}
}
const request: WorktreeCreationRequest = {
repoId,
...(ephemeralVmRecipe ? { ephemeralVmRecipe } : {}),
worktreeCreateProgressMode:
activeEphemeralVmRecipeId ||
getActiveRuntimeTarget(selectedRepoSettings).kind !== 'local'
? 'indeterminate'
: 'stepped',
...(taskSourceContext ? { taskSourceContext } : {}),
...(creationWorkspaceRunContext
? { workspaceRunContext: creationWorkspaceRunContext }
: {}),
name: workspaceName,
...(createDisplayName ? { displayName: createDisplayName } : {}),
...(selectedRepoIsGit && submitBaseBranch ? { baseBranch: submitBaseBranch } : {}),
...(selectedRepoIsGit && submitCompareBaseRef
? { compareBaseRef: submitCompareBaseRef }
: {}),
setupDecision: effectiveSetupDecision,
...(selectedRepoIsGit && sparseEnabled
? {
sparseCheckout: {
directories: normalizedSparseDirectories,
...(effectivePresetId ? { presetId: effectivePresetId } : {})
}
}
: {}),
...(telemetrySource ? { telemetrySource } : {}),
...(submitLinkedIssueNumber != null ? { linkedIssue: submitLinkedIssueNumber } : {}),
...(submitLinkedPR != null ? { linkedPR: submitLinkedPR } : {}),
...(submitPushTarget ? { pushTarget: submitPushTarget } : {}),
agent,
...(linkedLinearIssue ? { linkedLinearIssue } : {}),
...(linkedLinearIssueWorkspaceId !== undefined ? { linkedLinearIssueWorkspaceId } : {}),
...(linkedLinearIssueOrganizationUrlKey !== undefined
? { linkedLinearIssueOrganizationUrlKey }
: {}),
...(effectiveBranchNameOverride
? { branchNameOverride: effectiveBranchNameOverride }
: {}),
...(resolvedInitialWorkspaceStatus
? { workspaceStatus: resolvedInitialWorkspaceStatus }
: {}),
...(smartGitHubResolution.kind === 'none' && linkedGitLabMR != null
? { linkedGitLabMR }
: {}),
...(smartGitHubResolution.kind === 'none' && linkedGitLabIssue != null
? { linkedGitLabIssue }
: {}),
...(backendStartup ? { startup: backendStartup } : {}),
pendingFirstAgentMessageRename,
note: trimmedNote,
startupPlan,
quickPrompt,
quickTelemetry,
...(createMultiple ? { suppressTerminalFocusOnCompletion: true } : {})
}
// Why: git fetch + `git worktree add` can take 10–15s; holding the modal
// hostage to that made it feel frozen, so hand off to a background flow and
// close the modal immediately.
if (persistDraft) {
clearNewWorkspaceDraft()
}
runBackgroundWorktreeCreation(request)
if (createMultiple) {
// Why: keep the modal open and reset identity so the user can queue
// another worktree right away; the creation above runs in the background.
resetForNextCreate()
} else {
onCreated?.()
}
} catch (error) {
const formattedError = formatWorkspaceCreateError(error)
setCreateError(formattedError)
toast.error(getWorkspaceCreateErrorToastMessage(formattedError))
} finally {
setCreating(false)
}
},
[
baseBranch,
compareBaseRef,
branchNameOverride,
branchNameOverridePreservesNameEdits,
clearNewWorkspaceDraft,
fallbackCreatureName,
effectiveLinkedPR,
linkedGitLabIssue,
linkedGitLabMR,
linkedPR,
linkedWorkItem,
name,
normalizedSparseDirectories,
note,
onCreated,
parsedLinkedIssueNumber,
persistSetupAgentStartupPolicy,
persistDraft,
pushTarget,
repoId,
requiresExplicitSetupChoice,
resolvePendingSmartGitHubSubmit,
resolvedSetupDecision,
resolvedInitialWorkspaceStatus,
selectedRepo,
selectedRepoAgentLaunchPlatform,
selectedRepoIsRemote,
selectedRepoIsGit,
selectedRepoSettings,
selectedRepoRequiresConnection,
selectedWorkspaceTarget,
selectedEphemeralVmRecipeId,
ephemeralVmsEnabled,
showProjectRequiredError,
settings?.agentCmdOverrides,
settings?.agentDefaultArgs,
settings?.agentDefaultEnv,
settings?.autoRenameBranchFromWork,
disabledTuiAgents,
setupDecision,
sparseEnabled,
sparseError,
effectivePresetId,
telemetrySource,
taskSourceContext,
checkedHooksRepoId,
commitHookCheckIfCurrent,
loadHookCheckForRepo,
setupConfig,
setupPolicy,
isProjectGroupTarget,
submitFolderTarget,
createMultiple,
resetForNextCreate
]
)
const createGateInput = {
repoId,
workspaceSeedName,
creating,
shouldWaitForSetupCheck,
shouldWaitForIssueAutomationCheck,
requiresExplicitSetupChoice,
hasSetupDecision: Boolean(setupDecision),
selectedRepoRequiresConnection,
sparseError
}
const repoCreateDisabled =
createGateMode === 'quick'
? getQuickComposerCreateDisabled(createGateInput)
: getFullComposerCreateDisabled(createGateInput)
const createDisabled = isProjectGroupTarget ? folderCreateDisabled : repoCreateDisabled
const cardProps: ComposerCardProps = {
eligibleRepos: isProjectGroupTarget ? folderSourceRepos : eligibleRepos,
repoId,
projectOptions,
selectedProjectId,
selectedRepoIsGit: isProjectGroupTarget ? true : selectedRepoIsGit,
onRepoChange: isProjectGroupTarget ? handleFolderSourceRepoChange : handleRepoChange,
onProjectChange: handleProjectChange,
projectHostSetupOptions: isProjectGroupTarget ? [] : projectHostSetupOptions,
selectedProjectHostSetupId: isProjectGroupTarget ? null : selectedProjectHostSetupId,
onProjectHostSetupChange: handleProjectHostSetupChange,
ephemeralVmRecipes: isProjectGroupTarget || !ephemeralVmsEnabled ? [] : ephemeralVmRecipes,
selectedEphemeralVmRecipeId:
isProjectGroupTarget || !ephemeralVmsEnabled ? null : selectedEphemeralVmRecipeId,
onEphemeralVmRecipeChange: setSelectedEphemeralVmRecipeId,
ephemeralVmRecipeError:
isProjectGroupTarget || !ephemeralVmsEnabled ? null : ephemeralVmRecipeError,
repoBackedSearchRepos: isProjectGroupTarget ? folderSourceRepos : undefined,
repoBackedSourcesDisabled: isProjectGroupTarget ? folderSourceRepos.length === 0 : false,
allowSmartNameAddProject: !isProjectGroupTarget,
smartNameRepoSwitchTarget: isProjectGroupTarget ? 'task-source' : 'project',
name,
onNameValueChange: handleNameValueChange,
onSmartGitHubItemSelect: handleSmartGitHubItemSelect,
onSmartGitLabItemSelect: handleSmartGitLabItemSelect,
onSmartBranchSelect: isProjectGroupTarget ? () => {} : handleSmartBranchSelect,
onSmartLinearIssueSelect: handleSmartLinearIssueSelect,
smartNameGitHubSourceContext: selectedRepoGitHubSourceContext,
smartNameSelection,
onClearSmartNameSelection: handleClearSmartNameSelection,
canReuseSelectedBranch:
!isProjectGroupTarget &&
reuseEligibleBranch !== null &&
smartNameSelection?.kind === 'branch',
reuseSelectedBranch,
onReuseSelectedBranchChange: handleReuseSelectedBranchChange,
// Why: the "create multiple" toggle only applies to worktree (git) targets;
// folder-workspace targets keep the create-and-close behavior.
showCreateMultiple: !isProjectGroupTarget,
createMultiple,
onCreateMultipleChange: setCreateMultiple,
agentPrompt,
onAgentPromptChange: setAgentPrompt,
linkedOnlyTemplatePreview: shouldApplyLinkedOnlyTemplate ? linkedOnlyTemplatePrompt : null,
attachmentPaths,
getAttachmentLabel,
onAddAttachment: () => void handleAddAttachment(),
onRemoveAttachment: (pathValue) =>
setAttachmentPaths((current) => current.filter((currentPath) => currentPath !== pathValue)),
linkedWorkItem,
onRemoveLinkedWorkItem: handleRemoveLinkedWorkItem,
linkPopoverOpen,
onLinkPopoverOpenChange: handleLinkPopoverChange,
linkQuery,
onLinkQueryChange: setLinkQuery,
filteredLinkItems,
linkItemsLoading,
linkDirectLoading,
normalizedLinkQuery,
onSelectLinkedItem: handleSelectLinkedItem,
tuiAgent,
onTuiAgentChange: setTuiAgent,
detectedAgentIds: isProjectGroupTarget ? folderDetectedAgentIds : detectedAgentIds,
onOpenAgentSettings: handleOpenAgentSettings,
advancedOpen,
onToggleAdvanced: () => setAdvancedOpen((current) => !current),
createDisabled,
projectError: isProjectGroupTarget ? pathStatusProjectError : projectError,
creating,
onCreate: () => void submit(),
baseBranch: isProjectGroupTarget ? undefined : baseBranch,
onBaseBranchChange: isProjectGroupTarget ? () => {} : handleBaseBranchChange,
onBaseBranchPrSelect: isProjectGroupTarget ? () => {} : handleBaseBranchPrSelect,
onBaseBranchMrSelect: isProjectGroupTarget ? () => {} : handleBaseBranchMrSelect,
baseBranchLinkedPrNumber:
linkedWorkItem?.type === 'pr' && baseBranch ? linkedWorkItem.number : null,
selectedRepoPath: isProjectGroupTarget ? null : (selectedRepo?.path ?? null),
selectedRepoIsRemote: isProjectGroupTarget
? folderTargetIsRemote
: Boolean(selectedRepo?.connectionId),
selectedRepoConnectionId: isProjectGroupTarget
? folderTargetConnectionId
: selectedRepoConnectionId,
selectedRepoSshStatus: isProjectGroupTarget ? folderTargetSshStatus : selectedRepoSshStatus,
selectedRepoRequiresConnection: isProjectGroupTarget
? folderTargetRequiresConnection
: selectedRepoRequiresConnection,
selectedRepoConnectInProgress: isProjectGroupTarget
? folderTargetConnectInProgress
: selectedRepoConnectInProgress,
onConnectSelectedRepo: isProjectGroupTarget
? onConnectSelectedProjectGroup
: onConnectSelectedRepo,
startFromResetHint: isProjectGroupTarget ? null : startFromResetHint,
forkPushWarning: isProjectGroupTarget ? null : forkPushWarning,
note,
onNoteChange: setNote,
setupConfig: isProjectGroupTarget ? null : setupConfig,
requiresExplicitSetupChoice: isProjectGroupTarget ? false : requiresExplicitSetupChoice,
setupDecision: isProjectGroupTarget ? null : setupDecision,
onSetupDecisionChange: isProjectGroupTarget ? () => {} : setSetupDecision,
setupAgentStartupPolicy: isProjectGroupTarget ? 'start-immediately' : setupAgentStartupPolicy,
onSetupAgentStartupPolicyChange: isProjectGroupTarget
? () => {}
: handleSetupAgentStartupPolicyChange,
shouldWaitForSetupCheck: isProjectGroupTarget ? false : shouldWaitForSetupCheck,
resolvedSetupDecision: isProjectGroupTarget ? null : resolvedSetupDecision,
createError,
canUseSparseCheckout: isProjectGroupTarget
? false
: selectedRepoIsGit && !selectedRepo?.connectionId,
sparsePresets: isProjectGroupTarget ? [] : sparsePresets,
sparseSelectedPresetId: isProjectGroupTarget ? null : sparseSelectedPresetId,
onSparseSelectPreset: isProjectGroupTarget ? () => {} : handleSparseSelectPreset,
branchesEnabled: !isProjectGroupTarget,
setupControlsEnabled: !isProjectGroupTarget,
sparseControlsEnabled: !isProjectGroupTarget
}
return {
cardProps,
composerRef,
onComposerNodeChange: handleComposerNodeChange,
promptTextareaRef,
nameInputRef,
submit,
submitQuick,
createDisabled
}
}