Files
orca/src/renderer/src/components/NewWorkspaceComposerModal.tsx
T
2026-06-12 18:24:21 -07:00

261 lines
9.5 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { useAppStore } from '@/store'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import NewWorkspaceComposerCard from '@/components/NewWorkspaceComposerCard'
import AgentSettingsDialog from '@/components/agent/AgentSettingsDialog'
import { useComposerState } from '@/hooks/useComposerState'
import {
pickQuickWorkspaceAgent,
resolveQuickWorkspaceAgentSelection
} from '@/lib/quick-workspace-agent-selection'
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
import { shouldAllowComposerEnterSubmitTarget } from '@/lib/new-workspace-enter-guard'
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import type {
TuiAgent,
WorkspaceCreateTelemetrySource,
WorkspaceStatus
} from '../../../shared/types'
import type { TaskSourceContext } from '../../../shared/task-source-context'
import { translate } from '@/i18n/i18n'
type ComposerModalData = {
prefilledName?: string
initialRepoId?: string
linkedWorkItem?: LinkedWorkItemSummary | null
taskSourceContext?: TaskSourceContext | null
initialBaseBranch?: string
initialWorkspaceStatus?: WorkspaceStatus
/** Telemetry surface that opened the composer. Set by each
* `openModal('new-workspace-composer', ...)` site so
* `workspace_created.source` carries the right value. Falls back to
* `unknown` when omitted. */
telemetrySource?: WorkspaceCreateTelemetrySource
contextualTourSource?: string
setupGuideTourRequestId?: string
}
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 {
return (
<Dialog open onOpenChange={onOpenChange}>
<DialogContent
className="flex max-h-[calc(100vh-2rem)] flex-col overflow-hidden sm:max-w-lg"
onOpenAutoFocus={(event) => {
// Why: Radix's FocusScope fires this once the dialog has mounted.
// preventDefault stops it from focusing whatever first-tabbable it
// picks (close button), and we instead focus the repo picker so the
// keyboard flow starts at the top of the unified create form.
event.preventDefault()
const content = event.currentTarget as HTMLElement
const trigger = content.querySelector<HTMLElement>(
'[data-repo-combobox-root="true"][role="combobox"]'
)
trigger?.focus({ preventScroll: true })
}}
>
<QuickTabBody modalData={modalData} onClose={onClose} active />
</DialogContent>
</Dialog>
)
}
function QuickTabBody({
modalData,
onClose,
active
}: {
modalData: ComposerModalData
onClose: () => void
active: boolean
}): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const {
cardProps,
composerRef,
onComposerNodeChange,
nameInputRef,
submitQuick,
createDisabled
} = useComposerState({
initialName: modalData.prefilledName ?? '',
// Why: the modal is quick-create only now, so prompt-prefill state is
// intentionally ignored even if older callers still send it.
initialPrompt: '',
initialLinkedWorkItem: modalData.linkedWorkItem ?? null,
initialTaskSourceContext: modalData.taskSourceContext ?? null,
initialRepoId: modalData.initialRepoId,
initialWorkspaceStatus: modalData.initialWorkspaceStatus,
...(modalData.initialBaseBranch ? { initialBaseBranch: modalData.initialBaseBranch } : {}),
persistDraft: false,
onCreated: onClose,
...(modalData.telemetrySource ? { telemetrySource: modalData.telemetrySource } : {}),
enableIssueAutomation: false,
createGateMode: 'quick'
})
// Why: the composer's built-in `onOpenAgentSettings` handler navigates to
// the settings page and closes the modal. For the quick-create flow we want
// a less disruptive affordance — a nested dialog layered over the composer
// so the user can tweak agents without losing their in-progress workspace
// name/repo selection.
const [agentSettingsOpen, setAgentSettingsOpen] = useState(false)
// Why: once the user picks an agent, their choice wins and must not be
// overwritten when the derived "preferred" value changes (e.g. detection
// finishes and adds more installed agents to the set). Track that with an
// override rather than an effect that mirrors a prop into state — deriving
// during render keeps the selection in sync with the detected set without
// triggering an extra commit.
const [quickAgentOverride, setQuickAgentOverride] = useState<TuiAgent | null | undefined>(
undefined
)
const preferredQuickAgent = useMemo<TuiAgent | null>(() => {
const pref = settings?.defaultTuiAgent
// Why: detection can still be pending when quick-create submits; keep the
// prior catalog fallback while filtering disabled agents out of that choice.
return pickQuickWorkspaceAgent(pref, cardProps.detectedAgentIds, settings?.disabledTuiAgents)
}, [cardProps.detectedAgentIds, settings?.defaultTuiAgent, settings?.disabledTuiAgents])
const resolvedQuickAgentSelection = resolveQuickWorkspaceAgentSelection({
quickAgentOverride,
preferredQuickAgent,
detectedAgentIds: cardProps.detectedAgentIds,
disabledTuiAgents: settings?.disabledTuiAgents
})
if (resolvedQuickAgentSelection.quickAgentOverride !== quickAgentOverride) {
// Why: detection/settings changes can invalidate a user-picked agent; repair
// before the child selector renders an unavailable option for one commit.
setQuickAgentOverride(resolvedQuickAgentSelection.quickAgentOverride)
}
const quickAgent = resolvedQuickAgentSelection.quickAgent
const handleQuickAgentChange = useCallback((agent: TuiAgent | null) => {
setQuickAgentOverride(agent)
}, [])
const handleCreate = useCallback(async (): Promise<void> => {
await submitQuick(quickAgent)
}, [quickAgent, submitQuick])
const primaryActionLabel = cardProps.selectedRepoIsGit ? 'Create Worktree' : 'Create Workspace'
// Cmd/Ctrl+Enter submits, Esc first blurs the focused input (like the full page).
useEffect(() => {
if (!active) {
return
}
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
}
// Why: workspace creation is screen-local submit behavior, not a
// user-configurable app command.
if (!isScreenSubmitShortcut(event)) {
return
}
if (!shouldAllowComposerEnterSubmitTarget(target, composerRef.current)) {
return
}
if (createDisabled) {
return
}
event.preventDefault()
void handleCreate()
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [active, composerRef, createDisabled, handleCreate, onClose])
return (
<>
<DialogHeader className="gap-1">
<DialogTitle className="text-base font-semibold">{primaryActionLabel}</DialogTitle>
<DialogDescription className="sr-only">
{translate(
'auto.components.NewWorkspaceComposerModal.fa90f739a5',
'Choose the project, workspace name, and agent before creating the workspace.'
)}
</DialogDescription>
</DialogHeader>
<NewWorkspaceComposerCard
contextualTourSource={modalData.contextualTourSource}
// Why: the scroll container clips children, while Orca's standard
// field focus ring paints 3px outside the control. Inset both sides so
// keyboard focus stays fully visible at the dialog edges.
containerClassName="min-h-0 flex-1 overflow-y-auto px-1 scrollbar-sleek"
composerRef={composerRef}
onComposerNodeChange={onComposerNodeChange}
nameInputRef={nameInputRef}
quickAgent={quickAgent}
onQuickAgentChange={handleQuickAgentChange}
{...cardProps}
primaryActionLabel={primaryActionLabel}
onOpenAgentSettings={() => setAgentSettingsOpen(true)}
onCreate={() => void handleCreate()}
/>
<AgentSettingsDialog open={agentSettingsOpen} onOpenChange={setAgentSettingsOpen} />
</>
)
}