Simplify new workspace quick-create and wire Use button to direct launch (#913)

* Refine workspace composer controls

* Working repo autofocus

* after selects

* Simplify workspace quick create dialog

* Refine quick create form focus flow

* autofocus fix

* normal dialog

* base dialogs

* Wire Use button to launch workspace directly

Skips the composer modal for the common case: creates the workspace,
activates it, launches the default agent, and pastes the work-item URL
into the agent's input as a reviewable draft. Falls back to the modal
when setupRunPolicy is 'ask' or no compatible agent is detected.

* Stretch combobox dropdowns to trigger width

Repository and Agent rows in the quick-create dialog now span the full
dialog width like the Workspace Name input, and their dropdown popovers
inherit the trigger width so the entries align edge-to-edge with the
trigger instead of clipping to a fixed 288/320px.
This commit is contained in:
Neil 2026-04-21 15:55:18 -07:00 committed by GitHub
parent 35f5fe72e6
commit 369ddb6fe6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1516 additions and 831 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,22 @@
import React, { useCallback, useEffect } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { useAppStore } from '@/store'
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
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 { AGENT_CATALOG } from '@/lib/agent-catalog'
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard'
import type { TuiAgent } from '../../../shared/types'
type ComposerModalData = {
prefilledName?: string
prefilledPrompt?: string
initialRepoId?: string
linkedWorkItem?: LinkedWorkItemSummary | null
}
@ -52,25 +60,55 @@ function ComposerModalBody({
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
})
const settings = useAppStore((s) => s.settings)
const { cardProps, composerRef, 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,
initialRepoId: modalData.initialRepoId,
persistDraft: false,
onCreated: onClose
})
// 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)
const [quickAgentTouched, setQuickAgentTouched] = useState(false)
const preferredQuickAgent = useMemo<TuiAgent | null>(() => {
const pref = settings?.defaultTuiAgent
if (pref === 'blank') {
// Why: 'blank' is the explicit "no agent" preference — the quick agent
// model already uses null to mean "blank terminal", so translate here.
return null
}
if (pref) {
return pref
}
const detected = cardProps.detectedAgentIds
return AGENT_CATALOG.find((agent) => detected === null || detected.has(agent.id))?.id ?? null
}, [cardProps.detectedAgentIds, settings?.defaultTuiAgent])
const [quickAgent, setQuickAgent] = useState<TuiAgent | null>(preferredQuickAgent)
// Autofocus the prompt textarea on open.
useEffect(() => {
const frame = requestAnimationFrame(() => {
promptTextareaRef.current?.focus()
})
return () => cancelAnimationFrame(frame)
}, [promptTextareaRef])
if (!quickAgentTouched) {
setQuickAgent(preferredQuickAgent)
}
}, [preferredQuickAgent, quickAgentTouched])
// Enter submits, Esc first blurs the focused input (like the full page).
const handleQuickAgentChange = useCallback((agent: TuiAgent | null) => {
setQuickAgentTouched(true)
setQuickAgent(agent)
}, [])
const handleCreate = useCallback(async (): Promise<void> => {
await submitQuick(quickAgent)
}, [quickAgent, submitQuick])
// Cmd/Ctrl+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') {
@ -97,44 +135,68 @@ function ComposerModalBody({
return
}
// Why: require the platform modifier (Cmd on macOS, Ctrl elsewhere) so
// plain Enter inside fields (notes, repo search) doesn't accidentally
// submit — users can type or confirm selections without triggering
// workspace creation.
const hasModifier = event.metaKey || event.ctrlKey
if (!hasModifier) {
return
}
if (!composerRef.current?.contains(target)) {
return
}
if (createDisabled) {
return
}
if (shouldSuppressEnterSubmit(event, target instanceof HTMLTextAreaElement)) {
if (shouldSuppressEnterSubmit(event, false)) {
return
}
event.preventDefault()
void submit()
void handleCreate()
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [composerRef, createDisabled, onClose, submit])
}, [composerRef, createDisabled, handleCreate, onClose])
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}
className="sm:max-w-md"
onOpenAutoFocus={(event) => {
// Why: Radix's FocusScope fires this once the dialog has mounted and
// the DOM is ready. preventDefault stops it from focusing the first
// tabbable (which would otherwise steal focus to whatever ships
// first in markup); we then focus the repo combobox trigger so the
// guessed value sits as a confirmed selection without opening its
// popover — matching the "default = selection, typing = search"
// combobox pattern. Doing it here (instead of a child rAF) avoids
// Strict-Mode effect double-invocation dropping the focus call.
event.preventDefault()
promptTextareaRef.current?.focus()
const content = event.currentTarget as HTMLElement
const trigger = content.querySelector<HTMLElement>(
'[data-repo-combobox-root="true"][role="combobox"]'
)
trigger?.focus({ preventScroll: true })
}}
>
<DialogTitle className="sr-only">Create New Workspace</DialogTitle>
<DialogDescription className="sr-only">
Configure a name and prompt for the new workspace.
</DialogDescription>
<DialogHeader>
<DialogTitle className="text-sm">Create Workspace</DialogTitle>
<DialogDescription className="text-xs">
Pick a repository and agent to spin up a new workspace.
</DialogDescription>
</DialogHeader>
<NewWorkspaceComposerCard
containerClassName="bg-card/98 shadow-2xl supports-[backdrop-filter]:bg-card/95"
composerRef={composerRef}
nameInputRef={nameInputRef}
promptTextareaRef={promptTextareaRef}
quickAgent={quickAgent}
onQuickAgentChange={handleQuickAgentChange}
{...cardProps}
onOpenAgentSettings={() => setAgentSettingsOpen(true)}
onCreate={() => void handleCreate()}
/>
</DialogContent>
<AgentSettingsDialog open={agentSettingsOpen} onOpenChange={setAgentSettingsOpen} />
</Dialog>
)
}

View File

