diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index bbf62c20..decbbcc3 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -21,6 +21,7 @@ import Settings from './components/settings/Settings' import RightSidebar from './components/right-sidebar' import QuickOpen from './components/QuickOpen' import WorktreeJumpPalette from './components/WorktreeJumpPalette' +import NewWorkspaceComposerModal from './components/NewWorkspaceComposerModal' import { StatusBar } from './components/status-bar/StatusBar' import { UpdateCard } from './components/UpdateCard' import { ZoomOverlay } from './components/ZoomOverlay' @@ -803,6 +804,11 @@ function App(): React.JSX.Element { {showRightSidebarControls ? : null} + {/* Why: NewWorkspaceComposerCard renders Radix s that crash + when mounted outside a TooltipProvider ancestor. Keep the global + composer modal inside this provider so the card renders safely + whether triggered from Cmd+J or any future entry point. */} + diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx new file mode 100644 index 00000000..3c82fa71 --- /dev/null +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -0,0 +1,135 @@ +import React, { useCallback, useEffect } from 'react' +import { useAppStore } from '@/store' +import { Dialog, DialogContent } from '@/components/ui/dialog' +import NewWorkspaceComposerCard from '@/components/NewWorkspaceComposerCard' +import { useComposerState } from '@/hooks/useComposerState' +import type { LinkedWorkItemSummary } from '@/lib/new-workspace' + +type ComposerModalData = { + prefilledName?: string + prefilledPrompt?: string + initialRepoId?: string + linkedWorkItem?: LinkedWorkItemSummary | null +} + +export default function NewWorkspaceComposerModal(): React.JSX.Element | null { + const visible = useAppStore((s) => s.activeModal === 'new-workspace-composer') + const modalData = useAppStore((s) => s.modalData as ComposerModalData | undefined) + const closeModal = useAppStore((s) => s.closeModal) + + // Why: Dialog open-state transitions must be driven by the store, not a + // mirror useState, so palette/open-modal calls feel instantaneous and the + // modal doesn't linger with stale data after close. + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + closeModal() + } + }, + [closeModal] + ) + + if (!visible) { + return null + } + + return ( + + ) +} + +function ComposerModalBody({ + modalData, + onClose, + onOpenChange +}: { + modalData: ComposerModalData + onClose: () => void + onOpenChange: (open: boolean) => void +}): React.JSX.Element { + const { cardProps, composerRef, promptTextareaRef, nameInputRef, submit, createDisabled } = + useComposerState({ + initialName: modalData.prefilledName ?? '', + initialPrompt: modalData.prefilledPrompt ?? '', + initialLinkedWorkItem: modalData.linkedWorkItem ?? null, + initialRepoId: modalData.initialRepoId, + persistDraft: false, + onCreated: onClose + }) + + // Autofocus the prompt textarea on open. + useEffect(() => { + const frame = requestAnimationFrame(() => { + promptTextareaRef.current?.focus() + }) + return () => cancelAnimationFrame(frame) + }, [promptTextareaRef]) + + // Enter submits, Esc first blurs the focused input (like the full page). + useEffect(() => { + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Enter' && event.key !== 'Escape') { + return + } + const target = event.target + if (!(target instanceof HTMLElement)) { + return + } + + if (event.key === 'Escape') { + if ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || + target.isContentEditable + ) { + event.preventDefault() + target.blur() + return + } + event.preventDefault() + onClose() + return + } + + if (!composerRef.current?.contains(target)) { + return + } + if (createDisabled) { + return + } + if (target instanceof HTMLTextAreaElement && event.shiftKey) { + return + } + event.preventDefault() + void submit() + } + window.addEventListener('keydown', onKeyDown, { capture: true }) + return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) + }, [composerRef, createDisabled, onClose, submit]) + + return ( + + { + event.preventDefault() + promptTextareaRef.current?.focus() + }} + > + + + + ) +} diff --git a/src/renderer/src/components/NewWorkspacePage.tsx b/src/renderer/src/components/NewWorkspacePage.tsx index 55ca5be6..c55aaf6e 100644 --- a/src/renderer/src/components/NewWorkspacePage.tsx +++ b/src/renderer/src/components/NewWorkspacePage.tsx @@ -1,7 +1,8 @@ -/* eslint-disable max-lines -- Why: the new-workspace flow intentionally keeps the -composer, task picker, and create-side effects co-located so the draft sidebar -state and launch logic stay in one place while this surface is still evolving. */ -import React, { startTransition, useEffect, useCallback, useMemo, useRef, useState } from 'react' +/* eslint-disable max-lines -- Why: the new-workspace page keeps the composer, +task source controls, and GitHub task list co-located so the wiring between the +selected repo, the draft composer, and the work-item list stays readable in one +place while this surface is still evolving. */ +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { ArrowRight, CircleDot, @@ -18,7 +19,6 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Dialog, DialogContent } from '@/components/ui/dialog' import { DropdownMenu, DropdownMenuContent, @@ -30,23 +30,10 @@ import RepoCombobox from '@/components/repo/RepoCombobox' import NewWorkspaceComposerCard from '@/components/NewWorkspaceComposerCard' import { cn } from '@/lib/utils' import { LightRays } from '@/components/ui/light-rays' -import { parseGitHubIssueOrPRNumber } from '@/lib/github-links' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' -import { - buildAgentStartupPlan, - isShellProcess, - type AgentStartupPlan -} from '@/lib/tui-agent-startup' -import { isGitRepoKind } from '../../../shared/repo-kind' -import { AGENT_CATALOG } from '@/lib/agent-catalog' -import type { - GitHubWorkItem, - OrcaHooks, - SetupDecision, - SetupRunPolicy, - TuiAgent, - TaskViewPresetId -} from '../../../shared/types' +import { useComposerState } from '@/hooks/useComposerState' +import { getLinkedWorkItemSuggestedName } from '@/lib/new-workspace' +import type { LinkedWorkItemSummary } from '@/lib/new-workspace' +import type { GitHubWorkItem, TaskViewPresetId } from '../../../shared/types' type TaskSource = 'github' | 'linear' type TaskQueryPreset = { @@ -104,183 +91,6 @@ function getTaskPresetQuery(presetId: TaskViewPresetId | null): string { } const TASK_SEARCH_DEBOUNCE_MS = 300 -const isMac = navigator.userAgent.includes('Mac') -const ADD_ATTACHMENT_SHORTCUT = isMac ? '⌘U' : 'Ctrl+U' -const CLIENT_PLATFORM: NodeJS.Platform = navigator.userAgent.includes('Windows') - ? 'win32' - : navigator.userAgent.includes('Mac') - ? 'darwin' - : 'linux' - -type RepoSlug = { - owner: string - repo: string -} - -type GitHubLinkQuery = { - query: string - repoMismatch: string | null - directNumber: number | null -} - -function buildAgentPromptWithContext( - prompt: string, - attachments: string[], - linkedUrls: string[] -): string { - const trimmedPrompt = prompt.trim() - if (attachments.length === 0 && linkedUrls.length === 0) { - return trimmedPrompt - } - - const sections: string[] = [] - if (attachments.length > 0) { - const attachmentBlock = attachments.map((pathValue) => `- ${pathValue}`).join('\n') - sections.push(`Attachments:\n${attachmentBlock}`) - } - if (linkedUrls.length > 0) { - const linkBlock = linkedUrls.map((url) => `- ${url}`).join('\n') - sections.push(`Linked work items:\n${linkBlock}`) - } - // Why: the new-workspace flow launches each agent with a single plain-text - // startup prompt. Appending attachments and linked URLs keeps extra context - // visible to Claude/Codex/OpenCode without cluttering the visible textarea. - if (!trimmedPrompt) { - return sections.join('\n\n') - } - return `${trimmedPrompt}\n\n${sections.join('\n\n')}` -} - -function getAttachmentLabel(pathValue: string): string { - const segments = pathValue.split(/[/\\]/) - return segments.at(-1) || pathValue -} - -function normalizeGitHubLinkQuery(raw: string, repoSlug: RepoSlug | null): GitHubLinkQuery { - const trimmed = raw.trim() - if (!trimmed) { - return { query: '', repoMismatch: null, directNumber: null } - } - - const direct = parseGitHubIssueOrPRNumber(trimmed) - if (direct !== null && !trimmed.startsWith('http')) { - return { query: trimmed, repoMismatch: null, directNumber: direct } - } - - let parsedUrl: URL - try { - parsedUrl = new URL(trimmed) - } catch { - return { query: trimmed, repoMismatch: null, directNumber: null } - } - - if (!/^(?:www\.)?github\.com$/i.test(parsedUrl.hostname)) { - return { query: trimmed, repoMismatch: null, directNumber: null } - } - - const match = parsedUrl.pathname.match(/^\/([^/]+)\/([^/]+)\/(?:issues|pull)\/(\d+)(?:\/)?$/i) - if (!match) { - return { query: trimmed, repoMismatch: null, directNumber: null } - } - - if ( - repoSlug && - (match[1].toLowerCase() !== repoSlug.owner.toLowerCase() || - match[2].toLowerCase() !== repoSlug.repo.toLowerCase()) - ) { - return { - query: '', - repoMismatch: `${repoSlug.owner}/${repoSlug.repo}`, - directNumber: null - } - } - - return { - query: trimmed, - repoMismatch: null, - directNumber: Number.parseInt(match[3], 10) - } -} - -function getWorkItemSearchText(item: GitHubWorkItem): string { - return [ - item.type, - item.number, - item.title, - item.author ?? '', - item.labels.join(' '), - item.branchName ?? '', - item.baseRefName ?? '' - ] - .join(' ') - .toLowerCase() -} - -async function ensureAgentStartupInTerminal(args: { - worktreeId: string - startup: AgentStartupPlan -}): Promise { - const { worktreeId, startup } = args - if (startup.followupPrompt === null) { - return - } - - let promptInjected = false - - for (let attempt = 0; attempt < 30; attempt += 1) { - if (attempt > 0) { - await new Promise((resolve) => window.setTimeout(resolve, 150)) - } - - const state = useAppStore.getState() - const tabId = - state.activeTabIdByWorktree[worktreeId] ?? state.tabsByWorktree[worktreeId]?.[0]?.id ?? null - if (!tabId) { - continue - } - - const ptyId = state.ptyIdsByTabId[tabId]?.[0] - if (!ptyId) { - continue - } - - try { - const foreground = (await window.api.pty.getForegroundProcess(ptyId))?.toLowerCase() ?? '' - const agentOwnsForeground = - foreground === startup.expectedProcess || - foreground.startsWith(`${startup.expectedProcess}.`) - - if (agentOwnsForeground && !promptInjected && startup.followupPrompt) { - window.api.pty.write(ptyId, `${startup.followupPrompt}\r`) - promptInjected = true - return - } - - if (agentOwnsForeground && promptInjected) { - return - } - - const hasChildProcesses = await window.api.pty.hasChildProcesses(ptyId) - if ( - !promptInjected && - startup.followupPrompt && - hasChildProcesses && - !isShellProcess(foreground) && - attempt >= 4 - ) { - // Why: the initial agent launch is already queued on the first terminal - // tab. Only agents without a verified startup-prompt flag need extra - // help here: once the TUI owns the PTY, type the draft prompt into the - // live session instead of launching the binary a second time. - window.api.pty.write(ptyId, `${startup.followupPrompt}\r`) - promptInjected = true - return - } - } catch { - // Ignore transient PTY inspection failures and keep polling. - } - } -} function formatRelativeTime(input: string): string { const date = new Date(input) @@ -305,21 +115,6 @@ function formatRelativeTime(input: string): string { return formatter.format(diffDays, 'day') } -function getSetupConfig( - repo: { hookSettings?: { scripts?: { setup?: string } } } | undefined, - yamlHooks: OrcaHooks | null -): { source: 'yaml' | 'legacy'; command: string } | null { - const yamlSetup = yamlHooks?.scripts?.setup?.trim() - if (yamlSetup) { - return { source: 'yaml', command: yamlSetup } - } - const legacySetup = repo?.hookSettings?.scripts?.setup?.trim() - if (legacySetup) { - return { source: 'legacy', command: legacySetup } - } - return null -} - function getTaskStatusLabel(item: GitHubWorkItem): string { if (item.type === 'issue') { return 'Open' @@ -340,120 +135,28 @@ function getTaskStatusTone(item: GitHubWorkItem): string { return 'border-cyan-500/30 bg-cyan-500/10 text-cyan-700 dark:text-cyan-200' } -function getLinkedWorkItemSuggestedName(item: GitHubWorkItem): string { - const withoutLeadingNumber = item.title - .trim() - .replace(/^(?:issue|pr|pull request)\s*#?\d+\s*[:-]\s*/i, '') - .replace(/^#\d+\s*[:-]\s*/, '') - .replace(/\(#\d+\)/gi, '') - .replace(/\b#\d+\b/g, '') - .trim() - const seed = withoutLeadingNumber || item.title.trim() - return seed - .toLowerCase() - .replace(/[\\/]+/g, '-') - .replace(/\s+/g, '-') - .replace(/[^a-z0-9._-]+/g, '-') - .replace(/-+/g, '-') - .replace(/^[.-]+|[.-]+$/g, '') - .slice(0, 48) - .replace(/[-._]+$/g, '') -} - -function getWorkspaceSeedName(args: { - explicitName: string - prompt: string - linkedIssueNumber: number | null - linkedPR: number | null -}): string { - const { explicitName, prompt, linkedIssueNumber, linkedPR } = args - if (explicitName.trim()) { - return explicitName.trim() - } - if (linkedPR !== null) { - return `pr-${linkedPR}` - } - if (linkedIssueNumber !== null) { - return `issue-${linkedIssueNumber}` - } - if (prompt.trim()) { - return prompt.trim() - } - // Why: the prompt is optional in this flow. Fall back to a stable default - // branch/workspace seed so users can launch an empty draft without first - // writing a brief or naming the workspace manually. - return 'workspace' -} - export default function NewWorkspacePage(): React.JSX.Element { - const repos = useAppStore((s) => s.repos) - const activeRepoId = useAppStore((s) => s.activeRepoId) const settings = useAppStore((s) => s.settings) const pageData = useAppStore((s) => s.newWorkspacePageData) - const newWorkspaceDraft = useAppStore((s) => s.newWorkspaceDraft) const closeNewWorkspacePage = useAppStore((s) => s.closeNewWorkspacePage) - const openSettingsPage = useAppStore((s) => s.openSettingsPage) - const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) - const setNewWorkspaceDraft = useAppStore((s) => s.setNewWorkspaceDraft) const clearNewWorkspaceDraft = useAppStore((s) => s.clearNewWorkspaceDraft) - const createWorktree = useAppStore((s) => s.createWorktree) + const activeModal = useAppStore((s) => s.activeModal) + const openModal = useAppStore((s) => s.openModal) const updateSettings = useAppStore((s) => s.updateSettings) - const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) - const setSidebarOpen = useAppStore((s) => s.setSidebarOpen) - const setRightSidebarOpen = useAppStore((s) => s.setRightSidebarOpen) - const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab) - const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos]) - const initialRepoId = - newWorkspaceDraft?.repoId && eligibleRepos.some((repo) => repo.id === newWorkspaceDraft.repoId) - ? newWorkspaceDraft.repoId - : pageData.preselectedRepoId && - eligibleRepos.some((repo) => repo.id === pageData.preselectedRepoId) - ? pageData.preselectedRepoId - : activeRepoId && eligibleRepos.some((repo) => repo.id === activeRepoId) - ? activeRepoId - : (eligibleRepos[0]?.id ?? '') + const { cardProps, composerRef, promptTextareaRef, submit, createDisabled } = useComposerState({ + persistDraft: true, + initialRepoId: pageData.preselectedRepoId, + initialName: pageData.prefilledName, + onCreated: () => { + clearNewWorkspaceDraft() + closeNewWorkspacePage() + } + }) + + const { repoId, eligibleRepos, onRepoChange } = cardProps + const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId) - const [repoId, setRepoId] = useState(initialRepoId) - const [name, setName] = useState(newWorkspaceDraft?.name ?? pageData.prefilledName ?? '') - const [linkedIssue, setLinkedIssue] = useState(newWorkspaceDraft?.linkedIssue ?? '') - const [linkedPR, setLinkedPR] = useState(newWorkspaceDraft?.linkedPR ?? null) - const [linkedWorkItem, setLinkedWorkItem] = useState(newWorkspaceDraft?.linkedWorkItem ?? null) - const [agentPrompt, setAgentPrompt] = useState(newWorkspaceDraft?.prompt ?? '') - const [note, setNote] = useState(newWorkspaceDraft?.note ?? '') - const [attachmentPaths, setAttachmentPaths] = useState( - newWorkspaceDraft?.attachments ?? [] - ) - const [tuiAgent, setTuiAgent] = useState( - newWorkspaceDraft?.agent ?? settings?.defaultTuiAgent ?? 'claude' - ) - const [detectedAgentIds, setDetectedAgentIds] = useState | null>(null) - const [yamlHooks, setYamlHooks] = useState(null) - const [checkedHooksRepoId, setCheckedHooksRepoId] = useState(null) - const [setupDecision, setSetupDecision] = useState<'run' | 'skip' | null>(null) - const [runIssueAutomation, setRunIssueAutomation] = useState(false) - const [issueCommandTemplate, setIssueCommandTemplate] = useState('') - const [hasLoadedIssueCommand, setHasLoadedIssueCommand] = useState(false) - const [creating, setCreating] = useState(false) - const [createError, setCreateError] = useState(null) - const [modalName, setModalName] = useState('') - const [modalLinkedIssue, setModalLinkedIssue] = useState('') - const [modalLinkedPR, setModalLinkedPR] = useState(null) - const [modalLinkedWorkItem, setModalLinkedWorkItem] = useState<{ - type: 'issue' | 'pr' - number: number - title: string - url: string - } | null>(null) - const [modalAgentPrompt, setModalAgentPrompt] = useState('') - const [modalNote, setModalNote] = useState('') - const [modalAttachmentPaths, setModalAttachmentPaths] = useState([]) - const [modalTuiAgent, setModalTuiAgent] = useState( - newWorkspaceDraft?.agent ?? settings?.defaultTuiAgent ?? 'claude' - ) - const [modalSetupDecision, setModalSetupDecision] = useState<'run' | 'skip' | null>(null) - const [modalCreating, setModalCreating] = useState(false) - const [modalCreateError, setModalCreateError] = useState(null) const [taskSource, setTaskSource] = useState('github') const [taskSearchInput, setTaskSearchInput] = useState('') const [appliedTaskSearch, setAppliedTaskSearch] = useState('') @@ -462,68 +165,8 @@ export default function NewWorkspacePage(): React.JSX.Element { const [tasksError, setTasksError] = useState(null) const [taskRefreshNonce, setTaskRefreshNonce] = useState(0) const [workItems, setWorkItems] = useState([]) - const [linkPopoverOpen, setLinkPopoverOpen] = useState(false) - const [linkQuery, setLinkQuery] = useState('') - const [linkRepoSlug, setLinkRepoSlug] = useState(null) - const [linkItems, setLinkItems] = useState([]) - const [linkItemsLoading, setLinkItemsLoading] = useState(false) - const [linkDirectItem, setLinkDirectItem] = useState(null) - const [linkDirectLoading, setLinkDirectLoading] = useState(false) - const [linkDebouncedQuery, setLinkDebouncedQuery] = useState('') - const [modalLinkPopoverOpen, setModalLinkPopoverOpen] = useState(false) - const [modalLinkQuery, setModalLinkQuery] = useState('') - const [modalLinkDirectItem, setModalLinkDirectItem] = useState(null) - const [modalLinkDirectLoading, setModalLinkDirectLoading] = useState(false) - const [modalLinkDebouncedQuery, setModalLinkDebouncedQuery] = useState('') - const [advancedOpen, setAdvancedOpen] = useState(Boolean((newWorkspaceDraft?.note ?? '').trim())) - const [modalAdvancedOpen, setModalAdvancedOpen] = useState(false) - const [composerModalOpen, setComposerModalOpen] = useState(false) - const lastAutoNameRef = useRef(newWorkspaceDraft?.name ?? pageData.prefilledName ?? '') - const modalLastAutoNameRef = useRef('') - const nameInputRef = useRef(null) - const promptTextareaRef = useRef(null) - const composerRef = useRef(null) - const modalNameInputRef = useRef(null) - const modalPromptTextareaRef = useRef(null) - const modalComposerRef = useRef(null) - const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId) - const parsedLinkedIssueNumber = useMemo( - () => (linkedIssue.trim() ? parseGitHubIssueOrPRNumber(linkedIssue) : null), - [linkedIssue] - ) - const parsedModalLinkedIssueNumber = useMemo( - () => (modalLinkedIssue.trim() ? parseGitHubIssueOrPRNumber(modalLinkedIssue) : null), - [modalLinkedIssue] - ) const defaultTaskViewPreset = settings?.defaultTaskViewPreset ?? 'all' - 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 - const shouldRunIssueAutomation = canOfferIssueAutomation && runIssueAutomation - const shouldWaitForIssueAutomationCheck = - parsedLinkedIssueNumber !== null && !hasLoadedIssueCommand - const requiresExplicitSetupChoice = Boolean(setupConfig) && setupPolicy === 'ask' - const resolvedSetupDecision = - setupDecision ?? - (!setupConfig || setupPolicy === 'ask' - ? null - : setupPolicy === 'run-by-default' - ? 'run' - : 'skip') - const resolvedModalSetupDecision = - modalSetupDecision ?? - (!setupConfig || setupPolicy === 'ask' - ? null - : setupPolicy === 'run-by-default' - ? 'run' - : 'skip') - const isSetupCheckPending = Boolean(repoId) && checkedHooksRepoId !== repoId - const shouldWaitForSetupCheck = Boolean(selectedRepo) && isSetupCheckPending const filteredWorkItems = useMemo(() => { if (!activeTaskPreset) { @@ -550,259 +193,10 @@ export default function NewWorkspacePage(): React.JSX.Element { }) }, [activeTaskPreset, workItems]) - const workspaceSeedName = useMemo( - () => - getWorkspaceSeedName({ - explicitName: name, - prompt: agentPrompt, - linkedIssueNumber: parsedLinkedIssueNumber, - linkedPR - }), - [agentPrompt, linkedPR, name, parsedLinkedIssueNumber] - ) - const modalWorkspaceSeedName = useMemo( - () => - getWorkspaceSeedName({ - explicitName: modalName, - prompt: modalAgentPrompt, - linkedIssueNumber: parsedModalLinkedIssueNumber, - linkedPR: modalLinkedPR - }), - [modalAgentPrompt, modalLinkedPR, modalName, parsedModalLinkedIssueNumber] - ) - const startupPrompt = useMemo( - () => - buildAgentPromptWithContext( - agentPrompt, - attachmentPaths, - linkedWorkItem?.url ? [linkedWorkItem.url] : [] - ), - [agentPrompt, attachmentPaths, linkedWorkItem?.url] - ) - const modalStartupPrompt = useMemo( - () => - buildAgentPromptWithContext( - modalAgentPrompt, - modalAttachmentPaths, - modalLinkedWorkItem?.url ? [modalLinkedWorkItem.url] : [] - ), - [modalAgentPrompt, modalAttachmentPaths, modalLinkedWorkItem?.url] - ) - const normalizedLinkQuery = useMemo( - () => normalizeGitHubLinkQuery(linkDebouncedQuery, linkRepoSlug), - [linkDebouncedQuery, linkRepoSlug] - ) - const normalizedModalLinkQuery = useMemo( - () => normalizeGitHubLinkQuery(modalLinkDebouncedQuery, linkRepoSlug), - [modalLinkDebouncedQuery, linkRepoSlug] - ) - const filteredLinkItems = useMemo(() => { - if (normalizedLinkQuery.directNumber !== null) { - return linkDirectItem ? [linkDirectItem] : [] - } - - const query = normalizedLinkQuery.query.trim().toLowerCase() - if (!query) { - return linkItems - } - - return linkItems.filter((item) => getWorkItemSearchText(item).includes(query)) - }, [linkDirectItem, linkItems, normalizedLinkQuery.directNumber, normalizedLinkQuery.query]) - const filteredModalLinkItems = useMemo(() => { - if (normalizedModalLinkQuery.directNumber !== null) { - return modalLinkDirectItem ? [modalLinkDirectItem] : [] - } - - const query = normalizedModalLinkQuery.query.trim().toLowerCase() - if (!query) { - return linkItems - } - - return linkItems.filter((item) => getWorkItemSearchText(item).includes(query)) - }, [ - linkItems, - modalLinkDirectItem, - normalizedModalLinkQuery.directNumber, - normalizedModalLinkQuery.query - ]) + // Autofocus prompt on mount so the user can start typing immediately. useEffect(() => { - setNewWorkspaceDraft({ - repoId: repoId || null, - name, - prompt: agentPrompt, - note, - attachments: attachmentPaths, - linkedWorkItem, - agent: tuiAgent, - linkedIssue, - linkedPR - }) - }, [ - agentPrompt, - attachmentPaths, - linkedIssue, - linkedPR, - linkedWorkItem, - note, - name, - repoId, - setNewWorkspaceDraft, - tuiAgent - ]) - - useEffect(() => { - if (!repoId && eligibleRepos[0]?.id) { - setRepoId(eligibleRepos[0].id) - } - }, [eligibleRepos, repoId]) - - useEffect(() => { - const timeout = window.setTimeout(() => setLinkDebouncedQuery(linkQuery), 250) - return () => window.clearTimeout(timeout) - }, [linkQuery]) - - useEffect(() => { - const timeout = window.setTimeout(() => setModalLinkDebouncedQuery(modalLinkQuery), 250) - return () => window.clearTimeout(timeout) - }, [modalLinkQuery]) - - useEffect(() => { - if (composerModalOpen) { - modalPromptTextareaRef.current?.focus() - return - } promptTextareaRef.current?.focus() - }, [composerModalOpen]) - - // Why: detect which agents are installed once on mount so the dropdown can - // surface installed agents first. If settings.defaultTuiAgent is null (auto), - // also update the selection to the first detected agent so the pre-selection - // reflects the user's actual environment without requiring manual configuration. - useEffect(() => { - void window.api.preflight.detectAgents().then((ids) => { - setDetectedAgentIds(new Set(ids)) - if (!newWorkspaceDraft?.agent && !settings?.defaultTuiAgent && ids.length > 0) { - const firstInCatalogOrder = AGENT_CATALOG.find((a) => ids.includes(a.id)) - if (firstInCatalogOrder) { - setTuiAgent(firstInCatalogOrder.id) - } - } - }) - // Why: intentionally run only once on mount — detection is a best-effort - // PATH snapshot and does not need to re-run when the draft or settings change. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - 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]) - - useEffect(() => { - if (!selectedRepo) { - setLinkRepoSlug(null) - return - } - - let cancelled = false - void window.api.gh - .repoSlug({ repoPath: selectedRepo.path }) - .then((slug) => { - if (!cancelled) { - setLinkRepoSlug(slug) - } - }) - .catch(() => { - if (!cancelled) { - setLinkRepoSlug(null) - } - }) - - return () => { - cancelled = true - } - }, [selectedRepo]) - - 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]) - - useEffect(() => { - if (shouldWaitForSetupCheck) { - setModalSetupDecision(null) - return - } - if (!setupConfig) { - setModalSetupDecision(null) - return - } - if (setupPolicy === 'ask') { - setModalSetupDecision(null) - return - } - setModalSetupDecision(setupPolicy === 'run-by-default' ? 'run' : 'skip') - }, [setupConfig, setupPolicy, shouldWaitForSetupCheck]) - - useEffect(() => { - if (!canOfferIssueAutomation) { - setRunIssueAutomation(false) - return - } - setRunIssueAutomation(true) - }, [canOfferIssueAutomation]) + }, [promptTextareaRef]) useEffect(() => { const timeout = window.setTimeout(() => { @@ -853,189 +247,6 @@ export default function NewWorkspacePage(): React.JSX.Element { } }, [appliedTaskSearch, selectedRepo, taskRefreshNonce, taskSource]) - useEffect(() => { - if (!linkPopoverOpen || !selectedRepo) { - return - } - - let cancelled = false - setLinkItemsLoading(true) - - void window.api.gh - .listWorkItems({ repoPath: selectedRepo.path, limit: 100 }) - .then((items) => { - if (!cancelled) { - setLinkItems(items) - } - }) - .catch(() => { - if (!cancelled) { - setLinkItems([]) - } - }) - .finally(() => { - if (!cancelled) { - setLinkItemsLoading(false) - } - }) - - return () => { - cancelled = true - } - }, [linkPopoverOpen, selectedRepo]) - - useEffect(() => { - if (!modalLinkPopoverOpen || !selectedRepo) { - return - } - - let cancelled = false - setLinkItemsLoading(true) - - void window.api.gh - .listWorkItems({ repoPath: selectedRepo.path, limit: 100 }) - .then((items) => { - if (!cancelled) { - setLinkItems(items) - } - }) - .catch(() => { - if (!cancelled) { - setLinkItems([]) - } - }) - .finally(() => { - if (!cancelled) { - setLinkItemsLoading(false) - } - }) - - return () => { - cancelled = true - } - }, [modalLinkPopoverOpen, 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. - void window.api.gh - .workItem({ repoPath: selectedRepo.path, number: normalizedLinkQuery.directNumber }) - .then((item) => { - if (!cancelled) { - setLinkDirectItem(item) - } - }) - .catch(() => { - if (!cancelled) { - setLinkDirectItem(null) - } - }) - .finally(() => { - if (!cancelled) { - setLinkDirectLoading(false) - } - }) - - return () => { - cancelled = true - } - }, [linkPopoverOpen, normalizedLinkQuery.directNumber, selectedRepo]) - - useEffect(() => { - if (!modalLinkPopoverOpen || !selectedRepo || normalizedModalLinkQuery.directNumber === null) { - setModalLinkDirectItem(null) - setModalLinkDirectLoading(false) - return - } - - let cancelled = false - setModalLinkDirectLoading(true) - void window.api.gh - .workItem({ repoPath: selectedRepo.path, number: normalizedModalLinkQuery.directNumber }) - .then((item) => { - if (!cancelled) { - setModalLinkDirectItem(item) - } - }) - .catch(() => { - if (!cancelled) { - setModalLinkDirectItem(null) - } - }) - .finally(() => { - if (!cancelled) { - setModalLinkDirectLoading(false) - } - }) - - return () => { - cancelled = true - } - }, [modalLinkPopoverOpen, normalizedModalLinkQuery.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 handleSelectWorkItem = (item: GitHubWorkItem): void => { - if (item.type === 'issue') { - setModalLinkedIssue(String(item.number)) - setModalLinkedPR(null) - } else { - setModalLinkedIssue('') - setModalLinkedPR(item.number) - } - setModalLinkedWorkItem({ - type: item.type, - number: item.number, - title: item.title, - url: item.url - }) - const suggestedName = getLinkedWorkItemSuggestedName(item) - setModalName(suggestedName) - modalLastAutoNameRef.current = suggestedName - setModalAgentPrompt('') - setModalNote('') - setModalAttachmentPaths([]) - setModalCreateError(null) - setModalLinkPopoverOpen(false) - setModalLinkQuery('') - setModalLinkDebouncedQuery('') - setModalLinkDirectItem(null) - setModalAdvancedOpen(false) - setComposerModalOpen(true) - } - useEffect(() => { // Why: the composer should reflect the user's saved default once on mount // and after clearing a custom query, but only when there's no active custom @@ -1092,414 +303,39 @@ export default function NewWorkspacePage(): React.JSX.Element { [handleApplyTaskSearch] ) - const handleLinkPopoverChange = useCallback((open: boolean): void => { - setLinkPopoverOpen(open) - if (!open) { - setLinkQuery('') - setLinkDebouncedQuery('') - setLinkDirectItem(null) - } - }, []) - - const handleModalLinkPopoverChange = useCallback((open: boolean): void => { - setModalLinkPopoverOpen(open) - if (!open) { - setModalLinkQuery('') - setModalLinkDebouncedQuery('') - setModalLinkDirectItem(null) - } - }, []) - - const handleSelectLinkedItem = useCallback( + const handleSelectWorkItem = useCallback( (item: GitHubWorkItem): void => { - applyLinkedWorkItem(item) - handleLinkPopoverChange(false) - }, - [applyLinkedWorkItem, handleLinkPopoverChange] - ) - - const handleSelectModalLinkedItem = useCallback( - (item: GitHubWorkItem): void => { - if (item.type === 'issue') { - setModalLinkedIssue(String(item.number)) - setModalLinkedPR(null) - } else { - setModalLinkedIssue('') - setModalLinkedPR(item.number) - } - setModalLinkedWorkItem({ + // Why: selecting a task from the list opens the same lightweight composer + // modal used by Cmd+J, so the prompt path is identical whether the user + // arrives via palette URL, picked issue/PR, or chose one from this list. + const linkedWorkItem: LinkedWorkItemSummary = { type: item.type, number: item.number, title: item.title, url: item.url - }) - const suggestedName = getLinkedWorkItemSuggestedName(item) - if (suggestedName && (!modalName.trim() || modalName === modalLastAutoNameRef.current)) { - setModalName(suggestedName) - modalLastAutoNameRef.current = suggestedName } - handleModalLinkPopoverChange(false) + openModal('new-workspace-composer', { + linkedWorkItem, + prefilledName: getLinkedWorkItemSuggestedName(item), + initialRepoId: repoId + }) }, - [handleModalLinkPopoverChange, modalName] + [openModal, repoId] ) - const handleNameChange = useCallback( - (event: React.ChangeEvent): void => { - const nextName = event.target.value - // 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 handleModalNameChange = useCallback( - (event: React.ChangeEvent): void => { - const nextName = event.target.value - if (!nextName.trim()) { - modalLastAutoNameRef.current = '' - } else if (modalName !== modalLastAutoNameRef.current) { - modalLastAutoNameRef.current = '' - } - setModalName(nextName) - setModalCreateError(null) - }, - [modalName] - ) - - const handleAddAttachment = useCallback(async (): Promise => { - 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) - } - }, []) - - const handleAddModalAttachment = useCallback(async (): Promise => { - try { - const selectedPath = await window.api.shell.pickAttachment() - if (!selectedPath) { - return - } - setModalAttachmentPaths((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) - } - }, []) - - const handlePromptKeyDown = useCallback( - (event: React.KeyboardEvent): void => { - const mod = isMac ? 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 handleModalPromptKeyDown = useCallback( - (event: React.KeyboardEvent): void => { - const mod = isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey - if (!mod || event.altKey || event.shiftKey || event.key.toLowerCase() !== 'u') { - return - } - - event.preventDefault() - void handleAddModalAttachment() - }, - [handleAddModalAttachment] - ) - - const handleCreate = useCallback(async (): Promise => { - // Why: the full-page composer mirrors chat-first tools where the prompt or - // selected task is the primary input. When the user leaves the name blank, - // derive a stable branch/workspace name from the selected task or prompt - // instead of forcing a duplicate manual field. - const workspaceName = workspaceSeedName - if ( - !repoId || - !workspaceName || - !selectedRepo || - shouldWaitForSetupCheck || - shouldWaitForIssueAutomationCheck || - (requiresExplicitSetupChoice && !setupDecision) - ) { - return - } - - setCreateError(null) - setCreating(true) - try { - const result = await createWorktree( - repoId, - workspaceName, - undefined, - (resolvedSetupDecision ?? 'inherit') as SetupDecision - ) - const worktree = result.worktree - - try { - const metaUpdates: { - linkedIssue?: number - linkedPR?: number - comment?: string - } = {} - if (parsedLinkedIssueNumber !== null) { - metaUpdates.linkedIssue = parsedLinkedIssueNumber - } - if (linkedPR !== null) { - metaUpdates.linkedPR = linkedPR - } - if (note.trim()) { - metaUpdates.comment = note.trim() - } - if (Object.keys(metaUpdates).length > 0) { - await updateWorktreeMeta(worktree.id, metaUpdates) - } - } catch { - console.error('Failed to update worktree meta after creation') - } - - const issueCommand = shouldRunIssueAutomation - ? { - command: issueCommandTemplate.replace(/\{\{issue\}\}/g, String(parsedLinkedIssueNumber)) - } - : undefined - const startupPlan = buildAgentStartupPlan({ - agent: tuiAgent, - prompt: startupPrompt, - cmdOverrides: settings?.agentCmdOverrides ?? {}, - platform: CLIENT_PLATFORM - }) - - activateAndRevealWorktree(worktree.id, { - setup: result.setup, - issueCommand, - ...(startupPlan ? { startup: { command: startupPlan.launchCommand } } : {}) - }) - if (startupPlan) { - void ensureAgentStartupInTerminal({ - worktreeId: worktree.id, - startup: startupPlan - }) - } - setSidebarOpen(true) - if (settings?.rightSidebarOpenByDefault) { - setRightSidebarTab('explorer') - setRightSidebarOpen(true) - } - clearNewWorkspaceDraft() - closeNewWorkspacePage() - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to create worktree.' - setCreateError(message) - toast.error(message) - } finally { - setCreating(false) - } - }, [ - clearNewWorkspaceDraft, - closeNewWorkspacePage, - createWorktree, - issueCommandTemplate, - linkedPR, - note, - parsedLinkedIssueNumber, - repoId, - requiresExplicitSetupChoice, - resolvedSetupDecision, - selectedRepo, - settings?.agentCmdOverrides, - settings?.rightSidebarOpenByDefault, - setRightSidebarOpen, - setRightSidebarTab, - setSidebarOpen, - setupDecision, - tuiAgent, - shouldRunIssueAutomation, - shouldWaitForIssueAutomationCheck, - shouldWaitForSetupCheck, - startupPrompt, - updateWorktreeMeta, - workspaceSeedName - ]) - - const handleModalCreate = useCallback(async (): Promise => { - const workspaceName = modalWorkspaceSeedName - if ( - !repoId || - !workspaceName || - !selectedRepo || - shouldWaitForSetupCheck || - (requiresExplicitSetupChoice && !modalSetupDecision) - ) { - return - } - - setModalCreateError(null) - setModalCreating(true) - try { - const result = await createWorktree( - repoId, - workspaceName, - undefined, - (resolvedModalSetupDecision ?? 'inherit') as SetupDecision - ) - const worktree = result.worktree - - try { - const metaUpdates: { - linkedIssue?: number - linkedPR?: number - comment?: string - } = {} - if (parsedModalLinkedIssueNumber !== null) { - metaUpdates.linkedIssue = parsedModalLinkedIssueNumber - } - if (modalLinkedPR !== null) { - metaUpdates.linkedPR = modalLinkedPR - } - if (modalNote.trim()) { - metaUpdates.comment = modalNote.trim() - } - if (Object.keys(metaUpdates).length > 0) { - await updateWorktreeMeta(worktree.id, metaUpdates) - } - } catch { - console.error('Failed to update worktree meta after creation') - } - - const startupPlan = buildAgentStartupPlan({ - agent: modalTuiAgent, - prompt: modalStartupPrompt, - cmdOverrides: settings?.agentCmdOverrides ?? {}, - platform: CLIENT_PLATFORM - }) - - activateAndRevealWorktree(worktree.id, { - setup: result.setup, - ...(startupPlan ? { startup: { command: startupPlan.launchCommand } } : {}) - }) - if (startupPlan) { - void ensureAgentStartupInTerminal({ - worktreeId: worktree.id, - startup: startupPlan - }) - } - setSidebarOpen(true) - if (settings?.rightSidebarOpenByDefault) { - setRightSidebarTab('explorer') - setRightSidebarOpen(true) - } - clearNewWorkspaceDraft() - closeNewWorkspacePage() - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to create worktree.' - setModalCreateError(message) - toast.error(message) - } finally { - setModalCreating(false) - } - }, [ - clearNewWorkspaceDraft, - closeNewWorkspacePage, - createWorktree, - modalLinkedPR, - modalNote, - modalSetupDecision, - modalStartupPrompt, - modalTuiAgent, - modalWorkspaceSeedName, - parsedModalLinkedIssueNumber, - repoId, - requiresExplicitSetupChoice, - resolvedModalSetupDecision, - selectedRepo, - settings?.agentCmdOverrides, - settings?.rightSidebarOpenByDefault, - setRightSidebarOpen, - setRightSidebarTab, - setSidebarOpen, - shouldWaitForSetupCheck, - updateWorktreeMeta - ]) - - const createDisabled = - !repoId || - !workspaceSeedName || - creating || - shouldWaitForSetupCheck || - shouldWaitForIssueAutomationCheck || - (requiresExplicitSetupChoice && !setupDecision) - const modalCreateDisabled = - !repoId || - !modalWorkspaceSeedName || - modalCreating || - shouldWaitForSetupCheck || - (requiresExplicitSetupChoice && !modalSetupDecision) - const handleDiscardDraft = useCallback((): void => { clearNewWorkspaceDraft() closeNewWorkspacePage() }, [clearNewWorkspaceDraft, closeNewWorkspacePage]) - const handleComposerRepoChange = useCallback((value: string): void => { - startTransition(() => { - setRepoId(value) - setLinkedIssue('') - setLinkedPR(null) - setLinkedWorkItem(null) - setModalLinkedIssue('') - setModalLinkedPR(null) - setModalLinkedWorkItem(null) - }) - }, []) - - const handleRemoveLinkedWorkItem = useCallback((): void => { - setLinkedWorkItem(null) - setLinkedIssue('') - setLinkedPR(null) - if (name === lastAutoNameRef.current) { - lastAutoNameRef.current = '' - } - }, [name]) - - const handleOpenAgentSettings = useCallback((): void => { - openSettingsTarget({ pane: 'agents', repoId: null }) - openSettingsPage() - }, [openSettingsPage, openSettingsTarget]) - useEffect(() => { + // Why: when the global composer modal is on top, let its own scoped key + // handler own Enter/Esc so we don't double-fire (e.g. modal Esc closes + // itself *and* this handler tries to discard the underlying page draft). + if (activeModal === 'new-workspace-composer') { + return + } + const onKeyDown = (event: KeyboardEvent): void => { if (event.key !== 'Enter' && event.key !== 'Escape') { return @@ -1514,19 +350,6 @@ export default function NewWorkspacePage(): React.JSX.Element { // Why: Esc should first dismiss the focused control so users can back // out of text entry without accidentally closing the whole composer. // Once focus is already outside an input, Esc becomes the discard shortcut. - if (composerModalOpen) { - if ( - target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement || - target instanceof HTMLSelectElement || - target.isContentEditable - ) { - event.preventDefault() - target.blur() - } - return - } - if ( target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || @@ -1543,13 +366,11 @@ export default function NewWorkspacePage(): React.JSX.Element { return } - const activeComposerRef = composerModalOpen ? modalComposerRef : composerRef - if (!activeComposerRef.current?.contains(target)) { + if (!composerRef.current?.contains(target)) { return } - const activeCreateDisabled = composerModalOpen ? modalCreateDisabled : createDisabled - if (activeCreateDisabled) { + if (createDisabled) { return } @@ -1558,23 +379,12 @@ export default function NewWorkspacePage(): React.JSX.Element { } event.preventDefault() - if (composerModalOpen) { - void handleModalCreate() - return - } - void handleCreate() + void submit() } window.addEventListener('keydown', onKeyDown, { capture: true }) return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) - }, [ - composerModalOpen, - createDisabled, - handleCreate, - handleDiscardDraft, - handleModalCreate, - modalCreateDisabled - ]) + }, [activeModal, composerRef, createDisabled, handleDiscardDraft, submit]) return (
@@ -1619,133 +429,9 @@ export default function NewWorkspacePage(): React.JSX.Element {
- void handleAddAttachment()} - onRemoveAttachment={(pathValue) => - setAttachmentPaths((current) => - current.filter((currentPath) => currentPath !== pathValue) - ) - } - addAttachmentShortcut={ADD_ATTACHMENT_SHORTCUT} - linkedWorkItem={linkedWorkItem} - onRemoveLinkedWorkItem={handleRemoveLinkedWorkItem} - linkPopoverOpen={linkPopoverOpen} - onLinkPopoverOpenChange={handleLinkPopoverChange} - linkQuery={linkQuery} - onLinkQueryChange={setLinkQuery} - filteredLinkItems={filteredLinkItems} - linkItemsLoading={linkItemsLoading} - linkDirectLoading={linkDirectLoading} - normalizedLinkQuery={normalizedLinkQuery} - onSelectLinkedItem={handleSelectLinkedItem} - tuiAgent={tuiAgent} - onTuiAgentChange={setTuiAgent} - detectedAgentIds={detectedAgentIds as Set | null} - onOpenAgentSettings={handleOpenAgentSettings} - advancedOpen={advancedOpen} - onToggleAdvanced={() => setAdvancedOpen((current) => !current)} - createDisabled={createDisabled} - creating={creating} - onCreate={() => void handleCreate()} - note={note} - onNoteChange={setNote} - setupConfig={setupConfig} - requiresExplicitSetupChoice={requiresExplicitSetupChoice} - setupDecision={setupDecision} - onSetupDecisionChange={setSetupDecision} - shouldWaitForSetupCheck={shouldWaitForSetupCheck} - resolvedSetupDecision={resolvedSetupDecision} - createError={createError} - /> +
- - { - event.preventDefault() - modalPromptTextareaRef.current?.focus() - }} - > -
- Esc closes -
- void handleAddModalAttachment()} - onRemoveAttachment={(pathValue) => - setModalAttachmentPaths((current) => - current.filter((currentPath) => currentPath !== pathValue) - ) - } - addAttachmentShortcut={ADD_ATTACHMENT_SHORTCUT} - linkedWorkItem={modalLinkedWorkItem} - onRemoveLinkedWorkItem={() => { - setModalLinkedWorkItem(null) - setModalLinkedIssue('') - setModalLinkedPR(null) - if (modalName === modalLastAutoNameRef.current) { - modalLastAutoNameRef.current = '' - } - }} - linkPopoverOpen={modalLinkPopoverOpen} - onLinkPopoverOpenChange={handleModalLinkPopoverChange} - linkQuery={modalLinkQuery} - onLinkQueryChange={setModalLinkQuery} - filteredLinkItems={filteredModalLinkItems} - linkItemsLoading={linkItemsLoading} - linkDirectLoading={modalLinkDirectLoading} - normalizedLinkQuery={normalizedModalLinkQuery} - onSelectLinkedItem={handleSelectModalLinkedItem} - tuiAgent={modalTuiAgent} - onTuiAgentChange={setModalTuiAgent} - detectedAgentIds={detectedAgentIds as Set | null} - onOpenAgentSettings={handleOpenAgentSettings} - advancedOpen={modalAdvancedOpen} - onToggleAdvanced={() => setModalAdvancedOpen((current) => !current)} - createDisabled={modalCreateDisabled} - creating={modalCreating} - onCreate={() => void handleModalCreate()} - note={modalNote} - onNoteChange={setModalNote} - setupConfig={setupConfig} - requiresExplicitSetupChoice={requiresExplicitSetupChoice} - setupDecision={modalSetupDecision} - onSetupDecisionChange={setModalSetupDecision} - shouldWaitForSetupCheck={shouldWaitForSetupCheck} - resolvedSetupDecision={resolvedModalSetupDecision} - createError={modalCreateError} - /> -
-
-
@@ -1782,14 +468,7 @@ export default function NewWorkspacePage(): React.JSX.Element { { - startTransition(() => { - setRepoId(value) - setLinkedIssue('') - setLinkedPR(null) - setLinkedWorkItem(null) - }) - }} + onValueChange={onRepoChange} placeholder="Select a repository" triggerClassName="h-11 w-full rounded-[10px] border border-border/50 bg-background/50 backdrop-blur-md px-3 text-sm font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none supports-[backdrop-filter]:bg-background/50" /> diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index cb62a1d7..b2fe73ec 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -11,6 +11,9 @@ import { CommandItem } from '@/components/ui/command' import { branchName } from '@/lib/git-utils' +import { parseGitHubIssueOrPRNumber, parseGitHubIssueOrPRLink } from '@/lib/github-links' +import { getLinkedWorkItemSuggestedName } from '@/lib/new-workspace' +import type { LinkedWorkItemSummary } from '@/lib/new-workspace' import { sortWorktreesSmart } from '@/components/sidebar/smart-sort' import StatusIndicator from '@/components/sidebar/StatusIndicator' import { cn } from '@/lib/utils' @@ -131,7 +134,7 @@ function findBrowserSelection( export default function WorktreeJumpPalette(): React.JSX.Element | null { const visible = useAppStore((s) => s.activeModal === 'worktree-palette') const closeModal = useAppStore((s) => s.closeModal) - const openNewWorkspacePage = useAppStore((s) => s.openNewWorkspacePage) + const openModal = useAppStore((s) => s.openModal) const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const repos = useAppStore((s) => s.repos) const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) @@ -473,13 +476,133 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const handleCreateWorktree = useCallback(() => { skipRestoreFocusRef.current = true - closeModal() - // Why: we open the full-page new-workspace view in a microtask so Radix - // fully unmounts before the next autofocus cycle runs, avoiding focus churn. - queueMicrotask(() => - openNewWorkspacePage(createWorktreeName ? { prefilledName: createWorktreeName } : {}) - ) - }, [closeModal, createWorktreeName, openNewWorkspacePage]) + const trimmed = createWorktreeName.trim() + const ghLink = parseGitHubIssueOrPRLink(trimmed) + const ghNumber = parseGitHubIssueOrPRNumber(trimmed) + + const openComposer = (data: Record): void => { + closeModal() + // Why: defer opening so Radix fully unmounts the palette's dialog before + // the composer modal mounts, avoiding focus churn between the two. + queueMicrotask(() => openModal('new-workspace-composer', data)) + } + + // Case 1: user pasted a GH issue/PR URL. + if (ghLink) { + const { slug, number } = ghLink + const state = useAppStore.getState() + + // Why: the existing-worktree check only needs the issue/PR number, which + // is repo-agnostic on the worktree meta side. We don't currently cache a + // repo-slug map, so slug-matching against a specific repo happens + // implicitly when we pick a repo for the `gh workItem` lookup below. + const allWorktrees = Object.values(state.worktreesByRepo).flat() + const matches = allWorktrees.filter( + (w) => !w.isArchived && (w.linkedIssue === number || w.linkedPR === number) + ) + const activeMatch = matches.find((w) => w.repoId === state.activeRepoId) ?? matches[0] + if (activeMatch) { + closeModal() + activateAndRevealWorktree(activeMatch.id) + return + } + + // Resolve via gh.workItem: prefer the active repo, else the first eligible. + const eligibleRepos = state.repos.filter((r) => isGitRepoKind(r)) + const repoForLookup = + (state.activeRepoId && eligibleRepos.find((r) => r.id === state.activeRepoId)) || + eligibleRepos[0] + if (!repoForLookup) { + openComposer({ prefilledName: trimmed }) + return + } + + // Why: awaiting inside the user gesture would leave the palette open + // indefinitely on slow networks. Close immediately and populate the + // composer once the lookup returns. + closeModal() + void window.api.gh + .workItem({ repoPath: repoForLookup.path, number }) + .then((item) => { + const data: Record = { initialRepoId: repoForLookup.id } + if (item) { + const linkedWorkItem: LinkedWorkItemSummary = { + type: item.type, + number: item.number, + title: item.title, + url: item.url + } + data.linkedWorkItem = linkedWorkItem + data.prefilledName = getLinkedWorkItemSuggestedName(item) + } else { + // Fallback: we couldn't resolve the URL, just seed the name. + data.prefilledName = `${slug.owner}-${slug.repo}-${number}` + } + queueMicrotask(() => openModal('new-workspace-composer', data)) + }) + .catch(() => { + queueMicrotask(() => + openModal('new-workspace-composer', { initialRepoId: repoForLookup.id }) + ) + }) + return + } + + // Case 2: user typed a raw issue number. Resolve against the active repo. + if (ghNumber !== null) { + const state = useAppStore.getState() + const allWorktrees = Object.values(state.worktreesByRepo).flat() + const matches = allWorktrees.filter( + (w) => !w.isArchived && (w.linkedIssue === ghNumber || w.linkedPR === ghNumber) + ) + const activeMatch = matches.find((w) => w.repoId === state.activeRepoId) ?? matches[0] + if (activeMatch) { + closeModal() + activateAndRevealWorktree(activeMatch.id) + return + } + + const repoForLookup = + (state.activeRepoId && state.repos.find((r) => r.id === state.activeRepoId)) || + state.repos.find((r) => isGitRepoKind(r)) + if (!repoForLookup || !isGitRepoKind(repoForLookup)) { + openComposer({ prefilledName: trimmed }) + return + } + + closeModal() + void window.api.gh + .workItem({ repoPath: repoForLookup.path, number: ghNumber }) + .then((item) => { + const data: Record = { initialRepoId: repoForLookup.id } + if (item) { + const linkedWorkItem: LinkedWorkItemSummary = { + type: item.type, + number: item.number, + title: item.title, + url: item.url + } + data.linkedWorkItem = linkedWorkItem + data.prefilledName = getLinkedWorkItemSuggestedName(item) + } else { + data.prefilledName = trimmed + } + queueMicrotask(() => openModal('new-workspace-composer', data)) + }) + .catch(() => { + queueMicrotask(() => + openModal('new-workspace-composer', { + initialRepoId: repoForLookup.id, + prefilledName: trimmed + }) + ) + }) + return + } + + // Case 3: plain name — open composer prefilled. + openComposer(trimmed ? { prefilledName: trimmed } : {}) + }, [closeModal, createWorktreeName, openModal]) const handleCloseAutoFocus = useCallback((e: Event) => { e.preventDefault() diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts new file mode 100644 index 00000000..b4c48c14 --- /dev/null +++ b/src/renderer/src/hooks/useComposerState.ts @@ -0,0 +1,824 @@ +/* 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 { useAppStore } from '@/store' +import { AGENT_CATALOG } from '@/lib/agent-catalog' +import { parseGitHubIssueOrPRNumber, normalizeGitHubLinkQuery } from '@/lib/github-links' +import type { RepoSlug } from '@/lib/github-links' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' +import { isGitRepoKind } from '../../../shared/repo-kind' +import type { + GitHubWorkItem, + OrcaHooks, + SetupDecision, + SetupRunPolicy, + TuiAgent +} from '../../../shared/types' +import { + ADD_ATTACHMENT_SHORTCUT, + CLIENT_PLATFORM, + IS_MAC, + buildAgentPromptWithContext, + ensureAgentStartupInTerminal, + getAttachmentLabel, + getLinkedWorkItemSuggestedName, + getSetupConfig, + getWorkspaceSeedName, + type LinkedWorkItemSummary +} from '@/lib/new-workspace' + +export type UseComposerStateOptions = { + initialRepoId?: string + initialName?: string + initialPrompt?: string + initialLinkedWorkItem?: LinkedWorkItemSummary | null + /** 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 NewWorkspacePage's task list + * which wants to drive repo selection from the page header, not the card. */ + repoIdOverride?: string + onRepoIdOverrideChange?: (value: string) => void +} + +export type ComposerCardProps = { + eligibleRepos: ReturnType['repos'] + repoId: string + onRepoChange: (value: string) => void + name: string + onNameChange: (event: React.ChangeEvent) => void + agentPrompt: string + onAgentPromptChange: (value: string) => void + onPromptKeyDown: (event: React.KeyboardEvent) => void + 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; repoMismatch: string | null } + onSelectLinkedItem: (item: GitHubWorkItem) => void + tuiAgent: TuiAgent + onTuiAgentChange: (value: TuiAgent) => void + detectedAgentIds: Set | null + onOpenAgentSettings: () => void + advancedOpen: boolean + onToggleAdvanced: () => void + createDisabled: boolean + creating: boolean + onCreate: () => void + note: string + onNoteChange: (value: string) => void + 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 +} + +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 + promptTextareaRef: React.RefObject + nameInputRef: React.RefObject + submit: () => Promise + /** Invoked by the Enter handler to re-check whether submission should fire. */ + createDisabled: boolean +} + +export function useComposerState(options: UseComposerStateOptions): UseComposerStateResult { + const { + initialRepoId, + initialName = '', + initialPrompt = '', + initialLinkedWorkItem = null, + persistDraft, + onCreated, + repoIdOverride, + onRepoIdOverrideChange + } = options + + 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 setNewWorkspaceDraft = useAppStore((s) => s.setNewWorkspaceDraft) + const clearNewWorkspaceDraft = useAppStore((s) => s.clearNewWorkspaceDraft) + const createWorktree = useAppStore((s) => s.createWorktree) + const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) + const setSidebarOpen = useAppStore((s) => s.setSidebarOpen) + const setRightSidebarOpen = useAppStore((s) => s.setRightSidebarOpen) + const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab) + const openSettingsPage = useAppStore((s) => s.openSettingsPage) + const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + + 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(resolvedInitialRepoId) + const repoId = repoIdOverride ?? internalRepoId + const setRepoId = useCallback( + (value: string) => { + if (onRepoIdOverrideChange) { + onRepoIdOverrideChange(value) + } else { + setInternalRepoId(value) + } + }, + [onRepoIdOverrideChange] + ) + + const [name, setName] = useState( + persistDraft ? (newWorkspaceDraft?.name ?? initialName) : initialName + ) + const [agentPrompt, setAgentPrompt] = useState( + persistDraft ? (newWorkspaceDraft?.prompt ?? initialPrompt) : initialPrompt + ) + const [note, setNote] = useState(persistDraft ? (newWorkspaceDraft?.note ?? '') : '') + const [attachmentPaths, setAttachmentPaths] = useState( + persistDraft ? (newWorkspaceDraft?.attachments ?? []) : [] + ) + const [linkedWorkItem, setLinkedWorkItem] = useState( + persistDraft + ? (newWorkspaceDraft?.linkedWorkItem ?? initialLinkedWorkItem) + : initialLinkedWorkItem + ) + const [linkedIssue, setLinkedIssue] = useState(() => { + if (persistDraft && newWorkspaceDraft?.linkedIssue) { + return newWorkspaceDraft.linkedIssue + } + if (initialLinkedWorkItem?.type === 'issue') { + return String(initialLinkedWorkItem.number) + } + return '' + }) + const [linkedPR, setLinkedPR] = useState(() => { + if (persistDraft && newWorkspaceDraft?.linkedPR !== undefined) { + return newWorkspaceDraft.linkedPR + } + return initialLinkedWorkItem?.type === 'pr' ? initialLinkedWorkItem.number : null + }) + const [tuiAgent, setTuiAgent] = useState( + persistDraft + ? (newWorkspaceDraft?.agent ?? settings?.defaultTuiAgent ?? 'claude') + : (settings?.defaultTuiAgent ?? 'claude') + ) + const [detectedAgentIds, setDetectedAgentIds] = useState | null>(null) + + const [yamlHooks, setYamlHooks] = useState(null) + const [checkedHooksRepoId, setCheckedHooksRepoId] = useState(null) + const [issueCommandTemplate, setIssueCommandTemplate] = useState('') + const [hasLoadedIssueCommand, setHasLoadedIssueCommand] = useState(false) + const [setupDecision, setSetupDecision] = useState<'run' | 'skip' | null>(null) + const [runIssueAutomation, setRunIssueAutomation] = useState(false) + const [creating, setCreating] = useState(false) + const [createError, setCreateError] = useState(null) + const [advancedOpen, setAdvancedOpen] = useState( + persistDraft ? Boolean((newWorkspaceDraft?.note ?? '').trim()) : false + ) + + const [linkPopoverOpen, setLinkPopoverOpen] = useState(false) + const [linkQuery, setLinkQuery] = useState('') + const [linkDebouncedQuery, setLinkDebouncedQuery] = useState('') + const [linkItems, setLinkItems] = useState([]) + const [linkItemsLoading, setLinkItemsLoading] = useState(false) + const [linkDirectItem, setLinkDirectItem] = useState(null) + const [linkDirectLoading, setLinkDirectLoading] = useState(false) + const [linkRepoSlug, setLinkRepoSlug] = useState(null) + + const lastAutoNameRef = useRef( + persistDraft ? (newWorkspaceDraft?.name ?? initialName) : initialName + ) + const composerRef = useRef(null) + const promptTextareaRef = useRef(null) + const nameInputRef = useRef(null) + + const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId) + const parsedLinkedIssueNumber = useMemo( + () => (linkedIssue.trim() ? parseGitHubIssueOrPRNumber(linkedIssue) : null), + [linkedIssue] + ) + 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 + const shouldRunIssueAutomation = canOfferIssueAutomation && runIssueAutomation + const shouldWaitForIssueAutomationCheck = + parsedLinkedIssueNumber !== null && !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) && isSetupCheckPending + + const workspaceSeedName = useMemo( + () => + getWorkspaceSeedName({ + explicitName: name, + prompt: agentPrompt, + linkedIssueNumber: parsedLinkedIssueNumber, + linkedPR + }), + [agentPrompt, linkedPR, name, parsedLinkedIssueNumber] + ) + const startupPrompt = useMemo( + () => + buildAgentPromptWithContext( + agentPrompt, + attachmentPaths, + linkedWorkItem?.url ? [linkedWorkItem.url] : [] + ), + [agentPrompt, attachmentPaths, linkedWorkItem?.url] + ) + const normalizedLinkQuery = useMemo( + () => normalizeGitHubLinkQuery(linkDebouncedQuery, linkRepoSlug), + [linkDebouncedQuery, linkRepoSlug] + ) + + 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 + }) + }, [ + persistDraft, + agentPrompt, + attachmentPaths, + 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]) + + // Detect installed agents once on mount. + useEffect(() => { + void window.api.preflight.detectAgents().then((ids) => { + // Why: detectAgents returns agent IDs as strings, but AGENT_CATALOG + the + // dropdown consume the narrower TuiAgent union. Cast once at the boundary + // so downstream consumers keep their precise types without leaking an + // `as unknown` through the card props. + setDetectedAgentIds(new Set(ids as TuiAgent[])) + if (!newWorkspaceDraft?.agent && !settings?.defaultTuiAgent && ids.length > 0) { + const firstInCatalogOrder = AGENT_CATALOG.find((a) => ids.includes(a.id)) + if (firstInCatalogOrder) { + setTuiAgent(firstInCatalogOrder.id) + } + } + }) + // Why: intentionally run only once on mount — detection is a best-effort + // PATH snapshot and does not need to re-run when the draft or settings change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // 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]) + + // Per-repo: resolve repo slug for GH URL mismatch detection. + useEffect(() => { + if (!selectedRepo) { + setLinkRepoSlug(null) + return + } + + let cancelled = false + void window.api.gh + .repoSlug({ repoPath: selectedRepo.path }) + .then((slug) => { + if (!cancelled) { + setLinkRepoSlug(slug) + } + }) + .catch(() => { + if (!cancelled) { + setLinkRepoSlug(null) + } + }) + + return () => { + cancelled = true + } + }, [selectedRepo]) + + // 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]) + + useEffect(() => { + if (!canOfferIssueAutomation) { + setRunIssueAutomation(false) + return + } + setRunIssueAutomation(true) + }, [canOfferIssueAutomation]) + + // 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) + + void window.api.gh + .listWorkItems({ repoPath: selectedRepo.path, limit: 100 }) + .then((items) => { + if (!cancelled) { + setLinkItems(items) + } + }) + .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. + void window.api.gh + .workItem({ repoPath: selectedRepo.path, number: normalizedLinkQuery.directNumber }) + .then((item) => { + if (!cancelled) { + setLinkDirectItem(item) + } + }) + .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 handleNameChange = useCallback( + (event: React.ChangeEvent): void => { + const nextName = event.target.value + // 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 => { + 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) + } + }, []) + + const handlePromptKeyDown = useCallback( + (event: React.KeyboardEvent): 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 => { + setRepoId(value) + setLinkedIssue('') + setLinkedPR(null) + setLinkedWorkItem(null) + }, + [setRepoId] + ) + + const handleOpenAgentSettings = useCallback((): void => { + openSettingsTarget({ pane: 'agents', repoId: null }) + openSettingsPage() + }, [openSettingsPage, openSettingsTarget]) + + const submit = useCallback(async (): Promise => { + const workspaceName = workspaceSeedName + if ( + !repoId || + !workspaceName || + !selectedRepo || + shouldWaitForSetupCheck || + shouldWaitForIssueAutomationCheck || + (requiresExplicitSetupChoice && !setupDecision) + ) { + return + } + + setCreateError(null) + setCreating(true) + try { + const result = await createWorktree( + repoId, + workspaceName, + undefined, + (resolvedSetupDecision ?? 'inherit') as SetupDecision + ) + const worktree = result.worktree + + try { + const metaUpdates: { + linkedIssue?: number + linkedPR?: number + comment?: string + } = {} + if (parsedLinkedIssueNumber !== null) { + metaUpdates.linkedIssue = parsedLinkedIssueNumber + } + if (linkedPR !== null) { + metaUpdates.linkedPR = linkedPR + } + if (note.trim()) { + metaUpdates.comment = note.trim() + } + if (Object.keys(metaUpdates).length > 0) { + await updateWorktreeMeta(worktree.id, metaUpdates) + } + } catch { + console.error('Failed to update worktree meta after creation') + } + + const issueCommand = shouldRunIssueAutomation + ? { + command: issueCommandTemplate.replace(/\{\{issue\}\}/g, String(parsedLinkedIssueNumber)) + } + : undefined + const startupPlan = buildAgentStartupPlan({ + agent: tuiAgent, + prompt: startupPrompt, + cmdOverrides: settings?.agentCmdOverrides ?? {}, + platform: CLIENT_PLATFORM + }) + + activateAndRevealWorktree(worktree.id, { + setup: result.setup, + issueCommand, + ...(startupPlan ? { startup: { command: startupPlan.launchCommand } } : {}) + }) + 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) + } + }, [ + clearNewWorkspaceDraft, + createWorktree, + issueCommandTemplate, + linkedPR, + note, + onCreated, + parsedLinkedIssueNumber, + persistDraft, + repoId, + requiresExplicitSetupChoice, + resolvedSetupDecision, + selectedRepo, + settings?.agentCmdOverrides, + settings?.rightSidebarOpenByDefault, + setRightSidebarOpen, + setRightSidebarTab, + setSidebarOpen, + setupDecision, + tuiAgent, + shouldRunIssueAutomation, + shouldWaitForIssueAutomationCheck, + shouldWaitForSetupCheck, + startupPrompt, + updateWorktreeMeta, + workspaceSeedName + ]) + + const createDisabled = + !repoId || + !workspaceSeedName || + creating || + shouldWaitForSetupCheck || + shouldWaitForIssueAutomationCheck || + (requiresExplicitSetupChoice && !setupDecision) + + const cardProps: ComposerCardProps = { + eligibleRepos, + repoId, + onRepoChange: handleRepoChange, + name, + onNameChange: handleNameChange, + agentPrompt, + onAgentPromptChange: setAgentPrompt, + onPromptKeyDown: handlePromptKeyDown, + 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(), + note, + onNoteChange: setNote, + setupConfig, + requiresExplicitSetupChoice, + setupDecision, + onSetupDecisionChange: setSetupDecision, + shouldWaitForSetupCheck, + resolvedSetupDecision, + createError + } + + return { + cardProps, + composerRef, + promptTextareaRef, + nameInputRef, + submit, + createDisabled + } +} diff --git a/src/renderer/src/lib/github-links.ts b/src/renderer/src/lib/github-links.ts index a7e2555a..b7e71c93 100644 --- a/src/renderer/src/lib/github-links.ts +++ b/src/renderer/src/lib/github-links.ts @@ -1,4 +1,16 @@ const GH_ITEM_PATH_RE = /^\/[^/]+\/[^/]+\/(?:issues|pull)\/(\d+)(?:\/)?$/i +const GH_ITEM_PATH_FULL_RE = /^\/([^/]+)\/([^/]+)\/(?:issues|pull)\/(\d+)(?:\/)?$/i + +export type RepoSlug = { + owner: string + repo: string +} + +export type GitHubLinkQuery = { + query: string + repoMismatch: string | null + directNumber: number | null +} /** * Parses a GitHub issue/PR reference from plain input. @@ -33,3 +45,78 @@ export function parseGitHubIssueOrPRNumber(input: string): number | null { return Number.parseInt(match[1], 10) } + +/** + * Parses an owner/repo slug plus issue/PR number from a GitHub URL. Returns + * null for anything that isn't a recognizable github.com issue or pull URL. + */ +export function parseGitHubIssueOrPRLink(input: string): { + slug: RepoSlug + number: number +} | null { + const trimmed = input.trim() + if (!trimmed) { + return null + } + + let url: URL + try { + url = new URL(trimmed) + } catch { + return null + } + + if (!/^(?:www\.)?github\.com$/i.test(url.hostname)) { + return null + } + + const match = GH_ITEM_PATH_FULL_RE.exec(url.pathname) + if (!match) { + return null + } + + return { + slug: { owner: match[1], repo: match[2] }, + number: Number.parseInt(match[3], 10) + } +} + +/** + * Normalizes link-picker input so both raw issue/PR numbers and full GitHub + * URLs resolve to a usable query + direct-number lookup. Returns a repo + * mismatch when a URL targets a different repo than the selected one. + */ +export function normalizeGitHubLinkQuery(raw: string, repoSlug: RepoSlug | null): GitHubLinkQuery { + const trimmed = raw.trim() + if (!trimmed) { + return { query: '', repoMismatch: null, directNumber: null } + } + + const direct = parseGitHubIssueOrPRNumber(trimmed) + if (direct !== null && !trimmed.startsWith('http')) { + return { query: trimmed, repoMismatch: null, directNumber: direct } + } + + const link = parseGitHubIssueOrPRLink(trimmed) + if (!link) { + return { query: trimmed, repoMismatch: null, directNumber: null } + } + + if ( + repoSlug && + (link.slug.owner.toLowerCase() !== repoSlug.owner.toLowerCase() || + link.slug.repo.toLowerCase() !== repoSlug.repo.toLowerCase()) + ) { + return { + query: '', + repoMismatch: `${repoSlug.owner}/${repoSlug.repo}`, + directNumber: null + } + } + + return { + query: trimmed, + repoMismatch: null, + directNumber: link.number + } +} diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts new file mode 100644 index 00000000..8cfd02dc --- /dev/null +++ b/src/renderer/src/lib/new-workspace.ts @@ -0,0 +1,178 @@ +import { useAppStore } from '@/store' +import type { AgentStartupPlan } from '@/lib/tui-agent-startup' +import { isShellProcess } from '@/lib/tui-agent-startup' +import type { GitHubWorkItem, OrcaHooks } from '../../../shared/types' + +export const IS_MAC = navigator.userAgent.includes('Mac') +export const ADD_ATTACHMENT_SHORTCUT = IS_MAC ? '⌘U' : 'Ctrl+U' +export const CLIENT_PLATFORM: NodeJS.Platform = navigator.userAgent.includes('Windows') + ? 'win32' + : IS_MAC + ? 'darwin' + : 'linux' + +export type LinkedWorkItemSummary = { + type: 'issue' | 'pr' + number: number + title: string + url: string +} + +export function buildAgentPromptWithContext( + prompt: string, + attachments: string[], + linkedUrls: string[] +): string { + const trimmedPrompt = prompt.trim() + if (attachments.length === 0 && linkedUrls.length === 0) { + return trimmedPrompt + } + + const sections: string[] = [] + if (attachments.length > 0) { + const attachmentBlock = attachments.map((pathValue) => `- ${pathValue}`).join('\n') + sections.push(`Attachments:\n${attachmentBlock}`) + } + if (linkedUrls.length > 0) { + const linkBlock = linkedUrls.map((url) => `- ${url}`).join('\n') + sections.push(`Linked work items:\n${linkBlock}`) + } + // Why: the new-workspace flow launches each agent with a single plain-text + // startup prompt. Appending attachments and linked URLs keeps extra context + // visible to Claude/Codex/OpenCode without cluttering the visible textarea. + if (!trimmedPrompt) { + return sections.join('\n\n') + } + return `${trimmedPrompt}\n\n${sections.join('\n\n')}` +} + +export function getAttachmentLabel(pathValue: string): string { + const segments = pathValue.split(/[/\\]/) + return segments.at(-1) || pathValue +} + +export function getSetupConfig( + repo: { hookSettings?: { scripts?: { setup?: string } } } | undefined, + yamlHooks: OrcaHooks | null +): { source: 'yaml' | 'legacy'; command: string } | null { + const yamlSetup = yamlHooks?.scripts?.setup?.trim() + if (yamlSetup) { + return { source: 'yaml', command: yamlSetup } + } + const legacySetup = repo?.hookSettings?.scripts?.setup?.trim() + if (legacySetup) { + return { source: 'legacy', command: legacySetup } + } + return null +} + +export function getLinkedWorkItemSuggestedName(item: GitHubWorkItem): string { + const withoutLeadingNumber = item.title + .trim() + .replace(/^(?:issue|pr|pull request)\s*#?\d+\s*[:-]\s*/i, '') + .replace(/^#\d+\s*[:-]\s*/, '') + .replace(/\(#\d+\)/gi, '') + .replace(/\b#\d+\b/g, '') + .trim() + const seed = withoutLeadingNumber || item.title.trim() + return seed + .toLowerCase() + .replace(/[\\/]+/g, '-') + .replace(/\s+/g, '-') + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^[.-]+|[.-]+$/g, '') + .slice(0, 48) + .replace(/[-._]+$/g, '') +} + +export function getWorkspaceSeedName(args: { + explicitName: string + prompt: string + linkedIssueNumber: number | null + linkedPR: number | null +}): string { + const { explicitName, prompt, linkedIssueNumber, linkedPR } = args + if (explicitName.trim()) { + return explicitName.trim() + } + if (linkedPR !== null) { + return `pr-${linkedPR}` + } + if (linkedIssueNumber !== null) { + return `issue-${linkedIssueNumber}` + } + if (prompt.trim()) { + return prompt.trim() + } + // Why: the prompt is optional in this flow. Fall back to a stable default + // branch/workspace seed so users can launch an empty draft without first + // writing a brief or naming the workspace manually. + return 'workspace' +} + +export async function ensureAgentStartupInTerminal(args: { + worktreeId: string + startup: AgentStartupPlan +}): Promise { + const { worktreeId, startup } = args + if (startup.followupPrompt === null) { + return + } + + let promptInjected = false + + for (let attempt = 0; attempt < 30; attempt += 1) { + if (attempt > 0) { + await new Promise((resolve) => window.setTimeout(resolve, 150)) + } + + const state = useAppStore.getState() + const tabId = + state.activeTabIdByWorktree[worktreeId] ?? state.tabsByWorktree[worktreeId]?.[0]?.id ?? null + if (!tabId) { + continue + } + + const ptyId = state.ptyIdsByTabId[tabId]?.[0] + if (!ptyId) { + continue + } + + try { + const foreground = (await window.api.pty.getForegroundProcess(ptyId))?.toLowerCase() ?? '' + const agentOwnsForeground = + foreground === startup.expectedProcess || + foreground.startsWith(`${startup.expectedProcess}.`) + + if (agentOwnsForeground && !promptInjected && startup.followupPrompt) { + window.api.pty.write(ptyId, `${startup.followupPrompt}\r`) + promptInjected = true + return + } + + if (agentOwnsForeground && promptInjected) { + return + } + + const hasChildProcesses = await window.api.pty.hasChildProcesses(ptyId) + if ( + !promptInjected && + startup.followupPrompt && + hasChildProcesses && + !isShellProcess(foreground) && + attempt >= 4 + ) { + // Why: the initial agent launch is already queued on the first terminal + // tab. Only agents without a verified startup-prompt flag need extra + // help here: once the TUI owns the PTY, type the draft prompt into the + // live session instead of launching the binary a second time. + window.api.pty.write(ptyId, `${startup.followupPrompt}\r`) + promptInjected = true + return + } + } catch { + // Ignore transient PTY inspection failures and keep polling. + } + } +} diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index c21f4897..4f10a1af 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -76,6 +76,7 @@ export type UISlice = { | 'add-repo' | 'quick-open' | 'worktree-palette' + | 'new-workspace-composer' modalData: Record openModal: (modal: UISlice['activeModal'], data?: Record) => void closeModal: () => void