New workspace create page (#710)

* New workspace page with agent catalog, composer modal, and terminal integration

* fix lint

* better blank state

* fix: resolve typecheck errors in new-workspace flow

- widen activateAndRevealWorktree's issueCommand to accept direct
  command shape used by NewWorkspacePage, not just the main-process
  runner-script variant
- use a ref object in pty-connection tests to dodge TS narrowing the
  captured callback to never across the mock closure boundary

* fix: bound worktree name auto-suffix loop to prevent OOM in tests

The auto-suffix loop in createLocalWorktree had no termination cap.
When tests (or a misconfigured env) mocked getBranchConflictKind /
getPRForBranch to always return a collision, the loop ran forever and
the vitest worker crashed with "Ineffective mark-compacts near heap
limit".

Cap the search at 100 suffixes, and if all fail, fall back to the
original specific error messages (branch already exists / PR already
owns the name) instead of a generic failure.

Update the two tests that asserted the old throw-on-first-conflict
behavior to verify the new auto-suffix path end-to-end.
This commit is contained in:
Neil 2026-04-16 15:41:40 -07:00 committed by GitHub
parent c8a8ef1b5c
commit d2a53c8fcc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 5699 additions and 1052 deletions

View file

@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
execFileAsyncMock,
ghExecFileAsyncMock,
getOwnerRepoMock,
gitExecFileAsyncMock,
acquireMock,
releaseMock
} = vi.hoisted(() => ({
execFileAsyncMock: vi.fn(),
ghExecFileAsyncMock: vi.fn(),
getOwnerRepoMock: vi.fn(),
gitExecFileAsyncMock: vi.fn(),
acquireMock: vi.fn(),
releaseMock: vi.fn()
}))
vi.mock('./gh-utils', () => ({
execFileAsync: execFileAsyncMock,
ghExecFileAsync: ghExecFileAsyncMock,
getOwnerRepo: getOwnerRepoMock,
acquire: acquireMock,
release: releaseMock,
_resetOwnerRepoCache: vi.fn()
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock
}))
import { getPRChecks, _resetOwnerRepoCache } from './client'
describe('getPRChecks', () => {
beforeEach(() => {
execFileAsyncMock.mockReset()
ghExecFileAsyncMock.mockReset()
getOwnerRepoMock.mockReset()
gitExecFileAsyncMock.mockReset()
acquireMock.mockReset()
releaseMock.mockReset()
acquireMock.mockResolvedValue(undefined)
_resetOwnerRepoCache()
})
it('queries check-runs by PR head SHA when GitHub remote metadata is available', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
check_runs: [
{
name: 'build',
status: 'completed',
conclusion: 'success',
html_url: 'https://github.com/acme/widgets/actions/runs/1',
details_url: null
}
]
})
})
const checks = await getPRChecks('/repo-root', 42, 'head-oid')
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
['api', '--cache', '60s', 'repos/acme/widgets/commits/head-oid/check-runs?per_page=100'],
{ cwd: '/repo-root' }
)
expect(checks).toEqual([
{
name: 'build',
status: 'completed',
conclusion: 'success',
url: 'https://github.com/acme/widgets/actions/runs/1'
}
])
})
it('falls back to gh pr checks when the cached head SHA no longer resolves', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockRejectedValueOnce(new Error('gh: No commit found for SHA: stale-head (HTTP 422)'))
.mockResolvedValueOnce({
stdout: JSON.stringify([{ name: 'lint', state: 'PASS', link: 'https://example.com/lint' }])
})
const checks = await getPRChecks('/repo-root', 42, 'stale-head')
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
['pr', 'checks', '42', '--json', 'name,state,link'],
{ cwd: '/repo-root' }
)
expect(checks).toEqual([
{
name: 'lint',
status: 'completed',
conclusion: 'success',
url: 'https://example.com/lint'
}
])
})
})

View file

@ -0,0 +1,288 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
execFileAsyncMock,
ghExecFileAsyncMock,
getOwnerRepoMock,
gitExecFileAsyncMock,
acquireMock,
releaseMock
} = vi.hoisted(() => ({
execFileAsyncMock: vi.fn(),
ghExecFileAsyncMock: vi.fn(),
getOwnerRepoMock: vi.fn(),
gitExecFileAsyncMock: vi.fn(),
acquireMock: vi.fn(),
releaseMock: vi.fn()
}))
vi.mock('./gh-utils', () => ({
execFileAsync: execFileAsyncMock,
ghExecFileAsync: ghExecFileAsyncMock,
getOwnerRepo: getOwnerRepoMock,
acquire: acquireMock,
release: releaseMock,
_resetOwnerRepoCache: vi.fn()
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock
}))
import { listWorkItems, _resetOwnerRepoCache } from './client'
describe('listWorkItems', () => {
beforeEach(() => {
execFileAsyncMock.mockReset()
ghExecFileAsyncMock.mockReset()
getOwnerRepoMock.mockReset()
gitExecFileAsyncMock.mockReset()
acquireMock.mockReset()
releaseMock.mockReset()
acquireMock.mockResolvedValue(undefined)
_resetOwnerRepoCache()
})
it('runs both issue and PR GitHub searches for a mixed query and merges the results by recency', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 12,
title: 'Fix bug',
state: 'OPEN',
url: 'https://github.com/acme/widgets/issues/12',
labels: [],
updatedAt: '2026-03-29T00:00:00Z',
author: { login: 'octocat' }
}
])
})
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 42,
title: 'Add feature',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/42',
labels: [],
updatedAt: '2026-03-28T00:00:00Z',
author: { login: 'octocat' },
isDraft: false,
headRefName: 'feature/add-feature',
baseRefName: 'main'
}
])
})
const items = await listWorkItems('/repo-root', 10, 'assignee:@me')
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
[
'issue',
'list',
'--limit',
'10',
'--json',
'number,title,state,url,labels,updatedAt,author',
'--repo',
'acme/widgets',
'--assignee',
'@me'
],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
[
'pr',
'list',
'--limit',
'10',
'--json',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName',
'--repo',
'acme/widgets',
'--assignee',
'@me'
],
{ cwd: '/repo-root' }
)
expect(items).toEqual([
{
id: 'issue:12',
type: 'issue',
number: 12,
title: 'Fix bug',
state: 'open',
url: 'https://github.com/acme/widgets/issues/12',
labels: [],
updatedAt: '2026-03-29T00:00:00Z',
author: 'octocat'
},
{
id: 'pr:42',
type: 'pr',
number: 42,
title: 'Add feature',
state: 'open',
url: 'https://github.com/acme/widgets/pull/42',
labels: [],
updatedAt: '2026-03-28T00:00:00Z',
author: 'octocat',
branchName: 'feature/add-feature',
baseRefName: 'main'
}
])
})
it('routes draft queries to PR search only', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 7,
title: 'Draft work',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/7',
labels: [],
updatedAt: '2026-03-30T00:00:00Z',
author: { login: 'octocat' },
isDraft: true,
headRefName: 'draft/work',
baseRefName: 'main'
}
])
})
const items = await listWorkItems('/repo-root', 10, 'is:pr is:draft')
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
[
'pr',
'list',
'--limit',
'10',
'--json',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName',
'--repo',
'acme/widgets',
'--state',
'open',
'--draft'
],
{ cwd: '/repo-root' }
)
expect(items).toEqual([
{
id: 'pr:7',
type: 'pr',
number: 7,
title: 'Draft work',
state: 'draft',
url: 'https://github.com/acme/widgets/pull/7',
labels: [],
updatedAt: '2026-03-30T00:00:00Z',
author: 'octocat',
branchName: 'draft/work',
baseRefName: 'main'
}
])
})
it('returns open issues and PRs for the all-open preset query', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 1,
title: 'Open issue',
state: 'OPEN',
url: 'https://github.com/acme/widgets/issues/1',
labels: [],
updatedAt: '2026-03-31T00:00:00Z',
author: { login: 'octocat' }
}
])
})
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 2,
title: 'Open PR',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/2',
labels: [],
updatedAt: '2026-03-30T00:00:00Z',
author: { login: 'octocat' },
isDraft: false,
headRefName: 'feature/open-pr',
baseRefName: 'main'
}
])
})
const items = await listWorkItems('/repo-root', 10, 'is:open')
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
[
'issue',
'list',
'--limit',
'10',
'--json',
'number,title,state,url,labels,updatedAt,author',
'--repo',
'acme/widgets',
'--state',
'open'
],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
[
'pr',
'list',
'--limit',
'10',
'--json',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName',
'--repo',
'acme/widgets',
'--state',
'open'
],
{ cwd: '/repo-root' }
)
expect(items).toEqual([
{
id: 'issue:1',
type: 'issue',
number: 1,
title: 'Open issue',
state: 'open',
url: 'https://github.com/acme/widgets/issues/1',
labels: [],
updatedAt: '2026-03-31T00:00:00Z',
author: 'octocat'
},
{
id: 'pr:2',
type: 'pr',
number: 2,
title: 'Open PR',
state: 'open',
url: 'https://github.com/acme/widgets/pull/2',
labels: [],
updatedAt: '2026-03-30T00:00:00Z',
author: 'octocat',
branchName: 'feature/open-pr',
baseRefName: 'main'
}
])
})
})

View file

@ -29,7 +29,7 @@ vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock
}))
import { getPRForBranch, getPRChecks, _resetOwnerRepoCache } from './client'
import { getPRForBranch, _resetOwnerRepoCache } from './client'
describe('getPRForBranch', () => {
beforeEach(() => {
@ -257,73 +257,3 @@ describe('getPRForBranch', () => {
expect(pr).toBeNull()
})
})
describe('getPRChecks', () => {
beforeEach(() => {
execFileAsyncMock.mockReset()
ghExecFileAsyncMock.mockReset()
getOwnerRepoMock.mockReset()
gitExecFileAsyncMock.mockReset()
acquireMock.mockReset()
releaseMock.mockReset()
acquireMock.mockResolvedValue(undefined)
_resetOwnerRepoCache()
})
it('queries check-runs by PR head SHA when GitHub remote metadata is available', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
check_runs: [
{
name: 'build',
status: 'completed',
conclusion: 'success',
html_url: 'https://github.com/acme/widgets/actions/runs/1',
details_url: null
}
]
})
})
const checks = await getPRChecks('/repo-root', 42, 'head-oid')
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
['api', '--cache', '60s', 'repos/acme/widgets/commits/head-oid/check-runs?per_page=100'],
{ cwd: '/repo-root' }
)
expect(checks).toEqual([
{
name: 'build',
status: 'completed',
conclusion: 'success',
url: 'https://github.com/acme/widgets/actions/runs/1'
}
])
})
it('falls back to gh pr checks when the cached head SHA no longer resolves', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockRejectedValueOnce(new Error('gh: No commit found for SHA: stale-head (HTTP 422)'))
.mockResolvedValueOnce({
stdout: JSON.stringify([{ name: 'lint', state: 'PASS', link: 'https://example.com/lint' }])
})
const checks = await getPRChecks('/repo-root', 42, 'stale-head')
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
['pr', 'checks', '42', '--json', 'name,state,link'],
{ cwd: '/repo-root' }
)
expect(checks).toEqual([
{
name: 'lint',
status: 'completed',
conclusion: 'success',
url: 'https://example.com/lint'
}
])
})
})

View file

