mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
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:
parent
c8a8ef1b5c
commit
d2a53c8fcc
52 changed files with 5699 additions and 1052 deletions
102
src/main/github/client-pr-checks.test.ts
Normal file
102
src/main/github/client-pr-checks.test.ts
Normal 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'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
288
src/main/github/client-work-items.test.ts
Normal file
288
src/main/github/client-work-items.test.ts
Normal 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'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
(
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
10
src/preload/api-types.d.ts
vendored
10
src/preload/api-types.d.ts
vendored
|
|
@ -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>
|
||||
|
|
|
|||
9
src/preload/index.d.ts
vendored
9
src/preload/index.d.ts
vendored
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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> =>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
659
src/renderer/src/components/NewWorkspaceComposerCard.tsx
Normal file
659
src/renderer/src/components/NewWorkspaceComposerCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
2035
src/renderer/src/components/NewWorkspacePage.tsx
Normal file
2035
src/renderer/src/components/NewWorkspacePage.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
356
src/renderer/src/components/settings/AgentsPane.tsx
Normal file
356
src/renderer/src/components/settings/AgentsPane.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
38
src/renderer/src/components/settings/agents-search.ts
Normal file
38
src/renderer/src/components/settings/agents-search.ts
Normal 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'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'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...`
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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+1–9 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}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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…
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
159
src/renderer/src/components/ui/light-rays.tsx
Normal file
159
src/renderer/src/components/ui/light-rays.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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' {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
283
src/renderer/src/lib/agent-catalog.tsx
Normal file
283
src/renderer/src/lib/agent-catalog.tsx
Normal 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} />
|
||||
}
|
||||
103
src/renderer/src/lib/tui-agent-startup.test.ts
Normal file
103
src/renderer/src/lib/tui-agent-startup.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
82
src/renderer/src/lib/tui-agent-startup.ts
Normal file
82
src/renderer/src/lib/tui-agent-startup.ts
Normal 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'
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
155
src/shared/tui-agent-config.ts
Normal file
155
src/shared/tui-agent-config.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in a new issue