@@ -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