mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 00:02:54 +00:00
@@ -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 ? (
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user