mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
feat: open composer modal from Cmd+J with GH URL support (#739)
This commit is contained in:
parent
63b2353cfd
commit
a3dd4e8fb3
8 changed files with 1411 additions and 1378 deletions
|
|
@ -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 ? <RightSidebar /> : null}
|
||||
</div>
|
||||
<StatusBar />
|
||||
{/* Why: NewWorkspaceComposerCard renders Radix <Tooltip>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. */}
|
||||
<NewWorkspaceComposerModal />
|
||||
</TooltipProvider>
|
||||
<QuickOpen />
|
||||
<WorktreeJumpPalette />
|
||||
|
|
|
|||
135
src/renderer/src/components/NewWorkspaceComposerModal.tsx
Normal file
135
src/renderer/src/components/NewWorkspaceComposerModal.tsx
Normal file
|
|
@ -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 (
|
||||
<ComposerModalBody
|
||||
modalData={modalData ?? {}}
|
||||
onClose={closeModal}
|
||||
onOpenChange={handleOpenChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog open onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-[calc(100vw-2rem)] border-none bg-transparent p-0 shadow-none sm:max-w-[880px]"
|
||||
showCloseButton={false}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
promptTextareaRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<NewWorkspaceComposerCard
|
||||
containerClassName="bg-card/98 shadow-2xl supports-[backdrop-filter]:bg-card/95"
|
||||
composerRef={composerRef}
|
||||
nameInputRef={nameInputRef}
|
||||
promptTextareaRef={promptTextareaRef}
|
||||
{...cardProps}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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<string, unknown>): 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<string, unknown> = { 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<string, unknown> = { 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()
|
||||
|
|
|
|||
824
src/renderer/src/hooks/useComposerState.ts
Normal file
824
src/renderer/src/hooks/useComposerState.ts
Normal file
|
|
@ -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<typeof useAppStore.getState>['repos']
|
||||
repoId: string
|
||||
onRepoChange: (value: string) => void
|
||||
name: string
|
||||
onNameChange: (event: React.ChangeEvent<HTMLInputElement>) => void
|
||||
agentPrompt: string
|
||||
onAgentPromptChange: (value: string) => void
|
||||
onPromptKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => 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<TuiAgent> | 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<HTMLDivElement | null>
|
||||
promptTextareaRef: React.RefObject<HTMLTextAreaElement | null>
|
||||
nameInputRef: React.RefObject<HTMLInputElement | null>
|
||||
submit: () => Promise<void>
|
||||
/** 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<string>(resolvedInitialRepoId)
|
||||
const repoId = repoIdOverride ?? internalRepoId
|
||||
const setRepoId = useCallback(
|
||||
(value: string) => {
|
||||
if (onRepoIdOverrideChange) {
|
||||
onRepoIdOverrideChange(value)
|
||||
} else {
|
||||
setInternalRepoId(value)
|
||||
}
|
||||
},
|
||||
[onRepoIdOverrideChange]
|
||||
)
|
||||
|
||||
const [name, setName] = useState<string>(
|
||||
persistDraft ? (newWorkspaceDraft?.name ?? initialName) : initialName
|
||||
)
|
||||
const [agentPrompt, setAgentPrompt] = useState<string>(
|
||||
persistDraft ? (newWorkspaceDraft?.prompt ?? initialPrompt) : initialPrompt
|
||||
)
|
||||
const [note, setNote] = useState<string>(persistDraft ? (newWorkspaceDraft?.note ?? '') : '')
|
||||
const [attachmentPaths, setAttachmentPaths] = useState<string[]>(
|
||||
persistDraft ? (newWorkspaceDraft?.attachments ?? []) : []
|
||||
)
|
||||
const [linkedWorkItem, setLinkedWorkItem] = useState<LinkedWorkItemSummary | null>(
|
||||
persistDraft
|
||||
? (newWorkspaceDraft?.linkedWorkItem ?? initialLinkedWorkItem)
|
||||
: initialLinkedWorkItem
|
||||
)
|
||||
const [linkedIssue, setLinkedIssue] = useState<string>(() => {
|
||||
if (persistDraft && newWorkspaceDraft?.linkedIssue) {
|
||||
return newWorkspaceDraft.linkedIssue
|
||||
}
|
||||
if (initialLinkedWorkItem?.type === 'issue') {
|
||||
return String(initialLinkedWorkItem.number)
|
||||
}
|
||||
return ''
|
||||
})
|
||||
const [linkedPR, setLinkedPR] = useState<number | null>(() => {
|
||||
if (persistDraft && newWorkspaceDraft?.linkedPR !== undefined) {
|
||||
return newWorkspaceDraft.linkedPR
|
||||
}
|
||||
return initialLinkedWorkItem?.type === 'pr' ? initialLinkedWorkItem.number : null
|
||||
})
|
||||
const [tuiAgent, setTuiAgent] = useState<TuiAgent>(
|
||||
persistDraft
|
||||
? (newWorkspaceDraft?.agent ?? settings?.defaultTuiAgent ?? 'claude')
|
||||
: (settings?.defaultTuiAgent ?? 'claude')
|
||||
)
|
||||
const [detectedAgentIds, setDetectedAgentIds] = useState<Set<TuiAgent> | null>(null)
|
||||
|
||||
const [yamlHooks, setYamlHooks] = useState<OrcaHooks | null>(null)
|
||||
const [checkedHooksRepoId, setCheckedHooksRepoId] = useState<string | null>(null)
|
||||
const [issueCommandTemplate, setIssueCommandTemplate] = useState('')
|
||||
const [hasLoadedIssueCommand, setHasLoadedIssueCommand] = useState(false)
|
||||
const [setupDecision, setSetupDecision] = useState<'run' | 'skip' | null>(null)
|
||||
const [runIssueAutomation, setRunIssueAutomation] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [createError, setCreateError] = useState<string | null>(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<GitHubWorkItem[]>([])
|
||||
const [linkItemsLoading, setLinkItemsLoading] = useState(false)
|
||||
const [linkDirectItem, setLinkDirectItem] = useState<GitHubWorkItem | null>(null)
|
||||
const [linkDirectLoading, setLinkDirectLoading] = useState(false)
|
||||
const [linkRepoSlug, setLinkRepoSlug] = useState<RepoSlug | null>(null)
|
||||
|
||||
const lastAutoNameRef = useRef<string>(
|
||||
persistDraft ? (newWorkspaceDraft?.name ?? initialName) : initialName
|
||||
)
|
||||
const composerRef = useRef<HTMLDivElement | null>(null)
|
||||
const promptTextareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const nameInputRef = useRef<HTMLInputElement | null>(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<HTMLInputElement>): 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<void> => {
|
||||
try {
|
||||
const selectedPath = await window.api.shell.pickAttachment()
|
||||
if (!selectedPath) {
|
||||
return
|
||||
}
|
||||
setAttachmentPaths((current) => {
|
||||
if (current.includes(selectedPath)) {
|
||||
return current
|
||||
}
|
||||
return [...current, selectedPath]
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to add attachment.'
|
||||
toast.error(message)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handlePromptKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||
const mod = IS_MAC ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
|
||||
if (!mod || event.altKey || event.shiftKey || event.key.toLowerCase() !== 'u') {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: the attachment picker should only steal Cmd/Ctrl+U while the user
|
||||
// is composing a prompt, so the shortcut is scoped to the textarea rather
|
||||
// than registered globally for the whole new-workspace surface.
|
||||
event.preventDefault()
|
||||
void handleAddAttachment()
|
||||
},
|
||||
[handleAddAttachment]
|
||||
)
|
||||
|
||||
const handleRepoChange = useCallback(
|
||||
(value: string): void => {
|
||||
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<void> => {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
178
src/renderer/src/lib/new-workspace.ts
Normal file
178
src/renderer/src/lib/new-workspace.ts
Normal file
|
|
@ -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<void> {
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -76,6 +76,7 @@ export type UISlice = {
|
|||
| 'add-repo'
|
||||
| 'quick-open'
|
||||
| 'worktree-palette'
|
||||
| 'new-workspace-composer'
|
||||
modalData: Record<string, unknown>
|
||||
openModal: (modal: UISlice['activeModal'], data?: Record<string, unknown>) => void
|
||||
closeModal: () => void
|
||||
|
|
|
|||
Loading…
Reference in a new issue