diff --git a/src/main/github/client-pr-checks.test.ts b/src/main/github/client-pr-checks.test.ts new file mode 100644 index 00000000..2585ea52 --- /dev/null +++ b/src/main/github/client-pr-checks.test.ts @@ -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' + } + ]) + }) +}) diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts new file mode 100644 index 00000000..7749527d --- /dev/null +++ b/src/main/github/client-work-items.test.ts @@ -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' + } + ]) + }) +}) diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index b2908ba4..a778c057 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -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' - } - ]) - }) -}) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 85dcea3e..89bc860d 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -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 { } } +function mapIssueWorkItem(item: Record): 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): 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 { + 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[]) + // 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[]).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[]).map( + mapIssueWorkItem + ) + const prs = (JSON.parse(prsResult.stdout) as Record[]).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 { + const fetchers: Promise[] = [] + 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[]).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[]).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 { + 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 { + 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 + 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 + 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 + 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 + 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. diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 44301a30..16059fc4 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -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', ( diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index 76c254ed..707eb112 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -21,7 +21,12 @@ vi.mock('child_process', () => { } }) -import { _resetPreflightCache, registerPreflightHandlers, runPreflightCheck } from './preflight' +import { + _resetPreflightCache, + detectInstalledAgents, + registerPreflightHandlers, + runPreflightCheck +} from './preflight' type HandlerMap = Record Promise> @@ -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']) + }) }) diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index d836941b..984930ac 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -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 { } } +// 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 { + 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 { + 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 { try { await execFileAsync('gh', ['auth', 'status'], { @@ -73,4 +106,8 @@ export function registerPreflightHandlers(): void { return runPreflightCheck(args?.force) } ) + + ipcMain.handle('preflight:detectAgents', async (): Promise => { + return detectInstalledAgents() + }) } diff --git a/src/main/ipc/shell.ts b/src/main/ipc/shell.ts index 8081ca55..62fc383c 100644 --- a/src/main/ipc/shell.ts +++ b/src/main/ipc/shell.ts @@ -96,6 +96,18 @@ export function registerShellHandlers(): void { } ) + // Why: window.prompt() and 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 => { + const result = await dialog.showOpenDialog({ + properties: ['openFile'] + }) + if (result.canceled || result.filePaths.length === 0) { + return null + } + return result.filePaths[0] + }) + // Why: window.prompt() and 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 => { diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index b463df2a..26f583a5 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -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 { 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> | 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> | 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) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index de00dce7..2bd3e1c8 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -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 = [ diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index db2de1d7..52a77de9 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -209,9 +209,17 @@ export function writeStartupCommandWhenShellReady( onExit: (cleanup: () => void) => void ): void { let sent = false + let postReadyTimer: ReturnType | 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 ; 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) } diff --git a/src/preload/api-types.d.ts b/src/preload/api-types.d.ts index cc7a20ab..a9216e1d 100644 --- a/src/preload/api-types.d.ts +++ b/src/preload/api-types.d.ts @@ -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 + detectAgents: () => Promise } export type StatsApi = { @@ -264,9 +266,16 @@ export type PreloadApi = { } gh: { viewer: () => Promise + repoSlug: (args: { repoPath: string }) => Promise<{ owner: string; repo: string } | null> prForBranch: (args: { repoPath: string; branch: string }) => Promise issue: (args: { repoPath: string; number: number }) => Promise + workItem: (args: { repoPath: string; number: number }) => Promise listIssues: (args: { repoPath: string; limit?: number }) => Promise + listWorkItems: (args: { + repoPath: string + limit?: number + query?: string + }) => Promise prChecks: (args: { repoPath: string prNumber: number @@ -320,6 +329,7 @@ export type PreloadApi = { openFilePath: (path: string) => Promise openFileUri: (uri: string) => Promise pathExists: (path: string) => Promise + pickAttachment: () => Promise pickImage: () => Promise pickDirectory: (args: { defaultPath?: string }) => Promise copyFile: (args: { srcPath: string; destPath: string }) => Promise diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 347e19a2..7312f4d8 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -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 + repoSlug: (args: { repoPath: string }) => Promise<{ owner: string; repo: string } | null> prForBranch: (args: { repoPath: string; branch: string }) => Promise issue: (args: { repoPath: string; number: number }) => Promise + workItem: (args: { repoPath: string; number: number }) => Promise listIssues: (args: { repoPath: string; limit?: number }) => Promise + listWorkItems: (args: { + repoPath: string + limit?: number + query?: string + }) => Promise prChecks: (args: { repoPath: string prNumber: number @@ -118,6 +126,7 @@ type ShellApi = { openFilePath: (path: string) => Promise openFileUri: (uri: string) => Promise pathExists: (path: string) => Promise + pickAttachment: () => Promise pickImage: () => Promise pickDirectory: (args: { defaultPath?: string }) => Promise copyFile: (args: { srcPath: string; destPath: string }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 13e5225b..c7b18158 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -300,15 +300,27 @@ const api = { gh: { viewer: (): Promise => ipcRenderer.invoke('gh:viewer'), + repoSlug: (args: { repoPath: string }): Promise => + ipcRenderer.invoke('gh:repoSlug', args), + prForBranch: (args: { repoPath: string; branch: string }): Promise => ipcRenderer.invoke('gh:prForBranch', args), issue: (args: { repoPath: string; number: number }): Promise => ipcRenderer.invoke('gh:issue', args), + workItem: (args: { repoPath: string; number: number }): Promise => + ipcRenderer.invoke('gh:workItem', args), + listIssues: (args: { repoPath: string; limit?: number }): Promise => ipcRenderer.invoke('gh:listIssues', args), + listWorkItems: (args: { + repoPath: string + limit?: number + query?: string + }): Promise => 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 => ipcRenderer.invoke('preflight:detectAgents') }, notifications: { @@ -397,6 +410,8 @@ const api = { pathExists: (path: string): Promise => ipcRenderer.invoke('shell:pathExists', path), + pickAttachment: (): Promise => ipcRenderer.invoke('shell:pickAttachment'), + pickImage: (): Promise => ipcRenderer.invoke('shell:pickImage'), pickDirectory: (args: { defaultPath?: string }): Promise => diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 202883f1..29585838 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 { ) - const rightSidebarToggle = showSidebar ? ( + const rightSidebarToggle = showRightSidebarControls ? (