@ -5,7 +5,8 @@ import type {
PRMergeableState,
PRCheckDetail,
PRComment,
GitHubViewer
GitHubViewer,
GitHubWorkItem
} from '../../shared/types'
import { getPRConflictSummary } from './conflict-summary'
import { execFileAsync, ghExecFileAsync, acquire, release, getOwnerRepo } from './gh-utils'
@ -88,6 +89,547 @@ export async function getAuthenticatedViewer(): Promise<GitHubViewer | null> {
}
}
function mapIssueWorkItem(item: Record<string, unknown>): GitHubWorkItem {
return {
id: `issue:${String(item.number)}`,
type: 'issue',
number: Number(item.number),
title: String(item.title ?? ''),
state: String(item.state ?? 'open') === 'closed' ? 'closed' : 'open',
url: String(item.html_url ?? item.url ?? ''),
labels: Array.isArray(item.labels)
? item.labels
.map((label) =>
typeof label === 'object' && label !== null && 'name' in label
? String((label as { name?: unknown }).name ?? '')
: ''
)
.filter(Boolean)
: [],
updatedAt: String(item.updated_at ?? item.updatedAt ?? ''),
author:
typeof item.user === 'object' && item.user !== null && 'login' in item.user
? String((item.user as { login?: unknown }).login ?? '')
: typeof item.author === 'object' && item.author !== null && 'login' in item.author
? String((item.author as { login?: unknown }).login ?? '')
: null
}
}
function mapPullRequestWorkItem(item: Record<string, unknown>): GitHubWorkItem {
return {
id: `pr:${String(item.number)}`,
type: 'pr',
number: Number(item.number),
title: String(item.title ?? ''),
state:
item.state === 'closed'
? item.merged_at || item.mergedAt
? 'merged'
: 'closed'
: item.isDraft || item.draft
? 'draft'
: 'open',
url: String(item.html_url ?? item.url ?? ''),
labels: Array.isArray(item.labels)
? item.labels
.map((label) =>
typeof label === 'object' && label !== null && 'name' in label
? String((label as { name?: unknown }).name ?? '')
: ''
)
.filter(Boolean)
: [],
updatedAt: String(item.updated_at ?? item.updatedAt ?? ''),
author:
typeof item.user === 'object' && item.user !== null && 'login' in item.user
? String((item.user as { login?: unknown }).login ?? '')
: typeof item.author === 'object' && item.author !== null && 'login' in item.author
? String((item.author as { login?: unknown }).login ?? '')
: null,
branchName:
typeof item.head === 'object' && item.head !== null && 'ref' in item.head
? String((item.head as { ref?: unknown }).ref ?? '')
: String(item.headRefName ?? ''),
baseRefName:
typeof item.base === 'object' && item.base !== null && 'ref' in item.base
? String((item.base as { ref?: unknown }).ref ?? '')
: String(item.baseRefName ?? '')
}
}
function sortWorkItemsByUpdatedAt(items: GitHubWorkItem[]): GitHubWorkItem[] {
return [...items].sort((left, right) => {
return new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime()
})
}
type ParsedTaskQuery = {
scope: 'all' | 'issue' | 'pr'
state: 'open' | 'closed' | 'all' | 'merged' | null
assignee: string | null
author: string | null
reviewRequested: string | null
reviewedBy: string | null
labels: string[]
freeText: string
}
function tokenizeSearchQuery(rawQuery: string): string[] {
const tokens: string[] = []
const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g
let match: RegExpExecArray | null
while ((match = pattern.exec(rawQuery)) !== null) {
tokens.push(match[1] ?? match[2] ?? match[3] ?? '')
}
return tokens
}
function parseTaskQuery(rawQuery: string): ParsedTaskQuery {
const query: ParsedTaskQuery = {
scope: 'all',
state: null,
assignee: null,
author: null,
reviewRequested: null,
reviewedBy: null,
labels: [],
freeText: ''
}
const freeTextTokens: string[] = []
for (const token of tokenizeSearchQuery(rawQuery.trim())) {
const normalized = token.toLowerCase()
if (normalized === 'is:issue') {
if (query.scope === 'pr') {
continue
}
query.scope = 'issue'
continue
}
if (normalized === 'is:pr') {
query.scope = query.scope === 'issue' ? 'all' : 'pr'
continue
}
if (normalized === 'is:open') {
query.state = 'open'
continue
}
if (normalized === 'is:closed') {
query.state = 'closed'
continue
}
if (normalized === 'is:merged') {
query.state = 'merged'
continue
}
if (normalized === 'is:draft') {
query.scope = 'pr'
query.state = 'open'
continue
}
const [rawKey, ...rest] = token.split(':')
const value = rest.join(':').trim()
const key = rawKey.toLowerCase()
if (!value) {
freeTextTokens.push(token)
continue
}
if (key === 'assignee') {
query.assignee = value
continue
}
if (key === 'author') {
query.author = value
continue
}
if (key === 'review-requested') {
query.scope = 'pr'
query.reviewRequested = value
continue
}
if (key === 'reviewed-by') {
query.scope = 'pr'
query.reviewedBy = value
continue
}
if (key === 'label') {
query.labels.push(value)
continue
}
freeTextTokens.push(token)
}
query.freeText = freeTextTokens.join(' ').trim()
return query
}
function buildWorkItemListArgs(args: {
kind: 'issue' | 'pr'
ownerRepo: { owner: string; repo: string } | null
limit: number
query: ParsedTaskQuery
}): string[] {
const { kind, ownerRepo, limit, query } = args
const fields =
kind === 'issue'
? 'number,title,state,url,labels,updatedAt,author'
: 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName'
const command = kind === 'issue' ? ['issue', 'list'] : ['pr', 'list']
const out = [...command, '--limit', String(limit), '--json', fields]
if (ownerRepo) {
out.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
}
const state = query.state
if (state && !(kind === 'issue' && state === 'merged')) {
out.push('--state', state === 'all' ? 'all' : state)
}
if (kind === 'pr' && query.state === 'merged') {
out.push('--state', 'merged')
}
if (query.assignee) {
out.push('--assignee', query.assignee)
}
if (query.author) {
out.push('--author', query.author)
}
if (query.labels.length > 0) {
for (const label of query.labels) {
out.push('--label', label)
}
}
if (kind === 'pr' && query.reviewRequested) {
out.push('--review-requested', query.reviewRequested)
}
if (kind === 'pr' && query.reviewedBy) {
out.push('--reviewed-by', query.reviewedBy)
}
if (kind === 'pr' && query.scope === 'pr' && query.state === 'open' && query.freeText === '') {
out.push('--draft')
}
if (query.freeText) {
out.push('--search', query.freeText)
}
return out
}
async function listRecentWorkItems(
repoPath: string,
ownerRepo: { owner: string; repo: string } | null,
limit: number
): Promise<GitHubWorkItem[]> {
if (ownerRepo) {
const [issuesResult, prsResult] = await Promise.all([
ghExecFileAsync(
[
'api',
'--cache',
'120s',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues?per_page=${limit}&state=open&sort=updated&direction=desc`
],
{ cwd: repoPath }
),
ghExecFileAsync(
[
'api',
'--cache',
'120s',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls?per_page=${limit}&state=open&sort=updated&direction=desc`
],
{ cwd: repoPath }
)
])
const issues = (JSON.parse(issuesResult.stdout) as Record<string, unknown>[])
// Why: the GitHub issues REST endpoint also returns pull requests with a
// `pull_request` marker. The new-workspace task picker needs distinct
// issue vs PR buckets, so drop PR-shaped issue rows here before merging.
.filter((item) => !('pull_request' in item))
.map(mapIssueWorkItem)
const prs = (JSON.parse(prsResult.stdout) as Record<string, unknown>[]).map(
mapPullRequestWorkItem
)
return sortWorkItemsByUpdatedAt([...issues, ...prs]).slice(0, limit)
}
const [issuesResult, prsResult] = await Promise.all([
ghExecFileAsync(
[
'issue',
'list',
'--limit',
String(limit),
'--state',
'open',
'--json',
'number,title,state,url,labels,updatedAt,author'
],
{ cwd: repoPath }
),
ghExecFileAsync(
[
'pr',
'list',
'--limit',
String(limit),
'--state',
'open',
'--json',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName'
],
{ cwd: repoPath }
)
])
const issues = (JSON.parse(issuesResult.stdout) as Record<string, unknown>[]).map(
mapIssueWorkItem
)
const prs = (JSON.parse(prsResult.stdout) as Record<string, unknown>[]).map(
mapPullRequestWorkItem
)
return sortWorkItemsByUpdatedAt([...issues, ...prs]).slice(0, limit)
}
async function listQueriedWorkItems(
repoPath: string,
ownerRepo: { owner: string; repo: string } | null,
query: ParsedTaskQuery,
limit: number
): Promise<GitHubWorkItem[]> {
const fetchers: Promise<GitHubWorkItem[]>[] = []
const issueScope = query.scope !== 'pr'
const prScope = query.scope !== 'issue'
if (issueScope) {
fetchers.push(
(async () => {
const args = buildWorkItemListArgs({ kind: 'issue', ownerRepo, limit, query })
try {
const { stdout } = await ghExecFileAsync(args, { cwd: repoPath })
return (JSON.parse(stdout) as Record<string, unknown>[]).map(mapIssueWorkItem)
} catch {
return []
}
})()
)
}
if (prScope) {
fetchers.push(
(async () => {
const args = buildWorkItemListArgs({ kind: 'pr', ownerRepo, limit, query })
try {
const { stdout } = await ghExecFileAsync(args, { cwd: repoPath })
return (JSON.parse(stdout) as Record<string, unknown>[]).map(mapPullRequestWorkItem)
} catch {
return []
}
})()
)
}
const results = await Promise.all(fetchers)
return sortWorkItemsByUpdatedAt(results.flat()).slice(0, limit)
}
export async function listWorkItems(
repoPath: string,
limit = 24,
query?: string
): Promise<GitHubWorkItem[]> {
const ownerRepo = await getOwnerRepo(repoPath)
const trimmedQuery = query?.trim() ?? ''
await acquire()
try {
if (!trimmedQuery) {
return await listRecentWorkItems(repoPath, ownerRepo, limit)
}
const parsedQuery = parseTaskQuery(trimmedQuery)
return await listQueriedWorkItems(repoPath, ownerRepo, parsedQuery, limit)
} catch {
return []
} finally {
release()
}
}
export async function getRepoSlug(
repoPath: string
): Promise<{ owner: string; repo: string } | null> {
return getOwnerRepo(repoPath)
}
export async function getWorkItem(
repoPath: string,
number: number
): Promise<GitHubWorkItem | null> {
await acquire()
try {
const ownerRepo = await getOwnerRepo(repoPath)
if (ownerRepo) {
const { stdout } = await ghExecFileAsync(
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${number}`],
{ cwd: repoPath }
)
const item = JSON.parse(stdout) as Record<string, unknown>
if ('pull_request' in item) {
const prResult = await ghExecFileAsync(
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`],
{ cwd: repoPath }
)
const pr = JSON.parse(prResult.stdout) as Record<string, unknown>
return {
id: `pr:${String(pr.number)}`,
type: 'pr',
number: Number(pr.number),
title: String(pr.title ?? ''),
state:
pr.state === 'closed'
? pr.merged_at
? 'merged'
: 'closed'
: pr.draft
? 'draft'
: 'open',
url: String(pr.html_url ?? pr.url ?? ''),
labels: Array.isArray(pr.labels)
? pr.labels
.map((label) =>
typeof label === 'object' && label !== null && 'name' in label
? String((label as { name?: unknown }).name ?? '')
: ''
)
.filter(Boolean)
: [],
updatedAt: String(pr.updated_at ?? ''),
author:
typeof pr.user === 'object' && pr.user !== null && 'login' in pr.user
? String((pr.user as { login?: unknown }).login ?? '')
: null,
branchName:
typeof pr.head === 'object' && pr.head !== null && 'ref' in pr.head
? String((pr.head as { ref?: unknown }).ref ?? '')
: undefined,
baseRefName:
typeof pr.base === 'object' && pr.base !== null && 'ref' in pr.base
? String((pr.base as { ref?: unknown }).ref ?? '')
: undefined
}
}
return {
id: `issue:${String(item.number)}`,
type: 'issue',
number: Number(item.number),
title: String(item.title ?? ''),
state: String(item.state ?? 'open') === 'closed' ? 'closed' : 'open',
url: String(item.html_url ?? item.url ?? ''),
labels: Array.isArray(item.labels)
? item.labels
.map((label) =>
typeof label === 'object' && label !== null && 'name' in label
? String((label as { name?: unknown }).name ?? '')
: ''
)
.filter(Boolean)
: [],
updatedAt: String(item.updated_at ?? ''),
author:
typeof item.user === 'object' && item.user !== null && 'login' in item.user
? String((item.user as { login?: unknown }).login ?? '')
: null
}
}
try {
const { stdout } = await ghExecFileAsync(
[
'issue',
'view',
String(number),
'--json',
'number,title,state,url,labels,updatedAt,author'
],
{ cwd: repoPath }
)
const item = JSON.parse(stdout) as Record<string, unknown>
return {
id: `issue:${String(item.number)}`,
type: 'issue',
number: Number(item.number),
title: String(item.title ?? ''),
state: String(item.state ?? 'open') === 'closed' ? 'closed' : 'open',
url: String(item.url ?? ''),
labels: Array.isArray(item.labels)
? item.labels
.map((label) =>
typeof label === 'object' && label !== null && 'name' in label
? String((label as { name?: unknown }).name ?? '')
: ''
)
.filter(Boolean)
: [],
updatedAt: String(item.updatedAt ?? ''),
author:
typeof item.author === 'object' && item.author !== null && 'login' in item.author
? String((item.author as { login?: unknown }).login ?? '')
: null
}
} catch {
const { stdout } = await ghExecFileAsync(
[
'pr',
'view',
String(number),
'--json',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName'
],
{ cwd: repoPath }
)
const item = JSON.parse(stdout) as Record<string, unknown>
return {
id: `pr:${String(item.number)}`,
type: 'pr',
number: Number(item.number),
title: String(item.title ?? ''),
state: item.isDraft ? 'draft' : String(item.state ?? 'open') === 'open' ? 'open' : 'closed',
url: String(item.url ?? ''),
labels: Array.isArray(item.labels)
? item.labels
.map((label) =>
typeof label === 'object' && label !== null && 'name' in label
? String((label as { name?: unknown }).name ?? '')
: ''
)
.filter(Boolean)
: [],
updatedAt: String(item.updatedAt ?? ''),
author:
typeof item.author === 'object' && item.author !== null && 'login' in item.author
? String((item.author as { login?: unknown }).login ?? '')
: null,
branchName: String(item.headRefName ?? ''),
baseRefName: String(item.baseRefName ?? '')
}
}
} catch {
return null
} finally {
release()
}
}
/**
* Get PR info for a given branch using gh CLI.
* Returns null if gh is not installed, or no PR exists for the branch.

View file

@ -6,7 +6,10 @@ import type { StatsCollector } from '../stats/collector'
import {
getPRForBranch,
getIssue,
getRepoSlug,
listIssues,
listWorkItems,
getWorkItem,
getAuthenticatedViewer,
getPRChecks,
getPRComments,
@ -56,6 +59,24 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
return listIssues(repo.path, args.limit)
})
ipcMain.handle(
'gh:listWorkItems',
(_event, args: { repoPath: string; limit?: number; query?: string }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return listWorkItems(repo.path, args.limit, args.query)
}
)
ipcMain.handle('gh:workItem', (_event, args: { repoPath: string; number: number }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return getWorkItem(repo.path, args.number)
})
ipcMain.handle('gh:repoSlug', (_event, args: { repoPath: string }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return getRepoSlug(repo.path)
})
ipcMain.handle(
'gh:prChecks',
(

View file

@ -21,7 +21,12 @@ vi.mock('child_process', () => {
}
})
import { _resetPreflightCache, registerPreflightHandlers, runPreflightCheck } from './preflight'
import {
_resetPreflightCache,
detectInstalledAgents,
registerPreflightHandlers,
runPreflightCheck
} from './preflight'
type HandlerMap = Record<string, (_event?: unknown, args?: { force?: boolean }) => Promise<unknown>>
@ -137,4 +142,42 @@ describe('preflight', () => {
gh: { installed: true, authenticated: true }
})
})
it('only reports agents when which/where resolves to a real executable path', async () => {
execFileAsyncMock.mockImplementation(async (command, args) => {
if (command !== 'which') {
throw new Error(`unexpected command ${String(command)}`)
}
const target = String(args[0])
if (target === 'claude') {
return { stdout: '/Users/test/.local/bin/claude\n' }
}
if (target === 'continue') {
return { stdout: 'continue: shell built-in command\n' }
}
if (target === 'cursor-agent') {
return { stdout: '/Users/test/.local/bin/cursor-agent\n' }
}
throw new Error('not found')
})
await expect(detectInstalledAgents()).resolves.toEqual(['claude', 'cursor'])
})
it('registers agent detection through the shared launch config commands', async () => {
execFileAsyncMock.mockImplementation(async (command, args) => {
if (command !== 'which') {
throw new Error(`unexpected command ${String(command)}`)
}
if (String(args[0]) === 'cursor-agent') {
return { stdout: '/Users/test/.local/bin/cursor-agent\n' }
}
throw new Error('not found')
})
registerPreflightHandlers()
await expect(handlers['preflight:detectAgents']()).resolves.toEqual(['cursor'])
})
})

View file

@ -1,6 +1,8 @@
import { ipcMain } from 'electron'
import { execFile } from 'child_process'
import { promisify } from 'util'
import path from 'path'
import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
const execFileAsync = promisify(execFile)
@ -27,6 +29,37 @@ async function isCommandAvailable(command: string): Promise<boolean> {
}
}
// Why: `which`/`where` is faster than spawning the agent binary itself and avoids
// triggering any agent-specific startup side-effects. This gives a reliable
// PATH-based check without requiring `--version` support from each agent.
async function isCommandOnPath(command: string): Promise<boolean> {
const finder = process.platform === 'win32' ? 'where' : 'which'
try {
const { stdout } = await execFileAsync(finder, [command], { encoding: 'utf-8' })
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.some((line) => path.isAbsolute(line))
} catch {
return false
}
}
const KNOWN_AGENT_COMMANDS = Object.entries(TUI_AGENT_CONFIG).map(([id, config]) => ({
id,
cmd: config.detectCmd
}))
export async function detectInstalledAgents(): Promise<string[]> {
const checks = await Promise.all(
KNOWN_AGENT_COMMANDS.map(async ({ id, cmd }) => ({
id,
installed: await isCommandOnPath(cmd)
}))
)
return checks.filter((c) => c.installed).map((c) => c.id)
}
async function isGhAuthenticated(): Promise<boolean> {
try {
await execFileAsync('gh', ['auth', 'status'], {
@ -73,4 +106,8 @@ export function registerPreflightHandlers(): void {
return runPreflightCheck(args?.force)
}
)
ipcMain.handle('preflight:detectAgents', async (): Promise<string[]> => {
return detectInstalledAgents()
})
}

View file

@ -96,6 +96,18 @@ export function registerShellHandlers(): void {
}
)
// Why: window.prompt() and <input type="file"> are unreliable in Electron,
// so we use the native OS dialog to let the user pick any attachment file.
ipcMain.handle('shell:pickAttachment', async (): Promise<string | null> => {
const result = await dialog.showOpenDialog({
properties: ['openFile']
})
if (result.canceled || result.filePaths.length === 0) {
return null
}
return result.filePaths[0]
})
// Why: window.prompt() and <input type="file"> are unreliable in Electron,
// so we use the native OS dialog to let the user pick an image file.
ipcMain.handle('shell:pickImage', async (): Promise<string | null> => {

View file

@ -4,6 +4,7 @@
import type { BrowserWindow } from 'electron'
import { join } from 'path'
import { existsSync } from 'fs'
import type { Store } from '../persistence'
import type {
CreateWorktreeArgs,
@ -136,38 +137,9 @@ export async function createLocalWorktree(
): Promise<CreateWorktreeResult> {
const settings = store.getSettings()
const username = getGitUsername(repo.path)
const requestedName = args.name
const sanitizedName = sanitizeWorktreeName(args.name)
// Compute branch name with prefix
const username = getGitUsername(repo.path)
const branchName = computeBranchName(sanitizedName, settings, username)
const branchConflictKind = await getBranchConflictKind(repo.path, branchName)
if (branchConflictKind) {
throw new Error(
`Branch "${branchName}" already exists ${branchConflictKind === 'local' ? 'locally' : 'on a remote'}. Pick a different worktree name.`
)
}
// Why: the UI resolves PR status by branch name alone. Reusing a historical
// PR head name would make a fresh worktree inherit that old merged/closed PR
// immediately, so we reject the name instead of silently suffixing it.
// The lookup is best-effort — don't block creation if GitHub is unreachable.
let existingPR: Awaited<ReturnType<typeof getPRForBranch>> | null = null
try {
existingPR = await getPRForBranch(repo.path, branchName)
} catch {
// GitHub API may be unreachable, rate-limited, or token missing
}
if (existingPR) {
throw new Error(
`Branch "${branchName}" already has PR #${existingPR.number}. Pick a different worktree name.`
)
}
// Compute worktree path
let worktreePath = computeWorktreePath(sanitizedName, repo.path, settings)
// Why: WSL worktrees live under ~/orca/workspaces inside the WSL
// filesystem. Validate against that root, not the Windows workspace dir.
// If WSL home lookup fails, keep using the configured workspace root so
@ -175,7 +147,79 @@ export async function createLocalWorktree(
const wslInfo = isWslPath(repo.path) ? parseWslPath(repo.path) : null
const wslHome = wslInfo ? getWslHome(wslInfo.distro) : null
const workspaceRoot = wslHome ? join(wslHome, 'orca', 'workspaces') : settings.workspaceDir
worktreePath = ensurePathWithinWorkspace(worktreePath, workspaceRoot)
let effectiveRequestedName = requestedName
let effectiveSanitizedName = sanitizedName
let branchName = ''
let worktreePath = ''
// Why: silently resolve branch/path/PR name collisions by appending -2/-3/etc.
// instead of failing and forcing the user back to the name picker. This is
// especially important for the new-workspace flow where the user may not have
// direct control over the branch name. Bounded by MAX_SUFFIX_ATTEMPTS so a
// misconfigured environment (e.g. a mock or stub that always reports a
// conflict) cannot spin this loop indefinitely.
const MAX_SUFFIX_ATTEMPTS = 100
let resolved = false
let lastBranchConflictKind: 'local' | 'remote' | null = null
let lastExistingPR: Awaited<ReturnType<typeof getPRForBranch>> | null = null
for (let suffix = 1; suffix <= MAX_SUFFIX_ATTEMPTS; suffix += 1) {
effectiveSanitizedName = suffix === 1 ? sanitizedName : `${sanitizedName}-${suffix}`
effectiveRequestedName =
suffix === 1
? requestedName
: requestedName.trim()
? `${requestedName}-${suffix}`
: effectiveSanitizedName
branchName = computeBranchName(effectiveSanitizedName, settings, username)
lastBranchConflictKind = await getBranchConflictKind(repo.path, branchName)
if (lastBranchConflictKind) {
continue
}
// Why: the UI resolves PR status by branch name alone. Reusing a historical
// PR head name would make a fresh worktree inherit that old merged/closed PR
// immediately, so auto-suffix until we land on a fresh branch identity.
lastExistingPR = null
try {
lastExistingPR = await getPRForBranch(repo.path, branchName)
} catch {
// GitHub API may be unreachable, rate-limited, or token missing
}
if (lastExistingPR) {
continue
}
worktreePath = ensurePathWithinWorkspace(
computeWorktreePath(effectiveSanitizedName, repo.path, settings),
workspaceRoot
)
if (existsSync(worktreePath)) {
continue
}
resolved = true
break
}
if (!resolved) {
// Why: if every suffix in range collides, fall back to the original
// "reject with a specific reason" behavior so the user sees why creation
// failed instead of a generic error or (worse) an infinite spinner.
if (lastExistingPR) {
throw new Error(
`Branch "${branchName}" already has PR #${lastExistingPR.number}. Pick a different worktree name.`
)
}
if (lastBranchConflictKind) {
throw new Error(
`Branch "${branchName}" already exists ${lastBranchConflictKind === 'local' ? 'locally' : 'on a remote'}. Pick a different worktree name.`
)
}
throw new Error(
`Could not find an available worktree name for "${sanitizedName}". Pick a different worktree name.`
)
}
// Determine base branch
const baseBranch = args.baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path)
@ -214,8 +258,8 @@ export async function createLocalWorktree(
// immediately — prevents scroll-to-reveal racing with a later
// bumpWorktreeActivity that would re-sort the list.
lastActivityAt: Date.now(),
...(shouldSetDisplayName(requestedName, branchName, sanitizedName)
? { displayName: requestedName }
...(shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName)
? { displayName: effectiveRequestedName }
: {})
}
const meta = store.setWorktreeMeta(worktreeId, metaUpdates)

View file

@ -219,20 +219,40 @@ describe('registerWorktreeHandlers', () => {
registerWorktreeHandlers(mainWindow as never, store as never)
})
it('rejects worktree creation when the branch already exists on a remote', async () => {
getBranchConflictKindMock.mockResolvedValue('remote')
await expect(
handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard'
})
).rejects.toThrow(
'Branch "improve-dashboard" already exists on a remote. Pick a different worktree name.'
it('auto-suffixes the branch name when the first choice collides with a remote branch', async () => {
// Why: new-workspace flow should silently try improve-dashboard-2, -3, ...
// rather than failing and forcing the user back to the name picker.
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
branch === 'improve-dashboard' ? 'remote' : null
)
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/improve-dashboard-2',
head: 'abc123',
branch: 'improve-dashboard-2',
isBare: false,
isMainWorktree: false
}
])
expect(getPRForBranchMock).not.toHaveBeenCalled()
expect(addWorktreeMock).not.toHaveBeenCalled()
const result = await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard'
})
expect(addWorktreeMock).toHaveBeenCalledWith(
'/workspace/repo',
'/workspace/improve-dashboard-2',
'improve-dashboard-2',
'origin/main',
false
)
expect(result).toEqual({
worktree: expect.objectContaining({
path: '/workspace/improve-dashboard-2',
branch: 'improve-dashboard-2'
})
})
})
it('creates an issue-command runner for an existing repo/worktree pair', async () => {
@ -292,27 +312,51 @@ describe('registerWorktreeHandlers', () => {
expect(listWorktreesMock).not.toHaveBeenCalled()
})
it('rejects worktree creation when the branch name already belongs to a PR', async () => {
getPRForBranchMock.mockResolvedValue({
number: 3127,
title: 'Existing PR',
state: 'merged',
url: 'https://example.com/pr/3127',
checksStatus: 'success',
updatedAt: '2026-04-01T00:00:00Z',
mergeable: 'UNKNOWN'
it('auto-suffixes the branch name when the first choice already belongs to a PR', async () => {
// Why: reusing a historical PR head would make a fresh worktree inherit
// that old PR, so the loop suffixes past the name until it finds one that
// is not associated with any PR.
getPRForBranchMock.mockImplementation(async (_repoPath: string, branch: string) =>
branch === 'improve-dashboard'
? {
number: 3127,
title: 'Existing PR',
state: 'merged',
url: 'https://example.com/pr/3127',
checksStatus: 'success',
updatedAt: '2026-04-01T00:00:00Z',
mergeable: 'UNKNOWN'
}
: null
)
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/improve-dashboard-2',
head: 'abc123',
branch: 'improve-dashboard-2',
isBare: false,
isMainWorktree: false
}
])
const result = await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard'
})
await expect(
handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard'
})
).rejects.toThrow(
'Branch "improve-dashboard" already has PR #3127. Pick a different worktree name.'
expect(addWorktreeMock).toHaveBeenCalledWith(
'/workspace/repo',
'/workspace/improve-dashboard-2',
'improve-dashboard-2',
'origin/main',
false
)
expect(addWorktreeMock).not.toHaveBeenCalled()
expect(result).toEqual({
worktree: expect.objectContaining({
path: '/workspace/improve-dashboard-2',
branch: 'improve-dashboard-2'
})
})
})
const createdWorktreeList = [

View file

@ -209,9 +209,17 @@ export function writeStartupCommandWhenShellReady(
onExit: (cleanup: () => void) => void
): void {
let sent = false
let postReadyTimer: ReturnType<typeof setTimeout> | null = null
let postReadyDataDisposable: { dispose: () => void } | null = null
const cleanup = (): void => {
sent = true
if (postReadyTimer !== null) {
clearTimeout(postReadyTimer)
postReadyTimer = null
}
postReadyDataDisposable?.dispose()
postReadyDataDisposable = null
}
const flush = (): void => {
@ -219,6 +227,12 @@ export function writeStartupCommandWhenShellReady(
return
}
sent = true
postReadyDataDisposable?.dispose()
postReadyDataDisposable = null
if (postReadyTimer !== null) {
clearTimeout(postReadyTimer)
postReadyTimer = null
}
// Why: run startup commands inside the same interactive shell Orca keeps
// open for the pane. Spawning `shell -c <command>; exec shell -l` would
// avoid the race, but it would also replace the session after the agent
@ -230,7 +244,37 @@ export function writeStartupCommandWhenShellReady(
proc.write(payload)
}
readyPromise.then(flush)
readyPromise.then(() => {
if (sent) {
return
}
// Why: the shell-ready marker (OSC 133;A) fires from precmd/PROMPT_COMMAND,
// before the prompt is drawn and before zle/readline switches the PTY into
// raw mode. Writing the command while the kernel still has ECHO enabled
// causes the characters to be echoed once by the kernel and then redisplayed
// by the line editor after the prompt — producing a visible duplicate.
//
// Strategy: wait for the next PTY data event after the ready marker. That
// data is the shell drawing its prompt, which means the shell is about to
// (or has already) switched to raw mode. A brief follow-up delay covers the
// gap between the last prompt write() and the tcsetattr() that enables raw
// mode. The 50ms fallback timeout handles the case where the prompt data
// arrived in the same chunk as the ready marker (no subsequent onData).
postReadyDataDisposable = proc.onData(() => {
postReadyDataDisposable?.dispose()
postReadyDataDisposable = null
if (postReadyTimer !== null) {
clearTimeout(postReadyTimer)
}
postReadyTimer = setTimeout(flush, 30)
})
postReadyTimer = setTimeout(() => {
postReadyDataDisposable?.dispose()
postReadyDataDisposable = null
postReadyTimer = null
flush()
}, 50)
})
onExit(cleanup)
}

View file

@ -15,6 +15,7 @@ import type {
GitConflictOperation,
GitDiffResult,
GitStatusEntry,
GitHubWorkItem,
GitHubViewer,
IssueInfo,
NotificationDispatchRequest,
@ -148,6 +149,7 @@ export type PreflightStatus = {
export type PreflightApi = {
check: (args?: { force?: boolean }) => Promise<PreflightStatus>
detectAgents: () => Promise<string[]>
}
export type StatsApi = {
@ -264,9 +266,16 @@ export type PreloadApi = {
}
gh: {
viewer: () => Promise<GitHubViewer | null>
repoSlug: (args: { repoPath: string }) => Promise<{ owner: string; repo: string } | null>
prForBranch: (args: { repoPath: string; branch: string }) => Promise<PRInfo | null>
issue: (args: { repoPath: string; number: number }) => Promise<IssueInfo | null>
workItem: (args: { repoPath: string; number: number }) => Promise<GitHubWorkItem | null>
listIssues: (args: { repoPath: string; limit?: number }) => Promise<IssueInfo[]>
listWorkItems: (args: {
repoPath: string
limit?: number
query?: string
}) => Promise<GitHubWorkItem[]>
prChecks: (args: {
repoPath: string
prNumber: number
@ -320,6 +329,7 @@ export type PreloadApi = {
openFilePath: (path: string) => Promise<void>
openFileUri: (uri: string) => Promise<void>
pathExists: (path: string) => Promise<boolean>
pickAttachment: () => Promise<string | null>
pickImage: () => Promise<string | null>
pickDirectory: (args: { defaultPath?: string }) => Promise<string | null>
copyFile: (args: { srcPath: string; destPath: string }) => Promise<void>

View file

@ -1,6 +1,7 @@
import type { ElectronAPI } from '@electron-toolkit/preload'
import type {
CreateWorktreeResult,
GitHubWorkItem,
GitHubViewer,
CreateWorktreeArgs,
OpenCodeStatusEvent
@ -66,9 +67,16 @@ type PtyApi = {
type GhApi = {
viewer: () => Promise<GitHubViewer | null>
repoSlug: (args: { repoPath: string }) => Promise<{ owner: string; repo: string } | null>
prForBranch: (args: { repoPath: string; branch: string }) => Promise<PRInfo | null>
issue: (args: { repoPath: string; number: number }) => Promise<IssueInfo | null>
workItem: (args: { repoPath: string; number: number }) => Promise<GitHubWorkItem | null>
listIssues: (args: { repoPath: string; limit?: number }) => Promise<IssueInfo[]>
listWorkItems: (args: {
repoPath: string
limit?: number
query?: string
}) => Promise<GitHubWorkItem[]>
prChecks: (args: {
repoPath: string
prNumber: number
@ -118,6 +126,7 @@ type ShellApi = {
openFilePath: (path: string) => Promise<void>
openFileUri: (uri: string) => Promise<void>
pathExists: (path: string) => Promise<boolean>
pickAttachment: () => Promise<string | null>
pickImage: () => Promise<string | null>
pickDirectory: (args: { defaultPath?: string }) => Promise<string | null>
copyFile: (args: { srcPath: string; destPath: string }) => Promise<void>

View file

@ -300,15 +300,27 @@ const api = {
gh: {
viewer: (): Promise<unknown> => ipcRenderer.invoke('gh:viewer'),
repoSlug: (args: { repoPath: string }): Promise<unknown> =>
ipcRenderer.invoke('gh:repoSlug', args),
prForBranch: (args: { repoPath: string; branch: string }): Promise<unknown> =>
ipcRenderer.invoke('gh:prForBranch', args),
issue: (args: { repoPath: string; number: number }): Promise<unknown> =>
ipcRenderer.invoke('gh:issue', args),
workItem: (args: { repoPath: string; number: number }): Promise<unknown> =>
ipcRenderer.invoke('gh:workItem', args),
listIssues: (args: { repoPath: string; limit?: number }): Promise<unknown[]> =>
ipcRenderer.invoke('gh:listIssues', args),
listWorkItems: (args: {
repoPath: string
limit?: number
query?: string
}): Promise<unknown[]> => ipcRenderer.invoke('gh:listWorkItems', args),
prChecks: (args: {
repoPath: string
prNumber: number
@ -377,7 +389,8 @@ const api = {
}): Promise<{
git: { installed: boolean }
gh: { installed: boolean; authenticated: boolean }
}> => ipcRenderer.invoke('preflight:check', args)
}> => ipcRenderer.invoke('preflight:check', args),
detectAgents: (): Promise<string[]> => ipcRenderer.invoke('preflight:detectAgents')
},
notifications: {
@ -397,6 +410,8 @@ const api = {
pathExists: (path: string): Promise<boolean> => ipcRenderer.invoke('shell:pathExists', path),
pickAttachment: (): Promise<string | null> => ipcRenderer.invoke('shell:pickAttachment'),
pickImage: (): Promise<string | null> => ipcRenderer.invoke('shell:pickImage'),
pickDirectory: (args: { defaultPath?: string }): Promise<string | null> =>

View file

@ -16,6 +16,7 @@ import Sidebar from './components/Sidebar'
import Terminal from './components/Terminal'
import { shutdownBufferCaptures } from './components/terminal-pane/TerminalPane'
import Landing from './components/Landing'
import NewWorkspacePage from './components/NewWorkspacePage'
import Settings from './components/settings/Settings'
import RightSidebar from './components/right-sidebar'
import QuickOpen from './components/QuickOpen'
@ -87,7 +88,8 @@ function App(): React.JSX.Element {
toggleRightSidebar: s.toggleRightSidebar,
setRightSidebarOpen: s.setRightSidebarOpen,
setRightSidebarTab: s.setRightSidebarTab,
updateSettings: s.updateSettings
updateSettings: s.updateSettings,
openNewWorkspacePage: s.openNewWorkspacePage
}))
)
@ -374,7 +376,7 @@ function App(): React.JSX.Element {
? (expandedPaneByTabId[effectiveActiveTabId] ?? false)
: false
const showTitlebarExpandButton =
activeView !== 'settings' &&
activeView === 'terminal' &&
activeWorktreeId !== null &&
!hasTabBar &&
effectiveActiveTabExpanded
@ -383,6 +385,9 @@ function App(): React.JSX.Element {
// full-width titlebar is replaced by a sidebar-width left header so the
// terminal + tab groups extend to the very top of the window.
const workspaceActive = activeView !== 'settings' && activeWorktreeId !== null
// Why: suppress right sidebar controls on new-workspace page since that
// surface is intentionally distraction-free (no right sidebar).
const showRightSidebarControls = activeView !== 'settings' && activeView !== 'new-workspace'
const handleToggleExpand = (): void => {
if (!effectiveActiveTabId) {
@ -430,6 +435,12 @@ function App(): React.JSX.Element {
return
}
// Why: the new-workspace composer should not be able to reveal the right
// sidebar at all, because that surface is intentionally distraction-free.
if (activeView === 'new-workspace') {
return
}
// Cmd/Ctrl+L — toggle right sidebar
if (!e.altKey && !e.shiftKey && e.key.toLowerCase() === 'l') {
e.preventDefault()
@ -437,13 +448,13 @@ function App(): React.JSX.Element {
return
}
// Cmd/Ctrl+N — create worktree
// Cmd/Ctrl+N — new workspace
if (!e.altKey && !e.shiftKey && e.key.toLowerCase() === 'n') {
if (!repos.some((repo) => isGitRepoKind(repo))) {
return
}
e.preventDefault()
actions.openModal('create-worktree')
actions.openNewWorkspacePage()
return
}
@ -642,7 +653,7 @@ function App(): React.JSX.Element {
</div>
)
const rightSidebarToggle = showSidebar ? (
const rightSidebarToggle = showRightSidebarControls ? (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -659,6 +670,14 @@ function App(): React.JSX.Element {
</Tooltip>
) : null
useEffect(() => {
if (activeView === 'new-workspace' && rightSidebarOpen) {
// Why: hide the right sidebar immediately when entering the composer so
// a previous open state can't bleed into the dedicated workspace flow.
actions.setRightSidebarOpen(false)
}
}, [activeView, rightSidebarOpen, actions])
return (
<div
className="flex flex-col h-screen w-screen overflow-hidden"
@ -672,7 +691,7 @@ function App(): React.JSX.Element {
{/* Why: in workspace view (split groups always enabled), the full-width
titlebar is removed so tab groups + terminal extend to the top of
the window. Left titlebar controls move to a header above the sidebar.
Settings and landing views keep the full-width titlebar. */}
Settings, landing, and new-workspace views keep the full-width titlebar. */}
{!workspaceActive ? (
<div className="titlebar">
<div
@ -683,7 +702,7 @@ function App(): React.JSX.Element {
</div>
<div
id="titlebar-tabs"
className={`flex flex-1 min-w-0 self-stretch${activeView === 'settings' || !activeWorktreeId ? ' invisible pointer-events-none' : ''}`}
className={`flex flex-1 min-w-0 self-stretch${activeView !== 'terminal' || !activeWorktreeId ? ' invisible pointer-events-none' : ''}`}
/>
{showTitlebarExpandButton && (
<Tooltip>
@ -758,21 +777,24 @@ function App(): React.JSX.Element {
<div className="flex flex-1 min-w-0 min-h-0 flex-col">
<div
className={
activeView === 'settings' || !activeWorktreeId
activeView !== 'terminal' || !activeWorktreeId
? 'hidden flex-1 min-w-0 min-h-0'
: 'flex flex-1 min-w-0 min-h-0'
}
>
<Terminal />
</div>
{activeView === 'settings' ? <Settings /> : !activeWorktreeId ? <Landing /> : null}
{activeView === 'settings' ? <Settings /> : null}
{activeView === 'new-workspace' ? <NewWorkspacePage /> : null}
{activeView === 'terminal' && !activeWorktreeId ? <Landing /> : null}
</div>
</div>
{/* Why: keep RightSidebar mounted even when closed so that its
child components (FileExplorer, SourceControl, etc.) and their
filesystem watchers + cached directory trees survive across
open/close toggles. */}
{showSidebar ? <RightSidebar /> : null}
open/close toggles. Unmount on new-workspace view since that
surface is intentionally distraction-free. */}
{showRightSidebarControls ? <RightSidebar /> : null}
</div>
<StatusBar />
</TooltipProvider>

View file

@ -148,6 +148,7 @@ function PreflightBanner({ issues }: { issues: PreflightIssue[] }): React.JSX.El
export default function Landing(): React.JSX.Element {
const repos = useAppStore((s) => s.repos)
const openNewWorkspacePage = useAppStore((s) => s.openNewWorkspacePage)
const openModal = useAppStore((s) => s.openModal)
const canCreateWorktree = repos.some((repo) => isGitRepoKind(repo))
@ -247,7 +248,7 @@ export default function Landing(): React.JSX.Element {
className="inline-flex items-center gap-1.5 bg-secondary/70 border border-border/80 text-foreground font-medium text-sm px-4 py-2 rounded-md transition-colors disabled:opacity-40 disabled:cursor-not-allowed enabled:cursor-pointer enabled:hover:bg-accent"
disabled={!canCreateWorktree}
title={!canCreateWorktree ? 'Add a Git repo first' : undefined}
onClick={() => openModal('create-worktree')}
onClick={() => openNewWorkspacePage()}
>
<GitBranchPlus className="size-3.5" />
Create Worktree

View file

@ -0,0 +1,659 @@
/* eslint-disable max-lines -- Why: this component intentionally keeps the full
composer card markup together so the inline and modal variants share one UI
surface without splitting the controlled form into hard-to-follow fragments. */
import React from 'react'
import {
Check,
ChevronDown,
CircleDot,
CornerDownLeft,
GitPullRequest,
Github,
LoaderCircle,
Paperclip,
Plus,
X
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import RepoCombobox from '@/components/repo/RepoCombobox'
import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog'
import { cn } from '@/lib/utils'
import type { GitHubWorkItem, TuiAgent } from '../../../shared/types'
type RepoOption = React.ComponentProps<typeof RepoCombobox>['repos'][number]
type LinkedWorkItemSummary = {
type: 'issue' | 'pr'
number: number
title: string
url: string
} | null
type NewWorkspaceComposerCardProps = {
containerClassName?: string
composerRef?: React.RefObject<HTMLDivElement | null>
nameInputRef?: React.RefObject<HTMLInputElement | null>
promptTextareaRef?: React.RefObject<HTMLTextAreaElement | null>
eligibleRepos: RepoOption[]
repoId: string
onRepoChange: (value: string) => void
name: string
onNameChange: (event: React.ChangeEvent<HTMLInputElement>) => void
agentPrompt: string
onAgentPromptChange: (value: string) => void
onPromptKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void
attachmentPaths: string[]
getAttachmentLabel: (pathValue: string) => string
onAddAttachment: () => void
onRemoveAttachment: (pathValue: string) => void
addAttachmentShortcut: string
linkedWorkItem: LinkedWorkItemSummary
onRemoveLinkedWorkItem: () => void
linkPopoverOpen: boolean
onLinkPopoverOpenChange: (open: boolean) => void
linkQuery: string
onLinkQueryChange: (value: string) => void
filteredLinkItems: GitHubWorkItem[]
linkItemsLoading: boolean
linkDirectLoading: boolean
normalizedLinkQuery: {
query: string
repoMismatch: string | null
}
onSelectLinkedItem: (item: GitHubWorkItem) => void
tuiAgent: TuiAgent
onTuiAgentChange: (value: TuiAgent) => void
detectedAgentIds: Set<TuiAgent> | null
onOpenAgentSettings: () => void
advancedOpen: boolean
onToggleAdvanced: () => void
createDisabled: boolean
creating: boolean
onCreate: () => void
note: string
onNoteChange: (value: string) => void
setupConfig: { source: 'yaml' | 'legacy'; command: string } | null
requiresExplicitSetupChoice: boolean
setupDecision: 'run' | 'skip' | null
onSetupDecisionChange: (value: 'run' | 'skip') => void
shouldWaitForSetupCheck: boolean
resolvedSetupDecision: 'run' | 'skip' | null
createError: string | null
}
function LinearIcon({ className }: { className?: string }): React.JSX.Element {
return (
<svg viewBox="0 0 24 24" aria-hidden className={className} fill="currentColor">
<path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z" />
</svg>
)
}
function renderSetupYamlPreview(command: string): React.JSX.Element[] {
const lines = ['scripts:', ' setup: |', ...command.split('\n').map((line) => ` ${line}`)]
return lines.map((line, index) => {
const keyMatch = line.match(/^(\s*)([a-zA-Z][\w-]*)(:\s*)(\|)?$/)
if (keyMatch) {
return (
<div key={`${line}-${index}`} className="whitespace-pre">
<span className="text-muted-foreground">{keyMatch[1]}</span>
<span className="font-semibold text-sky-600 dark:text-sky-300">{keyMatch[2]}</span>
<span className="text-muted-foreground">{keyMatch[3]}</span>
{keyMatch[4] ? (
<span className="text-amber-600 dark:text-amber-300">{keyMatch[4]}</span>
) : null}
</div>
)
}
return (
<div key={`${line}-${index}`} className="whitespace-pre">
<span className="text-emerald-700 dark:text-emerald-300/95">{line}</span>
</div>
)
})
}
function SetupCommandPreview({
setupConfig,
headerAction
}: {
setupConfig: { source: 'yaml' | 'legacy'; command: string }
headerAction?: React.ReactNode
}): React.JSX.Element {
if (setupConfig.source === 'yaml') {
return (
<div className="rounded-2xl border border-border/60 bg-muted/40 shadow-inner">
<div className="flex items-center justify-between gap-3 border-b border-border/60 px-4 py-2.5">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground">
orca.yaml
</div>
{headerAction}
</div>
<pre className="overflow-x-auto px-4 py-4 font-mono text-[12px] leading-6 text-foreground">
{renderSetupYamlPreview(setupConfig.command)}
</pre>
</div>
)
}
return (
<div className="rounded-2xl border border-border/60 bg-muted/35 px-4 py-3 shadow-inner">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
Legacy setup command
</div>
{headerAction}
</div>
<pre className="overflow-x-auto whitespace-pre-wrap break-words font-mono text-[12px] leading-5 text-foreground">
{setupConfig.command}
</pre>
</div>
)
}
export default function NewWorkspaceComposerCard({
containerClassName,
composerRef,
nameInputRef,
promptTextareaRef,
eligibleRepos,
repoId,
onRepoChange,
name,
onNameChange,
agentPrompt,
onAgentPromptChange,
onPromptKeyDown,
attachmentPaths,
getAttachmentLabel,
onAddAttachment,
onRemoveAttachment,
addAttachmentShortcut,
linkedWorkItem,
onRemoveLinkedWorkItem,
linkPopoverOpen,
onLinkPopoverOpenChange,
linkQuery,
onLinkQueryChange,
filteredLinkItems,
linkItemsLoading,
linkDirectLoading,
normalizedLinkQuery,
onSelectLinkedItem,
tuiAgent,
onTuiAgentChange,
detectedAgentIds,
onOpenAgentSettings,
advancedOpen,
onToggleAdvanced,
createDisabled,
creating,
onCreate,
note,
onNoteChange,
setupConfig,
requiresExplicitSetupChoice,
setupDecision,
onSetupDecisionChange,
shouldWaitForSetupCheck,
resolvedSetupDecision,
createError
}: NewWorkspaceComposerCardProps): React.JSX.Element {
return (
<div className="grid gap-3">
<div
ref={composerRef}
className={cn(
'rounded-[20px] border border-border/50 bg-background/40 p-3 shadow-lg backdrop-blur-xl supports-[backdrop-filter]:bg-background/40',
containerClassName
)}
>
<div className="grid gap-3">
<div className="flex items-center justify-between gap-3">
<input
ref={nameInputRef}
type="text"
value={name}
onChange={onNameChange}
placeholder="[Optional] Workspace name"
className="h-10 min-w-0 flex-1 bg-transparent px-1 text-[18px] font-medium text-foreground outline-none placeholder:text-muted-foreground/90"
/>
<div className="w-[240px] shrink-0">
<RepoCombobox
repos={eligibleRepos}
value={repoId}
onValueChange={onRepoChange}
placeholder="Select a repository"
triggerClassName="h-9 w-full rounded-[10px] border border-border/50 bg-background/50 px-3 text-sm font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none backdrop-blur-md supports-[backdrop-filter]:bg-background/50"
/>
</div>
</div>
<div className="flex flex-col rounded-[16px] border border-border/60 bg-input/30 shadow-sm transition focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/20">
<div className="flex items-start">
<span className="select-none pl-4 pt-4 font-mono text-[15px] leading-7 font-bold text-foreground">
{'>'}
</span>
<textarea
ref={promptTextareaRef}
value={agentPrompt}
onChange={(event) => onAgentPromptChange(event.target.value)}
onKeyDown={onPromptKeyDown}
placeholder="Describe a task to start an agent, or leave blank..."
className="min-h-[110px] w-full resize-none bg-transparent py-4 pl-3 pr-4 font-mono text-[15px] leading-7 text-foreground outline-none placeholder:text-muted-foreground/50"
spellCheck={false}
/>
</div>
{attachmentPaths.length > 0 || linkedWorkItem ? (
<div className="flex flex-wrap gap-2 px-3">
{linkedWorkItem ? (
<div className="inline-flex max-w-full items-center gap-2 rounded-full border border-border/50 bg-background/60 px-3 py-1 text-xs text-foreground transition hover:bg-muted/60 supports-[backdrop-filter]:bg-background/50">
{linkedWorkItem.type === 'pr' ? (
<GitPullRequest className="size-3.5 shrink-0" />
) : (
<CircleDot className="size-3.5 shrink-0" />
)}
<span className="shrink-0 font-mono text-muted-foreground">
#{linkedWorkItem.number}
</span>
<span className="truncate" title={linkedWorkItem.url}>
{linkedWorkItem.title}
</span>
<button
type="button"
aria-label={`Remove linked ${linkedWorkItem.type} #${linkedWorkItem.number}`}
onClick={onRemoveLinkedWorkItem}
className="shrink-0 text-muted-foreground transition hover:text-foreground"
>
<X className="size-3.5" />
</button>
</div>
) : null}
{attachmentPaths.map((pathValue) => (
<div
key={pathValue}
className="inline-flex max-w-full items-center gap-2 rounded-full border border-border/50 bg-background/60 px-3 py-1 text-xs text-foreground transition hover:bg-muted/60 supports-[backdrop-filter]:bg-background/50"
>
<Paperclip className="size-3.5 shrink-0" />
<span className="truncate" title={pathValue}>
{getAttachmentLabel(pathValue)}
</span>
<button
type="button"
aria-label={`Remove attachment ${getAttachmentLabel(pathValue)}`}
onClick={() => onRemoveAttachment(pathValue)}
className="shrink-0 text-muted-foreground transition hover:text-foreground"
>
<X className="size-3.5" />
</button>
</div>
))}
</div>
) : null}
<div className="flex items-center justify-between px-3 pb-3 pt-1">
<div className="flex items-center gap-1.5">
<Tooltip>
<TooltipTrigger asChild>
<div>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full bg-transparent p-0 text-foreground hover:bg-muted/60 hover:text-foreground"
aria-label="Add attachment"
>
<Plus className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onSelect={() => onAddAttachment()}>
<Paperclip className="size-4" />
Add attachment
<DropdownMenuShortcut>{addAttachmentShortcut}</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
Add files
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Popover open={linkPopoverOpen} onOpenChange={onLinkPopoverOpenChange}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
className="h-8 w-8 rounded-full bg-muted/55 p-0 text-foreground backdrop-blur-md hover:bg-muted/75 hover:text-foreground supports-[backdrop-filter]:bg-muted/50"
aria-label="Link GitHub issue or pull request"
>
<Github className="size-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-80 p-0">
<Command shouldFilter={false}>
<CommandInput
autoFocus
placeholder="Search issues or pull requests..."
value={linkQuery}
onValueChange={onLinkQueryChange}
/>
<CommandList className="max-h-[280px]">
{filteredLinkItems.length === 0 ? (
<CommandEmpty>
{normalizedLinkQuery.repoMismatch
? `GitHub URL must match ${normalizedLinkQuery.repoMismatch}.`
: linkItemsLoading || linkDirectLoading
? normalizedLinkQuery.query.trim()
? 'Searching...'
: 'Loading...'
: normalizedLinkQuery.query.trim()
? 'No issues or pull requests found.'
: 'No recent issues or pull requests found.'}
</CommandEmpty>
) : null}
{filteredLinkItems.length > 0 ? (
<CommandGroup
heading={
normalizedLinkQuery.query.trim()
? `${filteredLinkItems.length} result${filteredLinkItems.length === 1 ? '' : 's'}`
: 'Recent issues & pull requests'
}
>
{filteredLinkItems.map((item) => (
<CommandItem
key={item.id}
value={`${item.type}-${item.number}-${item.title}`}
onSelect={() => onSelectLinkedItem(item)}
className="group"
>
{item.type === 'pr' ? (
<GitPullRequest className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<CircleDot className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="shrink-0 font-mono text-xs text-muted-foreground">
#{item.number}
</span>
<span className="min-w-0 flex-1 truncate text-xs">
{item.title}
</span>
<Check className="size-3.5 shrink-0 opacity-0 group-data-[selected=true]:opacity-100" />
</CommandItem>
))}
</CommandGroup>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
Add GH Issue / PR
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
type="button"
variant="ghost"
disabled
className="h-8 w-8 rounded-full bg-muted/35 p-0 text-muted-foreground/70 backdrop-blur-md supports-[backdrop-filter]:bg-muted/30"
aria-label="Link Linear issue"
>
<LinearIcon className="size-3.5" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
coming soon
</TooltipContent>
</Tooltip>
</div>
<Select
value={tuiAgent}
onValueChange={(value) => onTuiAgentChange(value as TuiAgent)}
>
<SelectTrigger
size="sm"
className={cn(
'h-8 rounded-full border-border/50 bg-background/50 px-3 backdrop-blur-md supports-[backdrop-filter]:bg-background/50 transition-opacity',
!agentPrompt.trim() && 'opacity-60 hover:opacity-100 grayscale-[0.5]'
)}
>
<SelectValue>
<span className="flex items-center gap-2">
<AgentIcon agent={tuiAgent} />
<span>{AGENT_CATALOG.find((a) => a.id === tuiAgent)?.label ?? tuiAgent}</span>
</span>
</SelectValue>
</SelectTrigger>
<SelectContent align="end">
{AGENT_CATALOG.filter(
(a) => detectedAgentIds === null || detectedAgentIds.has(a.id)
).map((option) => (
<SelectItem key={option.id} value={option.id}>
<span className="flex items-center gap-2">
<AgentIcon agent={option.id} />
<span>{option.label}</span>
</span>
</SelectItem>
))}
<div className="border-t border-border/50 px-1 pb-0.5 pt-1">
<button
type="button"
className="flex w-full items-center gap-1.5 rounded-sm px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
onPointerDown={(event) => event.preventDefault()}
onClick={onOpenAgentSettings}
>
Manage agents
<svg
className="size-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</button>
</div>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center justify-between">
<Button
type="button"
variant="ghost"
size="sm"
className="rounded-full px-2.5 text-muted-foreground hover:text-foreground"
onClick={onToggleAdvanced}
>
Advanced
<ChevronDown
className={cn('size-4 transition-transform', advancedOpen && 'rotate-180')}
/>
</Button>
<div className="flex justify-end">
<Button
onClick={() => void onCreate()}
disabled={createDisabled}
size="sm"
className="rounded-full px-3"
>
{creating ? <LoaderCircle className="size-4 animate-spin" /> : null}
{agentPrompt.trim() ? 'Start Agent' : 'Create Worktree'}
<span className="ml-1 rounded-full border border-white/20 p-1 text-current/80">
<CornerDownLeft className="size-3" />
</span>
</Button>
</div>
</div>
<div
className={cn(
'grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out',
advancedOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
)}
aria-hidden={!advancedOpen}
>
<div className="min-h-0 px-3 pt-3">
<div className="grid gap-5 pb-3">
<div className="grid gap-1.5">
<label className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Note
</label>
<Input
value={note}
onChange={(event) => onNoteChange(event.target.value)}
placeholder="Write a note"
className="h-10 rounded-xl border-border/60 bg-input/30 shadow-sm"
/>
</div>
{setupConfig ? (
<div className="grid gap-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<label className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Setup script
</label>
<span className="rounded-full border border-border/70 bg-muted/45 px-2.5 py-1 text-[10px] font-medium uppercase tracking-[0.16em] text-foreground/70 shadow-sm">
{setupConfig.source === 'yaml' ? 'orca.yaml' : 'legacy hooks'}
</span>
</div>
{/* Why: `orca.yaml` is the committed source of truth for shared setup,
so the preview reconstructs the real YAML shape instead of showing a raw
shell blob that hides where the command came from. */}
<SetupCommandPreview
setupConfig={setupConfig}
headerAction={
requiresExplicitSetupChoice ? null : (
<label className="group flex items-center gap-2 text-xs text-foreground">
<span
className={cn(
'flex size-4 items-center justify-center rounded-[3px] border transition shadow-sm',
resolvedSetupDecision === 'run'
? 'border-emerald-500/60 bg-emerald-500 text-white'
: 'border-foreground/20 bg-background dark:border-white/20 dark:bg-muted/10'
)}
>
<Check
className={cn(
'size-3 transition-opacity',
resolvedSetupDecision === 'run' ? 'opacity-100' : 'opacity-0'
)}
/>
</span>
<input
type="checkbox"
checked={resolvedSetupDecision === 'run'}
onChange={(event) =>
onSetupDecisionChange(event.target.checked ? 'run' : 'skip')
}
className="sr-only"
/>
<span>Run setup command</span>
</label>
)
}
/>
{requiresExplicitSetupChoice ? (
<div className="grid gap-2.5">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Run setup now?
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => onSetupDecisionChange('run')}
className={cn(
'rounded-full border px-3.5 py-2 text-xs font-medium transition',
setupDecision === 'run'
? 'border-emerald-500/40 bg-emerald-500/12 text-foreground shadow-sm'
: 'border-border/70 bg-muted/35 text-foreground/75 hover:text-foreground'
)}
>
Run setup now
</button>
<button
type="button"
onClick={() => onSetupDecisionChange('skip')}
className={cn(
'rounded-full border px-3.5 py-2 text-xs font-medium transition',
setupDecision === 'skip'
? 'border-border/70 bg-foreground/10 text-foreground shadow-sm'
: 'border-border/70 bg-muted/35 text-foreground/75 hover:text-foreground'
)}
>
Skip for now
</button>
</div>
{!setupDecision ? (
<div className="text-xs text-muted-foreground">
{shouldWaitForSetupCheck
? 'Checking setup configuration...'
: 'Choose whether to run setup before creating this workspace.'}
</div>
) : null}
</div>
) : null}
</div>
) : null}
</div>
</div>
</div>
</div>
</div>
{createError ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{createError}
</div>
) : null}
</div>
)
}

File diff suppressed because it is too large Load diff

View file

@ -842,7 +842,9 @@ function Terminal(): React.JSX.Element | null {
if (!layout) {
return null
}
const isVisible = activeView !== 'settings' && worktree.id === activeWorktreeId
// Why: use strict equality with 'terminal' instead of !== 'settings'
// so the terminal/browser surface hides on the new-workspace page too.
const isVisible = activeView === 'terminal' && worktree.id === activeWorktreeId
return (
<div
key={`tab-groups-${worktree.id}`}
@ -887,8 +889,9 @@ function Terminal(): React.JSX.Element | null {
{allWorktrees
.filter((wt) => mountedWorktreeIdsRef.current.has(wt.id))
.map((worktree) => {
const isVisible = activeView !== 'settings' && worktree.id === activeWorktreeId
// Why: use strict equality with 'terminal' instead of !== 'settings'
// so the terminal/browser surface hides on the new-workspace page too.
const isVisible = activeView === 'terminal' && worktree.id === activeWorktreeId
return (
<div
key={worktree.id}
@ -932,8 +935,10 @@ function Terminal(): React.JSX.Element | null {
>
{allWorktrees.map((worktree) => {
const browserTabs = browserTabsByWorktree[worktree.id] ?? []
// Why: use strict equality with 'terminal' instead of !== 'settings'
// so browser panes also hide on the new-workspace page.
const isVisibleWorktree =
activeView !== 'settings' && worktree.id === activeWorktreeId
activeView === 'terminal' && worktree.id === activeWorktreeId
if (browserTabs.length === 0) {
return null
}

View file

@ -131,7 +131,7 @@ function findBrowserSelection(
export default function WorktreeJumpPalette(): React.JSX.Element | null {
const visible = useAppStore((s) => s.activeModal === 'worktree-palette')
const closeModal = useAppStore((s) => s.closeModal)
const openModal = useAppStore((s) => s.openModal)
const openNewWorkspacePage = useAppStore((s) => s.openNewWorkspacePage)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const repos = useAppStore((s) => s.repos)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
@ -474,10 +474,12 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const handleCreateWorktree = useCallback(() => {
skipRestoreFocusRef.current = true
closeModal()
// Why: we open the full-page new-workspace view in a microtask so Radix
// fully unmounts before the next autofocus cycle runs, avoiding focus churn.
queueMicrotask(() =>
openModal('create-worktree', createWorktreeName ? { prefilledName: createWorktreeName } : {})
openNewWorkspacePage(createWorktreeName ? { prefilledName: createWorktreeName } : {})
)
}, [closeModal, createWorktreeName, openModal])
}, [closeModal, createWorktreeName, openNewWorkspacePage])
const handleCloseAutoFocus = useCallback((e: Event) => {
e.preventDefault()

View file

@ -1994,7 +1994,7 @@ function BrowserPagePane({
title="Browser Settings"
onClick={() => {
useAppStore.getState().openSettingsTarget({ pane: 'general', repoId: null })
useAppStore.getState().setActiveView('settings')
useAppStore.getState().openSettingsPage()
}}
>
<Settings className="size-4" />

View file

@ -19,13 +19,15 @@ type RepoComboboxProps = {
value: string
onValueChange: (repoId: string) => void
placeholder?: string
triggerClassName?: string
}
export default function RepoCombobox({
repos,
value,
onValueChange,
placeholder = 'Select repo...'
placeholder = 'Select repo...',
triggerClassName
}: RepoComboboxProps): React.JSX.Element {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
@ -63,7 +65,7 @@ export default function RepoCombobox({
variant="outline"
role="combobox"
aria-expanded={open}
className="h-8 w-full justify-between px-3 text-xs font-normal"
className={cn('h-8 w-full justify-between px-3 text-xs font-normal', triggerClassName)}
data-repo-combobox-root="true"
>
{selectedRepo ? (

View file

@ -0,0 +1,356 @@
import { useEffect, useState } from 'react'
import { Check, ChevronDown, ExternalLink, Terminal } from 'lucide-react'
import type { GlobalSettings, TuiAgent } from '../../../../shared/types'
import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { cn } from '@/lib/utils'
export { AGENTS_PANE_SEARCH_ENTRIES } from './agents-search'
type AgentsPaneProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
}
type AgentRowProps = {
agentId: TuiAgent
label: string
homepageUrl: string
defaultCmd: string
isDetected: boolean
isDefault: boolean
cmdOverride: string | undefined
onSetDefault: () => void
onSaveOverride: (value: string) => void
}
function AgentRow({
agentId,
label,
homepageUrl,
defaultCmd,
isDetected,
isDefault,
cmdOverride,
onSetDefault,
onSaveOverride
}: AgentRowProps): React.JSX.Element {
const [cmdOpen, setCmdOpen] = useState(Boolean(cmdOverride))
const [cmdDraft, setCmdDraft] = useState(cmdOverride ?? defaultCmd)
useEffect(() => {
setCmdDraft(cmdOverride ?? defaultCmd)
}, [cmdOverride, defaultCmd])
const commitCmd = (): void => {
const trimmed = cmdDraft.trim()
if (!trimmed || trimmed === defaultCmd) {
onSaveOverride('')
setCmdDraft(defaultCmd)
} else {
onSaveOverride(trimmed)
}
}
return (
<div
className={cn(
'group rounded-xl border transition-all',
isDetected ? 'border-border/60 bg-card/60' : 'border-border/30 bg-card/20 opacity-60'
)}
>
<div className="flex items-center gap-3 px-4 py-3">
{/* Icon */}
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border/50 bg-background/60">
<AgentIcon agent={agentId} size={18} />
</div>
{/* Name + status */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold leading-none">{label}</span>
{isDetected ? (
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700 dark:text-emerald-300">
<span className="size-1.5 rounded-full bg-emerald-500" />
Detected
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full border border-border/40 bg-muted/30 px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
Not installed
</span>
)}
</div>
<div className="mt-0.5 font-mono text-[11px] text-muted-foreground">
{cmdOverride ? (
<span>
<span className="text-muted-foreground/60 line-through">{defaultCmd}</span>
<span className="ml-1.5 text-foreground/70">{cmdOverride}</span>
</span>
) : (
defaultCmd
)}
</div>
</div>
{/* Actions */}
<div className="flex shrink-0 items-center gap-1">
{/* Set as default — only for detected agents */}
{isDetected && (
<button
type="button"
onClick={onSetDefault}
title={isDefault ? 'Default agent' : 'Set as default'}
className={cn(
'flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors',
isDefault
? 'bg-foreground/10 text-foreground ring-1 ring-foreground/20'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
)}
>
{isDefault && <Check className="size-3" />}
{isDefault ? 'Default' : 'Set default'}
</button>
)}
{/* Customize command — only for detected agents */}
{isDetected && (
<button
type="button"
onClick={() => setCmdOpen((prev) => !prev)}
title="Customize command"
className={cn(
'flex size-7 items-center justify-center rounded-lg transition-colors',
cmdOpen || cmdOverride
? 'bg-muted/60 text-foreground'
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
)}
>
<Terminal className="size-3.5" />
</button>
)}
{/* Homepage link */}
<a
href={homepageUrl}
target="_blank"
rel="noopener noreferrer"
title={isDetected ? 'Docs' : 'Install'}
className="flex size-7 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
>
<ExternalLink className="size-3.5" />
</a>
{/* Expand chevron for cmd override */}
{isDetected && (
<button
type="button"
onClick={() => setCmdOpen((prev) => !prev)}
className="flex size-7 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
>
<ChevronDown
className={cn('size-3.5 transition-transform', cmdOpen && 'rotate-180')}
/>
</button>
)}
</div>
</div>
{/* Command override row */}
{isDetected && cmdOpen && (
<div className="border-t border-border/40 px-4 py-3">
<div className="flex items-center gap-2">
<span className="shrink-0 text-xs text-muted-foreground">Command</span>
<Input
value={cmdDraft}
onChange={(e) => setCmdDraft(e.target.value)}
onBlur={commitCmd}
onKeyDown={(e) => {
if (e.key === 'Enter') {
commitCmd()
e.currentTarget.blur()
}
if (e.key === 'Escape') {
setCmdDraft(cmdOverride ?? defaultCmd)
e.currentTarget.blur()
}
}}
placeholder={defaultCmd}
spellCheck={false}
className="h-7 flex-1 font-mono text-xs"
/>
{cmdOverride && (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => {
onSaveOverride('')
setCmdDraft(defaultCmd)
}}
className="h-7 shrink-0 text-xs text-muted-foreground hover:text-foreground"
>
Reset
</Button>
)}
</div>
<p className="mt-1.5 text-[11px] text-muted-foreground">
Override the binary path or name used to launch this agent.
</p>
</div>
)}
</div>
)
}
export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React.JSX.Element {
const [detectedIds, setDetectedIds] = useState<Set<string> | null>(null)
useEffect(() => {
void window.api.preflight.detectAgents().then((ids) => {
setDetectedIds(new Set(ids))
})
}, [])
const defaultAgent = settings.defaultTuiAgent
const cmdOverrides = settings.agentCmdOverrides ?? {}
const setDefault = (id: TuiAgent | null): void => {
updateSettings({ defaultTuiAgent: id })
}
const saveOverride = (id: TuiAgent, value: string): void => {
const next = { ...cmdOverrides }
if (value) {
next[id] = value
} else {
delete next[id]
}
updateSettings({ agentCmdOverrides: next })
}
const detectedAgents = AGENT_CATALOG.filter((a) => detectedIds === null || detectedIds.has(a.id))
const undetectedAgents = AGENT_CATALOG.filter(
(a) => detectedIds !== null && !detectedIds.has(a.id)
)
const isAutoDefault = defaultAgent === null || !detectedIds?.has(defaultAgent)
return (
<div className="space-y-8">
{/* Default agent picker */}
<section className="space-y-4">
<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.
</p>
</div>
<div className="flex flex-wrap gap-2">
{/* Auto option */}
<button
type="button"
onClick={() => setDefault(null)}
className={cn(
'flex items-center gap-2 rounded-xl border px-3 py-2 text-sm transition-all',
isAutoDefault
? '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'
)}
>
{isAutoDefault && <Check className="size-3.5" />}
Auto
</button>
{/* Detected agent pills */}
{detectedAgents.map((agent) => {
const isActive = defaultAgent === agent.id
return (
<button
key={agent.id}
type="button"
onClick={() => setDefault(agent.id)}
className={cn(
'flex items-center gap-2 rounded-xl border px-3 py-2 text-sm transition-all',
isActive
? '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'
)}
>
<AgentIcon agent={agent.id} size={14} />
{agent.label}
{isActive && <Check className="size-3.5" />}
</button>
)
})}
</div>
</section>
{/* Detected agents */}
{detectedAgents.length > 0 && (
<section className="space-y-3">
<div className="flex items-center gap-3">
<h3 className="text-sm font-semibold">Installed</h3>
<span className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-700 dark:text-emerald-300">
{detectedAgents.length} detected
</span>
</div>
<div className="space-y-2">
{detectedAgents.map((agent) => (
<AgentRow
key={agent.id}
agentId={agent.id}
label={agent.label}
homepageUrl={agent.homepageUrl}
defaultCmd={agent.cmd}
isDetected
isDefault={defaultAgent === agent.id}
cmdOverride={cmdOverrides[agent.id]}
onSetDefault={() => setDefault(agent.id)}
onSaveOverride={(v) => saveOverride(agent.id, v)}
/>
))}
</div>
</section>
)}
{/* Undetected agents */}
{undetectedAgents.length > 0 && (
<section className="space-y-3">
<div className="flex items-center gap-3">
<h3 className="text-sm font-semibold text-muted-foreground">Available to install</h3>
<span className="rounded-full border border-border/40 bg-muted/30 px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{undetectedAgents.length} agents
</span>
</div>
<div className="space-y-2">
{undetectedAgents.map((agent) => (
<AgentRow
key={agent.id}
agentId={agent.id}
label={agent.label}
homepageUrl={agent.homepageUrl}
defaultCmd={agent.cmd}
isDetected={false}
isDefault={false}
cmdOverride={undefined}
onSetDefault={() => {}}
onSaveOverride={() => {}}
/>
))}
</div>
</section>
)}
{detectedIds === null && (
<div className="flex items-center justify-center rounded-xl border border-dashed border-border/50 py-8 text-sm text-muted-foreground">
Detecting installed agents
</div>
)}
</div>
)
}

View file

@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
BarChart3,
Bell,
Bot,
GitBranch,
Keyboard,
Palette,
@ -25,6 +26,7 @@ import { getTerminalPaneSearchEntries } from './terminal-search'
import { GitPane, GIT_PANE_SEARCH_ENTRIES } from './GitPane'
import { NotificationsPane, NOTIFICATIONS_PANE_SEARCH_ENTRIES } from './NotificationsPane'
import { SshPane, SSH_PANE_SEARCH_ENTRIES } from './SshPane'
import { AgentsPane, AGENTS_PANE_SEARCH_ENTRIES } from './AgentsPane'
import { StatsPane, STATS_PANE_SEARCH_ENTRIES } from '../stats/StatsPane'
import { SettingsSidebar } from './SettingsSidebar'
import { SettingsSection } from './SettingsSection'
@ -39,6 +41,7 @@ type SettingsNavTarget =
| 'shortcuts'
| 'stats'
| 'ssh'
| 'agents'
| 'repo'
type SettingsNavSection = {
@ -65,7 +68,7 @@ function Settings(): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const updateSettings = useAppStore((s) => s.updateSettings)
const fetchSettings = useAppStore((s) => s.fetchSettings)
const setActiveView = useAppStore((s) => s.setActiveView)
const closeSettingsPage = useAppStore((s) => s.closeSettingsPage)
const repos = useAppStore((s) => s.repos)
const updateRepo = useAppStore((s) => s.updateRepo)
const removeRepo = useAppStore((s) => s.removeRepo)
@ -231,6 +234,13 @@ function Settings(): React.JSX.Element {
icon: SlidersHorizontal,
searchEntries: GENERAL_PANE_SEARCH_ENTRIES
},
{
id: 'agents',
title: 'Agents',
description: 'Manage AI agents, set a default, and customize commands.',
icon: Bot,
searchEntries: AGENTS_PANE_SEARCH_ENTRIES
},
{
id: 'git',
title: 'Git',
@ -402,7 +412,7 @@ function Settings(): React.JSX.Element {
repoSections={repoNavSections}
hasRepos={repos.length > 0}
searchQuery={settingsSearchQuery}
onBack={() => setActiveView('terminal')}
onBack={closeSettingsPage}
onSearchChange={setSettingsSearchQuery}
onSelectSection={scrollToSection}
/>
@ -425,6 +435,15 @@ function Settings(): React.JSX.Element {
<GeneralPane settings={settings} updateSettings={updateSettings} />
</SettingsSection>
<SettingsSection
id="agents"
title="Agents"
description="Manage AI agents, set a default, and customize commands."
searchEntries={AGENTS_PANE_SEARCH_ENTRIES}
>
<AgentsPane settings={settings} updateSettings={updateSettings} />
</SettingsSection>
<SettingsSection
id="git"
title="Git"

View file

@ -0,0 +1,38 @@
import type { SettingsSearchEntry } from './settings-search'
export const AGENTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Agents',
description: 'Configure AI coding agents, default agent, and command overrides.',
keywords: [
'agent',
'default',
'claude',
'codex',
'opencode',
'pi',
'gemini',
'aider',
'goose',
'amp',
'kilocode',
'kiro',
'charm',
'auggie',
'cline',
'codebuff',
'continue',
'cursor',
'droid',
'kimi',
'mistral',
'qwen',
'rovo',
'hermes',
'command',
'override',
'install',
'detected'
]
}
]

View file

@ -104,6 +104,14 @@ export const GENERAL_CODEX_ACCOUNTS_SEARCH_ENTRIES: SettingsSearchEntry[] = [
}
]
export const GENERAL_AGENT_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Default Agent',
description: 'Pre-select an AI coding agent in the new-workspace composer.',
keywords: ['agent', 'default', 'claude', 'codex', 'opencode', 'pi', 'gemini', 'aider']
}
]
export const GENERAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
...GENERAL_WORKSPACE_SEARCH_ENTRIES,
...GENERAL_BROWSER_SEARCH_ENTRIES,

View file

@ -23,8 +23,8 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
const repos = useAppStore((s) => s.repos)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
const openModal = useAppStore((s) => s.openModal)
const setActiveView = useAppStore((s) => s.setActiveView)
const openNewWorkspacePage = useAppStore((s) => s.openNewWorkspacePage)
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const [step, setStep] = useState<'add' | 'clone' | 'remote' | 'setup'>('add')
@ -186,17 +186,19 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
const handleCreateWorktree = useCallback(() => {
closeModal()
// Why: small delay so the close animation finishes before the create dialog opens.
// Why: small delay so the close animation finishes before the full-page create
// view takes focus; otherwise the dialog teardown can steal the first focus
// frame from the workspace form.
setTimeout(() => {
openModal('create-worktree', { preselectedRepoId: repoId })
openNewWorkspacePage({ preselectedRepoId: repoId })
}, 150)
}, [closeModal, openModal, repoId])
}, [closeModal, openNewWorkspacePage, repoId])
const handleConfigureRepo = useCallback(() => {
closeModal()
openSettingsTarget({ pane: 'repo', repoId })
setActiveView('settings')
}, [closeModal, openSettingsTarget, setActiveView, repoId])
openSettingsPage()
}, [closeModal, openSettingsTarget, openSettingsPage, repoId])
// Why: handleBack reuses resetState which already aborts clones and resets all fields.
const handleBack = resetState

View file

@ -1,791 +0,0 @@
/* eslint-disable max-lines */
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react'
import { toast } from 'sonner'
import { ChevronRight } from 'lucide-react'
import { useAppStore } from '@/store'
import type {
OrcaHooks,
SetupDecision,
SetupRunPolicy,
WorktreeSetupLaunch
} from '../../../../shared/types'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import RepoCombobox from '@/components/repo/RepoCombobox'
import { parseGitHubIssueOrPRNumber } from '@/lib/github-links'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import { getSuggestedCreatureName, shouldApplySuggestedName } from './worktree-name-suggestions'
const DIALOG_CLOSE_RESET_DELAY_MS = 200
const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
const activeModal = useAppStore((s) => s.activeModal)
const modalData = useAppStore((s) => s.modalData)
const closeModal = useAppStore((s) => s.closeModal)
const repos = useAppStore((s) => s.repos)
const createWorktree = useAppStore((s) => s.createWorktree)
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const activeRepoId = useAppStore((s) => s.activeRepoId)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const setActiveView = useAppStore((s) => s.setActiveView)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const setSidebarOpen = useAppStore((s) => s.setSidebarOpen)
const setRightSidebarOpen = useAppStore((s) => s.setRightSidebarOpen)
const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const settings = useAppStore((s) => s.settings)
const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos])
const [repoId, setRepoId] = useState<string>('')
const [name, setName] = useState('')
const [linkedIssue, setLinkedIssue] = useState('')
const [comment, setComment] = useState('')
const [yamlHooks, setYamlHooks] = useState<OrcaHooks | null>(null)
const [checkedHooksRepoId, setCheckedHooksRepoId] = useState<string | null>(null)
const [setupDecision, setSetupDecision] = useState<'run' | 'skip' | null>(null)
const [runIssueAutomation, setRunIssueAutomation] = useState(false)
const [issueCommandTemplate, setIssueCommandTemplate] = useState('')
const [hasLoadedIssueCommand, setHasLoadedIssueCommand] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const nameInputRef = useRef<HTMLInputElement>(null)
const lastSuggestedNameRef = useRef('')
const resetTimeoutRef = useRef<number | null>(null)
const prevIsOpenRef = useRef(false)
const prevSuggestedNameRef = useRef('')
// Why: tracks whether the user has explicitly toggled the "Run GitHub issue
// command" checkbox. The auto-enable useEffect should only pre-check the box
// on the first opportunity; once the user makes a deliberate choice we must
// not override it when canOfferIssueAutomation re-fires (e.g. because the
// user clears and re-enters the linked issue number).
const userToggledIssueAutomationRef = useRef(false)
const issueAutomationUserChoiceRef = useRef<boolean | null>(null)
const isOpen = activeModal === 'create-worktree'
const preselectedRepoId =
typeof modalData.preselectedRepoId === 'string' ? modalData.preselectedRepoId : ''
const prefilledName = typeof modalData.prefilledName === 'string' ? modalData.prefilledName : ''
const activeWorktreeRepoId = useMemo(
() => findRepoIdForWorktree(activeWorktreeId, worktreesByRepo),
[activeWorktreeId, worktreesByRepo]
)
const selectedRepo = eligibleRepos.find((r) => r.id === repoId)
const parsedLinkedIssueNumber = useMemo(
() => (linkedIssue.trim() ? parseGitHubIssueOrPRNumber(linkedIssue) : null),
[linkedIssue]
)
const setupConfig = useMemo(
() => getSetupConfig(selectedRepo, yamlHooks),
[selectedRepo, yamlHooks]
)
const setupPolicy: SetupRunPolicy = selectedRepo?.hookSettings?.setupRunPolicy ?? 'run-by-default'
const hasIssueAutomationConfig = issueCommandTemplate.length > 0
const canOfferIssueAutomation = parsedLinkedIssueNumber !== null && hasIssueAutomationConfig
const shouldRunIssueAutomation = canOfferIssueAutomation && runIssueAutomation
// Why: the GitHub issue command changes the create result, so once the
// user has entered a valid linked issue we must not let create race ahead of
// the async repo-local template read and silently skip the command split.
const shouldWaitForIssueAutomationCheck =
parsedLinkedIssueNumber !== null && !hasLoadedIssueCommand
const requiresExplicitSetupChoice = Boolean(setupConfig) && setupPolicy === 'ask'
const resolvedSetupDecision =
setupDecision ??
(!setupConfig || setupPolicy === 'ask'
? null
: setupPolicy === 'run-by-default'
? 'run'
: 'skip')
const suggestedName = useMemo(
() => getSuggestedCreatureName(repoId, worktreesByRepo, settings?.nestWorkspaces ?? false),
[repoId, worktreesByRepo, settings?.nestWorkspaces]
)
// Why: setup visibility is part of the create decision no matter which default
// policy the repo uses. If we let create proceed before the async hook lookup
// finishes, a repo with `orca.yaml` setup can silently launch setup (or hide a
// skip/default choice) before the dialog ever surfaces that configuration.
// Track which repo has completed a lookup so the first render after opening or
// switching repos still counts as "checking".
const isSetupCheckPending = Boolean(repoId) && checkedHooksRepoId !== repoId
const shouldWaitForSetupCheck = Boolean(selectedRepo) && isSetupCheckPending
// Auto-select repo when dialog opens (adjusting state during render)
if (isOpen && !prevIsOpenRef.current && eligibleRepos.length > 0) {
if (preselectedRepoId && eligibleRepos.some((repo) => repo.id === preselectedRepoId)) {
setRepoId(preselectedRepoId)
} else if (
activeWorktreeRepoId &&
eligibleRepos.some((repo) => repo.id === activeWorktreeRepoId)
) {
setRepoId(activeWorktreeRepoId)
} else if (activeRepoId && eligibleRepos.some((repo) => repo.id === activeRepoId)) {
setRepoId(activeRepoId)
} else {
setRepoId(eligibleRepos[0].id)
}
if (prefilledName.trim()) {
// Why: when the Cmd+J palette offers "create worktree <query>" after a
// search miss, the follow-up dialog should preserve that exact name
// instead of replacing it with the default creature suggestion.
setName(prefilledName)
lastSuggestedNameRef.current = ''
}
}
prevIsOpenRef.current = isOpen
// Auto-fill name from suggestion (adjusting state during render)
if (isOpen && repoId && suggestedName && suggestedName !== prevSuggestedNameRef.current) {
const shouldApplySuggestion = shouldApplySuggestedName(name, lastSuggestedNameRef.current)
prevSuggestedNameRef.current = suggestedName
if (shouldApplySuggestion) {
setName(suggestedName)
lastSuggestedNameRef.current = suggestedName
}
}
if (!isOpen) {
prevSuggestedNameRef.current = ''
}
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
closeModal()
}
},
[closeModal]
)
const handleCreate = useCallback(async () => {
if (
!repoId ||
!name.trim() ||
shouldWaitForSetupCheck ||
shouldWaitForIssueAutomationCheck ||
!selectedRepo
) {
return
}
setCreateError(null)
setCreating(true)
try {
const result = await createWorktree(
repoId,
name.trim(),
undefined,
// Why: the renderer-side hook lookup only exists to preview setup and collect an `ask`
// choice before create. The main process is still the source of truth for whether a repo
// has setup and whether it should launch. Always pass the resolved decision through so a
// stale or failed preview lookup cannot silently suppress setup for a newly created worktree.
(resolvedSetupDecision ?? 'inherit') as SetupDecision
)
const wt = result.worktree
// Meta update is best-effort — the worktree already exists, so don't
// block the success path if only the metadata write fails.
try {
const metaUpdates: Record<string, unknown> = {}
if (parsedLinkedIssueNumber !== null) {
;(metaUpdates as { linkedIssue: number }).linkedIssue = parsedLinkedIssueNumber
}
if (comment.trim()) {
;(metaUpdates as { comment: string }).comment = comment.trim()
}
if (Object.keys(metaUpdates).length > 0) {
await updateWorktreeMeta(wt.id, metaUpdates as { linkedIssue?: number; comment?: string })
}
} catch {
console.error('Failed to update worktree meta after creation')
}
// Why: build the issue command payload before ensureWorktreeHasInitialTerminal
// so it can queue the split before TerminalPane mounts. The command template
// supports {{issue}} interpolation so the launched command gets the linked
// issue number without requiring a second, less-visible templating surface.
let issueCommand: WorktreeSetupLaunch | undefined
if (shouldRunIssueAutomation) {
const interpolatedIssueCommand = issueCommandTemplate.replace(
/\{\{issue\}\}/g,
String(parsedLinkedIssueNumber)
)
try {
// Why: issue automation is an optional post-create convenience. If
// runner preparation fails after git has already created the worktree,
// keep the success path intact and surface the automation failure
// separately instead of claiming the whole create operation failed.
issueCommand = await window.api.hooks.createIssueCommandRunner({
repoId,
worktreePath: wt.path,
command: interpolatedIssueCommand
})
} catch (error) {
console.error('Failed to prepare issue command runner after worktree creation', error)
toast.error('Worktree created, but failed to prepare the GitHub issue command.')
}
}
activateAndRevealWorktree(wt.id, {
setup: result.setup,
issueCommand
})
// Why: dialog-specific extras that remain after calling the shared
// helper — opening the sidebar and right sidebar are create-flow
// concerns, not general activation behavior.
setSidebarOpen(true)
if (settings?.rightSidebarOpenByDefault) {
setRightSidebarTab('explorer')
setRightSidebarOpen(true)
}
handleOpenChange(false)
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create worktree.'
setCreateError(message)
toast.error(message)
} finally {
setCreating(false)
}
}, [
repoId,
name,
comment,
createWorktree,
updateWorktreeMeta,
setSidebarOpen,
setRightSidebarOpen,
setRightSidebarTab,
settings?.rightSidebarOpenByDefault,
handleOpenChange,
issueCommandTemplate,
resolvedSetupDecision,
parsedLinkedIssueNumber,
selectedRepo,
shouldRunIssueAutomation,
shouldWaitForIssueAutomationCheck,
shouldWaitForSetupCheck
])
const handleNameChange = useCallback(
(value: string) => {
setName(value)
if (createError) {
setCreateError(null)
}
},
[createError]
)
const handleRepoChange = useCallback(
(value: string) => {
// Why: re-selecting the already-active repo resets checkedHooksRepoId and
// hasLoadedIssueCommand to null/false, but the useEffect that reloads them
// depends on [isOpen, repoId] — since repoId didn't change the effect never
// re-fires, leaving the Create button permanently disabled.
if (value === repoId) {
return
}
setRepoId(value)
setYamlHooks(null)
setCheckedHooksRepoId(null)
setSetupDecision(null)
setRunIssueAutomation(false)
setIssueCommandTemplate('')
setHasLoadedIssueCommand(false)
userToggledIssueAutomationRef.current = false
issueAutomationUserChoiceRef.current = null
if (createError) {
setCreateError(null)
}
},
[createError, repoId]
)
const handleOpenSetupSettings = useCallback(() => {
if (!selectedRepo) {
return
}
// Why: the create dialog intentionally keeps setup details collapsed so the
// branch-creation flow stays lightweight; clicking setup is the escape hatch
// into the full repository hook editor.
openSettingsTarget({ pane: 'repo', repoId: selectedRepo.id })
handleOpenChange(false)
setActiveView('settings')
}, [handleOpenChange, openSettingsTarget, selectedRepo, setActiveView])
// Auto-select repo when opening.
useEffect(() => {
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current)
resetTimeoutRef.current = null
}
if (isOpen) {
return
}
resetTimeoutRef.current = window.setTimeout(() => {
setRepoId('')
setName('')
setLinkedIssue('')
setComment('')
setYamlHooks(null)
setCheckedHooksRepoId(null)
setSetupDecision(null)
setRunIssueAutomation(false)
setIssueCommandTemplate('')
setHasLoadedIssueCommand(false)
setCreateError(null)
lastSuggestedNameRef.current = ''
userToggledIssueAutomationRef.current = false
issueAutomationUserChoiceRef.current = null
resetTimeoutRef.current = null
}, DIALOG_CLOSE_RESET_DELAY_MS)
return () => {
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current)
resetTimeoutRef.current = null
}
}
}, [isOpen])
// Focus and select name input when suggestion is applied
useEffect(() => {
if (!isOpen || !repoId || !suggestedName) {
return
}
requestAnimationFrame(() => {
const input = nameInputRef.current
if (!input) {
return
}
input.focus()
input.select()
})
}, [isOpen, repoId, suggestedName])
// Safety guard: creating a worktree requires at least one git repo. Non-git
// folders can exist in the sidebar, but this dialog only works for repos
// that can actually host a worktree, so close before rendering an empty picker.
useEffect(() => {
if (isOpen && eligibleRepos.length === 0) {
handleOpenChange(false)
}
}, [eligibleRepos.length, handleOpenChange, isOpen])
// Load hook state and the effective issue-command template for the selected repo.
useEffect(() => {
if (!isOpen || !repoId) {
return
}
let cancelled = false
// Why: when the dialog reopens quickly (before DIALOG_CLOSE_RESET_DELAY_MS
// fires) or when repoId changes via a path other than handleRepoChange,
// issue-automation state from the previous session could persist. Reset all
// three fields here for consistency with handleRepoChange and the close timeout.
setHasLoadedIssueCommand(false)
setIssueCommandTemplate('')
setRunIssueAutomation(false)
userToggledIssueAutomationRef.current = false
issueAutomationUserChoiceRef.current = null
void window.api.hooks
.check({ repoId })
.then((result) => {
if (!cancelled) {
setYamlHooks(result.hooks)
setCheckedHooksRepoId(repoId)
}
})
.catch(() => {
if (!cancelled) {
setYamlHooks(null)
setCheckedHooksRepoId(repoId)
}
})
// Why: issue automation now resolves from layered config: tracked
// `orca.yaml` first, then optional `.orca/issue-command` override. Fetch the
// effective command alongside hooks so the create dialog can offer the
// checkbox as soon as the user links a valid GitHub issue.
void window.api.hooks
.readIssueCommand({ repoId })
.then((result) => {
if (!cancelled) {
setIssueCommandTemplate(result.effectiveContent ?? '')
setHasLoadedIssueCommand(true)
}
})
.catch(() => {
if (!cancelled) {
setIssueCommandTemplate('')
setHasLoadedIssueCommand(true)
}
})
return () => {
cancelled = true
}
}, [isOpen, repoId])
useEffect(() => {
if (shouldWaitForSetupCheck) {
setSetupDecision(null)
return
}
if (!setupConfig) {
setSetupDecision(null)
return
}
if (setupPolicy === 'ask') {
setSetupDecision(null)
return
}
setSetupDecision(setupPolicy === 'run-by-default' ? 'run' : 'skip')
}, [setupConfig, setupPolicy, shouldWaitForSetupCheck])
// Auto-enable issue automation when a valid linked issue can use the repo template.
useEffect(() => {
if (!canOfferIssueAutomation) {
setRunIssueAutomation(issueAutomationUserChoiceRef.current ?? false)
return
}
// Why: when a repo defines `{repoRoot}/.orca/issue-command`, the create
// dialog should surface it automatically and start checked so the common
// path is "link issue, create worktree, start work" with one click.
// However, if the user has explicitly toggled the checkbox we must respect
// their choice instead of re-enabling it every time canOfferIssueAutomation
// re-fires (e.g. after clearing and re-entering the linked issue number).
if (!userToggledIssueAutomationRef.current) {
setRunIssueAutomation(true)
issueAutomationUserChoiceRef.current = true
return
}
setRunIssueAutomation(issueAutomationUserChoiceRef.current ?? false)
}, [canOfferIssueAutomation])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && repoId && name.trim() && !creating) {
// Why: Enter inside the repository search surface should select/filter
// repos, not bubble up and submit the entire create-worktree dialog.
// The guard is scoped to Enter only so future keyboard shortcuts added
// to this handler are not silently swallowed.
if ((e.target as HTMLElement | null)?.closest('[data-repo-combobox-root="true"]')) {
return
}
if (
shouldWaitForSetupCheck ||
shouldWaitForIssueAutomationCheck ||
(requiresExplicitSetupChoice && !setupDecision)
) {
return
}
e.preventDefault()
handleCreate()
}
},
[
repoId,
name,
creating,
handleCreate,
requiresExplicitSetupChoice,
setupDecision,
shouldWaitForIssueAutomationCheck,
shouldWaitForSetupCheck
]
)
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md" onKeyDown={handleKeyDown}>
<DialogHeader>
<DialogTitle className="text-sm">New Worktree</DialogTitle>
<DialogDescription className="text-xs">
Create a new git worktree on a fresh branch cut from the selected base ref.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{/* Repo selector */}
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">Repository</label>
<RepoCombobox repos={eligibleRepos} value={repoId} onValueChange={handleRepoChange} />
</div>
{/* Name */}
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">Name</label>
<Input
ref={nameInputRef}
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="feature/my-feature"
className="h-8 text-xs"
autoFocus
/>
{createError && <p className="text-[10px] text-destructive">{createError}</p>}
{shouldWaitForSetupCheck ? (
<p className="text-[10px] text-muted-foreground">Checking setup configuration...</p>
) : null}
{shouldWaitForIssueAutomationCheck ? (
<p className="text-[10px] text-muted-foreground">Checking GitHub issue command...</p>
) : null}
</div>
{setupConfig ? (
<div className="space-y-2 rounded-xl border border-border/60 bg-muted/20 p-3">
<div className="flex items-start justify-between gap-2">
<button
type="button"
onClick={setupConfig.source === 'yaml' ? undefined : handleOpenSetupSettings}
className="group min-w-0 flex-1 rounded-md text-left outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
>
<div className="flex items-center gap-1 text-[11px] font-medium text-foreground">
<span>Setup</span>
{setupConfig.source !== 'yaml' && (
<ChevronRight className="size-3 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
)}
</div>
<p className="text-[10px] text-muted-foreground">
{setupConfig.source === 'yaml' ? (
<>
This repository uses{' '}
<code className="rounded bg-muted px-1 py-0.5">orca.yaml</code> to define
its setup command.
</>
) : (
'Review setup status here and migrate this legacy command in repository settings.'
)}
</p>
</button>
<span className="rounded-full border border-border/60 px-2 py-0.5 text-[10px] text-muted-foreground">
{setupPolicy === 'ask'
? 'Ask every time'
: setupPolicy === 'run-by-default'
? 'Run by default'
: 'Skip by default'}
</span>
</div>
<div className="space-y-1 rounded-lg border border-border/50 bg-background/60 p-2">
<p className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
{setupConfig.source === 'yaml' ? 'orca.yaml' : 'Command Preview'}
</p>
<pre className="overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-5 text-muted-foreground">
{summarizeSetupCommand(setupConfig.command)}
</pre>
</div>
{requiresExplicitSetupChoice ? (
<div className="space-y-2">
<label className="text-[11px] font-medium text-muted-foreground">
Run setup now?
</label>
<div className="grid grid-cols-2 gap-2">
{(
[
['run', 'Run setup now'],
['skip', 'Skip for now']
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => setSetupDecision(value)}
className={`rounded-md border px-3 py-2 text-left text-xs transition-colors ${
setupDecision === value
? 'border-foreground bg-accent text-accent-foreground'
: 'border-border/60 text-muted-foreground hover:text-foreground'
}`}
>
{label}
</button>
))}
</div>
{!setupDecision ? (
<p className="text-[10px] text-muted-foreground">
{shouldWaitForSetupCheck
? 'Checking setup configuration...'
: 'Choose whether to run setup before creating this worktree.'}
</p>
) : null}
</div>
) : (
<label className="flex items-center gap-2 text-[11px] text-foreground">
<input
type="checkbox"
checked={resolvedSetupDecision === 'run'}
onChange={(e) => setSetupDecision(e.target.checked ? 'run' : 'skip')}
/>
Run setup command after creation
</label>
)}
</div>
) : null}
{/* Link GH Issue */}
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">
Link GH Issue <span className="text-muted-foreground/50">(optional)</span>
</label>
<Input
value={linkedIssue}
onChange={(e) => setLinkedIssue(e.target.value)}
placeholder="Issue # or GitHub URL"
className="h-8 text-xs"
/>
<p className="text-[10px] text-muted-foreground">
Paste an issue URL, or enter a number.
</p>
{linkedIssue.trim() && parsedLinkedIssueNumber === null ? (
<p className="text-[10px] text-muted-foreground">
Enter a valid GitHub issue number or URL to enable the GitHub issue command.
</p>
) : null}
</div>
{canOfferIssueAutomation ? (
<div className="space-y-2 rounded-xl border border-border/60 bg-muted/20 p-3">
<p className="text-[11px] font-medium text-foreground">GitHub Issue Command</p>
<label className="flex items-center gap-2 text-[11px] text-foreground">
<input
type="checkbox"
checked={runIssueAutomation}
onChange={(e) => {
userToggledIssueAutomationRef.current = true
issueAutomationUserChoiceRef.current = e.target.checked
setRunIssueAutomation(e.target.checked)
}}
/>
Run the repository&apos;s GitHub issue command after creating this worktree.
</label>
</div>
) : null}
{/* Comment */}
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">
Comment <span className="text-muted-foreground/50">(optional)</span>
</label>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Notes about this worktree..."
rows={2}
className="w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-2 text-xs shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 resize-none"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => handleOpenChange(false)}
className="text-xs"
>
Cancel
</Button>
<Button
size="sm"
onClick={handleCreate}
disabled={
!repoId ||
!name.trim() ||
creating ||
shouldWaitForSetupCheck ||
shouldWaitForIssueAutomationCheck ||
!selectedRepo ||
(requiresExplicitSetupChoice && !setupDecision)
}
className="text-xs"
>
{creating ? 'Creating...' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
})
export default AddWorktreeDialog
function findRepoIdForWorktree(
worktreeId: string | null,
worktreesByRepo: Record<string, { id: string }[]>
): string | null {
if (!worktreeId) {
return null
}
for (const [repoId, worktrees] of Object.entries(worktreesByRepo)) {
if (worktrees.some((worktree) => worktree.id === worktreeId)) {
return repoId
}
}
return null
}
function getSetupConfig(
repo:
| {
hookSettings?: {
setupRunPolicy?: SetupRunPolicy
scripts?: { setup?: string }
}
}
| undefined,
yamlHooks: OrcaHooks | null
): { source: 'yaml' | 'legacy-ui'; command: string } | null {
if (!repo) {
return null
}
const yamlSetup = yamlHooks?.scripts.setup?.trim()
if (yamlSetup) {
return { source: 'yaml', command: yamlSetup }
}
const legacySetup = repo.hookSettings?.scripts?.setup?.trim()
if (legacySetup) {
// Why: the backend still honors persisted pre-yaml hook commands for backwards
// compatibility, so the create dialog must surface the same effective setup
// command instead of pretending the repo has no setup configured.
return { source: 'legacy-ui', command: legacySetup }
}
return null
}
function summarizeSetupCommand(command: string): string {
const trimmed = command.trim()
if (!trimmed) {
return '(empty setup command)'
}
const lines = trimmed.split(/\r?\n/)
if (lines.length <= 4) {
return trimmed
}
return `${lines.slice(0, 4).join('\n')}\n...`
}

View file

@ -36,7 +36,7 @@ const isMac = navigator.userAgent.includes('Mac')
const newWorktreeShortcutLabel = isMac ? '⌘N' : 'Ctrl+N'
const SidebarHeader = React.memo(function SidebarHeader() {
const openModal = useAppStore((s) => s.openModal)
const openNewWorkspacePage = useAppStore((s) => s.openNewWorkspacePage)
const repos = useAppStore((s) => s.repos)
const canCreateWorktree = repos.some((repo) => isGitRepoKind(repo))
@ -112,7 +112,7 @@ const SidebarHeader = React.memo(function SidebarHeader() {
if (!canCreateWorktree) {
return
}
openModal('create-worktree')
openNewWorkspacePage()
}}
aria-label="Add worktree"
disabled={!canCreateWorktree}
@ -122,7 +122,7 @@ const SidebarHeader = React.memo(function SidebarHeader() {
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{canCreateWorktree
? `New worktree (${newWorktreeShortcutLabel})`
? `New workspace (${newWorktreeShortcutLabel})`
: 'Add a Git repo to create worktrees'}
</TooltipContent>
</Tooltip>

View file

@ -263,7 +263,7 @@ function FeedbackDialog({
const SidebarToolbar = React.memo(function SidebarToolbar() {
const openModal = useAppStore((s) => s.openModal)
const setActiveView = useAppStore((s) => s.setActiveView)
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const [feedbackOpen, setFeedbackOpen] = useState(false)
return (
@ -306,7 +306,7 @@ const SidebarToolbar = React.memo(function SidebarToolbar() {
<Button
variant="ghost"
size="icon-xs"
onClick={() => setActiveView('settings')}
onClick={openSettingsPage}
className="text-muted-foreground"
>
<Settings className="size-3.5" />

View file

@ -38,6 +38,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
hintNumber
}: WorktreeCardProps) {
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
const setActiveView = useAppStore((s) => s.setActiveView)
const openModal = useAppStore((s) => s.openModal)
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch)
@ -165,6 +166,12 @@ const WorktreeCard = React.memo(function WorktreeCard({
if (selection && selection.toString().length > 0) {
return
}
if (useAppStore.getState().activeView !== 'terminal') {
// Why: the sidebar remains visible during the new-workspace flow, so
// clicking a real worktree should switch the main pane back to that
// worktree instead of leaving the create surface visible.
setActiveView('terminal')
}
// Why: always activate the worktree so the user can see terminal history,
// editor state, etc. even when SSH is disconnected. Show the reconnect
// dialog as a non-blocking overlay rather than a gate.
@ -172,7 +179,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
if (isSshDisconnected) {
setShowDisconnectedDialog(true)
}
}, [worktree.id, setActiveWorktree, isSshDisconnected])
}, [worktree.id, setActiveView, setActiveWorktree, isSshDisconnected])
const handleDoubleClick = useCallback(() => {
openModal('edit-meta', {

View file

@ -404,7 +404,8 @@ const WorktreeList = React.memo(function WorktreeList() {
const sortBy = useAppStore((s) => s.sortBy)
const showActiveOnly = useAppStore((s) => s.showActiveOnly)
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
const openModal = useAppStore((s) => s.openModal)
const openNewWorkspacePage = useAppStore((s) => s.openNewWorkspacePage)
const activeView = useAppStore((s) => s.activeView)
const activeModal = useAppStore((s) => s.activeModal)
const pendingRevealWorktreeId = useAppStore((s) => s.pendingRevealWorktreeId)
const clearPendingRevealWorktreeId = useAppStore((s) => s.clearPendingRevealWorktreeId)
@ -490,7 +491,6 @@ const WorktreeList = React.memo(function WorktreeList() {
}
return m
}, [repos])
// ── Stable sort order ──────────────────────────────────────────
// The sort order is cached and only recomputed when `sortEpoch` changes
// (worktree add/remove, terminal activity, backend refresh, etc.).
@ -631,6 +631,10 @@ const WorktreeList = React.memo(function WorktreeList() {
.map((r) => r.worktree),
[rows]
)
// Why: when the new-workspace page is active, no sidebar card should appear
// selected — the user hasn't picked a worktree yet.
const selectedSidebarWorktreeId = activeView === 'new-workspace' ? null : activeWorktreeId
// Why layout effect instead of effect: the global Cmd/Ctrl+19 key handler
// can fire immediately after React commits the new grouped/collapsed order.
// Publishing after paint leaves a brief window where the sidebar shows the
@ -653,9 +657,9 @@ const WorktreeList = React.memo(function WorktreeList() {
const handleCreateForRepo = useCallback(
(repoId: string) => {
openModal('create-worktree', { preselectedRepoId: repoId })
openNewWorkspacePage({ preselectedRepoId: repoId })
},
[openModal]
[openNewWorkspacePage]
)
const hasFilters = !!(searchQuery || showActiveOnly || filterRepoIds.length)
@ -671,17 +675,19 @@ const WorktreeList = React.memo(function WorktreeList() {
if (worktrees.length === 0) {
return (
<div className="flex flex-col items-center gap-2 px-4 py-6 text-center text-[11px] text-muted-foreground">
<span>No worktrees found</span>
{hasFilters && (
<button
onClick={clearFilters}
className="inline-flex items-center gap-1.5 bg-secondary/70 border border-border/80 text-foreground font-medium text-[11px] px-2.5 py-1 rounded-md cursor-pointer hover:bg-accent transition-colors"
>
<CircleX className="size-3.5" />
Clear Filters
</button>
)}
<div className="flex flex-col">
<div className="flex flex-col items-center gap-2 px-4 py-6 text-center text-[11px] text-muted-foreground">
<span>No worktrees found</span>
{hasFilters && (
<button
onClick={clearFilters}
className="inline-flex items-center gap-1.5 bg-secondary/70 border border-border/80 text-foreground font-medium text-[11px] px-2.5 py-1 rounded-md cursor-pointer hover:bg-accent transition-colors"
>
<CircleX className="size-3.5" />
Clear Filters
</button>
)}
</div>
</div>
)
}
@ -690,7 +696,7 @@ const WorktreeList = React.memo(function WorktreeList() {
<VirtualizedWorktreeViewport
key={viewportResetKey}
rows={rows}
activeWorktreeId={activeWorktreeId}
activeWorktreeId={selectedSidebarWorktreeId}
setActiveWorktree={setActiveWorktree}
groupBy={groupBy}
toggleGroup={toggleGroup}

View file

@ -8,7 +8,6 @@ import SearchBar from './SearchBar'
import GroupControls from './GroupControls'
import WorktreeList from './WorktreeList'
import SidebarToolbar from './SidebarToolbar'
import AddWorktreeDialog from './AddWorktreeDialog'
import WorktreeMetaDialog from './WorktreeMetaDialog'
import DeleteWorktreeDialog from './DeleteWorktreeDialog'
import NonGitFolderDialog from './NonGitFolderDialog'
@ -50,9 +49,6 @@ function Sidebar(): React.JSX.Element {
'relative min-h-0 flex-shrink-0 bg-sidebar flex flex-col overflow-hidden scrollbar-sleek-parent',
isResizing ? 'transition-none' : 'transition-[width] duration-200'
)}
style={{
borderRight: sidebarOpen ? '1px solid var(--sidebar-border)' : 'none'
}}
>
{/* Fixed controls */}
<SidebarHeader />
@ -73,7 +69,6 @@ function Sidebar(): React.JSX.Element {
</div>
{/* Dialog (rendered outside sidebar to avoid clipping) */}
<AddWorktreeDialog />
<WorktreeMetaDialog />
<DeleteWorktreeDialog />
<NonGitFolderDialog />

View file

@ -147,7 +147,7 @@ function CodexSwitcherMenu({
activeAccountId: null
})
const [isSwitching, setIsSwitching] = useState(false)
const setActiveView = useAppStore((s) => s.setActiveView)
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const fetchSettings = useAppStore((s) => s.fetchSettings)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
@ -331,7 +331,7 @@ function CodexSwitcherMenu({
repoId: null,
sectionId: 'general-codex-accounts'
})
setActiveView('settings')
openSettingsPage()
}}
>
Manage Accounts

View file

@ -0,0 +1,29 @@
import type { PtyTransport } from './pty-transport'
export type PtyConnectionDeps = {
tabId: string
worktreeId: string
cwd?: string
startup?: { command: string; env?: Record<string, string> } | null
restoredLeafId?: string | null
restoredPtyIdByLeafId?: Record<string, string>
paneTransportsRef: React.RefObject<Map<number, PtyTransport>>
pendingWritesRef: React.RefObject<Map<number, string>>
isActiveRef: React.RefObject<boolean>
isVisibleRef: React.RefObject<boolean>
onPtyExitRef: React.RefObject<(ptyId: string) => void>
onPtyErrorRef?: React.RefObject<(paneId: number, message: string) => void>
clearTabPtyId: (tabId: string, ptyId: string) => void
consumeSuppressedPtyExit: (ptyId: string) => boolean
updateTabTitle: (tabId: string, title: string) => void
setRuntimePaneTitle: (tabId: string, paneId: number, title: string) => void
clearRuntimePaneTitle: (tabId: string, paneId: number) => void
updateTabPtyId: (tabId: string, ptyId: string) => void
markWorktreeUnread: (worktreeId: string) => void
dispatchNotification: (event: {
source: 'agent-task-complete' | 'terminal-bell'
terminalTitle?: string
}) => void
setCacheTimerStartedAt: (key: string, ts: number | null) => void
syncPanePtyLayoutBinding: (paneId: number, ptyId: string | null) => void
}

View file

@ -8,9 +8,20 @@ type StoreState = {
settings: { promptCacheTimerEnabled?: boolean } | null
}
type ConnectCallbacks = {
onData?: (data: string) => void
onError?: (msg: string) => void
}
type MockTransport = {
attach: ReturnType<typeof vi.fn>
connect: ReturnType<typeof vi.fn>
connect: ReturnType<typeof vi.fn> & {
mockImplementation: (
impl: (
opts: { callbacks?: ConnectCallbacks } & Record<string, unknown>
) => Promise<string | null>
) => unknown
}
sendInput: ReturnType<typeof vi.fn>
resize: ReturnType<typeof vi.fn>
getPtyId: ReturnType<typeof vi.fn>
@ -59,13 +70,13 @@ function createMockTransport(initialPtyId: string | null = null): MockTransport
attach: vi.fn(({ existingPtyId }: { existingPtyId: string }) => {
ptyId = existingPtyId
}),
connect: vi.fn().mockImplementation(async () => {
return ptyId
}),
connect: vi.fn(
async (_opts: { callbacks?: ConnectCallbacks } & Record<string, unknown>) => ptyId
),
sendInput: vi.fn(() => true),
resize: vi.fn(() => true),
getPtyId: vi.fn(() => ptyId)
}
} as MockTransport
}
function createPane(paneId: number) {
@ -162,6 +173,97 @@ describe('connectPanePty', () => {
}
})
it('does not send startup command via sendInput for local connections', async () => {
// Why: the local PTY provider already writes the command via
// writeStartupCommandWhenShellReady — sending it again from the renderer
// would cause the command to appear twice in the terminal.
const { connectPanePty } = await import('./pty-connection')
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
const transport = createMockTransport()
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'pty-local-1'
})
transportFactoryQueue.push(transport)
// Local connection: no connectionId
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
repos: [{ id: 'repo1', connectionId: null }]
}
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({ startup: { command: "claude 'say test'" } })
connectPanePty(pane as never, manager as never, deps as never)
expect(capturedDataCallback.current).not.toBeNull()
// Simulate PTY output (shell prompt arriving)
capturedDataCallback.current?.('(base) user@host $ ')
// Even after the debounce window, the renderer must not inject the command
// because the main process already wrote it via writeStartupCommandWhenShellReady.
expect(transport.sendInput).not.toHaveBeenCalledWith(
expect.stringContaining("claude 'say test'")
)
})
it('sends startup command via sendInput for SSH connections (relay has no shell-ready mechanism)', async () => {
// Capture the setTimeout callback directly so we can fire it without
// vi.useFakeTimers() (which would also replace the rAF mock from beforeEach).
const pendingTimeouts: (() => void)[] = []
const originalSetTimeout = globalThis.setTimeout
globalThis.setTimeout = vi.fn((fn: () => void) => {
pendingTimeouts.push(fn)
return 999 as unknown as ReturnType<typeof setTimeout>
}) as unknown as typeof setTimeout
try {
const { connectPanePty } = await import('./pty-connection')
const capturedDataCallback: { current: ((data: string) => void) | null } = {
current: null
}
const transport = createMockTransport()
transport.connect.mockImplementation(
async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'pty-ssh-1'
}
)
transportFactoryQueue.push(transport)
// SSH connection: connectionId is set, relay ignores the command field
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }]
}
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({ startup: { command: "claude 'say test'" } })
connectPanePty(pane as never, manager as never, deps as never)
expect(capturedDataCallback.current).not.toBeNull()
// Simulate shell prompt arriving — queues the debounce timer
capturedDataCallback.current?.('user@remote $ ')
// Fire all queued setTimeout callbacks (the debounce)
for (const fn of pendingTimeouts) {
fn()
}
expect(transport.sendInput).toHaveBeenCalledWith("claude 'say test'\r")
} finally {
globalThis.setTimeout = originalSetTimeout
}
})
it('reattaches a remounted split pane to its restored leaf PTY instead of the tab-level PTY', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()

View file

@ -3,40 +3,12 @@ import type { IDisposable } from '@xterm/xterm'
import { isGeminiTerminalTitle, isClaudeAgent } from '@/lib/agent-status'
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { useAppStore } from '@/store'
import type { PtyTransport } from './pty-transport'
import { createIpcPtyTransport } from './pty-transport'
import { shouldSeedCacheTimerOnInitialTitle } from './cache-timer-seeding'
import type { PtyConnectionDeps } from './pty-connection-types'
const pendingSpawnByTabId = new Map<string, Promise<string | null>>()
type PtyConnectionDeps = {
tabId: string
worktreeId: string
cwd?: string
startup?: { command: string; env?: Record<string, string> } | null
restoredLeafId?: string | null
restoredPtyIdByLeafId?: Record<string, string>
paneTransportsRef: React.RefObject<Map<number, PtyTransport>>
pendingWritesRef: React.RefObject<Map<number, string>>
isActiveRef: React.RefObject<boolean>
isVisibleRef: React.RefObject<boolean>
onPtyExitRef: React.RefObject<(ptyId: string) => void>
onPtyErrorRef?: React.RefObject<(paneId: number, message: string) => void>
clearTabPtyId: (tabId: string, ptyId: string) => void
consumeSuppressedPtyExit: (ptyId: string) => boolean
updateTabTitle: (tabId: string, title: string) => void
setRuntimePaneTitle: (tabId: string, paneId: number, title: string) => void
clearRuntimePaneTitle: (tabId: string, paneId: number) => void
updateTabPtyId: (tabId: string, ptyId: string) => void
markWorktreeUnread: (worktreeId: string) => void
dispatchNotification: (event: {
source: 'agent-task-complete' | 'terminal-bell'
terminalTitle?: string
}) => void
setCacheTimerStartedAt: (key: string, ts: number | null) => void
syncPanePtyLayoutBinding: (paneId: number, ptyId: string | null) => void
}
export function connectPanePty(
pane: ManagedPane,
manager: PaneManager,
@ -44,6 +16,7 @@ export function connectPanePty(
): IDisposable {
let disposed = false
let connectFrame: number | null = null
let startupInjectTimer: ReturnType<typeof setTimeout> | null = null
// Why: setup commands must only run once — in the initial pane of the tab.
// Capture and clear the startup reference synchronously so that panes
// created later by splits or layout restoration cannot re-execute the
@ -232,6 +205,14 @@ export function connectPanePty(
// terminal state is preserved. This matches the MAX_BUFFER_BYTES
// constant used for serialized scrollback capture.
const MAX_PENDING_BYTES = 512 * 1024
// Why: for local connections (connectionId === null) the local PTY provider
// already writes the startup command via writeStartupCommandWhenShellReady,
// which is shell-ready-aware and reliable. Re-sending it here would cause
// the command to appear twice in the terminal. For SSH connections the relay
// has no equivalent mechanism, so the renderer must inject it via sendInput.
let pendingStartupCommand = connectionId ? (paneStartup?.command ?? null) : null
const startFreshSpawn = (): void => {
const spawnPromise = Promise.resolve(
transport.connect({
@ -278,6 +259,20 @@ export function connectPanePty(
}
pending.set(pane.id, buf)
}
if (pendingStartupCommand) {
if (startupInjectTimer !== null) {
clearTimeout(startupInjectTimer)
}
startupInjectTimer = setTimeout(() => {
startupInjectTimer = null
if (!pendingStartupCommand || disposed) {
return
}
transport.sendInput(`${pendingStartupCommand}\r`)
pendingStartupCommand = null
}, 50)
}
}
// Why: re-read ptyId inside the rAF instead of capturing it before.
@ -382,6 +377,10 @@ export function connectPanePty(
return {
dispose() {
disposed = true
if (startupInjectTimer !== null) {
clearTimeout(startupInjectTimer)
startupInjectTimer = null
}
if (connectFrame !== null) {
// Why: StrictMode and split-group remounts can dispose a pane binding
// before its deferred PTY attach/spawn work runs. Cancel that queued

View file

@ -130,7 +130,7 @@ export function TerminalShell({
>
{mountedWorktrees.map((worktree) => {
const worktreeTabs = tabsByWorktree[worktree.id] ?? []
const isVisible = activeView !== 'settings' && worktree.id === activeWorktreeId
const isVisible = activeView === 'terminal' && worktree.id === activeWorktreeId
return (
<div

View file

@ -0,0 +1,159 @@
'use client'
import { useEffect, useState, type CSSProperties } from 'react'
import { cn } from '@/lib/utils'
type LightRaysProps = {
ref?: React.Ref<HTMLDivElement>
count?: number
color?: string
blur?: number
speed?: number
length?: string
} & React.HTMLAttributes<HTMLDivElement>
type LightRay = {
id: string
left: number
rotate: number
width: number
swing: number
delay: number
duration: number
intensity: number
}
const createRays = (count: number, cycle: number): LightRay[] => {
if (count <= 0) {
return []
}
return Array.from({ length: count }, (_, index) => {
const left = 8 + Math.random() * 84
const rotate = -28 + Math.random() * 56
const width = 160 + Math.random() * 160
const swing = 0.8 + Math.random() * 1.8
const delay = Math.random() * cycle
const duration = cycle * (0.75 + Math.random() * 0.5)
const intensity = 0.6 + Math.random() * 0.5
return {
id: `${index}-${Math.round(left * 10)}`,
left,
rotate,
width,
swing,
delay,
duration,
intensity
}
})
}
/**
* Why: CSS-only implementation of the magic-ui LightRays component to avoid
* adding a framer-motion dependency. Uses CSS @keyframes for the fade+swing
* animation cycle that the original drives with motion.animate.
*/
function Ray({
left,
rotate,
width,
swing,
delay,
duration,
intensity
}: LightRay): React.JSX.Element {
const animName = `ray-fade-swing`
return (
<div
className="pointer-events-none absolute -top-[12%] h-[var(--light-rays-length)] origin-top -translate-x-1/2 rounded-full bg-linear-to-b from-[color-mix(in_srgb,var(--light-rays-color)_70%,transparent)] to-transparent mix-blend-screen blur-[var(--light-rays-blur)]"
style={
{
left: `${left}%`,
width: `${width}px`,
'--ray-intensity': intensity,
'--ray-swing': `${swing}deg`,
'--ray-rotate': `${rotate}deg`,
animation: `${animName} ${duration}s ease-in-out ${delay}s infinite`,
transform: `translateX(-50%) rotate(${rotate}deg)`
} as CSSProperties
}
/>
)
}
export function LightRays({
className,
style,
count = 7,
color = 'rgba(160, 210, 255, 0.2)',
blur = 36,
speed = 14,
length = '70vh',
ref,
...props
}: LightRaysProps): React.JSX.Element {
const [rays, setRays] = useState<LightRay[]>([])
const cycleDuration = Math.max(speed, 0.1)
useEffect(() => {
setRays(createRays(count, cycleDuration))
}, [count, cycleDuration])
return (
<div
ref={ref}
className={cn(
'pointer-events-none absolute inset-0 isolate overflow-hidden rounded-[inherit]',
className
)}
style={
{
'--light-rays-color': color,
'--light-rays-blur': `${blur}px`,
'--light-rays-length': length,
...style
} as CSSProperties
}
{...props}
>
{/* Why: the @keyframes block lives here as a <style> tag so the component
is self-contained and doesn't require a global CSS import. The animation
fades each ray from transparent peak intensity transparent while
applying a subtle rotation swing. */}
<style>{`
@keyframes ray-fade-swing {
0%, 100% { opacity: 0; transform: translateX(-50%) rotate(calc(var(--ray-rotate, 0deg) - var(--ray-swing, 1deg))); }
50% { opacity: var(--ray-intensity, 0.7); transform: translateX(-50%) rotate(calc(var(--ray-rotate, 0deg) + var(--ray-swing, 1deg))); }
}
`}</style>
<div className="absolute inset-0 overflow-hidden">
<div
aria-hidden
className="absolute inset-0 opacity-60"
style={
{
background:
'radial-gradient(circle at 20% 15%, color-mix(in srgb, var(--light-rays-color) 45%, transparent), transparent 70%)'
} as CSSProperties
}
/>
<div
aria-hidden
className="absolute inset-0 opacity-60"
style={
{
background:
'radial-gradient(circle at 80% 10%, color-mix(in srgb, var(--light-rays-color) 35%, transparent), transparent 75%)'
} as CSSProperties
}
/>
{rays.map((ray) => (
<Ray key={ray.id} {...ray} />
))}
</div>
</div>
)
}

View file

@ -3,7 +3,7 @@
* based on current view, tab type, and focused element.
*/
export function resolveZoomTarget(args: {
activeView: 'terminal' | 'settings'
activeView: 'terminal' | 'settings' | 'new-workspace'
activeTabType: 'terminal' | 'editor' | 'browser'
activeElement: unknown
}): 'terminal' | 'editor' | 'ui' {

View file

@ -38,7 +38,7 @@ export function useIpcEvents(): void {
unsubs.push(
window.api.ui.onOpenSettings(() => {
useAppStore.getState().setActiveView('settings')
useAppStore.getState().openSettingsPage()
})
)
@ -68,7 +68,7 @@ export function useIpcEvents(): void {
unsubs.push(
window.api.ui.onOpenQuickOpen(() => {
const store = useAppStore.getState()
if (store.activeView !== 'settings' && store.activeWorktreeId !== null) {
if (store.activeView === 'terminal' && store.activeWorktreeId !== null) {
store.openModal('quick-open')
}
})
@ -77,7 +77,7 @@ export function useIpcEvents(): void {
unsubs.push(
window.api.ui.onJumpToWorktreeIndex((index) => {
const store = useAppStore.getState()
if (store.activeView === 'settings') {
if (store.activeView !== 'terminal') {
return
}
const visibleIds = getVisibleWorktreeIds()

View file

@ -0,0 +1,283 @@
import React from 'react'
import { ClaudeIcon, OpenAIIcon } from '@/components/status-bar/icons'
import type { TuiAgent } from '../../../shared/types'
export type AgentCatalogEntry = {
id: TuiAgent
label: string
/** Default CLI binary name used for PATH detection. */
cmd: string
/** Domain for Google's favicon service — used for agents without an SVG icon. */
faviconDomain?: string
/** Homepage/install docs URL, sourced from the README agent badge list. */
homepageUrl: string
}
// Full catalog of supported agents — ordered by priority for auto-default selection.
// homepageUrl matches the href used in the README agent badge list.
export const AGENT_CATALOG: AgentCatalogEntry[] = [
{
id: 'claude',
label: 'Claude',
cmd: 'claude',
homepageUrl: 'https://docs.anthropic.com/claude/docs/claude-code'
},
{
id: 'codex',
label: 'Codex',
cmd: 'codex',
homepageUrl: 'https://github.com/openai/codex'
},
{
id: 'opencode',
label: 'OpenCode',
cmd: 'opencode',
faviconDomain: 'opencode.ai',
homepageUrl: 'https://opencode.ai/docs/cli/'
},
{
id: 'pi',
label: 'Pi',
cmd: 'pi',
homepageUrl: 'https://pi.dev'
},
{
id: 'gemini',
label: 'Gemini',
cmd: 'gemini',
faviconDomain: 'gemini.google.com',
homepageUrl: 'https://github.com/google-gemini/gemini-cli'
},
{
id: 'aider',
label: 'Aider',
cmd: 'aider',
homepageUrl: 'https://aider.chat/docs/'
},
{
id: 'goose',
label: 'Goose',
cmd: 'goose',
faviconDomain: 'goose-docs.ai',
homepageUrl: 'https://block.github.io/goose/docs/quickstart/'
},
{
id: 'amp',
label: 'Amp',
cmd: 'amp',
faviconDomain: 'ampcode.com',
homepageUrl: 'https://ampcode.com/manual#install'
},
{
id: 'kilo',
label: 'Kilocode',
cmd: 'kilo',
faviconDomain: 'kilo.ai',
homepageUrl: 'https://kilo.ai/docs/cli'
},
{
id: 'kiro',
label: 'Kiro',
cmd: 'kiro',
faviconDomain: 'kiro.dev',
homepageUrl: 'https://kiro.dev/docs/cli/'
},
{
id: 'crush',
label: 'Charm',
cmd: 'crush',
faviconDomain: 'charm.sh',
homepageUrl: 'https://github.com/charmbracelet/crush'
},
{
id: 'aug',
label: 'Auggie',
cmd: 'aug',
faviconDomain: 'augmentcode.com',
homepageUrl: 'https://docs.augmentcode.com/cli/overview'
},
{
id: 'cline',
label: 'Cline',
cmd: 'cline',
faviconDomain: 'cline.bot',
homepageUrl: 'https://docs.cline.bot/cline-cli/overview'
},
{
id: 'codebuff',
label: 'Codebuff',
cmd: 'codebuff',
faviconDomain: 'codebuff.com',
homepageUrl: 'https://www.codebuff.com/docs/help/quick-start'
},
{
id: 'continue',
label: 'Continue',
cmd: 'continue',
faviconDomain: 'continue.dev',
homepageUrl: 'https://docs.continue.dev/guides/cli'
},
{
id: 'cursor',
label: 'Cursor',
cmd: 'cursor-agent',
faviconDomain: 'cursor.com',
homepageUrl: 'https://cursor.com/cli'
},
{
id: 'droid',
label: 'Droid',
cmd: 'droid',
faviconDomain: 'factory.ai',
homepageUrl: 'https://docs.factory.ai/cli/getting-started/quickstart'
},
{
id: 'kimi',
label: 'Kimi',
cmd: 'kimi',
faviconDomain: 'moonshot.cn',
homepageUrl: 'https://www.kimi.com/code/docs/en/kimi-cli/guides/getting-started.html'
},
{
id: 'mistral-vibe',
label: 'Mistral Vibe',
cmd: 'mistral-vibe',
faviconDomain: 'mistral.ai',
homepageUrl: 'https://github.com/mistralai/mistral-vibe'
},
{
id: 'qwen-code',
label: 'Qwen Code',
cmd: 'qwen-code',
faviconDomain: 'qwenlm.github.io',
homepageUrl: 'https://github.com/QwenLM/qwen-code'
},
{
id: 'rovo',
label: 'Rovo Dev',
cmd: 'rovo',
faviconDomain: 'atlassian.com',
homepageUrl:
'https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/'
},
{
id: 'hermes',
label: 'Hermes',
cmd: 'hermes',
faviconDomain: 'nousresearch.com',
homepageUrl: 'https://hermes-agent.nousresearch.com/docs/'
}
]
function PiIcon({ size = 14 }: { size?: number }): React.JSX.Element {
// SVG sourced from pi.dev/favicon.svg — the π shape rendered in currentColor.
// Why: className="text-current" opts out of shadcn's Select rule that forces
// text-muted-foreground on any <svg> that lacks a text-* class.
return (
<svg
height={size}
width={size}
viewBox="0 0 800 800"
xmlns="http://www.w3.org/2000/svg"
className="text-current"
>
<path
fill="currentColor"
fillRule="evenodd"
d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"
/>
<path fill="currentColor" d="M517.36 400 H634.72 V634.72 H517.36 Z" />
</svg>
)
}
function AiderIcon({ size = 14 }: { size?: number }): React.JSX.Element {
// SVG sourced from aider.chat/assets/icons/safari-pinned-tab.svg.
// Why: className="text-current" opts out of shadcn's Select rule that forces
// text-muted-foreground on any <svg> that lacks a text-* class.
return (
<svg
width={size}
height={size}
viewBox="0 0 436 436"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
className="text-current"
>
<g transform="translate(0,436) scale(0.1,-0.1)" fill="currentColor" stroke="none">
<path d="M0 2180 l0 -2180 2180 0 2180 0 0 2180 0 2180 -2180 0 -2180 0 0 -2180z m2705 1818 c20 -20 28 -121 30 -398 l2 -305 216 -5 c118 -3 218 -8 222 -12 3 -3 10 -46 15 -95 5 -48 16 -126 25 -172 17 -86 17 -81 -17 -233 -14 -67 -13 -365 2 -438 21 -100 22 -159 5 -247 -24 -122 -24 -363 1 -458 23 -88 23 -213 1 -330 -9 -49 -17 -109 -17 -132 l0 -43 203 0 c111 0 208 -4 216 -9 10 -6 18 -51 27 -148 8 -76 16 -152 20 -168 7 -39 -23 -361 -37 -387 -10 -18 -21 -19 -214 -16 -135 2 -208 7 -215 14 -22 22 -33 301 -21 501 6 102 8 189 5 194 -8 13 -417 12 -431 -2 -12 -12 -8 -146 8 -261 8 -55 8 -95 1 -140 -6 -35 -14 -99 -17 -143 -9 -123 -14 -141 -41 -154 -18 -8 -217 -11 -679 -11 l-653 0 -11 33 c-31 97 -43 336 -27 533 5 56 6 113 2 128 l-6 26 -194 0 c-211 0 -252 4 -261 28 -12 33 -17 392 -6 522 15 186 -2 174 260 180 115 3 213 8 217 12 4 4 1 52 -5 105 -7 54 -17 130 -22 168 -7 56 -5 91 11 171 10 55 22 130 26 166 4 36 10 72 15 79 7 12 128 15 665 19 l658 5 8 30 c5 18 4 72 -3 130 -12 115 -7 346 11 454 10 61 10 75 -1 82 -8 5 -300 9 -650 9 l-636 0 -27 25 c-18 16 -26 34 -26 57 0 18 -5 87 -10 153 -10 128 5 449 22 472 5 7 26 13 46 15 78 6 1281 3 1287 -4z" />
<path d="M1360 1833 c0 -5 -1 -164 -3 -356 l-2 -347 625 -1 c704 -1 708 -1 722 7 5 4 7 20 4 38 -29 141 -32 491 -6 595 9 38 8 45 -7 57 -15 11 -139 13 -675 14 -362 0 -658 -3 -658 -7z" />
</g>
</svg>
)
}
function AgentLetterIcon({
letter,
size = 14
}: {
letter: string
size?: number
}): React.JSX.Element {
return (
<svg
width={size}
height={size}
viewBox="0 0 14 14"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
className="text-current"
>
<rect width="14" height="14" rx="3" fill="currentColor" fillOpacity="0.2" />
<text
x="7"
y="10.5"
textAnchor="middle"
fontSize="8.5"
fill="currentColor"
fontWeight="700"
fontFamily="system-ui, -apple-system, sans-serif"
>
{letter}
</text>
</svg>
)
}
export function AgentIcon({
agent,
size = 14
}: {
agent: TuiAgent
size?: number
}): React.JSX.Element {
if (agent === 'claude') {
return <ClaudeIcon size={size} />
}
if (agent === 'codex') {
return <OpenAIIcon size={size} />
}
if (agent === 'pi') {
return <PiIcon size={size} />
}
if (agent === 'aider') {
return <AiderIcon size={size} />
}
const catalogEntry = AGENT_CATALOG.find((a) => a.id === agent)
if (catalogEntry?.faviconDomain) {
// Why: agents without a published SVG icon use their site favicon via
// Google's favicon service — same source the README uses for the agent badge list.
return (
<img
src={`https://www.google.com/s2/favicons?domain=${catalogEntry.faviconDomain}&sz=64`}
width={size}
height={size}
alt=""
aria-hidden
style={{ borderRadius: 2 }}
/>
)
}
const label = catalogEntry?.label ?? agent
return <AgentLetterIcon letter={label.charAt(0).toUpperCase()} size={size} />
}

View file

@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest'
import { buildAgentStartupPlan, isShellProcess } from './tui-agent-startup'
describe('buildAgentStartupPlan', () => {
it('passes Claude prompts as a positional interactive argument', () => {
expect(
buildAgentStartupPlan({
agent: 'claude',
prompt: 'Fix the bug',
cmdOverrides: {},
platform: 'darwin'
})
).toEqual({
launchCommand: "claude 'Fix the bug'",
expectedProcess: 'claude',
followupPrompt: null
})
})
it('uses Gemini interactive prompt mode instead of dropping the prompt', () => {
expect(
buildAgentStartupPlan({
agent: 'gemini',
prompt: 'Investigate this regression',
cmdOverrides: {},
platform: 'linux'
})
).toEqual({
launchCommand: "gemini --prompt-interactive 'Investigate this regression'",
expectedProcess: 'gemini',
followupPrompt: null
})
})
it('launches aider first and injects the draft prompt after startup', () => {
expect(
buildAgentStartupPlan({
agent: 'aider',
prompt: 'Refactor the parser',
cmdOverrides: {},
platform: 'linux'
})
).toEqual({
launchCommand: 'aider',
expectedProcess: 'aider',
followupPrompt: 'Refactor the parser'
})
})
it('uses cursor-agent as the actual launch binary', () => {
expect(
buildAgentStartupPlan({
agent: 'cursor',
prompt: 'Review this file',
cmdOverrides: {},
platform: 'darwin'
})
).toEqual({
launchCommand: "cursor-agent 'Review this file'",
expectedProcess: 'cursor-agent',
followupPrompt: null
})
})
it('applies command overrides without changing the prompt syntax contract', () => {
expect(
buildAgentStartupPlan({
agent: 'droid',
prompt: 'Ship the fix',
cmdOverrides: { droid: '/opt/factory/bin/droid' },
platform: 'linux'
})
).toEqual({
launchCommand: "/opt/factory/bin/droid 'Ship the fix'",
expectedProcess: 'droid',
followupPrompt: null
})
})
it('returns null when there is no prompt to inject', () => {
expect(
buildAgentStartupPlan({
agent: 'codex',
prompt: ' ',
cmdOverrides: {},
platform: 'darwin'
})
).toBeNull()
})
})
describe('isShellProcess', () => {
it('treats common shells as non-agent foreground processes', () => {
expect(isShellProcess('bash')).toBe(true)
expect(isShellProcess('pwsh.exe')).toBe(true)
expect(isShellProcess('')).toBe(true)
})
it('does not confuse agent processes with the host shell', () => {
expect(isShellProcess('gemini')).toBe(false)
expect(isShellProcess('cursor-agent')).toBe(false)
})
})

View file

@ -0,0 +1,82 @@
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import type { TuiAgent } from '../../../shared/types'
export type AgentStartupPlan = {
launchCommand: string
expectedProcess: string
followupPrompt: string | null
}
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
}): AgentStartupPlan | null {
const { agent, prompt, cmdOverrides, platform } = args
const trimmedPrompt = prompt.trim()
if (!trimmedPrompt) {
return null
}
const config = TUI_AGENT_CONFIG[agent]
const baseCommand = cmdOverrides[agent] ?? config.launchCmd
const quotedPrompt = quoteStartupArg(trimmedPrompt, platform)
if (config.promptInjectionMode === 'argv') {
return {
launchCommand: `${baseCommand} ${quotedPrompt}`,
expectedProcess: config.expectedProcess,
followupPrompt: null
}
}
if (config.promptInjectionMode === 'flag-prompt') {
return {
launchCommand: `${baseCommand} --prompt ${quotedPrompt}`,
expectedProcess: config.expectedProcess,
followupPrompt: null
}
}
if (config.promptInjectionMode === 'flag-prompt-interactive') {
return {
launchCommand: `${baseCommand} --prompt-interactive ${quotedPrompt}`,
expectedProcess: config.expectedProcess,
followupPrompt: null
}
}
return {
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 function isShellProcess(processName: string): boolean {
const normalized = processName.trim().toLowerCase()
return (
normalized === '' ||
normalized === 'bash' ||
normalized === 'zsh' ||
normalized === 'sh' ||
normalized === 'fish' ||
normalized === 'cmd.exe' ||
normalized === 'powershell.exe' ||
normalized === 'pwsh.exe' ||
normalized === 'nu'
)
}

View file

@ -7,6 +7,7 @@ function createMockStore(overrides: Record<string, unknown> = {}) {
createTab: vi.fn(() => ({ id: 'tab-1' })),
setActiveTab: vi.fn(),
reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 0 })),
queueTabStartupCommand: vi.fn(),
queueTabSetupSplit: vi.fn(),
queueTabIssueCommandSplit: vi.fn(),
...overrides
@ -17,7 +18,7 @@ describe('ensureWorktreeHasInitialTerminal', () => {
it('creates a tab and queues a setup split for newly created worktrees', () => {
const store = createMockStore()
ensureWorktreeHasInitialTerminal(store, 'wt-1', {
ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, {
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
envVars: {
ORCA_ROOT_PATH: '/tmp/repo',
@ -27,6 +28,7 @@ describe('ensureWorktreeHasInitialTerminal', () => {
expect(store.createTab).toHaveBeenCalledWith('wt-1')
expect(store.setActiveTab).toHaveBeenCalledWith('tab-1')
expect(store.queueTabStartupCommand).not.toHaveBeenCalled()
expect(store.queueTabSetupSplit).toHaveBeenCalledWith('tab-1', {
command: 'bash /tmp/repo/.git/orca/setup-runner.sh',
env: {
@ -43,6 +45,7 @@ describe('ensureWorktreeHasInitialTerminal', () => {
expect(store.createTab).toHaveBeenCalledWith('wt-1')
expect(store.setActiveTab).toHaveBeenCalledWith('tab-1')
expect(store.queueTabStartupCommand).not.toHaveBeenCalled()
expect(store.queueTabSetupSplit).not.toHaveBeenCalled()
})
@ -51,13 +54,34 @@ describe('ensureWorktreeHasInitialTerminal', () => {
reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 1 }))
})
ensureWorktreeHasInitialTerminal(store, 'wt-1', {
ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, {
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
envVars: {}
})
expect(store.createTab).not.toHaveBeenCalled()
expect(store.setActiveTab).not.toHaveBeenCalled()
expect(store.queueTabStartupCommand).not.toHaveBeenCalled()
expect(store.queueTabSetupSplit).not.toHaveBeenCalled()
expect(store.queueTabIssueCommandSplit).not.toHaveBeenCalled()
})
it('queues a startup command when agent launch is provided', () => {
const store = createMockStore()
ensureWorktreeHasInitialTerminal(
store,
'wt-1',
{ command: 'claude "Fix this bug"' },
undefined,
undefined
)
expect(store.createTab).toHaveBeenCalledWith('wt-1')
expect(store.setActiveTab).toHaveBeenCalledWith('tab-1')
expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-1', {
command: 'claude "Fix this bug"'
})
expect(store.queueTabSetupSplit).not.toHaveBeenCalled()
expect(store.queueTabIssueCommandSplit).not.toHaveBeenCalled()
})
@ -77,7 +101,7 @@ describe('ensureWorktreeHasInitialTerminal', () => {
it('queues an issue command split when issueCommand is provided', () => {
const store = createMockStore()
ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, {
ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, undefined, {
runnerScriptPath: '/tmp/repo/.git/orca/issue-command-runner.sh',
envVars: {
ORCA_ROOT_PATH: '/tmp/repo',
@ -103,6 +127,7 @@ describe('ensureWorktreeHasInitialTerminal', () => {
ensureWorktreeHasInitialTerminal(
store,
'wt-1',
undefined,
{
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
envVars: { ORCA_ROOT_PATH: '/tmp/repo' }
@ -113,6 +138,7 @@ describe('ensureWorktreeHasInitialTerminal', () => {
}
)
expect(store.queueTabStartupCommand).not.toHaveBeenCalled()
expect(store.queueTabSetupSplit).toHaveBeenCalledWith('tab-1', {
command: 'bash /tmp/repo/.git/orca/setup-runner.sh',
env: { ORCA_ROOT_PATH: '/tmp/repo' }
@ -128,6 +154,7 @@ describe('ensureWorktreeHasInitialTerminal', () => {
ensureWorktreeHasInitialTerminal(store, 'wt-1')
expect(store.queueTabStartupCommand).not.toHaveBeenCalled()
expect(store.queueTabIssueCommandSplit).not.toHaveBeenCalled()
})
})

View file

@ -4,11 +4,24 @@ import { buildSetupRunnerCommand } from './setup-runner'
import { useAppStore } from '@/store'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
// Why: issue commands can originate from two sources with different shapes —
// (1) a repo-level runner script generated by main (WorktreeSetupLaunch), or
// (2) a user-typed command template substituted in the NewWorkspacePage flow.
// We accept either so callers don't need to synthesize a runner script file
// just to queue a direct command string.
export type IssueCommandLaunch =
| WorktreeSetupLaunch
| { command: string; env?: Record<string, string> }
type WorktreeActivationStore = {
tabsByWorktree: Record<string, { id: string }[]>
createTab: (worktreeId: string) => { id: string }
setActiveTab: (tabId: string) => void
reconcileWorktreeTabModel: (worktreeId: string) => { renderableTabCount: number }
queueTabStartupCommand: (
tabId: string,
startup: { command: string; env?: Record<string, string> }
) => void
queueTabSetupSplit: (
tabId: string,
startup: { command: string; env?: Record<string, string> }
@ -22,7 +35,7 @@ type WorktreeActivationStore = {
/**
* Shared activation sequence used by the worktree palette, AddRepoDialog,
* and AddWorktreeDialog. Covers: cross-repo `activeRepoId` switch,
* `activeView` from settings, `setActiveWorktree`, initial terminal
* `activeView` back to terminal, `setActiveWorktree`, initial terminal
* creation, sidebar filter clearing, and sidebar reveal.
*
* The caller only passes `worktreeId`; the helper derives `repoId`
@ -32,8 +45,9 @@ type WorktreeActivationStore = {
export function activateAndRevealWorktree(
worktreeId: string,
opts?: {
startup?: { command: string; env?: Record<string, string> }
setup?: WorktreeSetupLaunch
issueCommand?: WorktreeSetupLaunch
issueCommand?: IssueCommandLaunch
}
): boolean {
const state = useAppStore.getState()
@ -47,8 +61,8 @@ export function activateAndRevealWorktree(
state.setActiveRepo(wt.repoId)
}
// 2. Switch activeView from settings to terminal
if (state.activeView === 'settings') {
// 2. Switch any non-terminal view back to terminal
if (state.activeView !== 'terminal') {
state.setActiveView('terminal')
}
@ -60,6 +74,7 @@ export function activateAndRevealWorktree(
ensureWorktreeHasInitialTerminal(
useAppStore.getState(),
worktreeId,
opts?.startup,
opts?.setup,
opts?.issueCommand
)
@ -84,8 +99,9 @@ export function activateAndRevealWorktree(
export function ensureWorktreeHasInitialTerminal(
store: WorktreeActivationStore,
worktreeId: string,
startup?: { command: string; env?: Record<string, string> },
setup?: WorktreeSetupLaunch,
issueCommand?: WorktreeSetupLaunch
issueCommand?: IssueCommandLaunch
): void {
const { renderableTabCount } = store.reconcileWorktreeTabModel(worktreeId)
// Why: activation can now restore editor- or browser-only worktrees from the
@ -98,6 +114,14 @@ export function ensureWorktreeHasInitialTerminal(
const terminalTab = store.createTab(worktreeId)
store.setActiveTab(terminalTab.id)
// Why: the new-workspace flow can seed the first terminal with a selected
// coding agent and user prompt. Queue that startup command on the initial
// pane so the main terminal begins in the requested agent session instead of
// opening to an idle shell and forcing the user to repeat the same prompt.
if (startup) {
store.queueTabStartupCommand(terminalTab.id, startup)
}
// Why: run the setup script in a split pane to the right so the main
// terminal stays immediately interactive. The TerminalPane reads this
// signal on mount, creates the initial pane clean, then splits right
@ -115,9 +139,16 @@ export function ensureWorktreeHasInitialTerminal(
// parallel; repo bootstrap and personal issue workflows are separate
// concerns, so Orca should not invent a dependency between them.
if (issueCommand) {
store.queueTabIssueCommandSplit(terminalTab.id, {
command: buildSetupRunnerCommand(issueCommand.runnerScriptPath),
env: issueCommand.envVars
})
// Why: WorktreeSetupLaunch carries a runner-script file (from main) and we
// shell out to bash; the NewWorkspacePage variant is already an expanded
// command string, so pass it through directly.
const queuedIssueCommand =
'runnerScriptPath' in issueCommand
? {
command: buildSetupRunnerCommand(issueCommand.runnerScriptPath),
env: issueCommand.envVars
}
: { command: issueCommand.command, env: issueCommand.env }
store.queueTabIssueCommandSplit(terminalTab.id, queuedIssueCommand)
}
}

View file

@ -79,3 +79,33 @@ describe('createUISlice hydratePersistedUI', () => {
expect(store.getState().showActiveOnly).toBe(true)
})
})
describe('createUISlice settings navigation', () => {
it('returns to the new workspace page after visiting settings from an in-progress draft', () => {
const store = createUIStore()
store.getState().openNewWorkspacePage({ preselectedRepoId: 'repo-1' })
store.getState().openSettingsPage()
expect(store.getState().activeView).toBe('settings')
expect(store.getState().previousViewBeforeSettings).toBe('new-workspace')
store.getState().closeSettingsPage()
expect(store.getState().activeView).toBe('new-workspace')
})
it('keeps the original return target when settings is reopened while already visible', () => {
const store = createUIStore()
store.getState().openNewWorkspacePage()
store.getState().openSettingsPage()
store.getState().openSettingsPage()
expect(store.getState().previousViewBeforeSettings).toBe('new-workspace')
store.getState().closeSettingsPage()
expect(store.getState().activeView).toBe('new-workspace')
})
})

View file

@ -4,6 +4,7 @@ import type {
ChangelogData,
PersistedUIState,
StatusBarItem,
TuiAgent,
UpdateStatus,
WorktreeCardProperty
} from '../../../../shared/types'
@ -28,10 +29,38 @@ export type UISlice = {
toggleSidebar: () => void
setSidebarOpen: (open: boolean) => void
setSidebarWidth: (width: number) => void
activeView: 'terminal' | 'settings'
activeView: 'terminal' | 'settings' | 'new-workspace'
previousViewBeforeNewWorkspace: 'terminal' | 'settings'
previousViewBeforeSettings: 'terminal' | 'new-workspace'
setActiveView: (view: UISlice['activeView']) => void
newWorkspacePageData: {
preselectedRepoId?: string
prefilledName?: string
}
newWorkspaceDraft: {
repoId: string | null
name: string
prompt: string
note: string
attachments: string[]
linkedWorkItem: {
type: 'issue' | 'pr'
number: number
title: string
url: string
} | null
agent: TuiAgent
linkedIssue: string
linkedPR: number | null
} | null
openNewWorkspacePage: (data?: UISlice['newWorkspacePageData']) => void
closeNewWorkspacePage: () => void
setNewWorkspaceDraft: (draft: NonNullable<UISlice['newWorkspaceDraft']>) => void
clearNewWorkspaceDraft: () => void
openSettingsPage: () => void
closeSettingsPage: () => void
settingsNavigationTarget: {
pane: 'general' | 'appearance' | 'terminal' | 'shortcuts' | 'repo'
pane: 'general' | 'appearance' | 'terminal' | 'shortcuts' | 'repo' | 'agents'
repoId: string | null
sectionId?: string
} | null
@ -101,7 +130,41 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set) => (
setSidebarWidth: (width) => set({ sidebarWidth: width }),
activeView: 'terminal',
previousViewBeforeNewWorkspace: 'terminal',
previousViewBeforeSettings: 'terminal',
setActiveView: (view) => set({ activeView: view }),
newWorkspacePageData: {},
newWorkspaceDraft: null,
openNewWorkspacePage: (data = {}) =>
set((state) => ({
activeView: 'new-workspace',
previousViewBeforeNewWorkspace:
state.activeView === 'new-workspace'
? state.previousViewBeforeNewWorkspace
: state.activeView,
newWorkspacePageData: data
})),
closeNewWorkspacePage: () =>
set((state) => ({
activeView: state.previousViewBeforeNewWorkspace,
newWorkspacePageData: {}
})),
setNewWorkspaceDraft: (draft) => set({ newWorkspaceDraft: draft }),
clearNewWorkspaceDraft: () => set({ newWorkspaceDraft: null }),
openSettingsPage: () =>
set((state) => ({
activeView: 'settings',
// Why: Settings is a temporary detour from either terminal or the
// full-page new-workspace composer. Preserve the originating view so the
// Settings back action restores an in-progress workspace draft instead of
// always dumping the user into terminal.
previousViewBeforeSettings:
state.activeView === 'settings' ? state.previousViewBeforeSettings : state.activeView
})),
closeSettingsPage: () =>
set((state) => ({
activeView: state.previousViewBeforeSettings
})),
settingsNavigationTarget: null,
openSettingsTarget: (target) => set({ settingsNavigationTarget: target }),
clearSettingsTarget: () => set({ settingsNavigationTarget: null }),

View file

@ -85,16 +85,42 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
},
createWorktree: async (repoId, name, baseBranch, setupDecision = 'inherit') => {
const retryableConflictPatterns = [
/already exists locally/i,
/already exists on a remote/i,
/already has pr #\d+/i
]
const nextCandidateName = (current: string, attempt: number): string =>
attempt === 0 ? current : `${current}-${attempt + 1}`
try {
const result = await window.api.worktrees.create({ repoId, name, baseBranch, setupDecision })
set((s) => ({
worktreesByRepo: {
...s.worktreesByRepo,
[repoId]: [...(s.worktreesByRepo[repoId] ?? []), result.worktree]
},
sortEpoch: s.sortEpoch + 1
}))
return result
for (let attempt = 0; attempt < 25; attempt += 1) {
const candidateName = nextCandidateName(name, attempt)
try {
const result = await window.api.worktrees.create({
repoId,
name: candidateName,
baseBranch,
setupDecision
})
set((s) => ({
worktreesByRepo: {
...s.worktreesByRepo,
[repoId]: [...(s.worktreesByRepo[repoId] ?? []), result.worktree]
},
sortEpoch: s.sortEpoch + 1
}))
return result
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
const shouldRetry = retryableConflictPatterns.some((pattern) => pattern.test(message))
if (!shouldRetry || attempt === 24) {
throw error
}
}
}
throw new Error('Failed to create worktree after retrying branch conflicts.')
} catch (err) {
console.error('Failed to create worktree:', err)
throw err

View file

@ -117,7 +117,10 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
promptCacheTtlMs: 300_000,
codexManagedAccounts: [],
activeCodexManagedAccountId: null,
terminalScopeHistoryByWorktree: true
terminalScopeHistoryByWorktree: true,
defaultTuiAgent: null,
defaultTaskViewPreset: 'all',
agentCmdOverrides: {}
}
}

View file

@ -0,0 +1,155 @@
import type { TuiAgent } from './types'
export type AgentPromptInjectionMode =
| 'argv'
| 'flag-prompt'
| 'flag-prompt-interactive'
| 'stdin-after-start'
export type TuiAgentConfig = {
detectCmd: string
launchCmd: string
expectedProcess: string
promptInjectionMode: AgentPromptInjectionMode
}
// Why: the new-workspace handoff depends on three pieces of per-agent
// knowledge staying in sync: how Orca detects the agent on PATH, which binary
// it actually launches, and whether the initial prompt should be passed as an
// argv flag/argument or typed into the interactive session after startup.
// Centralizing that metadata prevents the picker, launcher, and preflight
// checks from quietly drifting apart as new agents are added.
export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
claude: {
detectCmd: 'claude',
launchCmd: 'claude',
expectedProcess: 'claude',
promptInjectionMode: 'argv'
},
codex: {
detectCmd: 'codex',
launchCmd: 'codex',
expectedProcess: 'codex',
promptInjectionMode: 'argv'
},
opencode: {
detectCmd: 'opencode',
launchCmd: 'opencode',
expectedProcess: 'opencode',
promptInjectionMode: 'flag-prompt'
},
pi: {
detectCmd: 'pi',
launchCmd: 'pi',
expectedProcess: 'pi',
promptInjectionMode: 'argv'
},
gemini: {
detectCmd: 'gemini',
launchCmd: 'gemini',
expectedProcess: 'gemini',
promptInjectionMode: 'flag-prompt-interactive'
},
aider: {
detectCmd: 'aider',
launchCmd: 'aider',
expectedProcess: 'aider',
promptInjectionMode: 'stdin-after-start'
},
goose: {
detectCmd: 'goose',
launchCmd: 'goose',
expectedProcess: 'goose',
promptInjectionMode: 'stdin-after-start'
},
amp: {
detectCmd: 'amp',
launchCmd: 'amp',
expectedProcess: 'amp',
promptInjectionMode: 'stdin-after-start'
},
kilo: {
detectCmd: 'kilo',
launchCmd: 'kilo',
expectedProcess: 'kilo',
promptInjectionMode: 'stdin-after-start'
},
kiro: {
detectCmd: 'kiro',
launchCmd: 'kiro',
expectedProcess: 'kiro',
promptInjectionMode: 'stdin-after-start'
},
crush: {
detectCmd: 'crush',
launchCmd: 'crush',
expectedProcess: 'crush',
promptInjectionMode: 'stdin-after-start'
},
aug: {
detectCmd: 'aug',
launchCmd: 'aug',
expectedProcess: 'aug',
promptInjectionMode: 'stdin-after-start'
},
cline: {
detectCmd: 'cline',
launchCmd: 'cline',
expectedProcess: 'cline',
promptInjectionMode: 'stdin-after-start'
},
codebuff: {
detectCmd: 'codebuff',
launchCmd: 'codebuff',
expectedProcess: 'codebuff',
promptInjectionMode: 'stdin-after-start'
},
continue: {
detectCmd: 'continue',
launchCmd: 'continue',
expectedProcess: 'continue',
promptInjectionMode: 'stdin-after-start'
},
cursor: {
detectCmd: 'cursor-agent',
launchCmd: 'cursor-agent',
expectedProcess: 'cursor-agent',
promptInjectionMode: 'argv'
},
droid: {
detectCmd: 'droid',
launchCmd: 'droid',
expectedProcess: 'droid',
promptInjectionMode: 'argv'
},
kimi: {
detectCmd: 'kimi',
launchCmd: 'kimi',
expectedProcess: 'kimi',
promptInjectionMode: 'stdin-after-start'
},
'mistral-vibe': {
detectCmd: 'mistral-vibe',
launchCmd: 'mistral-vibe',
expectedProcess: 'mistral-vibe',
promptInjectionMode: 'stdin-after-start'
},
'qwen-code': {
detectCmd: 'qwen-code',
launchCmd: 'qwen-code',
expectedProcess: 'qwen-code',
promptInjectionMode: 'stdin-after-start'
},
rovo: {
detectCmd: 'rovo',
launchCmd: 'rovo',
expectedProcess: 'rovo',
promptInjectionMode: 'stdin-after-start'
},
hermes: {
detectCmd: 'hermes',
launchCmd: 'hermes',
expectedProcess: 'hermes',
promptInjectionMode: 'stdin-after-start'
}
}

View file

@ -352,6 +352,20 @@ export type GitHubViewer = {
email: string | null
}
export type GitHubWorkItem = {
id: string
type: 'issue' | 'pr'
number: number
title: string
state: 'open' | 'closed' | 'merged' | 'draft'
url: string
labels: string[]
updatedAt: string
author: string | null
branchName?: string
baseRefName?: string
}
// ─── Hooks (orca.yaml) ──────────────────────────────────────────────
export type OrcaHooks = {
scripts: {
@ -466,6 +480,34 @@ export type CodexRateLimitAccountsState = {
activeAccountId: string | null
}
/** All AI coding agents Orca knows how to launch. Used for the agent picker in the new-workspace
* flow and for the default-agent setting. Extend this union as new agents are added. */
export type TuiAgent =
| 'claude' // Claude Code
| 'codex' // OpenAI Codex
| 'opencode' // OpenCode
| 'pi' // Pi (pi.dev)
| 'gemini' // Gemini CLI
| 'aider' // Aider
| 'goose' // Goose
| 'amp' // Amp
| 'kilo' // Kilocode
| 'kiro' // Kiro
| 'crush' // Charm/Crush
| 'aug' // Augment/Auggie
| 'cline' // Cline
| 'codebuff' // Codebuff
| 'continue' // Continue
| 'cursor' // Cursor
| 'droid' // Factory Droid
| 'kimi' // Kimi
| 'mistral-vibe' // Mistral Vibe
| 'qwen-code' // Qwen Code
| 'rovo' // Rovo Dev
| 'hermes' // Hermes Agent
export type TaskViewPresetId = 'all' | 'issues' | 'review' | 'my-issues' | 'my-prs' | 'prs'
export type GlobalSettings = {
workspaceDir: string
nestWorkspaces: boolean
@ -522,6 +564,12 @@ 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
/** Default preset in the new-workspace GitHub task view. */
defaultTaskViewPreset: TaskViewPresetId
/** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */
agentCmdOverrides: Partial<Record<TuiAgent, string>>
}
export type NotificationEventSource = 'agent-task-complete' | 'terminal-bell' | 'test'