1688 lines
61 KiB
TypeScript
1688 lines
61 KiB
TypeScript
/* 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. */
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { toast } from 'sonner'
|
|
import { useShallow } from 'zustand/react/shallow'
|
|
import { useAppStore } from '@/store'
|
|
import { AGENT_CATALOG } from '@/lib/agent-catalog'
|
|
import {
|
|
parseGitHubIssueOrPRNumber,
|
|
parseGitHubIssueOrPRLink,
|
|
normalizeGitHubLinkQuery
|
|
} from '@/lib/github-links'
|
|
import { activateAndRevealWorktree, type AgentStartedTelemetry } from '@/lib/worktree-activation'
|
|
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup'
|
|
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
|
|
import { tuiAgentToAgentKind } from '@/lib/telemetry'
|
|
import { isGitRepoKind } from '../../../shared/repo-kind'
|
|
import type {
|
|
GitHubWorkItem,
|
|
LinearIssue,
|
|
OrcaHooks,
|
|
SetupDecision,
|
|
SetupRunPolicy,
|
|
SparsePreset,
|
|
TuiAgent,
|
|
WorkspaceCreateTelemetrySource
|
|
} from '../../../shared/types'
|
|
import {
|
|
ADD_ATTACHMENT_SHORTCUT,
|
|
CLIENT_PLATFORM,
|
|
DEFAULT_ISSUE_COMMAND_TEMPLATE,
|
|
IS_MAC,
|
|
buildAgentPromptWithContext,
|
|
ensureAgentStartupInTerminal,
|
|
getAttachmentLabel,
|
|
getLinkedWorkItemSuggestedName,
|
|
getSetupConfig,
|
|
getWorkspaceSeedName,
|
|
PER_REPO_FETCH_LIMIT,
|
|
renderIssueCommandTemplate,
|
|
type LinkedWorkItemSummary
|
|
} from '@/lib/new-workspace'
|
|
import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-suggestions'
|
|
import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField'
|
|
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
|
|
import { normalizeSparseDirectoryLines, sparseDirectoriesMatch } from '@/lib/sparse-paths'
|
|
|
|
export type UseComposerStateOptions = {
|
|
initialRepoId?: string
|
|
initialName?: string
|
|
initialPrompt?: string
|
|
initialLinkedWorkItem?: LinkedWorkItemSummary | null
|
|
/** 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
|
|
}
|
|
|
|
export type ComposerCardProps = {
|
|
eligibleRepos: ReturnType<typeof useAppStore.getState>['repos']
|
|
repoId: string
|
|
onRepoChange: (value: string) => void
|
|
name: string
|
|
onNameValueChange: (value: string) => void
|
|
onSmartGitHubItemSelect: (item: GitHubWorkItem) => void
|
|
onSmartBranchSelect: (refName: string) => void
|
|
onSmartLinearIssueSelect: (issue: LinearIssue) => void
|
|
smartNameSelection: SmartWorkspaceNameSelection | null
|
|
onClearSmartNameSelection: () => void
|
|
agentPrompt: string
|
|
onAgentPromptChange: (value: string) => void
|
|
onPromptKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => 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
|
|
addAttachmentShortcut: string
|
|
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
|
|
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) => 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; disables the PR tab in v1. */
|
|
selectedRepoIsRemote: 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
|
|
setupConfig: { source: 'yaml' | 'legacy'; command: string } | null
|
|
requiresExplicitSetupChoice: boolean
|
|
setupDecision: 'run' | 'skip' | null
|
|
onSetupDecisionChange: (value: 'run' | 'skip') => void
|
|
shouldWaitForSetupCheck: boolean
|
|
resolvedSetupDecision: 'run' | 'skip' | null
|
|
createError: string | 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
|
|
}
|
|
|
|
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>
|
|
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
|
|
}
|
|
|
|
// 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,
|
|
initialName = '',
|
|
initialPrompt = '',
|
|
initialLinkedWorkItem = null,
|
|
initialBaseBranch,
|
|
persistDraft,
|
|
onCreated,
|
|
repoIdOverride,
|
|
onRepoIdOverrideChange,
|
|
telemetrySource
|
|
} = 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,
|
|
updateWorktreeMeta: s.updateWorktreeMeta,
|
|
setSidebarOpen: s.setSidebarOpen,
|
|
setRightSidebarOpen: s.setRightSidebarOpen,
|
|
setRightSidebarTab: s.setRightSidebarTab,
|
|
closeModal: s.closeModal,
|
|
openSettingsPage: s.openSettingsPage,
|
|
openSettingsTarget: s.openSettingsTarget,
|
|
prefetchWorkItems: s.prefetchWorkItems,
|
|
fetchSparsePresets: s.fetchSparsePresets
|
|
}))
|
|
)
|
|
const {
|
|
setNewWorkspaceDraft,
|
|
clearNewWorkspaceDraft,
|
|
createWorktree,
|
|
updateWorktreeMeta,
|
|
setSidebarOpen,
|
|
setRightSidebarOpen,
|
|
setRightSidebarTab,
|
|
closeModal,
|
|
openSettingsPage,
|
|
openSettingsTarget,
|
|
prefetchWorkItems,
|
|
fetchSparsePresets
|
|
} = actions
|
|
|
|
const repos = useAppStore((s) => s.repos)
|
|
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 eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos])
|
|
const draftRepoId = persistDraft ? (newWorkspaceDraft?.repoId ?? null) : null
|
|
|
|
const resolvedInitialRepoId =
|
|
draftRepoId && eligibleRepos.some((repo) => repo.id === draftRepoId)
|
|
? draftRepoId
|
|
: initialRepoId && eligibleRepos.some((repo) => repo.id === initialRepoId)
|
|
? initialRepoId
|
|
: activeRepoId && eligibleRepos.some((repo) => repo.id === activeRepoId)
|
|
? activeRepoId
|
|
: (eligibleRepos[0]?.id ?? '')
|
|
|
|
const [internalRepoId, setInternalRepoId] = useState<string>(resolvedInitialRepoId)
|
|
const repoId = repoIdOverride ?? internalRepoId
|
|
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 [linkedIssue, setLinkedIssue] = useState<string>(() => {
|
|
if (persistDraft && newWorkspaceDraft?.linkedIssue) {
|
|
return newWorkspaceDraft.linkedIssue
|
|
}
|
|
if (initialLinkedWorkItem?.type === 'issue') {
|
|
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
|
|
})
|
|
const [baseBranch, setBaseBranch] = useState<string | undefined>(
|
|
persistDraft ? newWorkspaceDraft?.baseBranch : initialBaseBranch
|
|
)
|
|
// 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: 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 fallbackDefaultAgent: TuiAgent =
|
|
settings?.defaultTuiAgent && settings.defaultTuiAgent !== 'blank'
|
|
? settings.defaultTuiAgent
|
|
: '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. Derived from eligibleRepos directly because selectedRepo
|
|
// is declared later in this function.
|
|
const connectionId = eligibleRepos.find((r) => r.id === repoId)?.connectionId ?? null
|
|
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 [creating, setCreating] = useState(false)
|
|
const [createError, setCreateError] = useState<string | null>(null)
|
|
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>(
|
|
persistDraft ? (newWorkspaceDraft?.name ?? initialName) : initialName
|
|
)
|
|
// 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)
|
|
useEffect(() => {
|
|
noteRef.current = note
|
|
}, [note])
|
|
const composerRef = useRef<HTMLDivElement | null>(null)
|
|
const promptTextareaRef = useRef<HTMLTextAreaElement | 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 selectedRepo = eligibleRepos.find((repo) => repo.id === repoId)
|
|
|
|
// 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
|
|
useEffect(() => {
|
|
if (!selectedRepoPath) {
|
|
setSelectedRepoSlug(null)
|
|
return
|
|
}
|
|
let cancelled = false
|
|
void (
|
|
window.api.gh.repoSlug({ repoPath: selectedRepoPath }) as Promise<{
|
|
owner: string
|
|
repo: string
|
|
} | null>
|
|
)
|
|
.then((result) => {
|
|
if (cancelled) {
|
|
return
|
|
}
|
|
setSelectedRepoSlug(result)
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) {
|
|
setSelectedRepoSlug(null)
|
|
}
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [selectedRepoPath])
|
|
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 (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, 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(
|
|
() => getSetupConfig(selectedRepo, yamlHooks),
|
|
[selectedRepo, yamlHooks]
|
|
)
|
|
const setupPolicy: SetupRunPolicy = selectedRepo?.hookSettings?.setupRunPolicy ?? 'run-by-default'
|
|
const hasIssueAutomationConfig = issueCommandTemplate.length > 0
|
|
const canOfferIssueAutomation = parsedLinkedIssueNumber !== null && hasIssueAutomationConfig
|
|
// Why: the "no prompt + linked item" path below rehydrates the issueCommand
|
|
// template into the main startup prompt. When that happens we suppress the
|
|
// separate split pane that would otherwise run the same command twice.
|
|
const willApplyIssueCommandAsPrompt = !agentPrompt.trim() && Boolean(linkedWorkItem)
|
|
const shouldWaitForIssueAutomationCheck =
|
|
(parsedLinkedIssueNumber !== null || willApplyIssueCommandAsPrompt) && !hasLoadedIssueCommand
|
|
const shouldRunIssueAutomation = canOfferIssueAutomation && !willApplyIssueCommandAsPrompt
|
|
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) && isSetupCheckPending
|
|
|
|
// Why: when the user leaves the workspace name blank and provides no other
|
|
// seed source (prompt, linked issue/PR), pick a repo-scoped unique marine
|
|
// creature name so the workspace gets a distinct, readable identifier
|
|
// instead of colliding on a literal "workspace" default.
|
|
const fallbackCreatureName = useMemo(
|
|
() => getSuggestedCreatureName(repoId, worktreesByRepo, settings?.nestWorkspaces ?? true),
|
|
[repoId, worktreesByRepo, settings?.nestWorkspaces]
|
|
)
|
|
const workspaceSeedName = useMemo(
|
|
() =>
|
|
getWorkspaceSeedName({
|
|
explicitName: name,
|
|
prompt: agentPrompt,
|
|
linkedIssueNumber: parsedLinkedIssueNumber,
|
|
linkedPR,
|
|
fallbackName: fallbackCreatureName
|
|
}),
|
|
[agentPrompt, fallbackCreatureName, linkedPR, name, parsedLinkedIssueNumber]
|
|
)
|
|
// Why: when the user links an issue/PR but has not typed any prompt text
|
|
// (attachments don't count), swap the generic "Linked work items:" context
|
|
// block for the repo's issueCommand template — or the built-in
|
|
// "Complete {{artifact_url}}" default when none is configured. This makes
|
|
// the common "paste a link and hit enter" flow produce a useful agent task
|
|
// instead of a bare URL bullet.
|
|
const shouldApplyLinkedOnlyTemplate =
|
|
!agentPrompt.trim() && Boolean(linkedWorkItem) && hasLoadedIssueCommand
|
|
const linkedOnlyTemplatePrompt = useMemo(() => {
|
|
if (!shouldApplyLinkedOnlyTemplate || !linkedWorkItem) {
|
|
return ''
|
|
}
|
|
const template = issueCommandTemplate.trim() || DEFAULT_ISSUE_COMMAND_TEMPLATE
|
|
return renderIssueCommandTemplate(template, {
|
|
issueNumber: linkedWorkItem.type === 'issue' ? linkedWorkItem.number : null,
|
|
artifactUrl: linkedWorkItem.url
|
|
})
|
|
}, [issueCommandTemplate, linkedWorkItem, shouldApplyLinkedOnlyTemplate])
|
|
const startupPrompt = useMemo(() => {
|
|
if (shouldApplyLinkedOnlyTemplate) {
|
|
return buildAgentPromptWithContext(linkedOnlyTemplatePrompt, attachmentPaths, [])
|
|
}
|
|
return buildAgentPromptWithContext(
|
|
agentPrompt,
|
|
attachmentPaths,
|
|
linkedWorkItem?.url ? [linkedWorkItem.url] : []
|
|
)
|
|
}, [
|
|
agentPrompt,
|
|
attachmentPaths,
|
|
linkedOnlyTemplatePrompt,
|
|
linkedWorkItem?.url,
|
|
shouldApplyLinkedOnlyTemplate
|
|
])
|
|
const normalizedLinkQuery = useMemo(
|
|
() => normalizeGitHubLinkQuery(linkDebouncedQuery),
|
|
[linkDebouncedQuery]
|
|
)
|
|
|
|
const filteredLinkItems = useMemo(() => {
|
|
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])
|
|
|
|
// Persist draft whenever relevant fields change (full-page only).
|
|
useEffect(() => {
|
|
if (!persistDraft) {
|
|
return
|
|
}
|
|
setNewWorkspaceDraft({
|
|
repoId: repoId || null,
|
|
name,
|
|
prompt: agentPrompt,
|
|
note,
|
|
attachments: attachmentPaths,
|
|
linkedWorkItem,
|
|
agent: tuiAgent,
|
|
linkedIssue,
|
|
linkedPR,
|
|
...(baseBranch !== undefined ? { baseBranch } : {})
|
|
})
|
|
}, [
|
|
persistDraft,
|
|
agentPrompt,
|
|
attachmentPaths,
|
|
baseBranch,
|
|
linkedIssue,
|
|
linkedPR,
|
|
linkedWorkItem,
|
|
note,
|
|
name,
|
|
repoId,
|
|
setNewWorkspaceDraft,
|
|
tuiAgent
|
|
])
|
|
|
|
// Auto-pick the first eligible repo if we somehow start with none selected.
|
|
useEffect(() => {
|
|
if (!repoId && eligibleRepos[0]?.id) {
|
|
setRepoId(eligibleRepos[0].id)
|
|
}
|
|
}, [eligibleRepos, repoId, setRepoId])
|
|
|
|
// Why: the compact sparse dropdown is always visible under Advanced, so
|
|
// presets must load before sparse mode is enabled.
|
|
useEffect(() => {
|
|
if (!repoId || selectedRepo?.connectionId) {
|
|
return
|
|
}
|
|
if (sparsePresetsByRepo[repoId] !== undefined) {
|
|
return
|
|
}
|
|
void fetchSparsePresets(repoId)
|
|
}, [fetchSparsePresets, repoId, selectedRepo?.connectionId, 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(() => {
|
|
let cancelled = false
|
|
const detect = isRemote ? ensureRemoteDetectedAgents(connectionId) : ensureDetectedAgents()
|
|
void detect.then((ids) => {
|
|
if (cancelled) {
|
|
return
|
|
}
|
|
if (!newWorkspaceDraft?.agent && !settings?.defaultTuiAgent && ids.length > 0) {
|
|
const firstInCatalogOrder = AGENT_CATALOG.find((a) => ids.includes(a.id))
|
|
if (firstInCatalogOrder) {
|
|
setTuiAgent(firstInCatalogOrder.id)
|
|
}
|
|
}
|
|
})
|
|
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])
|
|
|
|
// Per-repo: load yaml hooks + issue command template.
|
|
useEffect(() => {
|
|
if (!repoId) {
|
|
return
|
|
}
|
|
|
|
let cancelled = false
|
|
setHasLoadedIssueCommand(false)
|
|
setIssueCommandTemplate('')
|
|
setYamlHooks(null)
|
|
setCheckedHooksRepoId(null)
|
|
|
|
void window.api.hooks
|
|
.check({ repoId })
|
|
.then((result) => {
|
|
if (!cancelled) {
|
|
setYamlHooks(result.hooks)
|
|
setCheckedHooksRepoId(repoId)
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) {
|
|
setYamlHooks(null)
|
|
setCheckedHooksRepoId(repoId)
|
|
}
|
|
})
|
|
|
|
void window.api.hooks
|
|
.readIssueCommand({ repoId })
|
|
.then((result) => {
|
|
if (!cancelled) {
|
|
setIssueCommandTemplate(result.effectiveContent ?? '')
|
|
setHasLoadedIssueCommand(true)
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) {
|
|
setIssueCommandTemplate('')
|
|
setHasLoadedIssueCommand(true)
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [repoId])
|
|
|
|
// 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. Local repos only — remote SSH repos disable the PR tab in v1.
|
|
useEffect(() => {
|
|
if (!selectedRepo?.path || selectedRepo.connectionId) {
|
|
return
|
|
}
|
|
prefetchWorkItems(selectedRepo.id, selectedRepo.path, PER_REPO_FETCH_LIMIT, 'is:pr is:open')
|
|
}, [prefetchWorkItems, selectedRepo?.connectionId, selectedRepo?.id, selectedRepo?.path])
|
|
|
|
// 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) {
|
|
return
|
|
}
|
|
|
|
let cancelled = false
|
|
setLinkItemsLoading(true)
|
|
|
|
const lookupRepoId = selectedRepo.id
|
|
void window.api.gh
|
|
.listWorkItems({ repoPath: selectedRepo.path, 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])
|
|
|
|
useEffect(() => {
|
|
if (!linkPopoverOpen || !selectedRepo || 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 window.api.gh
|
|
.workItem({ repoPath: selectedRepo.path, 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])
|
|
|
|
const applyLinkedWorkItem = useCallback(
|
|
(item: GitHubWorkItem): void => {
|
|
if (item.type === 'issue') {
|
|
setLinkedIssue(String(item.number))
|
|
setLinkedPR(null)
|
|
} else {
|
|
setLinkedIssue('')
|
|
setLinkedPR(item.number)
|
|
}
|
|
setLinkedWorkItem({
|
|
type: item.type,
|
|
number: item.number,
|
|
title: item.title,
|
|
url: item.url
|
|
})
|
|
const suggestedName = getLinkedWorkItemSuggestedName(item)
|
|
if (suggestedName && (!name.trim() || name === lastAutoNameRef.current)) {
|
|
setName(suggestedName)
|
|
lastAutoNameRef.current = suggestedName
|
|
}
|
|
},
|
|
[name]
|
|
)
|
|
|
|
const handleSelectLinkedItem = useCallback(
|
|
(item: GitHubWorkItem): void => {
|
|
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 => {
|
|
setLinkedWorkItem(null)
|
|
setLinkedIssue('')
|
|
setLinkedPR(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 = ''
|
|
}
|
|
setName(nextName)
|
|
setCreateError(null)
|
|
},
|
|
[name]
|
|
)
|
|
const handleAddAttachment = useCallback(async (): Promise<void> => {
|
|
try {
|
|
const selectedPath = await window.api.shell.pickAttachment()
|
|
if (!selectedPath) {
|
|
return
|
|
}
|
|
setAttachmentPaths((current) => {
|
|
if (current.includes(selectedPath)) {
|
|
return current
|
|
}
|
|
return [...current, selectedPath]
|
|
})
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Failed to add attachment.'
|
|
toast.error(message)
|
|
}
|
|
}, [])
|
|
|
|
// 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 (composerDropStack.at(-1) !== instanceId) {
|
|
return
|
|
}
|
|
void (async () => {
|
|
const fileAttachments: string[] = []
|
|
const folderPaths: string[] = []
|
|
for (const filePath of data.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 (fileAttachments.length > 0) {
|
|
setAttachmentPaths((current) => {
|
|
const next = [...current]
|
|
for (const p of fileAttachments) {
|
|
if (!next.includes(p)) {
|
|
next.push(p)
|
|
}
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
if (folderPaths.length > 0) {
|
|
// 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 the inserted text reads as a
|
|
// single token if the user pastes it into a terminal. Simple paths
|
|
// stay unadorned to match how Finder/Explorer drops appear.
|
|
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 — reading `textarea.selectionStart` inside the updater
|
|
// risks seeing a shifted caret. Read `agentPromptRef.current` for
|
|
// the latest prompt because this effect subscribes once and the
|
|
// outer closure's `agentPrompt` would be stale.
|
|
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) {
|
|
// Restore the caret to the end of the inserted text after React flushes.
|
|
requestAnimationFrame(() => {
|
|
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)
|
|
}
|
|
})()
|
|
})
|
|
return () => {
|
|
unsubscribe()
|
|
const idx = composerDropStack.lastIndexOf(instanceId)
|
|
if (idx !== -1) {
|
|
composerDropStack.splice(idx, 1)
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
const handlePromptKeyDown = useCallback(
|
|
(event: React.KeyboardEvent<HTMLTextAreaElement>): void => {
|
|
const mod = IS_MAC ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
|
|
if (!mod || event.altKey || event.shiftKey || event.key.toLowerCase() !== 'u') {
|
|
return
|
|
}
|
|
|
|
// Why: the attachment picker should only steal Cmd/Ctrl+U while the user
|
|
// is composing a prompt, so the shortcut is scoped to the textarea rather
|
|
// than registered globally for the whole new-workspace surface.
|
|
event.preventDefault()
|
|
void handleAddAttachment()
|
|
},
|
|
[handleAddAttachment]
|
|
)
|
|
|
|
const handleRepoChange = useCallback(
|
|
(value: string): void => {
|
|
if (value === repoId) {
|
|
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 (linkedWorkItem?.type === 'pr' && baseBranch) {
|
|
hint = `was PR #${linkedWorkItem.number}`
|
|
} else if (baseBranch) {
|
|
hint = `was ${baseBranch}`
|
|
}
|
|
setRepoId(value)
|
|
setLinkedIssue('')
|
|
setLinkedPR(null)
|
|
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.
|
|
setBaseBranch(undefined)
|
|
setStartFromResetHint(hint)
|
|
},
|
|
[baseBranch, linkedWorkItem, repoId, setRepoId]
|
|
)
|
|
|
|
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 => {
|
|
setBaseBranch(next)
|
|
setStartFromResetHint(null)
|
|
}, [])
|
|
|
|
const handleBaseBranchPrSelect = useCallback(
|
|
(nextBaseBranch: string, item: GitHubWorkItem): void => {
|
|
setBaseBranch(nextBaseBranch)
|
|
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)
|
|
// 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]
|
|
)
|
|
|
|
const handleSmartGitHubItemSelect = useCallback(
|
|
(item: GitHubWorkItem): void => {
|
|
applyLinkedWorkItem(item)
|
|
setStartFromResetHint(null)
|
|
const repoForItem = eligibleRepos.find((repo) => repo.id === item.repoId) ?? selectedRepo
|
|
if (item.type !== 'pr' || !repoForItem) {
|
|
return
|
|
}
|
|
void window.api.worktrees
|
|
.resolvePrBase({
|
|
repoId: repoForItem.id,
|
|
prNumber: item.number,
|
|
...(item.branchName ? { headRefName: item.branchName } : {}),
|
|
...(item.isCrossRepository !== undefined
|
|
? { isCrossRepository: item.isCrossRepository }
|
|
: {})
|
|
})
|
|
.then((result) => {
|
|
if ('error' in result) {
|
|
return
|
|
}
|
|
handleBaseBranchPrSelect(result.baseBranch, item)
|
|
})
|
|
},
|
|
[applyLinkedWorkItem, eligibleRepos, handleBaseBranchPrSelect, selectedRepo]
|
|
)
|
|
|
|
const handleSmartBranchSelect = useCallback(
|
|
(refName: string): void => {
|
|
setBaseBranch(refName)
|
|
setStartFromResetHint(null)
|
|
if (!name.trim() || name === lastAutoNameRef.current) {
|
|
setName(refName)
|
|
lastAutoNameRef.current = refName
|
|
}
|
|
},
|
|
[name]
|
|
)
|
|
|
|
const handleSmartLinearIssueSelect = useCallback(
|
|
(issue: LinearIssue): void => {
|
|
setLinkedIssue('')
|
|
setLinkedPR(null)
|
|
setLinkedWorkItem({
|
|
type: 'issue',
|
|
// Why: Linear identifiers are strings (e.g. ENG-123); keep GitHub
|
|
// numeric metadata empty and carry the real source through the URL.
|
|
number: 0,
|
|
title: issue.title,
|
|
url: issue.url
|
|
})
|
|
const suggestedName = issue.title
|
|
if (!name.trim() || name === lastAutoNameRef.current) {
|
|
setName(suggestedName)
|
|
lastAutoNameRef.current = suggestedName
|
|
}
|
|
// Why: match the GitHub issue/PR flow — paste only the URL as a draft
|
|
// into the agent's input (no auto-submit). The launch path already
|
|
// drafts `linkedWorkItem.url` when the note is empty; auto-filling the
|
|
// note here would flip Linear into the `isLinearTypedOnly` branch and
|
|
// auto-submit the full details block.
|
|
},
|
|
[name]
|
|
)
|
|
|
|
const handleClearSmartNameSelection = useCallback((): void => {
|
|
setLinkedIssue('')
|
|
setLinkedPR(null)
|
|
setLinkedWorkItem(null)
|
|
setBaseBranch(undefined)
|
|
setStartFromResetHint(null)
|
|
if (name === lastAutoNameRef.current) {
|
|
setName('')
|
|
lastAutoNameRef.current = ''
|
|
}
|
|
if (noteRef.current === lastAutoNoteRef.current) {
|
|
setNote('')
|
|
lastAutoNoteRef.current = ''
|
|
}
|
|
}, [name])
|
|
|
|
const smartNameSelection = useMemo<SmartWorkspaceNameSelection | null>(() => {
|
|
if (linkedWorkItem) {
|
|
const isLinear = linkedWorkItem.number === 0 && !linkedWorkItem.url.includes('github.com')
|
|
const kind: SmartWorkspaceNameSelection['kind'] = isLinear
|
|
? 'linear'
|
|
: linkedWorkItem.type === 'pr'
|
|
? 'github-pr'
|
|
: 'github-issue'
|
|
return {
|
|
kind,
|
|
label:
|
|
isLinear || linkedWorkItem.number === 0
|
|
? linkedWorkItem.title
|
|
: `#${linkedWorkItem.number} ${linkedWorkItem.title}`,
|
|
url: linkedWorkItem.url
|
|
}
|
|
}
|
|
if (baseBranch) {
|
|
return { kind: 'branch', label: baseBranch }
|
|
}
|
|
return null
|
|
}, [baseBranch, linkedWorkItem])
|
|
|
|
const handleOpenAgentSettings = useCallback((): void => {
|
|
openSettingsTarget({ pane: 'agents', repoId: null })
|
|
openSettingsPage()
|
|
closeModal()
|
|
}, [closeModal, openSettingsPage, openSettingsTarget])
|
|
|
|
const applyWorktreeMeta = useCallback(
|
|
async (
|
|
worktreeId: string,
|
|
meta: {
|
|
linkedIssue?: number
|
|
linkedPR?: number
|
|
comment?: string
|
|
}
|
|
): 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 submit = useCallback(async (): Promise<void> => {
|
|
const workspaceName = workspaceSeedName
|
|
if (
|
|
!repoId ||
|
|
!workspaceName ||
|
|
!selectedRepo ||
|
|
shouldWaitForSetupCheck ||
|
|
shouldWaitForIssueAutomationCheck ||
|
|
(requiresExplicitSetupChoice && !setupDecision) ||
|
|
sparseError !== null
|
|
) {
|
|
return
|
|
}
|
|
|
|
setCreateError(null)
|
|
setCreating(true)
|
|
try {
|
|
const setupTrustDecision = await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup')
|
|
const effectiveSetupDecision: SetupDecision =
|
|
setupTrustDecision === 'skip'
|
|
? 'skip'
|
|
: ((resolvedSetupDecision ?? 'inherit') as SetupDecision)
|
|
|
|
let issueCommandTrustDecision: 'run' | 'skip' = 'run'
|
|
if (shouldRunIssueAutomation) {
|
|
issueCommandTrustDecision =
|
|
setupTrustDecision === 'skip'
|
|
? 'skip'
|
|
: await ensureHooksConfirmed(useAppStore.getState(), repoId, 'issueCommand')
|
|
}
|
|
|
|
const result = await createWorktree(
|
|
repoId,
|
|
workspaceName,
|
|
baseBranch,
|
|
effectiveSetupDecision,
|
|
sparseEnabled
|
|
? {
|
|
directories: normalizedSparseDirectories,
|
|
...(effectivePresetId ? { presetId: effectivePresetId } : {})
|
|
}
|
|
: undefined,
|
|
telemetrySource
|
|
)
|
|
const worktree = result.worktree
|
|
|
|
await applyWorktreeMeta(worktree.id, {
|
|
...(parsedLinkedIssueNumber !== null ? { linkedIssue: parsedLinkedIssueNumber } : {}),
|
|
...(effectiveLinkedPR !== null ? { linkedPR: effectiveLinkedPR } : {}),
|
|
...(note.trim() ? { comment: note.trim() } : {})
|
|
})
|
|
|
|
const issueCommand =
|
|
shouldRunIssueAutomation && issueCommandTrustDecision === 'run'
|
|
? {
|
|
command: renderIssueCommandTemplate(issueCommandTemplate, {
|
|
issueNumber: parsedLinkedIssueNumber,
|
|
artifactUrl: linkedWorkItem?.url ?? null
|
|
})
|
|
}
|
|
: undefined
|
|
const startupPlan = buildAgentStartupPlan({
|
|
agent: tuiAgent,
|
|
prompt: startupPrompt,
|
|
cmdOverrides: settings?.agentCmdOverrides ?? {},
|
|
platform: CLIENT_PLATFORM
|
|
})
|
|
|
|
// Why: thread agent_started telemetry through the queued startup so
|
|
// main fires the event after the spawn succeeds. The composer
|
|
// "create" path is the new-workspace surface; request_kind is
|
|
// `'new'` because this is always a fresh session (issue/PR-driven
|
|
// follow-ups go through launch-work-item-direct.ts).
|
|
// Why: when the composer is opened from onboarding, the first
|
|
// `agent_started` must attribute to `onboarding` so D1 activation
|
|
// can be measured against the funnel.
|
|
const composerTelemetry: AgentStartedTelemetry = {
|
|
agent_kind: tuiAgentToAgentKind(tuiAgent),
|
|
launch_source: telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer',
|
|
request_kind: 'new'
|
|
}
|
|
activateAndRevealWorktree(worktree.id, {
|
|
setup: result.setup,
|
|
issueCommand,
|
|
...(startupPlan
|
|
? {
|
|
startup: {
|
|
command: startupPlan.launchCommand,
|
|
telemetry: composerTelemetry
|
|
}
|
|
}
|
|
: {})
|
|
})
|
|
if (startupPlan) {
|
|
void ensureAgentStartupInTerminal({
|
|
worktreeId: worktree.id,
|
|
startup: startupPlan
|
|
})
|
|
}
|
|
setSidebarOpen(true)
|
|
if (settings?.rightSidebarOpenByDefault) {
|
|
setRightSidebarTab('explorer')
|
|
setRightSidebarOpen(true)
|
|
}
|
|
if (persistDraft) {
|
|
clearNewWorkspaceDraft()
|
|
}
|
|
onCreated?.()
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Failed to create worktree.'
|
|
setCreateError(message)
|
|
toast.error(message)
|
|
} finally {
|
|
setCreating(false)
|
|
}
|
|
}, [
|
|
baseBranch,
|
|
clearNewWorkspaceDraft,
|
|
createWorktree,
|
|
applyWorktreeMeta,
|
|
issueCommandTemplate,
|
|
effectiveLinkedPR,
|
|
linkedWorkItem?.url,
|
|
normalizedSparseDirectories,
|
|
note,
|
|
onCreated,
|
|
parsedLinkedIssueNumber,
|
|
persistDraft,
|
|
repoId,
|
|
requiresExplicitSetupChoice,
|
|
resolvedSetupDecision,
|
|
selectedRepo,
|
|
settings?.agentCmdOverrides,
|
|
settings?.rightSidebarOpenByDefault,
|
|
setRightSidebarOpen,
|
|
setRightSidebarTab,
|
|
setSidebarOpen,
|
|
setupDecision,
|
|
sparseEnabled,
|
|
sparseError,
|
|
effectivePresetId,
|
|
telemetrySource,
|
|
tuiAgent,
|
|
shouldRunIssueAutomation,
|
|
shouldWaitForIssueAutomationCheck,
|
|
shouldWaitForSetupCheck,
|
|
startupPrompt,
|
|
workspaceSeedName
|
|
])
|
|
|
|
const submitQuick = useCallback(
|
|
async (agent: TuiAgent | null): Promise<void> => {
|
|
const workspaceName = getWorkspaceSeedName({
|
|
explicitName: name,
|
|
prompt: '',
|
|
linkedIssueNumber: parsedLinkedIssueNumber,
|
|
linkedPR,
|
|
fallbackName: fallbackCreatureName
|
|
})
|
|
if (
|
|
!repoId ||
|
|
!workspaceName ||
|
|
!selectedRepo ||
|
|
shouldWaitForSetupCheck ||
|
|
(requiresExplicitSetupChoice && !setupDecision) ||
|
|
sparseError !== null
|
|
) {
|
|
return
|
|
}
|
|
|
|
setCreateError(null)
|
|
setCreating(true)
|
|
try {
|
|
const trustDecision = await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup')
|
|
const effectiveSetupDecision: SetupDecision =
|
|
trustDecision === 'skip'
|
|
? 'skip'
|
|
: ((resolvedSetupDecision ?? 'inherit') as SetupDecision)
|
|
|
|
const result = await createWorktree(
|
|
repoId,
|
|
workspaceName,
|
|
baseBranch,
|
|
effectiveSetupDecision,
|
|
sparseEnabled
|
|
? {
|
|
directories: normalizedSparseDirectories,
|
|
...(effectivePresetId ? { presetId: effectivePresetId } : {})
|
|
}
|
|
: undefined,
|
|
telemetrySource
|
|
)
|
|
const worktree = result.worktree
|
|
|
|
const trimmedNote = note.trim()
|
|
await applyWorktreeMeta(worktree.id, {
|
|
...(parsedLinkedIssueNumber !== null ? { linkedIssue: parsedLinkedIssueNumber } : {}),
|
|
...(effectiveLinkedPR !== null ? { linkedPR: effectiveLinkedPR } : {}),
|
|
...(trimmedNote ? { comment: trimmedNote } : {})
|
|
})
|
|
|
|
// Why: when a linked work item is selected in the quick flow, launch
|
|
// the agent with a blank prompt and type the URL into its input as a
|
|
// draft (no trailing Enter). This lets the user review/edit before
|
|
// sending instead of auto-executing a "Complete <url>" template.
|
|
// Falls back to the trimmed note when the linked item carries no
|
|
// number/URL (Linear typed-only entries).
|
|
const isLinearTypedOnly = linkedWorkItem?.number === 0 && Boolean(trimmedNote)
|
|
const quickPrompt = isLinearTypedOnly && trimmedNote ? trimmedNote : ''
|
|
const quickDraftPrompt = linkedWorkItem && !isLinearTypedOnly ? linkedWorkItem.url : null
|
|
|
|
// Why: agents that gate first-launch behind a "Do you trust this
|
|
// folder?" menu (cursor-agent, copilot) consume the bracketed paste
|
|
// as menu input. Pre-write the trust artifact so the menu is
|
|
// skipped — best-effort, errors swallowed by main. Guard the IPC
|
|
// presence so a stale preload bundle doesn't crash the launch with
|
|
// "Cannot read properties of undefined".
|
|
if (agent && worktree.path && window.api.agentTrust?.markTrusted) {
|
|
const preflight = TUI_AGENT_CONFIG[agent].preflightTrust
|
|
if (preflight) {
|
|
try {
|
|
await window.api.agentTrust.markTrusted({
|
|
preset: preflight,
|
|
workspacePath: worktree.path
|
|
})
|
|
} catch {
|
|
// Best-effort: continue with launch.
|
|
}
|
|
}
|
|
}
|
|
|
|
// Why: prefer the agent's native prefill flag (currently Claude's
|
|
// `--prefill`) when it has one — sidesteps the readiness/paste race
|
|
// entirely. Falls through to the type-after-ready path for every
|
|
// other agent.
|
|
const draftLaunchPlan =
|
|
agent === null || !quickDraftPrompt
|
|
? null
|
|
: buildAgentDraftLaunchPlan({
|
|
agent,
|
|
draft: quickDraftPrompt,
|
|
cmdOverrides: settings?.agentCmdOverrides ?? {},
|
|
platform: CLIENT_PLATFORM
|
|
})
|
|
|
|
let startupPlan: ReturnType<typeof buildAgentStartupPlan> = null
|
|
if (draftLaunchPlan) {
|
|
startupPlan = {
|
|
agent: draftLaunchPlan.agent,
|
|
launchCommand: draftLaunchPlan.launchCommand,
|
|
expectedProcess: draftLaunchPlan.expectedProcess,
|
|
followupPrompt: null,
|
|
...(draftLaunchPlan.env ? { env: draftLaunchPlan.env } : {})
|
|
}
|
|
} else if (agent !== null) {
|
|
startupPlan = buildAgentStartupPlan({
|
|
agent,
|
|
prompt: quickPrompt,
|
|
cmdOverrides: settings?.agentCmdOverrides ?? {},
|
|
platform: CLIENT_PLATFORM,
|
|
allowEmptyPromptLaunch: true
|
|
})
|
|
if (startupPlan && quickDraftPrompt) {
|
|
startupPlan.draftPrompt = quickDraftPrompt
|
|
}
|
|
}
|
|
|
|
// Why: only attach telemetry when an agent was selected — the
|
|
// quick path also handles "blank shell" (agent === null) where no
|
|
// agent_started event should fire. When telemetry is present main
|
|
// emits the event after pty:spawn succeeds.
|
|
const quickTelemetry: AgentStartedTelemetry | null =
|
|
agent === null
|
|
? null
|
|
: {
|
|
agent_kind: tuiAgentToAgentKind(agent),
|
|
launch_source:
|
|
telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer',
|
|
request_kind: 'new'
|
|
}
|
|
activateAndRevealWorktree(worktree.id, {
|
|
setup: result.setup,
|
|
...(startupPlan
|
|
? {
|
|
startup: {
|
|
command: startupPlan.launchCommand,
|
|
...(startupPlan.env ? { env: startupPlan.env } : {}),
|
|
...(quickTelemetry ? { telemetry: quickTelemetry } : {})
|
|
}
|
|
}
|
|
: {})
|
|
})
|
|
if (startupPlan) {
|
|
void ensureAgentStartupInTerminal({
|
|
worktreeId: worktree.id,
|
|
startup: startupPlan
|
|
})
|
|
}
|
|
setSidebarOpen(true)
|
|
if (settings?.rightSidebarOpenByDefault) {
|
|
setRightSidebarTab('explorer')
|
|
setRightSidebarOpen(true)
|
|
}
|
|
if (persistDraft) {
|
|
clearNewWorkspaceDraft()
|
|
}
|
|
onCreated?.()
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Failed to create worktree.'
|
|
setCreateError(message)
|
|
toast.error(message)
|
|
} finally {
|
|
setCreating(false)
|
|
}
|
|
},
|
|
[
|
|
applyWorktreeMeta,
|
|
baseBranch,
|
|
clearNewWorkspaceDraft,
|
|
createWorktree,
|
|
fallbackCreatureName,
|
|
effectiveLinkedPR,
|
|
linkedPR,
|
|
linkedWorkItem,
|
|
name,
|
|
normalizedSparseDirectories,
|
|
note,
|
|
onCreated,
|
|
parsedLinkedIssueNumber,
|
|
persistDraft,
|
|
repoId,
|
|
requiresExplicitSetupChoice,
|
|
resolvedSetupDecision,
|
|
selectedRepo,
|
|
settings?.agentCmdOverrides,
|
|
settings?.rightSidebarOpenByDefault,
|
|
setRightSidebarOpen,
|
|
setRightSidebarTab,
|
|
setSidebarOpen,
|
|
setupDecision,
|
|
sparseEnabled,
|
|
sparseError,
|
|
effectivePresetId,
|
|
telemetrySource,
|
|
shouldWaitForSetupCheck
|
|
]
|
|
)
|
|
|
|
const createDisabled =
|
|
!repoId ||
|
|
!workspaceSeedName ||
|
|
creating ||
|
|
shouldWaitForSetupCheck ||
|
|
shouldWaitForIssueAutomationCheck ||
|
|
(requiresExplicitSetupChoice && !setupDecision) ||
|
|
sparseError !== null
|
|
|
|
const cardProps: ComposerCardProps = {
|
|
eligibleRepos,
|
|
repoId,
|
|
onRepoChange: handleRepoChange,
|
|
name,
|
|
onNameValueChange: handleNameValueChange,
|
|
onSmartGitHubItemSelect: handleSmartGitHubItemSelect,
|
|
onSmartBranchSelect: handleSmartBranchSelect,
|
|
onSmartLinearIssueSelect: handleSmartLinearIssueSelect,
|
|
smartNameSelection,
|
|
onClearSmartNameSelection: handleClearSmartNameSelection,
|
|
agentPrompt,
|
|
onAgentPromptChange: setAgentPrompt,
|
|
onPromptKeyDown: handlePromptKeyDown,
|
|
linkedOnlyTemplatePreview: shouldApplyLinkedOnlyTemplate ? linkedOnlyTemplatePrompt : null,
|
|
attachmentPaths,
|
|
getAttachmentLabel,
|
|
onAddAttachment: () => void handleAddAttachment(),
|
|
onRemoveAttachment: (pathValue) =>
|
|
setAttachmentPaths((current) => current.filter((currentPath) => currentPath !== pathValue)),
|
|
addAttachmentShortcut: ADD_ATTACHMENT_SHORTCUT,
|
|
linkedWorkItem,
|
|
onRemoveLinkedWorkItem: handleRemoveLinkedWorkItem,
|
|
linkPopoverOpen,
|
|
onLinkPopoverOpenChange: handleLinkPopoverChange,
|
|
linkQuery,
|
|
onLinkQueryChange: setLinkQuery,
|
|
filteredLinkItems,
|
|
linkItemsLoading,
|
|
linkDirectLoading,
|
|
normalizedLinkQuery,
|
|
onSelectLinkedItem: handleSelectLinkedItem,
|
|
tuiAgent,
|
|
onTuiAgentChange: setTuiAgent,
|
|
detectedAgentIds,
|
|
onOpenAgentSettings: handleOpenAgentSettings,
|
|
advancedOpen,
|
|
onToggleAdvanced: () => setAdvancedOpen((current) => !current),
|
|
createDisabled,
|
|
creating,
|
|
onCreate: () => void submit(),
|
|
baseBranch,
|
|
onBaseBranchChange: handleBaseBranchChange,
|
|
onBaseBranchPrSelect: handleBaseBranchPrSelect,
|
|
baseBranchLinkedPrNumber:
|
|
linkedWorkItem?.type === 'pr' && baseBranch ? linkedWorkItem.number : null,
|
|
selectedRepoPath: selectedRepo?.path ?? null,
|
|
selectedRepoIsRemote: Boolean(selectedRepo?.connectionId),
|
|
startFromResetHint,
|
|
note,
|
|
onNoteChange: setNote,
|
|
setupConfig,
|
|
requiresExplicitSetupChoice,
|
|
setupDecision,
|
|
onSetupDecisionChange: setSetupDecision,
|
|
shouldWaitForSetupCheck,
|
|
resolvedSetupDecision,
|
|
createError,
|
|
canUseSparseCheckout: !selectedRepo?.connectionId,
|
|
sparsePresets,
|
|
sparseSelectedPresetId,
|
|
onSparseSelectPreset: handleSparseSelectPreset
|
|
}
|
|
|
|
return {
|
|
cardProps,
|
|
composerRef,
|
|
promptTextareaRef,
|
|
nameInputRef,
|
|
submit,
|
|
submitQuick,
|
|
createDisabled
|
|
}
|
|
}
|