@ -40,6 +40,7 @@ import GitHubItemDrawer from '@/components/GitHubItemDrawer'
import { cn } from '@/lib/utils'
import { getLinkedWorkItemSuggestedName, getTaskPresetQuery } from '@/lib/new-workspace'
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
import { launchWorkItemDirect } from '@/lib/launch-work-item-direct'
import { isGitRepoKind } from '../../../shared/repo-kind'
import type { GitHubWorkItem, TaskViewPresetId } from '../../../shared/types'
import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard'
@ -213,8 +214,8 @@ export default function NewWorkspacePage(): React.JSX.Element {
return getCachedWorkItems(selectedRepo.path, WORK_ITEM_LIMIT, initialTaskQuery.trim()) ?? []
})
// Why: clicking a GitHub row opens this drawer for a read-only preview.
// The composer modal is only opened by the drawer's "Use" button, which
// calls the same handleSelectWorkItem as the old direct row-click flow.
// Drawer's "Use" button routes through the same direct-launch flow as the
// row-level "Use" CTA so behavior is consistent regardless of entry point.
const [drawerWorkItem, setDrawerWorkItem] = useState<GitHubWorkItem | null>(null)
const [newIssueOpen, setNewIssueOpen] = useState(false)
const [newIssueTitle, setNewIssueTitle] = useState('')
@ -362,11 +363,8 @@ export default function NewWorkspacePage(): React.JSX.Element {
[handleApplyTaskSearch]
)
const handleSelectWorkItem = useCallback(
const openComposerForItem = useCallback(
(item: GitHubWorkItem): void => {
// Why: selecting a task from the list opens the same lightweight composer
// modal used by Cmd+J, so the prompt path is identical whether the user
// arrives via palette URL, picked issue/PR, or chose one from this list.
const linkedWorkItem: LinkedWorkItemSummary = {
type: item.type,
number: item.number,
@ -382,6 +380,23 @@ export default function NewWorkspacePage(): React.JSX.Element {
[openModal, repoId]
)
const handleUseWorkItem = useCallback(
(item: GitHubWorkItem): void => {
// Why: the "Use" CTA is the primary way to start work from this page, so
// skip the composer for the common case and create+activate the workspace
// immediately, launch the user's default agent, and paste the work item
// URL into the agent's input as a reviewable draft. Fall back to the
// composer modal only when explicit per-workspace decisions are required
// (setupRunPolicy === 'ask') or the repo/agent resolution fails.
void launchWorkItemDirect({
item,
repoId,
openModalFallback: () => openComposerForItem(item)
})
},
[openComposerForItem, repoId]
)
const handleCreateNewIssue = useCallback(async (): Promise<void> => {
if (!selectedRepo) {
return
@ -814,7 +829,7 @@ export default function NewWorkspacePage(): React.JSX.Element {
type="button"
onClick={(e) => {
e.stopPropagation()
handleSelectWorkItem(item)
handleUseWorkItem(item)
}}
className="inline-flex items-center gap-1 rounded-xl border border-border/50 bg-background/50 backdrop-blur-md px-3 py-1.5 text-sm text-foreground transition hover:bg-muted/60 supports-[backdrop-filter]:bg-background/50"
>
@ -933,7 +948,7 @@ export default function NewWorkspacePage(): React.JSX.Element {
repoPath={selectedRepo?.path ?? null}
onUse={(item) => {
setDrawerWorkItem(null)
handleSelectWorkItem(item)
handleUseWorkItem(item)
}}
onClose={() => setDrawerWorkItem(null)}
/>

View File

@ -0,0 +1,308 @@
import React, { useCallback, useMemo, useState } from 'react'
import { Check, ChevronsUpDown, Star, Terminal } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { AgentIcon, type AgentCatalogEntry } from '@/lib/agent-catalog'
import { cn } from '@/lib/utils'
import type { TuiAgent } from '../../../../shared/types'
type DefaultAgentPreference = TuiAgent | 'blank' | null
type AgentComboboxProps = {
agents: AgentCatalogEntry[]
value: TuiAgent | null
onValueChange: (agent: TuiAgent | null) => void
onValueSelected?: (agent: TuiAgent | null) => void
onOpenManageAgents?: () => void
/** Current saved default agent preference. Used to render a subtle "default"
* indicator in the list and to tell which right-click menu item is the
* currently-applied choice. */
defaultAgent?: DefaultAgentPreference
/** Optional handler for right-click "Set as default" action. When provided,
* each list item (including Blank Terminal) gets a context menu. */
onSetDefault?: (agent: DefaultAgentPreference) => void
triggerClassName?: string
}
const BLANK_VALUE = '__none__'
type ItemRenderArgs = {
key: string
itemValue: string
isChecked: boolean
isDefault: boolean
onSelect: () => void
onSetDefault?: () => void
icon: React.ReactNode
label: string
}
function renderItem({
key,
itemValue,
isChecked,
isDefault,
onSelect,
onSetDefault,
icon,
label
}: ItemRenderArgs): React.ReactNode {
const row = (
<CommandItem
key={key}
value={itemValue}
onSelect={onSelect}
className="items-center gap-2 px-3 py-1.5"
>
<Check className={cn('size-4 text-foreground', isChecked ? 'opacity-100' : 'opacity-0')} />
<span className="inline-flex min-w-0 flex-1 items-center gap-1.5">
{icon}
<span className="truncate">{label}</span>
</span>
</CommandItem>
)
if (!onSetDefault) {
return row
}
return (
// Why: z-[70] sits above PopoverContent's z-[60] so the right-click menu
// renders in front of the still-open combobox popover instead of behind it.
<ContextMenu key={key}>
<ContextMenuTrigger asChild>{row}</ContextMenuTrigger>
<ContextMenuContent className="z-[70]">
<ContextMenuItem onSelect={onSetDefault} disabled={isDefault}>
<Star className="size-3.5" />
{isDefault ? 'Current default' : 'Set as default'}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
function searchAgents(agents: AgentCatalogEntry[], rawQuery: string): AgentCatalogEntry[] {
const query = rawQuery.trim().toLowerCase()
if (!query) {
return agents
}
// Why: cheap prefix-favored sort — label matches starting earlier in the
// string outrank later matches, mirroring repo-search semantics so the
// two comboboxes feel consistent.
const matches: { agent: AgentCatalogEntry; score: number; index: number }[] = []
agents.forEach((agent, index) => {
const labelIdx = agent.label.toLowerCase().indexOf(query)
const idIdx = agent.id.toLowerCase().indexOf(query)
const score = labelIdx !== -1 ? labelIdx : idIdx !== -1 ? 1000 + idIdx : -1
if (score !== -1) {
matches.push({ agent, score, index })
}
})
matches.sort((a, b) => a.score - b.score || a.index - b.index)
return matches.map((m) => m.agent)
}
export default function AgentCombobox({
agents,
value,
onValueChange,
onValueSelected,
onOpenManageAgents,
defaultAgent,
onSetDefault,
triggerClassName
}: AgentComboboxProps): React.JSX.Element {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
// Why: controlled cmdk selection so hovering the footer (which lives outside
// the cmdk tree) can clear the list's highlighted item — otherwise cmdk keeps
// the last-hovered agent visually selected while the mouse is on the footer.
const [commandValue, setCommandValue] = useState('')
const triggerRef = React.useRef<HTMLButtonElement | null>(null)
const selectedAgent = useMemo<AgentCatalogEntry | null>(
() => (value ? (agents.find((agent) => agent.id === value) ?? null) : null),
[agents, value]
)
const filteredAgents = useMemo(() => searchAgents(agents, query), [agents, query])
const blankMatchesQuery = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) {
return true
}
return 'blank terminal'.includes(q) || 'terminal'.startsWith(q)
}, [query])
React.useEffect(() => {
if (!open) {
return
}
setCommandValue(value ?? BLANK_VALUE)
const frame = requestAnimationFrame(() => {
const searchInput = document.querySelector<HTMLInputElement>(
'[data-agent-combobox-root="true"] [data-slot="command-input"]'
)
if (!searchInput) {
return
}
searchInput.focus()
// Why: when a printable keydown on the trigger seeded the query, the user
// expects the next keystroke to append to what they typed — not replace
// it — so drop the caret at the end instead of selecting all.
const end = searchInput.value.length
searchInput.setSelectionRange(end, end)
})
return () => cancelAnimationFrame(frame)
}, [open, value])
const handleOpenChange = useCallback((nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen) {
setQuery('')
}
}, [])
const handleSelect = useCallback(
(nextValue: TuiAgent | null) => {
onValueChange(nextValue)
setOpen(false)
setQuery('')
onValueSelected?.(nextValue)
},
[onValueChange, onValueSelected]
)
// Why: mirror RepoCombobox's trigger-keydown handling — the button-style
// trigger treats the current value as a confirmed selection. Plain focus does
// not open the dropdown. Only explicit intent opens: Arrow keys open without
// filtering; a printable non-whitespace char opens AND seeds the search
// query (treating the keystroke as the start of a new search).
const handleTriggerKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (open) {
return
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
setOpen(true)
return
}
if (event.metaKey || event.ctrlKey || event.altKey) {
return
}
if (event.key.length === 1 && /\S/.test(event.key)) {
event.preventDefault()
setQuery(event.key)
setOpen(true)
}
},
[open]
)
return (
<div className="flex w-full items-center">
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
ref={triggerRef}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
onKeyDown={handleTriggerKeyDown}
className={cn(
'h-8 min-w-[184px] justify-between px-3 text-xs font-normal',
triggerClassName
)}
data-agent-combobox-root="true"
>
{selectedAgent ? (
<span className="inline-flex min-w-0 items-center gap-1.5">
<AgentIcon agent={selectedAgent.id} />
<span className="truncate">{selectedAgent.label}</span>
</span>
) : (
<span className="inline-flex min-w-0 items-center gap-1.5">
<Terminal className="size-3.5" />
<span className="truncate">Blank Terminal</span>
</span>
)}
<ChevronsUpDown className="size-3.5 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] min-w-[18rem] p-0"
data-agent-combobox-root="true"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<Command shouldFilter={false} value={commandValue} onValueChange={setCommandValue}>
<CommandInput placeholder="Search agents..." value={query} onValueChange={setQuery} />
<CommandList>
<CommandEmpty>No agents match your search.</CommandEmpty>
{blankMatchesQuery
? renderItem({
key: BLANK_VALUE,
itemValue: BLANK_VALUE,
isChecked: value === null,
isDefault: defaultAgent === 'blank',
onSelect: () => handleSelect(null),
onSetDefault: onSetDefault ? () => onSetDefault('blank') : undefined,
icon: <Terminal className="size-3.5" />,
label: 'Blank Terminal'
})
: null}
{filteredAgents.map((agent) =>
renderItem({
key: agent.id,
itemValue: agent.id,
isChecked: value === agent.id,
isDefault: defaultAgent === agent.id,
onSelect: () => handleSelect(agent.id),
onSetDefault: onSetDefault ? () => onSetDefault(agent.id) : undefined,
icon: <AgentIcon agent={agent.id} />,
label: agent.label
})
)}
</CommandList>
{onOpenManageAgents ? (
<div className="border-t border-border">
<Button
type="button"
variant="ghost"
onClick={onOpenManageAgents}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => setCommandValue('')}
className="h-9 w-full justify-start rounded-none px-3 text-xs font-normal text-muted-foreground"
>
Manage agents
<svg
className="ml-auto size-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden
>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</Button>
</div>
) : null}
</Command>
</PopoverContent>
</Popover>
</div>
)
}

View File

@ -0,0 +1,47 @@
import React from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { AgentsPane } from '@/components/settings/AgentsPane'
import { useAppStore } from '@/store'
type AgentSettingsDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export default function AgentSettingsDialog({
open,
onOpenChange
}: AgentSettingsDialogProps): React.JSX.Element | null {
const settings = useAppStore((s) => s.settings)
const updateSettings = useAppStore((s) => s.updateSettings)
if (!settings) {
return null
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{/* Why: widen past the default sm:max-w-lg so the agent rows have room
for the name + pills + action cluster without wrapping, while a
bounded max-h plus overflow-y keeps the list scrollable when many
agents are detected. */}
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="text-sm">Agents</DialogTitle>
<DialogDescription className="text-xs">
Manage AI agents, set a default, and customize commands.
</DialogDescription>
</DialogHeader>
<div className="scrollbar-sleek -mr-2 max-h-[70vh] overflow-y-auto pr-2">
<AgentsPane settings={settings} updateSettings={updateSettings} />
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -20,16 +20,22 @@ type RepoComboboxProps = {
repos: Repo[]
value: string
onValueChange: (repoId: string) => void
onValueSelected?: (repoId: string) => void
placeholder?: string
triggerClassName?: string
autoOpenOnMount?: boolean
showStandaloneAddButton?: boolean
}
export default function RepoCombobox({
repos,
value,
onValueChange,
onValueSelected,
placeholder = 'Select repo...',
triggerClassName
triggerClassName,
autoOpenOnMount = false,
showStandaloneAddButton = true
}: RepoComboboxProps): React.JSX.Element {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
@ -40,6 +46,8 @@ export default function RepoCombobox({
const addRepo = useAppStore((s) => s.addRepo)
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
const [isAdding, setIsAdding] = useState(false)
const autoOpenedRef = React.useRef(false)
const triggerRef = React.useRef<HTMLButtonElement | null>(null)
const selectedRepo = useMemo(
() => repos.find((repo) => repo.id === value) ?? null,
@ -47,6 +55,36 @@ export default function RepoCombobox({
)
const filteredRepos = useMemo(() => searchRepos(repos, query), [repos, query])
React.useEffect(() => {
if (!autoOpenOnMount || autoOpenedRef.current) {
return
}
autoOpenedRef.current = true
setOpen(true)
}, [autoOpenOnMount])
React.useEffect(() => {
if (!open) {
return
}
setCommandValue(value)
const frame = requestAnimationFrame(() => {
const repoSearchInput = document.querySelector<HTMLInputElement>(
'[data-repo-combobox-root="true"] [data-slot="command-input"]'
)
if (!repoSearchInput) {
return
}
repoSearchInput.focus()
// Why: when a printable keydown on the trigger seeded the query, the
// user expects the next keystroke to append to what they typed — not
// replace it — so drop the caret at the end instead of selecting all.
const end = repoSearchInput.value.length
repoSearchInput.setSelectionRange(end, end)
})
return () => cancelAnimationFrame(frame)
}, [open, value])
const handleOpenChange = useCallback((nextOpen: boolean) => {
setOpen(nextOpen)
// Why: the create-worktree dialog delays its own field reset until after
@ -62,8 +100,39 @@ export default function RepoCombobox({
onValueChange(repoId)
setOpen(false)
setQuery('')
onValueSelected?.(repoId)
},
[onValueChange]
[onValueChange, onValueSelected]
)
// Why: the button-style trigger treats the current value as a confirmed
// selection — plain focus does not open the dropdown. We only open on
// explicit intent: ArrowDown/ArrowUp opens without filtering, and a printable
// non-whitespace character opens *and* seeds the search query (treating the
// keystroke as the start of a new search per the combobox pattern).
const handleTriggerKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (open) {
return
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
setOpen(true)
return
}
if (event.metaKey || event.ctrlKey || event.altKey) {
return
}
// Why: restrict to visible characters so whitespace/Enter keep their
// native button semantics (Space/Enter = click = open-without-filter via
// the PopoverTrigger) instead of leaking into the query as a stray char.
if (event.key.length === 1 && /\S/.test(event.key)) {
event.preventDefault()
setQuery(event.key)
setOpen(true)
}
},
[open]
)
const handleAddFolder = useCallback(async () => {
@ -87,99 +156,124 @@ export default function RepoCombobox({
}, [addRepo, fetchWorktrees, isAdding, onValueChange])
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<div className="flex w-full items-center gap-1.5">
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
ref={triggerRef}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
onKeyDown={handleTriggerKeyDown}
className={cn(
'h-8 min-w-[184px] justify-between px-3 text-xs font-normal',
triggerClassName
)}
data-repo-combobox-root="true"
>
{selectedRepo ? (
<span className="inline-flex min-w-0 items-center gap-1.5">
<RepoDotLabel
name={selectedRepo.displayName}
color={selectedRepo.badgeColor}
dotClassName="size-1.5"
/>
{selectedRepo.connectionId && (
<span className="shrink-0 inline-flex items-center gap-0.5 rounded bg-muted px-1 py-0.5 text-[9px] font-medium leading-none text-muted-foreground">
<Globe className="size-2.5" />
SSH
</span>
)}
</span>
) : (
<span className="text-muted-foreground">{placeholder}</span>
)}
<ChevronsUpDown className="size-3.5 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] min-w-[18rem] p-0"
data-repo-combobox-root="true"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<Command shouldFilter={false} value={commandValue} onValueChange={setCommandValue}>
<CommandInput
placeholder="Search repos/folders..."
value={query}
onValueChange={setQuery}
/>
<CommandList>
<CommandEmpty>No repos/folders match your search.</CommandEmpty>
{filteredRepos.map((repo) => (
<CommandItem
key={repo.id}
value={repo.id}
onSelect={() => handleSelect(repo.id)}
className="items-center gap-2 px-3 py-2"
>
<Check
className={cn(
'size-4 text-foreground',
value === repo.id ? 'opacity-100' : 'opacity-0'
)}
/>
<div className="min-w-0 flex-1">
<span className="inline-flex items-center gap-1.5">
<RepoDotLabel
name={repo.displayName}
color={repo.badgeColor}
className="max-w-full"
/>
{repo.connectionId && (
<span className="shrink-0 inline-flex items-center gap-0.5 rounded bg-muted px-1 py-0.5 text-[9px] font-medium leading-none text-muted-foreground">
<Globe className="size-2.5" />
SSH
</span>
)}
</span>
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">{repo.path}</p>
</div>
</CommandItem>
))}
</CommandList>
{/* Why: keep the in-list add action available for users who open
the picker expecting the historical footer affordance, while
the separate header icon covers the compact one-click path. */}
<div className="border-t border-border">
<Button
type="button"
variant="ghost"
disabled={isAdding}
onClick={() => void handleAddFolder()}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => setCommandValue('')}
className="h-9 w-full justify-start rounded-none px-3 text-xs font-normal"
>
<FolderPlus className="size-3.5 text-muted-foreground" />
<span>{isAdding ? 'Adding folder/repo…' : 'Add folder/repo'}</span>
</Button>
</div>
</Command>
</PopoverContent>
</Popover>
{showStandaloneAddButton ? (
/* Why: keep the add-repo action visible even when the repo selector is
collapsed so adding a new source stays one click away in the compact composer header. */
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className={cn('h-8 w-full justify-between px-3 text-xs font-normal', triggerClassName)}
data-repo-combobox-root="true"
size="default"
disabled={isAdding}
onClick={() => void handleAddFolder()}
className="size-9 shrink-0 p-0"
aria-label={isAdding ? 'Adding folder or repository' : 'Add folder or repository'}
>
{selectedRepo ? (
<span className="inline-flex min-w-0 items-center gap-1.5">
<RepoDotLabel
name={selectedRepo.displayName}
color={selectedRepo.badgeColor}
dotClassName="size-1.5"
/>
{selectedRepo.connectionId && (
<span className="shrink-0 inline-flex items-center gap-0.5 rounded bg-muted px-1 py-0.5 text-[9px] font-medium leading-none text-muted-foreground">
<Globe className="size-2.5" />
SSH
</span>
)}
</span>
) : (
<span className="text-muted-foreground">{placeholder}</span>
)}
<ChevronsUpDown className="size-3.5 opacity-50" />
<FolderPlus className="size-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-0"
data-repo-combobox-root="true"
>
<Command shouldFilter={false} value={commandValue} onValueChange={setCommandValue}>
<CommandInput
autoFocus
placeholder="Search repos/folders..."
value={query}
onValueChange={setQuery}
/>
<CommandList>
<CommandEmpty>No repos/folders match your search.</CommandEmpty>
{filteredRepos.map((repo) => (
<CommandItem
key={repo.id}
value={repo.id}
onSelect={() => handleSelect(repo.id)}
className="items-center gap-2 px-3 py-2"
>
<Check
className={cn(
'size-4 text-foreground',
value === repo.id ? 'opacity-100' : 'opacity-0'
)}
/>
<div className="min-w-0 flex-1">
<span className="inline-flex items-center gap-1.5">
<RepoDotLabel
name={repo.displayName}
color={repo.badgeColor}
className="max-w-full"
/>
{repo.connectionId && (
<span className="shrink-0 inline-flex items-center gap-0.5 rounded bg-muted px-1 py-0.5 text-[9px] font-medium leading-none text-muted-foreground">
<Globe className="size-2.5" />
SSH
</span>
)}
</span>
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">{repo.path}</p>
</div>
</CommandItem>
))}
</CommandList>
{/* Why: pinned footer (outside CommandList's scroll container) so the
add action stays visible regardless of list length or scroll position. */}
<div className="border-t border-border">
<button
type="button"
disabled={isAdding}
onClick={handleAddFolder}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => setCommandValue('')}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-foreground transition-colors hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-60"
>
<FolderPlus className="size-3.5 text-muted-foreground" />
<span>{isAdding ? 'Adding folder/repo…' : 'Add folder/repo'}</span>
</button>
</div>
</Command>
</PopoverContent>
</Popover>
) : null}
</div>
)
}

View File

@ -232,7 +232,7 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React
const defaultAgent = settings.defaultTuiAgent
const cmdOverrides = settings.agentCmdOverrides ?? {}
const setDefault = (id: TuiAgent | null): void => {
const setDefault = (id: TuiAgent | 'blank' | null): void => {
updateSettings({ defaultTuiAgent: id })
}
@ -251,7 +251,12 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React
(a) => detectedIds !== null && !detectedIds.has(a.id)
)
const isAutoDefault = defaultAgent === null || !detectedIds?.has(defaultAgent)
// Why: 'blank' is an explicit no-agent preference, not an auto fallback,
// so the Auto pill should only light up when the default is null OR when a
// selected agent id is no longer detected on PATH.
const isAutoDefault =
defaultAgent === null || (defaultAgent !== 'blank' && !detectedIds?.has(defaultAgent))
const isBlankDefault = defaultAgent === 'blank'
return (
<div className="space-y-8">
@ -260,8 +265,7 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React
<div className="space-y-1">
<h3 className="text-sm font-semibold">Default Agent</h3>
<p className="text-xs text-muted-foreground">
Pre-selected agent when opening a new workspace. Set to Auto to use the first detected
agent.
Pre-selected agent when opening a new workspace.
</p>
</div>
@ -281,6 +285,25 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React
Auto
</button>
{/* Why: users who prefer to open a raw shell by default need a
first-class "no agent" choice here without it, the Auto pill
is the closest option but silently launches the first detected
agent, which is the opposite of what they want. */}
<button
type="button"
onClick={() => setDefault('blank')}
className={cn(
'flex items-center gap-2 rounded-xl border px-3 py-2 text-sm transition-all',
isBlankDefault
? 'border-foreground/20 bg-foreground/8 font-medium ring-1 ring-foreground/15'
: 'border-border/50 bg-muted/30 text-muted-foreground hover:border-border hover:bg-muted/50 hover:text-foreground'
)}
>
<Terminal className="size-3.5" />
No agent (blank terminal)
{isBlankDefault && <Check className="size-3.5" />}
</button>
{/* Detected agent pills */}
{detectedAgents.map((agent) => {
const isActive = defaultAgent === agent.id

View File

@ -11,6 +11,7 @@ import { parseGitHubIssueOrPRNumber, normalizeGitHubLinkQuery } from '@/lib/gith
import type { RepoSlug } from '@/lib/github-links'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { buildAgentStartupPlan } from '@/lib/tui-agent-startup'
import { detectAgentsCached } from '@/lib/detect-agents-cached'
import { isGitRepoKind } from '../../../shared/repo-kind'
import type {
GitHubWorkItem,
@ -108,6 +109,7 @@ export type UseComposerStateResult = {
promptTextareaRef: React.RefObject<HTMLTextAreaElement | null>
nameInputRef: React.RefObject<HTMLInputElement | null>
submit: () => Promise<void>
submitQuick: (agent: TuiAgent | null) => Promise<void>
/** Invoked by the Enter handler to re-check whether submission should fire. */
createDisabled: boolean
}
@ -121,28 +123,6 @@ export type UseComposerStateResult = {
// closes.
const composerDropStack: symbol[] = []
// Why: agent detection runs `which` for every agent binary on PATH — an IPC
// round-trip that takes 50200ms. The set of installed agents doesn't change
// within a session, so cache the promise at module scope to collapse all
// mounts (page + modal, reopen, etc.) onto a single resolve.
let detectAgentsPromise: Promise<TuiAgent[]> | null = null
function detectAgentsCached(): Promise<TuiAgent[]> {
if (detectAgentsPromise) {
return detectAgentsPromise
}
const pending = window.api.preflight
.detectAgents()
.then((ids) => ids as TuiAgent[])
.catch(() => {
// Allow a retry on the next mount if detection blew up (e.g. IPC
// timeout during cold start).
detectAgentsPromise = null
return [] as TuiAgent[]
})
detectAgentsPromise = pending
return pending
}
export function useComposerState(options: UseComposerStateOptions): UseComposerStateResult {
const {
initialRepoId,
@ -168,6 +148,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setSidebarOpen: s.setSidebarOpen,
setRightSidebarOpen: s.setRightSidebarOpen,
setRightSidebarTab: s.setRightSidebarTab,
closeModal: s.closeModal,
openSettingsPage: s.openSettingsPage,
openSettingsTarget: s.openSettingsTarget
}))
@ -180,6 +161,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setSidebarOpen,
setRightSidebarOpen,
setRightSidebarTab,
closeModal,
openSettingsPage,
openSettingsTarget
} = actions
@ -244,10 +226,16 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
return initialLinkedWorkItem?.type === 'pr' ? initialLinkedWorkItem.number : null
})
// Why: the long-form composer's agent selection is a required TuiAgent (not
// null/blank), so 'blank' preferences from global settings must collapse to
// the Claude default here — the blank-terminal affordance only lives in the
// quick-create flow.
const fallbackDefaultAgent: TuiAgent =
settings?.defaultTuiAgent && settings.defaultTuiAgent !== 'blank'
? settings.defaultTuiAgent
: 'claude'
const [tuiAgent, setTuiAgent] = useState<TuiAgent>(
persistDraft
? (newWorkspaceDraft?.agent ?? settings?.defaultTuiAgent ?? 'claude')
: (settings?.defaultTuiAgent ?? 'claude')
persistDraft ? (newWorkspaceDraft?.agent ?? fallbackDefaultAgent) : fallbackDefaultAgent
)
const [detectedAgentIds, setDetectedAgentIds] = useState<Set<TuiAgent> | null>(null)
@ -841,7 +829,29 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const handleOpenAgentSettings = useCallback((): void => {
openSettingsTarget({ pane: 'agents', repoId: null })
openSettingsPage()
}, [openSettingsPage, openSettingsTarget])
closeModal()
}, [closeModal, openSettingsPage, openSettingsTarget])
const applyWorktreeMeta = useCallback(
async (
worktreeId: string,
meta: {
linkedIssue?: number
linkedPR?: number
comment?: string
}
): Promise<void> => {
if (Object.keys(meta).length === 0) {
return
}
try {
await updateWorktreeMeta(worktreeId, meta)
} catch {
console.error('Failed to update worktree meta after creation')
}
},
[updateWorktreeMeta]
)
const submit = useCallback(async (): Promise<void> => {
const workspaceName = workspaceSeedName
@ -867,27 +877,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
)
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')
}
await applyWorktreeMeta(worktree.id, {
...(parsedLinkedIssueNumber !== null ? { linkedIssue: parsedLinkedIssueNumber } : {}),
...(linkedPR !== null ? { linkedPR } : {}),
...(note.trim() ? { comment: note.trim() } : {})
})
const issueCommand = shouldRunIssueAutomation
? {
@ -934,6 +928,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}, [
clearNewWorkspaceDraft,
createWorktree,
applyWorktreeMeta,
issueCommandTemplate,
linkedPR,
linkedWorkItem?.url,
@ -956,10 +951,101 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
shouldWaitForIssueAutomationCheck,
shouldWaitForSetupCheck,
startupPrompt,
updateWorktreeMeta,
workspaceSeedName
])
const submitQuick = useCallback(
async (agent: TuiAgent | null): Promise<void> => {
const workspaceName = getWorkspaceSeedName({
explicitName: name,
prompt: '',
linkedIssueNumber: null,
linkedPR: null
})
if (
!repoId ||
!workspaceName ||
!selectedRepo ||
shouldWaitForSetupCheck ||
(requiresExplicitSetupChoice && !setupDecision)
) {
return
}
setCreateError(null)
setCreating(true)
try {
const result = await createWorktree(
repoId,
workspaceName,
undefined,
(resolvedSetupDecision ?? 'inherit') as SetupDecision
)
const worktree = result.worktree
const trimmedNote = note.trim()
await applyWorktreeMeta(worktree.id, trimmedNote ? { comment: trimmedNote } : {})
const startupPlan =
agent === null
? null
: buildAgentStartupPlan({
agent,
prompt: '',
cmdOverrides: settings?.agentCmdOverrides ?? {},
platform: CLIENT_PLATFORM,
allowEmptyPromptLaunch: true
})
activateAndRevealWorktree(worktree.id, {
setup: result.setup,
...(startupPlan ? { startup: { command: startupPlan.launchCommand } } : {})
})
if (startupPlan) {
void ensureAgentStartupInTerminal({
worktreeId: worktree.id,
startup: startupPlan
})
}
setSidebarOpen(true)
if (settings?.rightSidebarOpenByDefault) {
setRightSidebarTab('explorer')
setRightSidebarOpen(true)
}
if (persistDraft) {
clearNewWorkspaceDraft()
}
onCreated?.()
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create worktree.'
setCreateError(message)
toast.error(message)
} finally {
setCreating(false)
}
},
[
applyWorktreeMeta,
clearNewWorkspaceDraft,
createWorktree,
name,
note,
onCreated,
persistDraft,
repoId,
requiresExplicitSetupChoice,
resolvedSetupDecision,
selectedRepo,
settings?.agentCmdOverrides,
settings?.rightSidebarOpenByDefault,
setRightSidebarOpen,
setRightSidebarTab,
setSidebarOpen,
setupDecision,
shouldWaitForSetupCheck
]
)
const createDisabled =
!repoId ||
!workspaceSeedName ||
@ -1021,6 +1107,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
promptTextareaRef,
nameInputRef,
submit,
submitQuick,
createDisabled
}
}

View File

@ -0,0 +1,117 @@
import { detectAgentStatusFromTitle } from '../../../shared/agent-detection'
import { isShellProcess } from '@/lib/tui-agent-startup'
import { useAppStore } from '@/store'
// Why: agent CLIs vary widely in how they signal readiness. Title-based
// detection (OSC titles parsed by detectAgentStatusFromTitle) is the tightest
// signal we have — an agent that emits "✳ " or ". "/"* " prefixes has fully
// taken over the PTY. For agents that don't set titles, fall back to
// foreground-process equality (the launched binary is alive and owns the fg
// job), then finally to the presence of any non-shell child process. A hard
// timeout prevents the Use-button flow from hanging on a missing binary.
export type AgentReadyReason = 'title-idle' | 'foreground-match' | 'child-process' | 'timeout'
export type AgentReadyResult = {
ready: boolean
reason: AgentReadyReason
}
const DEFAULT_TIMEOUT_MS = 5000
const POLL_INTERVAL_MS = 120
function resolvePrimaryPtyId(tabId: string): string | null {
const state = useAppStore.getState()
const ptyIds = state.ptyIdsByTabId[tabId]
return ptyIds?.[0] ?? null
}
function titleSuggestsReady(tabId: string): boolean {
const state = useAppStore.getState()
const paneTitles = state.runtimePaneTitlesByTabId[tabId]
const titles: string[] = []
if (paneTitles) {
for (const title of Object.values(paneTitles)) {
if (title) {
titles.push(title)
}
}
}
// Why: fall back to the persisted tab.title when runtime pane titles haven't
// been populated yet (e.g. the TerminalPane has not mounted a title handler
// for this tab). Finding the tab by id walks every worktree, which is fine
// at poll rates — the map is small.
if (titles.length === 0) {
for (const tabs of Object.values(state.tabsByWorktree)) {
const tab = tabs.find((t) => t.id === tabId)
if (tab?.title) {
titles.push(tab.title)
break
}
}
}
return titles.some((title) => detectAgentStatusFromTitle(title) === 'idle')
}
/**
* Wait until the agent we launched on `tabId` is ready to accept typed input.
*
* Checks, in order of preference:
* 1. Terminal title reports an idle agent status.
* 2. Foreground process name matches `expectedProcess`.
* 3. PTY has at least one non-shell child process (after a brief grace
* period so we don't accept the shell's own transient children).
*
* Resolves early on the first match, or after `timeoutMs` with
* `{ ready: false, reason: 'timeout' }`. Never rejects.
*/
export async function waitForAgentReady(
tabId: string,
expectedProcess: string,
opts?: { timeoutMs?: number }
): Promise<AgentReadyResult> {
const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS
const deadline = Date.now() + timeoutMs
let attempt = 0
while (Date.now() < deadline) {
if (attempt > 0) {
await new Promise((resolve) => window.setTimeout(resolve, POLL_INTERVAL_MS))
}
attempt += 1
if (titleSuggestsReady(tabId)) {
return { ready: true, reason: 'title-idle' }
}
const ptyId = resolvePrimaryPtyId(tabId)
if (!ptyId) {
continue
}
try {
const foreground = (await window.api.pty.getForegroundProcess(ptyId))?.toLowerCase() ?? ''
if (
foreground === expectedProcess ||
foreground.startsWith(`${expectedProcess}.`) ||
foreground.endsWith(`/${expectedProcess}`)
) {
return { ready: true, reason: 'foreground-match' }
}
// Why: child-process check is the weakest signal (it fires for any
// non-shell subprocess, including `ls` or `git`). Gate it behind a few
// polls so the shell's own startup children don't spoof readiness on
// cold-start. Never accept it while the foreground is still a shell.
if (attempt >= 4 && !isShellProcess(foreground)) {
const hasChildProcesses = await window.api.pty.hasChildProcesses(ptyId)
if (hasChildProcesses) {
return { ready: true, reason: 'child-process' }
}
}
} catch {
// Swallow transient PTY inspection errors and keep polling.
}
}
return { ready: false, reason: 'timeout' }
}

View File

@ -0,0 +1,25 @@
import type { TuiAgent } from '../../../shared/types'
// Why: agent detection runs `which` for every agent binary on PATH — an IPC
// round-trip that takes 50200ms. The set of installed agents doesn't change
// within a session, so cache the promise at module scope to collapse all
// callers (composer page, quick-composer modal, "Use this task" flow, etc.)
// onto a single resolve.
let detectAgentsPromise: Promise<TuiAgent[]> | null = null
export function detectAgentsCached(): Promise<TuiAgent[]> {
if (detectAgentsPromise) {
return detectAgentsPromise
}
const pending = window.api.preflight
.detectAgents()
.then((ids) => ids as TuiAgent[])
.catch(() => {
// Allow a retry on the next mount if detection blew up (e.g. IPC
// timeout during cold start).
detectAgentsPromise = null
return [] as TuiAgent[]
})
detectAgentsPromise = pending
return pending
}

View File

@ -0,0 +1,214 @@
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import { detectAgentsCached } from '@/lib/detect-agents-cached'
import { waitForAgentReady } from '@/lib/agent-ready-wait'
import { buildAgentStartupPlan } from '@/lib/tui-agent-startup'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import {
CLIENT_PLATFORM,
getLinkedWorkItemSuggestedName,
getSetupConfig,
getWorkspaceSeedName
} from '@/lib/new-workspace'
import type {
GitHubWorkItem,
OrcaHooks,
RepoHookSettings,
SetupDecision,
TuiAgent
} from '../../../shared/types'
// Why: bracketed paste markers let modern TUIs treat the inserted text as a
// single atomic paste — Claude Code / Codex / Gemini put it in their input
// buffer as a draft instead of echoing character-by-character. Intentionally
// omit a trailing '\r' so the draft never auto-submits; the user gets to
// review and send the prompt themselves.
const BRACKETED_PASTE_BEGIN = '\x1b[200~'
const BRACKETED_PASTE_END = '\x1b[201~'
export type LaunchWorkItemDirectArgs = {
item: GitHubWorkItem
repoId: string
/** Called when the flow cannot proceed without user input (setup policy is
* `ask`, or the selected repo cannot resolve). Callers wire this to the
* existing modal opener so the user still gets a path forward. */
openModalFallback: () => void
}
function pickAgent(
preferred: TuiAgent | 'blank' | null | undefined,
detected: Set<TuiAgent>
): TuiAgent | null {
// Why: honor the explicit default when the agent is actually installed. A
// stale preference (uninstalled binary) must not block the flow — fall
// through to the first matching detected agent in catalog order, which
// matches the quick-composer's auto-pick behavior and keeps the experience
// consistent regardless of where the user launches the workspace from.
if (preferred && preferred !== 'blank' && detected.has(preferred)) {
return preferred
}
for (const entry of AGENT_CATALOG) {
if (detected.has(entry.id)) {
return entry.id
}
}
return null
}
async function resolveSetupDecision(
repoId: string,
repo: { hookSettings?: RepoHookSettings }
): Promise<{ kind: 'decided'; decision: SetupDecision } | { kind: 'needs-modal' }> {
let yamlHooks: OrcaHooks | null = null
try {
const result = await window.api.hooks.check({ repoId })
yamlHooks = (result.hooks as OrcaHooks | null) ?? null
} catch {
yamlHooks = null
}
const setupConfig = getSetupConfig(repo, yamlHooks)
if (!setupConfig) {
// Why: no setup script configured → the decision is irrelevant but `inherit`
// keeps the main-side behavior consistent with callers that don't pass one.
return { kind: 'decided', decision: 'inherit' }
}
const policy = repo.hookSettings?.setupRunPolicy ?? 'run-by-default'
if (policy === 'ask') {
return { kind: 'needs-modal' }
}
return {
kind: 'decided',
decision: policy === 'run-by-default' ? 'run' : 'skip'
}
}
/**
* "Use" flow: create the workspace, activate it, launch the default agent,
* and paste the work item URL into the agent's prompt as a draft (no submit).
*
* Falls back to `openModalFallback()` when:
* - the repo's `setupRunPolicy` is `'ask'` (the user must pick per-workspace)
* - the repo can't be resolved from `repoId`
* - no compatible agent is detected on PATH
*
* Best-effort: after the workspace is created and activated, failures during
* the agent-readiness or paste steps only toast a notice the user still
* has a usable workspace and can paste the URL themselves.
*/
export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Promise<void> {
const { item, repoId, openModalFallback } = args
const store = useAppStore.getState()
const repo = store.repos.find((r) => r.id === repoId)
if (!repo) {
openModalFallback()
return
}
const settings = store.settings
const detectedIds = new Set(await detectAgentsCached())
const effectiveAgent = pickAgent(settings?.defaultTuiAgent, detectedIds)
const setupResolution = await resolveSetupDecision(repoId, repo)
if (setupResolution.kind === 'needs-modal') {
openModalFallback()
return
}
const workspaceName = getWorkspaceSeedName({
explicitName: getLinkedWorkItemSuggestedName(item),
prompt: '',
linkedIssueNumber: item.type === 'issue' ? item.number : null,
linkedPR: item.type === 'pr' ? item.number : null
})
// Why: launch the agent with no prompt so the first frame it draws is the
// empty input box. The URL paste below populates that input buffer, which
// gives the user a reviewable draft instead of a submitted request.
const startupPlan =
effectiveAgent === null
? null
: buildAgentStartupPlan({
agent: effectiveAgent,
prompt: '',
cmdOverrides: settings?.agentCmdOverrides ?? {},
platform: CLIENT_PLATFORM,
allowEmptyPromptLaunch: true
})
let worktreeId: string
let primaryTabId: string | null
try {
const result = await store.createWorktree(
repoId,
workspaceName,
undefined,
setupResolution.decision
)
worktreeId = result.worktree.id
const activation = activateAndRevealWorktree(worktreeId, {
setup: result.setup,
...(startupPlan ? { startup: { command: startupPlan.launchCommand } } : {})
})
if (!activation) {
// Worktree vanished between create and activate — extremely unlikely but
// worth handling explicitly rather than silently dropping the URL.
toast.error('Workspace created but could not be activated.')
return
}
primaryTabId = activation.primaryTabId
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create workspace.'
toast.error(message)
return
}
const meta: { linkedIssue?: number; linkedPR?: number } = {}
if (item.type === 'issue') {
meta.linkedIssue = item.number
} else {
meta.linkedPR = item.number
}
try {
await store.updateWorktreeMeta(worktreeId, meta)
} catch {
// Meta update is non-critical for the draft flow — continue.
}
store.setSidebarOpen(true)
if (settings?.rightSidebarOpenByDefault) {
store.setRightSidebarTab('explorer')
store.setRightSidebarOpen(true)
}
// Why: at this point the workspace is live and the agent (if any) has been
// queued on `primaryTabId`. The paste step below is the only remaining
// draft-specific work; bail out cleanly when either prerequisite is missing.
if (!primaryTabId || !startupPlan) {
return
}
const readyResult = await waitForAgentReady(primaryTabId, startupPlan.expectedProcess, {
timeoutMs: 5000
})
if (!readyResult.ready) {
toast.message(
'Agent took too long to start. The workspace is ready — paste the issue URL when the agent is idle.'
)
return
}
const finalState = useAppStore.getState()
const ptyId = finalState.ptyIdsByTabId[primaryTabId]?.[0]
if (!ptyId) {
return
}
// Why: some TUIs buffer input while they paint their first frame even after
// the foreground/title signal flips ready. One extra tick lets the input box
// render before we shove bytes into the PTY.
await new Promise((resolve) => window.setTimeout(resolve, 120))
window.api.pty.write(ptyId, `${BRACKETED_PASTE_BEGIN}${item.url}${BRACKETED_PASTE_END}`)
}

View File

@ -20,15 +20,24 @@ export function buildAgentStartupPlan(args: {
prompt: string
cmdOverrides: Partial<Record<TuiAgent, string>>
platform: NodeJS.Platform
allowEmptyPromptLaunch?: boolean
}): AgentStartupPlan | null {
const { agent, prompt, cmdOverrides, platform } = args
const { agent, prompt, cmdOverrides, platform, allowEmptyPromptLaunch = false } = args
const trimmedPrompt = prompt.trim()
if (!trimmedPrompt) {
return null
}
const config = TUI_AGENT_CONFIG[agent]
const baseCommand = cmdOverrides[agent] ?? config.launchCmd
if (!trimmedPrompt) {
if (!allowEmptyPromptLaunch) {
return null
}
return {
launchCommand: baseCommand,
expectedProcess: config.expectedProcess,
followupPrompt: null
}
}
const quotedPrompt = quoteStartupArg(trimmedPrompt, platform)
if (config.promptInjectionMode === 'argv') {

View File

@ -43,6 +43,16 @@ type WorktreeActivationStore = {
* internally via `findWorktreeById`. Returns early without side effects
* if the worktree is not found (e.g. deleted between palette open and select).
*/
export type ActivateAndRevealResult = {
/** Id of the primary terminal tab seeded with `opts.startup`, when one was
* created during this activation call. Callers that want to target the
* exact pane the startup command ran in (e.g. to await agent readiness
* and paste follow-up text) should use this rather than peeking at
* `activeTabIdByWorktree`, which may point at another tab if setup or
* issue-command scripts opened their own. */
primaryTabId: string | null
}
export function activateAndRevealWorktree(
worktreeId: string,
opts?: {
@ -50,7 +60,7 @@ export function activateAndRevealWorktree(
setup?: WorktreeSetupLaunch
issueCommand?: IssueCommandLaunch
}
): boolean {
): ActivateAndRevealResult | false {
const state = useAppStore.getState()
const wt = findWorktreeById(state.worktreesByRepo, worktreeId)
if (!wt) {
@ -72,7 +82,7 @@ export function activateAndRevealWorktree(
state.setActiveWorktree(worktreeId)
// 4. Ensure a focusable surface exists for externally-created worktrees
ensureWorktreeHasInitialTerminal(
const primaryTabId = ensureWorktreeHasInitialTerminal(
useAppStore.getState(),
worktreeId,
opts?.startup,
@ -94,7 +104,7 @@ export function activateAndRevealWorktree(
// 6. Reveal in sidebar
state.revealWorktreeInSidebar(worktreeId)
return true
return { primaryTabId }
}
export function ensureWorktreeHasInitialTerminal(
@ -103,13 +113,13 @@ export function ensureWorktreeHasInitialTerminal(
startup?: { command: string; env?: Record<string, string> },
setup?: WorktreeSetupLaunch,
issueCommand?: IssueCommandLaunch
): void {
): string | null {
const { renderableTabCount } = store.reconcileWorktreeTabModel(worktreeId)
// Why: activation can now restore editor- or browser-only worktrees from the
// reconciled tab-group model. Creating a terminal just because the legacy
// terminal slice is empty would reopen worktrees with an unexpected extra tab.
if (!shouldAutoCreateInitialTerminal(renderableTabCount)) {
return
return null
}
const terminalTab = store.createTab(worktreeId)
@ -171,4 +181,6 @@ export function ensureWorktreeHasInitialTerminal(
: { command: issueCommand.command, env: issueCommand.env }
store.queueTabIssueCommandSplit(terminalTab.id, queuedIssueCommand)
}
return terminalTab.id
}

View File

@ -647,8 +647,11 @@ export type GlobalSettings = {
* does not surface commands from other worktrees. Defaults to true.
* Disable to revert to shared global shell history. */
terminalScopeHistoryByWorktree: boolean
/** Which agent to pre-select in the new-workspace composer. null = auto (first detected). */
defaultTuiAgent: TuiAgent | null
/** Which agent to pre-select in the new-workspace composer.
* - null: auto (first detected agent)
* - 'blank': blank terminal (no agent launched)
* - TuiAgent: a specific agent id */
defaultTuiAgent: TuiAgent | 'blank' | null
/** Why: worktree deletion is destructive (git worktree remove + rm -rf of the
* working directory), so Orca shows a confirmation dialog by default. Users
* who delete frequently can opt into skipping the dialog via a "Don't ask