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:
parent
35f5fe72e6
commit
369ddb6fe6
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 50–200ms. 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
}
|
||||
|
|
@ -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 50–200ms. 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
|
||||
}
|
||||
|
|
@ -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}`)
|
||||
}
|
||||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue