Add mobile Tasks parity (#2452)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-05-21 20:26:07 -07:00
committed by GitHub
co-authored by Orca
parent 3efc669bd4
commit b1973657ea
123 changed files with 26492 additions and 1461 deletions
@@ -463,7 +463,7 @@ export default function GitLabItemDialog({
className="gap-1.5"
>
<ExternalLink className="size-3.5" />
Open in browser
Open in GitLab
</Button>
<div className="flex items-center gap-2">
{onCreateWorkspace ? (
+5 -5
View File
@@ -1848,7 +1848,7 @@ function PaginationBar({
}): React.JSX.Element {
const pageNumbers = getPageNumbers(currentPage, totalPages)
const btnClass =
'inline-flex items-center gap-0.5 rounded-md px-2 py-1 text-sm text-muted-foreground transition hover:bg-muted/60 hover:text-foreground disabled:pointer-events-none disabled:opacity-40'
'inline-flex w-24 items-center justify-center gap-0.5 rounded-md px-2 py-1 text-sm text-muted-foreground transition hover:bg-muted/60 hover:text-foreground disabled:pointer-events-none disabled:opacity-40'
const numClass = (page: number): string =>
cn(
'inline-flex size-8 items-center justify-center rounded-md text-sm transition',
@@ -2673,9 +2673,9 @@ export default function TaskPage(): React.JSX.Element {
) {
return
}
// Why: GitLab queries don't work over SSH-relay (yet) and folder-
// mode repos have no remotes to derive a project from. Filter both.
const eligibleRepos = selectedRepos.filter((r) => !r.connectionId)
// Why: folder-mode repos have no remotes to derive a GitLab project from;
// SSH-backed Git repos go through the same provider-aware IPC path.
const eligibleRepos = selectedRepos
if (eligibleRepos.length === 0) {
setGitlabItems([])
setGitlabLoading(false)
@@ -5343,7 +5343,7 @@ export default function TaskPage(): React.JSX.Element {
e.stopPropagation()
void window.api.shell.openUrl(item.url)
}}
aria-label="Open in browser"
aria-label="Open in GitLab"
className="text-muted-foreground hover:text-foreground"
>
<ExternalLink className="size-3.5" />
@@ -1,7 +1,7 @@
import React from 'react'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { isIterationCurrent, type ProjectGroup } from './group-sort'
import { isIterationCurrent, type ProjectGroup } from '../../../../shared/github-project-group-sort'
type Props = {
group: ProjectGroup
@@ -5,7 +5,7 @@ import { cn } from '@/lib/utils'
import ColumnResizeHandle from './ColumnResizeHandle'
import ProjectGroupHeader from './ProjectGroupHeader'
import ProjectRow from './ProjectRow'
import { groupRows, sortRows } from './group-sort'
import { groupRows, sortRows } from '../../../../shared/github-project-group-sort'
import { getAvailableColumns, loadHiddenColumns, saveHiddenColumns } from './columns'
import {
ACTION_COLUMN_WIDTH,
@@ -9,7 +9,7 @@ import type {
GitHubProjectTable,
GitHubProjectView
} from '../../../../shared/github-project-types'
import { sortRows, groupRows } from './group-sort'
import { sortRows, groupRows } from '../../../../shared/github-project-group-sort'
const singleSelectField: GitHubProjectField = {
kind: 'single-select',
@@ -205,6 +205,18 @@ describe('sortRows', () => {
const sorted = sortRows(makeTable(view, rows), rows)
expect(sorted.map((r) => r.id)).toEqual(['rHas', 'rEmpty'])
})
it('keeps sort fallback finite when row positions are absent', () => {
const view = makeView(singleSelectField)
const rows = [
{ ...makeRow('rA', 0, {}), position: undefined as unknown as number },
{ ...makeRow('rB', 0, {}), position: undefined as unknown as number }
]
const sorted = sortRows(makeTable(view, rows), rows)
expect(sorted.map((r) => r.id)).toEqual(['rA', 'rB'])
})
})
describe('groupRows', () => {
@@ -1,247 +0,0 @@
// Why: grouping and sorting of Project rows is deterministic shared logic
// driven by `selectedView` — it must not depend on fetch ordering. Keeping it
// in a pure module makes the comparator easily fixture-testable.
import type {
GitHubProjectField,
GitHubProjectRow,
GitHubProjectSort,
GitHubProjectTable
} from '../../../../shared/github-project-types'
export type ProjectGroup = {
/** Stable key used for React reconciliation. */
key: string
/** Human-readable label used in the group header. */
label: string
/** Iteration metadata for headers that render a date range + Current pill. */
iteration: {
startDate: string
duration: number
completed: boolean
} | null
rows: GitHubProjectRow[]
}
const EMPTY_GROUP_KEY = '__empty__'
// Why: use a finite sentinel instead of Infinity so subtractions in the sort
// comparator stay finite. `Infinity - Infinity` is NaN, which makes
// Array.sort's behavior implementation-defined and skips later tie-breaks.
const UNKNOWN_INDEX_SENTINEL = Number.MAX_SAFE_INTEGER
function getFieldValueForGrouping(
row: GitHubProjectRow,
field: GitHubProjectField
): { key: string; label: string; orderHint: number; iteration: ProjectGroup['iteration'] } {
const value = row.fieldValuesByFieldId[field.id]
if (!value) {
return {
key: EMPTY_GROUP_KEY,
label: labelForEmpty(field),
orderHint: UNKNOWN_INDEX_SENTINEL,
iteration: null
}
}
if (field.kind === 'iteration' && value.kind === 'iteration') {
const idx = field.iterations.findIndex((it) => it.id === value.iterationId)
const meta = field.iterations.find((it) => it.id === value.iterationId)
return {
key: value.iterationId,
label: value.title || meta?.title || 'Iteration',
orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx,
iteration: meta
? { startDate: meta.startDate, duration: meta.duration, completed: meta.completed }
: null
}
}
if (field.kind === 'single-select' && value.kind === 'single-select') {
const idx = field.options.findIndex((o) => o.id === value.optionId)
return {
key: value.optionId,
label: value.name,
orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx,
iteration: null
}
}
// Fallback: use stringified value as both key and label.
const label = deriveStringValue(value)
return { key: `raw:${label}`, label, orderHint: 0, iteration: null }
}
function labelForEmpty(field: GitHubProjectField): string {
return `No ${field.name}`
}
function deriveStringValue(value: GitHubProjectRow['fieldValuesByFieldId'][string]): string {
switch (value.kind) {
case 'text':
return value.text
case 'number':
return String(value.number)
case 'date':
return value.date
case 'single-select':
return value.name
case 'iteration':
return value.title
case 'labels':
return value.labels.map((l) => l.name).join(', ')
case 'users':
return value.users.map((u) => u.login).join(', ')
default:
return ''
}
}
export function groupRows(
table: GitHubProjectTable,
rowsInOrder: GitHubProjectRow[]
): ProjectGroup[] {
const groupField = table.selectedView.groupByFields[0]
if (!groupField) {
return [{ key: 'all', label: '', iteration: null, rows: rowsInOrder }]
}
const buckets = new Map<
string,
{
label: string
orderHint: number
iteration: ProjectGroup['iteration']
rows: GitHubProjectRow[]
}
>()
for (const row of rowsInOrder) {
const { key, label, orderHint, iteration } = getFieldValueForGrouping(row, groupField)
let bucket = buckets.get(key)
if (!bucket) {
bucket = { label, orderHint, iteration, rows: [] }
buckets.set(key, bucket)
}
bucket.rows.push(row)
}
const entries = Array.from(buckets.entries())
// Ordering rules per design doc §Grouping.
entries.sort((a, b) => {
// Empty group always last.
if (a[0] === EMPTY_GROUP_KEY) {
return 1
}
if (b[0] === EMPTY_GROUP_KEY) {
return -1
}
if (groupField.kind === 'iteration' || groupField.kind === 'single-select') {
return a[1].orderHint - b[1].orderHint
}
return a[1].label.localeCompare(b[1].label)
})
return entries.map(([key, v]) => ({
key,
label: v.label,
iteration: v.iteration,
rows: v.rows
}))
}
function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProjectSort): number {
const field = sort.field
const aValue = a.fieldValuesByFieldId[field.id]
const bValue = b.fieldValuesByFieldId[field.id]
// Missing values sort last (regardless of direction).
if (!aValue && !bValue) {
return 0
}
if (!aValue) {
return 1
}
if (!bValue) {
return -1
}
let cmp = 0
if (
field.kind === 'single-select' &&
aValue.kind === 'single-select' &&
bValue.kind === 'single-select'
) {
const aIdx = field.options.findIndex((o) => o.id === aValue.optionId)
const bIdx = field.options.findIndex((o) => o.id === bValue.optionId)
cmp =
(aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx)
} else if (
field.kind === 'iteration' &&
aValue.kind === 'iteration' &&
bValue.kind === 'iteration'
) {
const aIdx = field.iterations.findIndex((it) => it.id === aValue.iterationId)
const bIdx = field.iterations.findIndex((it) => it.id === bValue.iterationId)
cmp =
(aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx)
} else if (aValue.kind === 'number' && bValue.kind === 'number') {
cmp = aValue.number - bValue.number
} else if (aValue.kind === 'date' && bValue.kind === 'date') {
cmp = aValue.date.localeCompare(bValue.date)
} else if (aValue.kind === 'text' && bValue.kind === 'text') {
cmp = aValue.text.localeCompare(bValue.text)
} else if (aValue.kind === 'users' && bValue.kind === 'users') {
const aLogin = aValue.users[0]?.login ?? ''
const bLogin = bValue.users[0]?.login ?? ''
if (!aLogin && !bLogin) {
cmp = 0
} else if (!aLogin) {
cmp = 1
} else if (!bLogin) {
cmp = -1
} else {
cmp = aLogin.localeCompare(bLogin)
}
} else if (aValue.kind === 'labels' && bValue.kind === 'labels') {
const aName = aValue.labels[0]?.name ?? ''
const bName = bValue.labels[0]?.name ?? ''
if (!aName && !bName) {
cmp = 0
} else if (!aName) {
cmp = 1
} else if (!bName) {
cmp = -1
} else {
cmp = aName.localeCompare(bName)
}
} else {
// Why: unknown sort-field kind — ignore this sort field and fall through
// to tie-breaks (and eventually row.position). Dev-time warning gated so
// production builds don't spam the console.
if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line no-console
console.warn('[projectView] unknown sort-field kind', field)
}
return 0
}
return sort.direction === 'DESC' ? -cmp : cmp
}
export function sortRows(table: GitHubProjectTable, rows: GitHubProjectRow[]): GitHubProjectRow[] {
const sorts = table.selectedView.sortByFields
const out = [...rows]
out.sort((a, b) => {
for (const sort of sorts) {
const cmp = compareSort(a, b, sort)
if (cmp !== 0) {
return cmp
}
}
// Final tie-break: row.position preserves GitHub rank order.
return a.position - b.position
})
return out
}
export function isIterationCurrent(iteration: { startDate: string; duration: number }): boolean {
// Parse as YYYY-MM-DD in UTC to avoid TZ-shift false negatives near midnight.
const start = new Date(`${iteration.startDate}T00:00:00Z`).getTime()
if (Number.isNaN(start)) {
return false
}
const end = start + iteration.duration * 86_400_000
const now = Date.now()
return now >= start && now < end
}
@@ -1,37 +1,4 @@
export type ComposerBranchSelection = {
baseBranch: string
branchNameOverride: string | undefined
branchAutoName: string
name: string | undefined
lastAutoName: string | undefined
}
export function resolveComposerBranchSelection(args: {
refName: string
localBranchName: string
currentName: string
lastAutoName: string
}): ComposerBranchSelection {
const trimmedCurrentName = args.currentName.trim()
const shouldAutoName =
!trimmedCurrentName ||
args.currentName === args.lastAutoName ||
args.localBranchName.startsWith(trimmedCurrentName) ||
args.refName.startsWith(trimmedCurrentName)
if (!shouldAutoName) {
return {
baseBranch: args.refName,
branchNameOverride: undefined,
branchAutoName: '',
name: undefined,
lastAutoName: undefined
}
}
return {
baseBranch: args.refName,
branchNameOverride: args.localBranchName,
branchAutoName: args.localBranchName,
name: args.localBranchName,
lastAutoName: args.localBranchName
}
}
export {
resolveComposerBranchSelection,
type ComposerBranchSelection
} from '../../../shared/composer-branch-selection'
+8 -3
View File
@@ -124,7 +124,11 @@ export type ComposerCardProps = {
onSmartBranchSelect: (refName: string, localBranchName: string) => void
onSmartLinearIssueSelect: (issue: LinearIssue) => void
/** GitLab parallel of onBaseBranchPrSelect. */
onBaseBranchMrSelect?: (baseBranch: string, item: GitLabWorkItem) => void
onBaseBranchMrSelect?: (
baseBranch: string,
item: GitLabWorkItem,
pushTarget?: GitPushTarget
) => void
smartNameSelection: SmartWorkspaceNameSelection | null
onClearSmartNameSelection: () => void
agentPrompt: string
@@ -1450,8 +1454,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
// semantics — except the note prefill uses GitLab's `!N` MR convention
// so a glance at the worktree sidebar makes the provider obvious.
const handleBaseBranchMrSelect = useCallback(
(nextBaseBranch: string, item: GitLabWorkItem): void => {
(nextBaseBranch: string, item: GitLabWorkItem, nextPushTarget?: GitPushTarget): void => {
setBaseBranch(nextBaseBranch)
setPushTarget(nextPushTarget)
setBranchNameOverride(undefined)
branchAutoNameRef.current = ''
setStartFromResetHint(null)
@@ -1551,7 +1556,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
if ('error' in result) {
return
}
handleBaseBranchMrSelect(result.baseBranch, item)
handleBaseBranchMrSelect(result.baseBranch, item, result.pushTarget)
})
},
[applyLinkedGitLabWorkItem, eligibleRepos, handleBaseBranchMrSelect, selectedRepo]
@@ -984,6 +984,43 @@ describe('useIpcEvents updater integration', () => {
title: 'Terminal 1'
})
storeState.terminalLayoutsByTabId = {
'tab-existing': {
root: { type: 'leaf', leafId: 'leaf-source' },
activeLeafId: 'leaf-source',
expandedLeafId: null,
ptyIdsByLeafId: { 'leaf-source': 'pty-bg' }
}
}
updateTabPtyId.mockClear()
setTabLayout.mockClear()
createTerminalListenerRef.current({
worktreeId: 'wt-2',
ptyId: 'pty-split-background',
tabId: 'tab-existing',
leafId: 'leaf-split-background',
splitFromLeafId: 'leaf-source',
splitDirection: 'vertical',
activate: false
})
expect(updateTabPtyId).toHaveBeenCalledWith('tab-existing', 'pty-split-background')
expect(setTabLayout).toHaveBeenCalledWith('tab-existing', {
root: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: 'leaf-source' },
second: { type: 'leaf', leafId: 'leaf-split-background' },
ratio: 0.5
},
activeLeafId: 'leaf-source',
expandedLeafId: null,
ptyIdsByLeafId: {
'leaf-source': 'pty-bg',
'leaf-split-background': 'pty-split-background'
}
})
const splitLayout = {
root: {
type: 'split',
+9 -3
View File
@@ -130,10 +130,15 @@ function addSplitLeafToLayout(
newLeafId: string,
ptyId: string,
direction: TerminalSplitDirection,
title?: string | null
title?: string | null,
activateNewLeaf = true
): TerminalLayoutSnapshot {
const root = layout?.root ?? { type: 'leaf', leafId: sourceLeafId }
const existingLeafIds = collectLeafIdsInOrder(root)
const nextActiveLeafId =
activateNewLeaf || !layout?.activeLeafId || !existingLeafIds.includes(layout.activeLeafId)
? newLeafId
: layout.activeLeafId
const nextRoot = existingLeafIds.includes(newLeafId)
? root
: (() => {
@@ -152,7 +157,7 @@ function addSplitLeafToLayout(
return {
...(layout ?? { root: null, activeLeafId: null, expandedLeafId: null }),
root: nextRoot,
activeLeafId: newLeafId,
activeLeafId: nextActiveLeafId,
expandedLeafId: null,
ptyIdsByLeafId: {
...layout?.ptyIdsByLeafId,
@@ -850,7 +855,8 @@ export function useIpcEvents(): void {
leafId,
ptyId,
splitDirection ?? 'horizontal',
title
title,
shouldActivate
)
)
window.dispatchEvent(
@@ -1,9 +1,9 @@
import { toast } from 'sonner'
import { useAppStore, type AppState } from '@/store'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft'
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import { pickTuiAgent } from '../../../shared/tui-agent-selection'
import { activateAndRevealWorktree, type AgentStartedTelemetry } from '@/lib/worktree-activation'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import {
@@ -69,26 +69,6 @@ export type LaunchWorkItemDirectArgs = {
telemetrySource?: WorkspaceCreateTelemetrySource
}
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 resolveDirectPrStartPoint(
repoId: string,
prNumber: number,
@@ -272,7 +252,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
const worktreePath = result.worktree.path
const detectedIds = new Set(await detectedAgentsPromise)
effectiveAgent = pickAgent(settings?.defaultTuiAgent, detectedIds)
effectiveAgent = pickTuiAgent(settings?.defaultTuiAgent, detectedIds)
if (effectiveAgent) {
// Why: direct task launch creates and starts the workspace in separate
// steps so agent detection can overlap git worktree creation. Persist
+2 -37
View File
@@ -9,6 +9,8 @@ import { isShellProcess } from '@/lib/tui-agent-startup'
import type { OrcaHooks, TaskViewPresetId } from '../../../shared/types'
import { resolveHookCommandSourcePolicy } from '../../../shared/hook-command-source-policy'
import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition'
import { slugifyForWorkspaceName } from '../../../shared/workspace-name'
export { getLinkedWorkItemSuggestedName } from '../../../shared/workspace-name'
/**
* Why: the TaskPage's preset buttons and the openTaskPage prefetcher both need
@@ -158,43 +160,6 @@ export function getSetupConfig(
return null
}
// Why: branch names and on-disk worktree directories must be short, lowercase,
// and ASCII-safe. Free-form text (prompts, GitHub titles) often contains
// emoji, CJK, or hundreds of characters, which would otherwise make
// sanitizeWorktreeName either produce a ludicrously long name or throw
// "Invalid worktree name" when every character is stripped.
function slugifyForWorkspaceName(input: string): string {
return (
input
.trim()
.toLowerCase()
.replace(/[\\/]+/g, '-')
.replace(/\s+/g, '-')
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
// Why: git check-ref-format rejects any ref containing `..`, so a prompt
// like "../../foo" must not turn into a branch seed with internal `..`
// sequences (the main-process sanitizer collapses these too, but we
// mirror the rule here so the renderer preview matches the real name).
.replace(/\.{2,}/g, '.')
.replace(/^[.-]+|[.-]+$/g, '')
.slice(0, 48)
.replace(/[-._]+$/g, '')
)
}
export function getLinkedWorkItemSuggestedName(item: { title: string }): string {
const withoutLeadingNumber = item.title
.trim()
.replace(/^(?:issue|pr|pull request)\s*#?\d+\s*[:-]\s*/i, '')
.replace(/^#\d+\s*[:-]\s*/, '')
.replace(/\(#\d+\)/gi, '')
.replace(/\b#\d+\b/g, '')
.trim()
const seed = withoutLeadingNumber || item.title.trim()
return slugifyForWorkspaceName(seed)
}
export function getWorkspaceSeedName(args: {
explicitName: string
prompt: string
+6 -173
View File
@@ -1,173 +1,6 @@
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import type { TuiAgent } from '../../../shared/types'
export type AgentStartupPlan = {
/** Why: surfaces the agent id so downstream paste-draft logic can resolve
* the per-agent draft injection strategy without re-deriving from the
* launch command string. */
agent: TuiAgent
launchCommand: string
expectedProcess: string
followupPrompt: string | null
/** Why: text to type into the live agent input WITHOUT submitting it (no
* trailing \r). Used by the quick-create flow to pre-fill a linked work
* item URL so the user can edit/add to it before sending. Independent from
* `followupPrompt` so the call site can choose: type-and-submit (followup)
* or type-and-leave-pending (draft). */
draftPrompt?: string | null
/** Why: env vars to apply at PTY spawn time. Currently used to deliver
* pi's `ORCA_PI_PREFILL` so the overlay's `orca-prefill` extension
* picks it up on session_start. Plumbed into `startup.env` by the
* activation site, NOT into the shell command, so the value isn't
* visibly prefixed onto the terminal. */
env?: Record<string, string>
}
function quoteStartupArg(value: string, platform: NodeJS.Platform): string {
if (platform === 'win32') {
return `"${value.replace(/"/g, '""')}"`
}
return `'${value.replace(/'/g, `'\\''`)}'`
}
export function buildAgentStartupPlan(args: {
agent: TuiAgent
prompt: string
cmdOverrides: Partial<Record<TuiAgent, string>>
platform: NodeJS.Platform
allowEmptyPromptLaunch?: boolean
}): AgentStartupPlan | null {
const { agent, prompt, cmdOverrides, platform, allowEmptyPromptLaunch = false } = args
const trimmedPrompt = prompt.trim()
const config = TUI_AGENT_CONFIG[agent]
const baseCommand = cmdOverrides[agent] ?? config.launchCmd
if (!trimmedPrompt) {
if (!allowEmptyPromptLaunch) {
return null
}
return {
agent,
launchCommand: baseCommand,
expectedProcess: config.expectedProcess,
followupPrompt: null
}
}
const quotedPrompt = quoteStartupArg(trimmedPrompt, platform)
if (config.promptInjectionMode === 'argv') {
return {
agent,
launchCommand: `${baseCommand} ${quotedPrompt}`,
expectedProcess: config.expectedProcess,
followupPrompt: null
}
}
if (config.promptInjectionMode === 'flag-prompt') {
return {
agent,
launchCommand: `${baseCommand} --prompt ${quotedPrompt}`,
expectedProcess: config.expectedProcess,
followupPrompt: null
}
}
if (config.promptInjectionMode === 'flag-prompt-interactive') {
return {
agent,
launchCommand: `${baseCommand} --prompt-interactive ${quotedPrompt}`,
expectedProcess: config.expectedProcess,
followupPrompt: null
}
}
if (config.promptInjectionMode === 'flag-interactive') {
return {
agent,
launchCommand: `${baseCommand} -i ${quotedPrompt}`,
expectedProcess: config.expectedProcess,
followupPrompt: null
}
}
return {
agent,
launchCommand: baseCommand,
expectedProcess: config.expectedProcess,
// Why: several agent TUIs either lack a documented "start interactive
// session with this prompt" flag or vary too much across versions. For
// those agents Orca launches the TUI first, then types the composed prompt
// into the live session once the agent owns the terminal.
followupPrompt: trimmedPrompt
}
}
export type AgentDraftLaunchPlan = {
agent: TuiAgent
launchCommand: string
expectedProcess: string
/** Why: env-var-based prefill (currently pi via the overlay-installed
* `orca-prefill` extension) ships the draft text in the PTY-spawn
* environment instead of via a CLI flag. Callers MUST plumb this into
* the queued `startup.env` so it reaches the shell that launches the
* agent. Empty/undefined when the agent uses a CLI flag (Claude). */
env?: Record<string, string>
}
/**
* Why: when the agent's CLI exposes a documented "prefill but don't submit"
* flag (currently only `claude --prefill <text>`), launch with that flag so
* the TUI mounts with the draft already in its input box. This is strictly
* better than the post-launch bracketed-paste fallback in agent-paste-draft.ts
* because it eliminates the empirical readiness wait entirely — the agent
* controls when its input is rendered.
*
* Returns `null` when the agent has no native prefill flag; callers fall
* back to the paste-after-ready path.
*/
export function buildAgentDraftLaunchPlan(args: {
agent: TuiAgent
draft: string
cmdOverrides: Partial<Record<TuiAgent, string>>
platform: NodeJS.Platform
}): AgentDraftLaunchPlan | null {
const { agent, draft, cmdOverrides, platform } = args
const config = TUI_AGENT_CONFIG[agent]
const trimmed = draft.trim()
if (!trimmed) {
return null
}
const baseCommand = cmdOverrides[agent] ?? config.launchCmd
if (config.draftPromptFlag) {
const quoted = quoteStartupArg(trimmed, platform)
return {
agent,
launchCommand: `${baseCommand} ${config.draftPromptFlag} ${quoted}`,
expectedProcess: config.expectedProcess
}
}
if (config.draftPromptEnvVar) {
// Why: the env var is set on the PTY-spawn env (not embedded in the
// shell command) so the value never has to be shell-escaped and the
// user doesn't see a `FOO='...'` prefix typed into their terminal.
// Append a clear-var command so the var is unset from the shell env
// once the agent exits — otherwise re-running the agent in the same
// terminal would inherit the stale value and re-prefill with the old URL.
const clearVar =
platform === 'win32'
? `set "${config.draftPromptEnvVar}="`
: `unset ${config.draftPromptEnvVar}`
return {
agent,
launchCommand: `${baseCommand}; ${clearVar}`,
expectedProcess: config.expectedProcess,
env: { [config.draftPromptEnvVar]: trimmed }
}
}
return null
}
export { isShellProcess } from '../../../shared/agent-detection'
export {
buildAgentDraftLaunchPlan,
buildAgentStartupPlan,
isShellProcess
} from '../../../shared/tui-agent-startup'
export type { AgentDraftLaunchPlan, AgentStartupPlan } from '../../../shared/tui-agent-startup'
+14 -4
View File
@@ -669,6 +669,8 @@ function createWorktreesApi(): NonNullable<Partial<PreloadApi>['worktrees']> {
linkedIssue: args.linkedIssue,
linkedPR: args.linkedPR,
linkedLinearIssue: args.linkedLinearIssue,
linkedGitLabIssue: args.linkedGitLabIssue,
linkedGitLabMR: args.linkedGitLabMR,
displayName: args.displayName,
sparseCheckout: args.sparseCheckout,
pushTarget: args.pushTarget,
@@ -685,9 +687,13 @@ function createWorktreesApi(): NonNullable<Partial<PreloadApi>['worktrees']> {
headRefName,
isCrossRepository
}),
resolveMrBase: async () => ({
error: 'GitLab merge request base resolution is unavailable on web.'
}),
resolveMrBase: async ({ repoId, mrIid, sourceBranch, isCrossRepository }) =>
callRuntimeResult('worktree.resolveMrBase', {
repo: repoId,
mrIid,
sourceBranch,
isCrossRepository
}),
remove: async ({ worktreeId, force }) => {
cachedWorktrees = null
await callRuntimeResult('worktree.rm', { worktree: worktreeId, force })
@@ -1763,9 +1769,13 @@ function mapRepoPathArg(args: unknown): unknown {
return args
}
const record = args as Record<string, unknown>
const repoId = typeof record.repoId === 'string' && record.repoId.trim() ? record.repoId : null
return {
...record,
repo: record.repoPath
// Why: runtime repo selectors accept loose path/name forms, but duplicate
// checked-out repos can make those ambiguous. The renderer already passes
// Orca's repo id on task calls, so prefer the explicit selector.
repo: repoId ? `id:${repoId}` : record.repoPath
}
}