fix: address review findings (#2695)

This commit is contained in:
Jinjing 2026-05-23 12:33:49 -07:00 committed by GitHub
parent 669ade2313
commit c93dd901a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 1110 additions and 270 deletions

View File

@ -1,4 +1,4 @@
import { splitWorktreeId } from '../../shared/worktree-id'
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
import { parseWslPath } from '../wsl'
import { parsePtySessionId } from './pty-session-id'
@ -8,7 +8,9 @@ export type WslSessionContext = {
export function getWslContextFromSessionId(sessionId: string): WslSessionContext | undefined {
const worktreeId = parsePtySessionId(sessionId).worktreeId
const worktreePath = worktreeId ? splitWorktreeId(worktreeId)?.worktreePath : undefined
const worktreePath = worktreeId
? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath
: undefined
const wslInfo = worktreePath ? parseWslPath(worktreePath) : null
return wslInfo ? { distro: wslInfo.distro } : undefined
}

View File

@ -253,7 +253,8 @@ describe('registerWorktreeHandlers', () => {
store.getAllWorktreeLineage,
store.removeWorktreeLineage,
killAllProcessesForWorktreeMock,
getLocalPtyProviderMock
getLocalPtyProviderMock,
deleteWorktreeHistoryDirMock
]) {
m.mockReset()
}
@ -486,6 +487,52 @@ describe('registerWorktreeHandlers', () => {
})
})
it('creates an additional workspace for folder-mode repos without git worktree add', async () => {
const repo = {
id: 'repo-folder',
path: '/workspace/folder',
displayName: 'folder',
badgeColor: '#000',
addedAt: 0,
kind: 'folder' as const
}
store.getRepo.mockReturnValue(repo)
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => ({
displayName: '',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
...meta
}))
const result = (await handlers['worktrees:create'](null, {
repoId: 'repo-folder',
name: 'folder-session',
createdWithAgent: 'codex'
})) as { worktree: { id: string } }
expect(addWorktreeMock).not.toHaveBeenCalled()
expect(result.worktree).toEqual(
expect.objectContaining({
id: expect.stringMatching(/^repo-folder::\/workspace\/folder::workspace:[0-9a-f-]{36}$/),
repoId: 'repo-folder',
path: '/workspace/folder',
displayName: 'folder-session',
instanceId: expect.stringMatching(/^[0-9a-f-]{36}$/),
createdWithAgent: 'codex'
})
)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
repoId: 'repo-folder'
})
})
it('checks out a selected existing local branch exactly', async () => {
listWorktreesMock
.mockResolvedValueOnce([
@ -2425,6 +2472,55 @@ describe('registerWorktreeHandlers', () => {
})
})
it('refuses to delete the root workspace for folder-mode repos', async () => {
store.getRepo.mockReturnValue({
id: 'repo-folder',
path: '/workspace/folder',
displayName: 'folder',
badgeColor: '#000',
addedAt: 0,
kind: 'folder'
})
await expect(
handlers['worktrees:remove'](null, {
worktreeId: 'repo-folder::/workspace/folder'
})
).rejects.toThrow('Cannot delete the project root workspace')
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled()
})
it('kills PTYs before removing additional folder workspace metadata', async () => {
const ptyProvider = {} as never
const worktreeId = 'repo-folder::/workspace/folder::workspace:child-1'
store.getRepo.mockReturnValue({
id: 'repo-folder',
path: '/workspace/folder',
displayName: 'folder',
badgeColor: '#000',
addedAt: 0,
kind: 'folder'
})
getLocalPtyProviderMock.mockReturnValue(ptyProvider)
await handlers['worktrees:remove'](null, { worktreeId })
expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith(worktreeId, {
runtime: runtimeStub,
localProvider: ptyProvider
})
expect(killAllProcessesForWorktreeMock.mock.invocationCallOrder[0]).toBeLessThan(
store.removeWorktreeMeta.mock.invocationCallOrder[0]
)
expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId)
expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
repoId: 'repo-folder'
})
})
it('runs the archive hook on remove when skipArchive is not set', async () => {
mockKnownFeatureWorktree()
removeWorktreeMock.mockResolvedValue(undefined)

View File

@ -33,7 +33,7 @@ import { gitExecFileAsync } from '../git/runner'
import { withWorktreeSpan } from '../observability/instrumentation'
import { resolveGitHubPrStartPoint } from '../github/pr-start-point'
import { getDefaultRemote } from '../git/repo'
import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees'
import { listRepoWorktrees } from '../repo-worktrees'
import { getSshGitProvider, requireSshGitProvider } from '../providers/ssh-git-dispatch'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import {
@ -84,6 +84,8 @@ import {
isWorktreePathMissing
} from '../worktree-removal-safety'
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses'
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id'
const WORKTREE_ARCHIVE_HOOK_TIMEOUT_MS = 120_000
@ -370,6 +372,139 @@ function stampAndMergeVisibleDetectedWorktree(
return mergeWorktree(repo.id, detected, meta, repo.displayName)
}
function getFolderWorkspaceRootId(repo: Repo): string {
return `${repo.id}::${repo.path}`
}
function getFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string {
return `${getFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}`
}
function getFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: string): string {
const prefix = `${getFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`
return worktreeId.startsWith(prefix) ? worktreeId.slice(prefix.length) : randomUUID()
}
function isFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean {
const rootId = getFolderWorkspaceRootId(repo)
return (
worktreeId === rootId ||
worktreeId.startsWith(`${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`)
)
}
function mergeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta): Worktree {
return {
id: worktreeId,
...(meta.instanceId !== undefined ? { instanceId: meta.instanceId } : {}),
repoId: repo.id,
path: repo.path,
head: '',
branch: '',
isBare: false,
isMainWorktree: worktreeId === getFolderWorkspaceRootId(repo),
displayName: meta.displayName || repo.displayName,
comment: meta.comment || '',
linkedIssue: meta.linkedIssue ?? null,
linkedPR: meta.linkedPR ?? null,
linkedLinearIssue: meta.linkedLinearIssue ?? null,
linkedGitLabMR: meta.linkedGitLabMR ?? null,
linkedGitLabIssue: meta.linkedGitLabIssue ?? null,
isArchived: meta.isArchived ?? false,
isUnread: meta.isUnread ?? false,
isPinned: meta.isPinned ?? false,
sortOrder: meta.sortOrder ?? 0,
...(meta.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}),
lastActivityAt: meta.lastActivityAt ?? 0,
...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}),
...(meta.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}),
workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID,
diffComments: meta.diffComments
}
}
function listFolderWorkspaces(store: Store, repo: Repo): Worktree[] {
const rootId = getFolderWorkspaceRootId(repo)
const allMeta = store.getAllWorktreeMeta()
const ids = Object.keys(allMeta).filter((worktreeId) =>
isFolderWorkspaceIdForRepo(repo, worktreeId)
)
if (!ids.includes(rootId)) {
ids.unshift(rootId)
}
return ids
.map((worktreeId) => {
const existing = allMeta[worktreeId]
const meta = existing?.instanceId
? existing
: store.setWorktreeMeta(worktreeId, {
instanceId: getFolderWorkspaceInstanceIdentity(repo, worktreeId),
...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() })
})
return mergeFolderWorkspace(repo, worktreeId, meta)
})
.sort((a, b) => {
if (a.id === rootId) {
return -1
}
if (b.id === rootId) {
return 1
}
return (b.createdAt ?? b.lastActivityAt) - (a.createdAt ?? a.lastActivityAt)
})
}
function buildFolderDetectedWorktrees(store: Store, repo: Repo): DetectedWorktree[] {
const settings = store.getSettings()
return listFolderWorkspaces(store, repo).map((worktree) =>
toDetectedWorktree({
repo,
worktree,
meta: store.getWorktreeMeta(worktree.id),
settings,
knownOrcaLayouts: [],
isLegacyRepoForVisibility: true
})
)
}
function listVisibleFolderWorkspaces(store: Store, repo: Repo): Worktree[] {
return buildFolderDetectedWorktrees(store, repo)
.filter((worktree) => worktree.visible)
.map((worktree) => {
const meta = store.getWorktreeMeta(worktree.id)
return mergeFolderWorkspace(repo, worktree.id, meta ?? store.setWorktreeMeta(worktree.id, {}))
})
}
function createFolderWorkspace(
args: CreateWorktreeArgs,
repo: Repo,
store: Store
): CreateWorktreeResult {
const now = Date.now()
const instanceId = randomUUID()
const worktreeId = getFolderWorkspaceInstanceId(repo, instanceId)
const meta = store.setWorktreeMeta(worktreeId, {
instanceId,
displayName: args.displayName || args.name,
lastActivityAt: now,
createdAt: now,
orcaCreatedAt: now,
orcaCreationSource: 'desktop',
...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}),
...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}),
...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}),
...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}),
...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}),
...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}),
...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}),
...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {})
})
return { worktree: mergeFolderWorkspace(repo, worktreeId, meta) }
}
function buildDisconnectedDetectedWorktrees(
store: Store,
repo: Repo,
@ -431,7 +566,7 @@ export function registerWorktreeHandlers(
try {
let gitWorktrees
if (isFolderRepo(repo)) {
gitWorktrees = [createFolderWorktree(repo)]
return listVisibleFolderWorkspaces(store, repo)
} else if (repo.connectionId) {
const provider = getSshGitProvider(repo.connectionId)
if (!provider) {
@ -497,7 +632,7 @@ export function registerWorktreeHandlers(
try {
let gitWorktrees
if (isFolderRepo(repo)) {
gitWorktrees = [createFolderWorktree(repo)]
return listVisibleFolderWorkspaces(store, repo)
} else if (repo.connectionId) {
const provider = getSshGitProvider(repo.connectionId)
if (!provider) {
@ -561,7 +696,12 @@ export function registerWorktreeHandlers(
try {
let gitWorktrees: GitWorktreeInfo[]
if (isFolderRepo(repo)) {
gitWorktrees = [createFolderWorktree(repo)]
return {
repoId: repo.id,
authoritative: true,
source: 'git',
worktrees: buildFolderDetectedWorktrees(store, repo)
}
} else if (repo.connectionId) {
const provider = getSshGitProvider(repo.connectionId)
if (!provider) {
@ -623,9 +763,6 @@ export function registerWorktreeHandlers(
if (!repo) {
throw new Error(`Repo not found: ${args.repoId}`)
}
if (isFolderRepo(repo)) {
throw new Error('Folder mode does not support creating worktrees.')
}
const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource)
const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown'
@ -637,9 +774,11 @@ export function registerWorktreeHandlers(
// worktrees`) signal IPC-shape bugs, not the user-visible
// git/filesystem failures the funnel cares about — bucketing them
// into `unknown` would pollute the failure taxonomy.
result = repo.connectionId
? await createRemoteWorktree(args, repo, store, mainWindow)
: await createLocalWorktree(args, repo, store, mainWindow, runtime)
result = isFolderRepo(repo)
? createFolderWorkspace(args, repo, store)
: repo.connectionId
? await createRemoteWorktree(args, repo, store, mainWindow)
: await createLocalWorktree(args, repo, store, mainWindow, runtime)
} catch (error) {
track('workspace_create_failed', {
source,
@ -659,10 +798,17 @@ export function registerWorktreeHandlers(
// the branch name itself.
track('workspace_created', {
source,
from_existing_branch: typeof args.baseBranch === 'string' && args.baseBranch.length > 0,
from_existing_branch:
!isFolderRepo(repo) &&
typeof args.baseBranch === 'string' &&
args.baseBranch.length > 0,
...getCohortAtEmit()
})
if (isFolderRepo(repo)) {
notifyWorktreesChanged(mainWindow, repo.id)
}
return result
})
}
@ -768,7 +914,23 @@ export function registerWorktreeHandlers(
throw new Error(`Repo not found: ${repoId}`)
}
if (isFolderRepo(repo)) {
throw new Error('Folder mode does not support deleting worktrees.')
if (args.worktreeId === getFolderWorkspaceRootId(repo)) {
throw new Error(
'Cannot delete the project root workspace. Remove the folder project instead.'
)
}
// Why: folder workspaces share one filesystem root, so there is no Git
// remove step to close shells; sweep PTYs before dropping metadata.
await killAllProcessesForWorktree(args.worktreeId, {
runtime,
localProvider: getLocalPtyProvider()
}).catch((err) => {
console.warn(`[worktree-teardown] failed for ${args.worktreeId}:`, err)
})
store.removeWorktreeMeta(args.worktreeId)
deleteWorktreeHistoryDir(args.worktreeId)
notifyWorktreesChanged(mainWindow, repoId)
return
}
// Why: the renderer-supplied worktreeId contains a filesystem path.

View File

@ -23,7 +23,7 @@ import { basename } from 'node:path'
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
import os from 'node:os'
import { splitWorktreeId } from '../../shared/worktree-id'
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
import { app } from 'electron'
import type {
AppMemory,
@ -375,7 +375,7 @@ function resolveWorktreeNames(
repoName: string
} {
// Orca worktree ids look like `${repoId}::${absolutePath}`.
const parsed = splitWorktreeId(worktreeId)
const parsed = splitWorktreeIdForFilesystem(worktreeId)
const repoId = parsed?.repoId ?? worktreeId
const worktreePath = parsed?.worktreePath ?? ''
const fallbackName = worktreePath ? basename(worktreePath) : worktreeId

View File

@ -1,6 +1,7 @@
import path from 'path'
import type { Store } from '../persistence'
import { splitWorktreeId } from '../../shared/worktree-id'
import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
import { isFolderRepo } from '../../shared/repo-kind'
import type {
WorkspacePortKillRequest,
WorkspacePortKillResult,
@ -27,12 +28,15 @@ export function getStoreWorkspacePortProbes(
if (!repo || repo.connectionId) {
return []
}
const worktreePath = isFolderRepo(repo)
? (splitWorktreeIdForFilesystem(worktreeId)?.worktreePath ?? parsed.worktreePath)
: parsed.worktreePath
return [
{
id: worktreeId,
repoId: parsed.repoId,
displayName: meta.displayName || path.basename(parsed.worktreePath),
path: parsed.worktreePath
displayName: meta.displayName || path.basename(worktreePath),
path: worktreePath
}
]
})

View File

@ -68,7 +68,8 @@ const {
reopenGitLabMRMock,
getGlabKnownHostsMock,
getGitLabWorkItemDetailsMock,
getIssueMock
getIssueMock,
deleteWorktreeHistoryDirMock
} = vi.hoisted(() => {
// Why: SSH runtime tests register providers through the public dispatcher API,
// so the mock needs the same registry semantics as the real module.
@ -120,7 +121,8 @@ const {
reopenGitLabMRMock: vi.fn(),
getGlabKnownHostsMock: vi.fn(),
getGitLabWorkItemDetailsMock: vi.fn(),
getIssueMock: vi.fn()
getIssueMock: vi.fn(),
deleteWorktreeHistoryDirMock: vi.fn()
}
})
@ -131,6 +133,10 @@ vi.mock('../git/worktree', () => ({
removeWorktree: removeWorktreeMock
}))
vi.mock('../terminal-history', () => ({
deleteWorktreeHistoryDir: deleteWorktreeHistoryDirMock
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock,
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE:
@ -917,6 +923,102 @@ describe('OrcaRuntimeService', () => {
})
})
it('creates additional workspace metadata for folder-mode repos through runtime create', async () => {
const folderRepo = {
id: 'folder-repo',
path: '/workspace/folder',
displayName: 'Folder',
badgeColor: 'blue',
addedAt: 1,
kind: 'folder' as const
}
const metaById: Record<string, WorktreeMeta> = {}
const runtimeStore = {
...store,
getRepos: () => [folderRepo],
getRepo: (id: string) => (id === folderRepo.id ? folderRepo : undefined),
getAllWorktreeMeta: () => metaById,
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta }
return metaById[worktreeId]
},
removeWorktreeMeta: (worktreeId: string) => {
delete metaById[worktreeId]
}
}
let deletedWorktreeId = ''
const localProvider = {
listProcesses: vi.fn(async () => [{ id: `${deletedWorktreeId}@@pty-1` }]),
shutdown: vi.fn(async () => undefined)
}
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
getLocalProvider: () => localProvider as never
})
const notifier = { worktreesChanged: vi.fn() }
runtime.setNotifier(notifier as never)
const result = await runtime.createManagedWorktree({
repoSelector: 'id:folder-repo',
name: 'folder-session',
createdWithAgent: 'codex'
})
expect(addWorktreeMock).not.toHaveBeenCalled()
expect(result.worktree).toEqual(
expect.objectContaining({
id: expect.stringMatching(/^folder-repo::\/workspace\/folder::workspace:[0-9a-f-]{36}$/),
repoId: 'folder-repo',
path: '/workspace/folder',
displayName: 'folder-session',
isMainWorktree: false,
createdWithAgent: 'codex'
})
)
expect(metaById[result.worktree.id]).toMatchObject({
instanceId: result.worktree.instanceId,
displayName: 'folder-session',
orcaCreationSource: 'runtime',
createdWithAgent: 'codex'
})
await expect(runtime.showManagedWorktree(`id:${result.worktree.id}`)).resolves.toMatchObject({
id: result.worktree.id,
repoId: 'folder-repo',
path: '/workspace/folder',
displayName: 'folder-session'
})
await expect(runtime.listManagedWorktrees('id:folder-repo')).resolves.toMatchObject({
totalCount: 2,
worktrees: [
expect.objectContaining({
id: 'folder-repo::/workspace/folder',
isMainWorktree: true
}),
expect.objectContaining({
id: result.worktree.id,
isMainWorktree: false
})
]
})
await expect(
runtime.updateManagedWorktreeMeta(`id:${result.worktree.id}`, { comment: 'note' })
).resolves.toMatchObject({
id: result.worktree.id,
comment: 'note'
})
await expect(
runtime.removeManagedWorktree('id:folder-repo::/workspace/folder')
).rejects.toThrow('Cannot delete the project root workspace')
deletedWorktreeId = result.worktree.id
await expect(runtime.removeManagedWorktree(`id:${result.worktree.id}`)).resolves.toEqual({})
expect(localProvider.shutdown).toHaveBeenCalledWith(`${result.worktree.id}@@pty-1`, {
immediate: true
})
expect(metaById[result.worktree.id]).toBeUndefined()
expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(result.worktree.id)
expect(notifier.worktreesChanged).toHaveBeenCalledWith('folder-repo')
})
it('refreshes runtime remote-tracking bases before creating local worktrees', async () => {
const runtime = new OrcaRuntimeService(store)
const refresh = deferred<{ stdout: string; stderr: string }>()

View File

@ -46,8 +46,9 @@ import type {
TabGroupLayoutNode,
TuiAgent
} from '../../shared/types'
import { splitWorktreeId } from '../../shared/worktree-id'
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, splitWorktreeId } from '../../shared/worktree-id'
import { isFolderRepo } from '../../shared/repo-kind'
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses'
import { buildSetupRunnerCommand } from '../../shared/setup-runner-command'
import { FIRST_PANE_ID } from '../../shared/pane-key'
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id'
@ -317,8 +318,9 @@ import {
writeIssueCommand
} from '../hooks'
import { DEFAULT_REPO_BADGE_COLOR, getDefaultVoiceSettings } from '../../shared/constants'
import { createFolderWorktree, listRepoWorktrees } from '../repo-worktrees'
import { listRepoWorktrees } from '../repo-worktrees'
import { createWorktreeSymlinks } from '../ipc/worktree-symlinks'
import { deleteWorktreeHistoryDir } from '../terminal-history'
import {
cleanupUnusedWorktreePushTargetRemote,
cleanupUnusedWorktreePushTargetRemoteSsh,
@ -704,6 +706,92 @@ function getRuntimeWorktreeRemovalOptionsKey(force: boolean, runHooks: boolean):
return `${force ? 'force' : 'normal'}:${runHooks ? 'run-hooks' : 'skip-hooks'}`
}
function getRuntimeFolderWorkspaceRootId(repo: Repo): string {
return `${repo.id}::${repo.path}`
}
function getRuntimeFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string {
return `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}`
}
function getRuntimeFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: string): string {
const prefix = `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`
return worktreeId.startsWith(prefix) ? worktreeId.slice(prefix.length) : randomUUID()
}
function isRuntimeFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean {
const rootId = getRuntimeFolderWorkspaceRootId(repo)
return (
worktreeId === rootId ||
worktreeId.startsWith(`${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`)
)
}
function mergeRuntimeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta): Worktree {
return {
id: worktreeId,
...(meta.instanceId !== undefined ? { instanceId: meta.instanceId } : {}),
repoId: repo.id,
path: repo.path,
head: '',
branch: '',
isBare: false,
isMainWorktree: worktreeId === getRuntimeFolderWorkspaceRootId(repo),
displayName: meta.displayName || repo.displayName,
comment: meta.comment || '',
linkedIssue: meta.linkedIssue ?? null,
linkedPR: meta.linkedPR ?? null,
linkedLinearIssue: meta.linkedLinearIssue ?? null,
linkedGitLabMR: meta.linkedGitLabMR ?? null,
linkedGitLabIssue: meta.linkedGitLabIssue ?? null,
isArchived: meta.isArchived ?? false,
isUnread: meta.isUnread ?? false,
isPinned: meta.isPinned ?? false,
sortOrder: meta.sortOrder ?? 0,
...(meta.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}),
lastActivityAt: meta.lastActivityAt ?? 0,
...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}),
...(meta.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}),
workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID,
diffComments: meta.diffComments
}
}
function listRuntimeFolderWorkspaces(
store: Pick<RuntimeStore, 'getAllWorktreeMeta' | 'setWorktreeMeta'>,
repo: Repo
): Worktree[] {
const rootId = getRuntimeFolderWorkspaceRootId(repo)
const allMeta = store.getAllWorktreeMeta()
const ids = Object.keys(allMeta).filter((worktreeId) =>
isRuntimeFolderWorkspaceIdForRepo(repo, worktreeId)
)
if (!ids.includes(rootId)) {
ids.unshift(rootId)
} else {
ids.sort((left, right) => {
if (left === rootId) {
return -1
}
if (right === rootId) {
return 1
}
return 0
})
}
return ids.map((worktreeId) => {
const existing = allMeta[worktreeId]
const meta = existing?.instanceId
? existing
: store.setWorktreeMeta(worktreeId, {
instanceId: getRuntimeFolderWorkspaceInstanceIdentity(repo, worktreeId),
...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() })
})
return mergeRuntimeFolderWorkspace(repo, worktreeId, meta)
})
}
function parseExactWorktreeIdSelector(selector: string): RuntimeWorktreeRemovalTarget | null {
const worktreeId = selector.startsWith('id:') ? selector.slice(3) : selector
const parsed = splitWorktreeId(worktreeId)
@ -6417,11 +6505,18 @@ export class OrcaRuntimeService {
async listDetectedManagedWorktrees(repoSelector: string): Promise<DetectedWorktreeListResult> {
const repo = await this.resolveRepoSelector(repoSelector)
if (isFolderRepo(repo)) {
const worktrees = listRuntimeFolderWorkspaces(this.requireStore(), repo)
return {
repoId: repo.id,
authoritative: true,
source: 'git',
worktrees: worktrees.map((worktree) => this.toRuntimeDetectedWorktree(repo, worktree))
}
}
let scan: RuntimeWorktreeScanResult
try {
scan = isFolderRepo(repo)
? { ok: true, worktrees: [createFolderWorktree(repo)] }
: await this.listRepoWorktreesForResolution(repo)
scan = await this.listRepoWorktreesForResolution(repo)
} catch {
scan = { ok: false, worktrees: [] }
}
@ -6902,15 +6997,102 @@ export class OrcaRuntimeService {
}
const repo = await this.resolveRepoSelector(args.repoSelector)
if (isFolderRepo(repo)) {
throw new Error('Folder mode does not support creating worktrees.')
}
const draftStartup = args.startupDraft
? await this.buildStartupForDraft(repo, args.startupDraft, args.createdWithAgent)
: null
const effectiveStartup = args.startup ?? draftStartup?.startup
const effectiveCreatedWithAgent = args.createdWithAgent ?? draftStartup?.agent
const effectiveDraftPaste = args.startupDraftPaste ?? draftStartup?.draftPaste
if (isFolderRepo(repo)) {
const now = Date.now()
const settings = this.store.getSettings()
const instanceId = randomUUID()
const worktreeId = getRuntimeFolderWorkspaceInstanceId(repo, instanceId)
const meta = this.store.setWorktreeMeta(worktreeId, {
instanceId,
displayName: args.displayName?.trim() || args.name,
lastActivityAt: now,
createdAt: now,
orcaCreatedAt: now,
orcaCreationSource: 'runtime',
orcaCreationWorkspaceLayout: {
path: settings.workspaceDir,
nestWorkspaces: settings.nestWorkspaces
},
...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}),
...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}),
...(args.linkedLinearIssue !== undefined
? { linkedLinearIssue: args.linkedLinearIssue }
: {}),
...(args.linkedGitLabIssue !== undefined
? { linkedGitLabIssue: args.linkedGitLabIssue }
: {}),
...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}),
...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}),
...(args.comment !== undefined ? { comment: args.comment } : {}),
...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}),
...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {})
})
const worktree = mergeRuntimeFolderWorkspace(repo, worktreeId, meta)
this.invalidateResolvedWorktreeCache()
this.notifier?.worktreesChanged(repo.id)
const shouldActivate = args.activate === true || args.runHooks === true
let warning: string | undefined
let didSpawnStartup = false
if (effectiveStartup && this.ptyController?.spawn) {
try {
const startupTrustAgent = effectiveDraftPaste?.agent ?? effectiveCreatedWithAgent
if (startupTrustAgent) {
this.markLocalWorkspaceTrustedForAgent(startupTrustAgent, worktree.path)
}
const terminal = await this.createTerminal(`id:${worktree.id}`, {
command: effectiveStartup.command,
env: effectiveStartup.env
})
if (effectiveDraftPaste) {
this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste)
}
didSpawnStartup = true
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
warning = `Failed to create the startup terminal for ${worktree.path}: ${message}`
console.warn(`[worktree-create] ${warning}`)
}
}
if (shouldActivate) {
if (effectiveStartup && !didSpawnStartup) {
this.notifier?.activateWorktree(repo.id, worktree.id, undefined, effectiveStartup)
} else {
this.notifier?.activateWorktree(repo.id, worktree.id)
}
} else if (this.ptyController?.spawn && !didSpawnStartup) {
try {
await this.createTerminal(`id:${worktree.id}`)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
warning = warning
? `${warning} Also failed to create the initial terminal for ${worktree.path}: ${message}`
: `Failed to create the initial terminal for ${worktree.path}: ${message}`
console.warn(`[worktree-create] ${warning}`)
}
}
return {
worktree: {
...worktree,
parentWorktreeId: null,
childWorktreeIds: [],
lineage: null,
git: {
path: worktree.path,
head: worktree.head,
branch: worktree.branch,
isBare: worktree.isBare,
isMainWorktree: worktree.isMainWorktree
}
},
...(warning ? { warning } : {})
}
}
if (repo.connectionId) {
return await this.createManagedRemoteWorktree(repo, {
...args,
@ -8259,7 +8441,27 @@ export class OrcaRuntimeService {
throw new Error('repo_not_found')
}
if (isFolderRepo(repo)) {
throw new Error('Folder mode does not support deleting worktrees.')
if (removalTarget.id === getRuntimeFolderWorkspaceRootId(repo)) {
throw new Error(
'Cannot delete the project root workspace. Remove the folder project instead.'
)
}
const localProvider = this.getLocalProvider()
if (localProvider) {
// Why: folder workspace deletion has no Git removal phase where PTYs
// would otherwise be swept; tear them down before hiding the workspace.
await killAllProcessesForWorktree(removalTarget.id, {
runtime: this,
localProvider
}).catch((err) => {
console.warn(`[worktree-teardown] failed for ${removalTarget.id}:`, err)
})
}
store.removeWorktreeMeta(removalTarget.id)
deleteWorktreeHistoryDir(removalTarget.id)
this.invalidateResolvedWorktreeCache()
this.notifier?.worktreesChanged(repo.id)
return {}
}
const provider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null
const registeredWorktrees = repo.connectionId
@ -9613,6 +9815,23 @@ export class OrcaRuntimeService {
const now = Date.now()
const perRepoWorktrees = await Promise.all(
this.store.getRepos().map(async (repo) => {
if (isFolderRepo(repo)) {
return listRuntimeFolderWorkspaces(this.requireStore(), repo).map((worktree) => ({
...worktree,
parentWorktreeId: null,
childWorktreeIds: [],
lineage: null,
git: {
path: worktree.path,
head: worktree.head,
branch: worktree.branch,
isBare: worktree.isBare,
isMainWorktree: worktree.isMainWorktree
},
displayName: worktree.displayName,
comment: worktree.comment
}))
}
// Why: mobile startup RPCs share this path. A slow repo scan should
// degrade one repo's metadata, not block all terminal/session loading.
const scan = await withTimeout(

View File

@ -1,6 +1,7 @@
import { basename } from 'path'
import type { Repo } from '../shared/types'
import { splitWorktreeId } from '../shared/worktree-id'
import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../shared/worktree-id'
import { isFolderRepo } from '../shared/repo-kind'
import type { Store } from './persistence'
export type UsageWorktreeRef = {
@ -40,15 +41,20 @@ export function loadKnownUsageWorktreesByRepo(
if (!parsed || !repoIds.has(parsed.repoId)) {
continue
}
const repo = localRepos.find((item) => item.id === parsed.repoId)
const worktreePath =
repo && isFolderRepo(repo)
? (splitWorktreeIdForFilesystem(worktreeId)?.worktreePath ?? parsed.worktreePath)
: parsed.worktreePath
const seenPaths = seenPathsByRepo.get(parsed.repoId)
if (seenPaths?.has(parsed.worktreePath)) {
if (seenPaths?.has(worktreePath)) {
continue
}
seenPaths?.add(parsed.worktreePath)
seenPaths?.add(worktreePath)
worktreesByRepo.get(parsed.repoId)?.push({
worktreeId,
path: parsed.worktreePath,
displayName: meta.displayName || getDefaultUsageWorktreeLabel(parsed.worktreePath)
path: worktreePath,
displayName: meta.displayName || getDefaultUsageWorktreeLabel(worktreePath)
})
}

View File

@ -31,8 +31,7 @@ function getPreflightIssues(status: {
issues.push({
id: 'git',
title: 'Git is not installed',
description:
'Git is required for Git repositories, source control, and workspace management.',
description: 'Git is required for Git projects, source control, and workspace management.',
fixLabel: 'Install Git',
fixUrl: 'https://git-scm.com/downloads'
})
@ -194,7 +193,9 @@ export default function Landing(): React.JSX.Element {
const repos = useAppStore((s) => s.repos)
const openModal = useAppStore((s) => s.openModal)
const canCreateWorktree = repos.some((repo) => isGitRepoKind(repo))
const canCreateWorktree = repos.length > 0
const createTargetLabel =
canCreateWorktree && repos.every((repo) => isGitRepoKind(repo)) ? 'Worktree' : 'Workspace'
const [preflightIssues, setPreflightIssues] = useState<PreflightIssue[]>([])
@ -250,11 +251,15 @@ export default function Landing(): React.JSX.Element {
const nextWorktreeKeys = useShortcutKeys('worktree.navigateDown')
const shortcuts = useMemo<ShortcutItem[]>(() => {
return [
{ id: 'create', keys: createWorktreeKeys, action: 'Create workspace' },
{
id: 'create',
keys: createWorktreeKeys,
action: `Create ${createTargetLabel.toLowerCase()}`
},
{ id: 'up', keys: previousWorktreeKeys, action: 'Move up workspace' },
{ id: 'down', keys: nextWorktreeKeys, action: 'Move down workspace' }
]
}, [createWorktreeKeys, nextWorktreeKeys, previousWorktreeKeys])
}, [createTargetLabel, createWorktreeKeys, nextWorktreeKeys, previousWorktreeKeys])
return (
<div className="absolute inset-0 flex items-center justify-center bg-background">
@ -288,11 +293,11 @@ export default function Landing(): React.JSX.Element {
<button
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 project first' : undefined}
title={!canCreateWorktree ? 'Add a project first' : undefined}
onClick={() => openModal('new-workspace-composer', { telemetrySource: 'unknown' })}
>
<GitBranchPlus className="size-3.5" />
Create Workspace
Create {createTargetLabel}
</button>
</div>

View File

@ -45,7 +45,9 @@ type NewWorkspaceComposerCardProps = {
onQuickAgentChange: (agent: TuiAgent | null) => void
eligibleRepos: RepoOption[]
repoId: string
selectedRepoIsGit: boolean
onRepoChange: (value: string) => void
primaryActionLabel: string
name: string
onNameValueChange: (value: string) => void
onSmartGitHubItemSelect: (item: GitHubWorkItem) => void
@ -207,7 +209,9 @@ export default function NewWorkspaceComposerCard({
onQuickAgentChange,
eligibleRepos,
repoId,
selectedRepoIsGit,
onRepoChange,
primaryActionLabel,
name,
onNameValueChange,
onSmartGitHubItemSelect,
@ -249,7 +253,7 @@ export default function NewWorkspaceComposerCard({
const submitShortcutModifierLabel = getScreenSubmitModifierLabel()
const selectedRepoName = React.useMemo(() => {
const repo = eligibleRepos.find((candidate) => candidate.id === repoId)
return repo?.displayName ?? repo?.path ?? 'This repository'
return repo?.displayName ?? repo?.path ?? 'This project'
}, [eligibleRepos, repoId])
const sshStatusLabel = selectedRepoSshStatus
? SSH_STATUS_LABELS[selectedRepoSshStatus]
@ -314,7 +318,7 @@ export default function NewWorkspaceComposerCard({
size="icon-xs"
onClick={handleAddRepo}
className="size-5 shrink-0 rounded-sm text-muted-foreground hover:text-foreground"
aria-label="Add folder or repository"
aria-label="Add project"
>
<FolderPlus className="size-3" />
</Button>
@ -372,7 +376,7 @@ export default function NewWorkspaceComposerCard({
<div className="min-w-0 space-y-1">
<label className="text-xs font-medium text-muted-foreground">
Name or &apos;Create From&apos;{' '}
{selectedRepoIsGit ? "Name or 'Create From'" : 'Workspace name'}{' '}
<span className="text-muted-foreground/70">[Optional]</span>
</label>
<SmartWorkspaceNameField
@ -390,6 +394,7 @@ export default function NewWorkspaceComposerCard({
onClearSelectedSource={onClearSmartNameSelection}
disabled={selectedRepoRequiresConnection}
disabledPlaceholder="Connect this repo first"
textOnly={!selectedRepoIsGit}
onPlainEnter={() => {
// Why: Enter on the workspace name advances focus to the next
// field (Agent combobox) rather than submitting, letting the user
@ -611,7 +616,7 @@ export default function NewWorkspaceComposerCard({
/>
{!canUseSparseCheckout ? (
<p className="text-[11px] text-muted-foreground">
Only available for local repositories.
Only available for local Git projects.
</p>
) : null}
</div>
@ -645,7 +650,7 @@ export default function NewWorkspaceComposerCard({
className="text-xs"
>
{creating ? <LoaderCircle className="size-4 animate-spin" /> : null}
Create Workspace
{primaryActionLabel}
<span className="ml-1 inline-flex items-center gap-0.5 rounded border border-white/20 px-1.5 py-0.5 text-[10px] font-medium leading-none text-current/80">
<span>{submitShortcutModifierLabel}</span>
<CornerDownLeft className="size-3" />

View File

@ -83,10 +83,6 @@ function ComposerModalBody({
trigger?.focus({ preventScroll: true })
}}
>
<DialogHeader className="gap-1">
<DialogTitle className="text-base font-semibold">Create Workspace</DialogTitle>
</DialogHeader>
<QuickTabBody modalData={modalData} onClose={onClose} active />
</DialogContent>
</Dialog>
@ -155,6 +151,7 @@ function QuickTabBody({
const handleCreate = useCallback(async (): Promise<void> => {
await submitQuick(quickAgent)
}, [quickAgent, submitQuick])
const primaryActionLabel = cardProps.selectedRepoIsGit ? 'Create Worktree' : 'Create Workspace'
// Cmd/Ctrl+Enter submits, Esc first blurs the focused input (like the full page).
useEffect(() => {
@ -206,12 +203,16 @@ function QuickTabBody({
return (
<>
<DialogHeader className="gap-1">
<DialogTitle className="text-base font-semibold">{primaryActionLabel}</DialogTitle>
</DialogHeader>
<NewWorkspaceComposerCard
composerRef={composerRef}
nameInputRef={nameInputRef}
quickAgent={quickAgent}
onQuickAgentChange={handleQuickAgentChange}
{...cardProps}
primaryActionLabel={primaryActionLabel}
onOpenAgentSettings={() => setAgentSettingsOpen(true)}
onCreate={() => void handleCreate()}
/>

View File

@ -4305,14 +4305,14 @@ export default function TaskPage(): React.JSX.Element {
onChange={(next) => {
setRepoSelection(next)
void updateSettings({ defaultRepoSelection: [...next] }).catch(() => {
toast.error('Failed to save repo selection.')
toast.error('Failed to save project selection.')
})
}}
onSelectAll={() => {
const allIds = new Set(eligibleRepos.map((r) => r.id))
setRepoSelection(allIds)
void updateSettings({ defaultRepoSelection: null }).catch(() => {
toast.error('Failed to save repo selection.')
toast.error('Failed to save project selection.')
})
}}
triggerClassName="h-8 w-auto max-w-[220px] rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none"
@ -4667,14 +4667,14 @@ export default function TaskPage(): React.JSX.Element {
onChange={(next) => {
setRepoSelection(next)
void updateSettings({ defaultRepoSelection: [...next] }).catch(() => {
toast.error('Failed to save repo selection.')
toast.error('Failed to save project selection.')
})
}}
onSelectAll={() => {
const allIds = new Set(eligibleRepos.map((r) => r.id))
setRepoSelection(allIds)
void updateSettings({ defaultRepoSelection: null }).catch(() => {
toast.error('Failed to save repo selection.')
toast.error('Failed to save project selection.')
})
}}
triggerClassName="h-8 w-full rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none"
@ -4823,7 +4823,7 @@ export default function TaskPage(): React.JSX.Element {
// Why: per-repo partial-failure signal — distinct from a hard
// IPC reject (tasksError). The two are mutually exclusive.
<div className="border-b border-border/50 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-200">
{failedCount} of {selectedRepos.length} repos failed to load
{failedCount} of {selectedRepos.length} projects failed to load
</div>
) : null}
@ -5177,7 +5177,7 @@ export default function TaskPage(): React.JSX.Element {
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
{primaryRepo
? 'No pending todos. Youre all caught up!'
: 'Select a repo so we can authenticate to GitLab.'}
: 'Select a project so we can authenticate to GitLab.'}
</div>
) : null}
<div className="divide-y divide-border/50">
@ -5271,7 +5271,7 @@ export default function TaskPage(): React.JSX.Element {
: gitlabView === 'mrs'
? 'No GitLab MRs match this filter.'
: 'No GitLab work matches this filter.'
: 'Select a repo to see GitLab work items.'}
: 'Select a project to see GitLab work items.'}
</div>
) : null}
<div className="divide-y divide-border/50">
@ -5990,7 +5990,7 @@ export default function TaskPage(): React.JSX.Element {
<div className="flex flex-col gap-3">
{selectedRepos.length > 1 ? (
<div className="flex flex-col gap-1">
<label className="text-[11px] font-medium text-muted-foreground">Repository</label>
<label className="text-[11px] font-medium text-muted-foreground">Project</label>
<Select
value={newIssueRepoId ?? undefined}
onValueChange={(v) => setNewIssueRepoId(v)}

View File

@ -195,7 +195,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const preserveCreateLookupOnCloseRef = useRef(false)
const repoMap = useMemo(() => new Map(repos.map((r) => [r.id, r])), [repos])
const canCreateWorktree = useMemo(() => repos.some((repo) => isGitRepoKind(repo)), [repos])
const canCreateWorktree = repos.length > 0
const hasQuery = deferredQuery.trim().length > 0
@ -1182,8 +1182,8 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
</div>
<div aria-live="polite" className="sr-only">
{deferredQuery.trim()
? `${resultCount} results found${showCreateAction ? ', create new worktree action available' : ''}`
: `${resultCount} items available${showCreateAction ? ', create new worktree action available' : ''}`}
? `${resultCount} results found${showCreateAction ? ', create workspace action available' : ''}`
: `${resultCount} items available${showCreateAction ? ', create workspace action available' : ''}`}
</div>
</CommandDialog>
)

View File

@ -83,6 +83,7 @@ type SmartWorkspaceNameFieldProps = {
onPlainEnter?: () => void
disabled?: boolean
disabledPlaceholder?: string
textOnly?: boolean
}
export type SmartWorkspaceNameSelection = {
@ -147,7 +148,8 @@ export default function SmartWorkspaceNameField({
inputRef,
onPlainEnter,
disabled = false,
disabledPlaceholder
disabledPlaceholder,
textOnly = false
}: SmartWorkspaceNameFieldProps): React.JSX.Element {
const {
addRepo,
@ -182,7 +184,7 @@ export default function SmartWorkspaceNameField({
() => repos.find((repo) => repo.id === repoId) ?? null,
[repoId, repos]
)
const [mode, setMode] = useState<SmartNameMode>('smart')
const [mode, setMode] = useState<SmartNameMode>(textOnly ? 'text' : 'smart')
const [mrStateFilter, setMrStateFilter] = useState<MrStateFilter>('opened')
const [open, setOpen] = useState(false)
const [debouncedQuery, setDebouncedQuery] = useState(value)
@ -216,6 +218,9 @@ export default function SmartWorkspaceNameField({
const availableModes = useMemo(
() =>
MODES.filter((item) => {
if (textOnly) {
return item.id === 'text'
}
if (item.id === 'gitlab') {
return gitlabAvailable
}
@ -224,7 +229,7 @@ export default function SmartWorkspaceNameField({
}
return true
}),
[gitlabAvailable, linearAvailable]
[gitlabAvailable, linearAvailable, textOnly]
)
const setInputNode = useCallback(
@ -238,7 +243,7 @@ export default function SmartWorkspaceNameField({
)
useEffect(() => {
if (disabled) {
if (disabled || textOnly) {
return
}
if (!preflightStatusChecked) {
@ -252,10 +257,18 @@ export default function SmartWorkspaceNameField({
disabled,
linearStatusChecked,
preflightStatusChecked,
refreshPreflightStatus
refreshPreflightStatus,
textOnly
])
useEffect(() => {
if (textOnly) {
if (mode !== 'text') {
setMode('text')
}
setOpen(false)
return
}
if ((mode === 'gitlab' && gitlabAvailable) || (mode === 'linear' && linearAvailable)) {
return
}
@ -268,7 +281,7 @@ export default function SmartWorkspaceNameField({
setGitlabLoading(false)
setLinearLoading(false)
setCommandValue('')
}, [gitlabAvailable, linearAvailable, mode])
}, [gitlabAvailable, linearAvailable, mode, textOnly])
useEffect(() => {
if (!disabled) {
@ -303,9 +316,9 @@ export default function SmartWorkspaceNameField({
[debouncedQuery]
)
const parsedGhLink = useMemo(() => parseGitHubIssueOrPRLink(debouncedQuery), [debouncedQuery])
const shouldQueryGithub = mode === 'smart' || mode === 'github'
const shouldQueryBranches = mode === 'smart' || mode === 'branches'
const shouldQueryLinear = linearAvailable && (mode === 'smart' || mode === 'linear')
const shouldQueryGithub = !textOnly && (mode === 'smart' || mode === 'github')
const shouldQueryBranches = !textOnly && (mode === 'smart' || mode === 'branches')
const shouldQueryLinear = !textOnly && linearAvailable && (mode === 'smart' || mode === 'linear')
useEffect(() => {
if (disabled || !shouldQueryGithub || !selectedRepo?.path) {
@ -512,7 +525,7 @@ export default function SmartWorkspaceNameField({
// GitLabWorkItem via the IPC. Skipped silently when the host hook
// hasn't supplied an onGitLabItemSelect handler.
const parsedGlLink = useMemo(() => parseGitLabIssueOrMRLink(debouncedQuery), [debouncedQuery])
const shouldQueryGitlab = gitlabAvailable && (mode === 'smart' || mode === 'gitlab')
const shouldQueryGitlab = !textOnly && gitlabAvailable && (mode === 'smart' || mode === 'gitlab')
useEffect(() => {
if (
!shouldQueryGitlab ||
@ -894,49 +907,44 @@ export default function SmartWorkspaceNameField({
}}
className="gap-0"
>
<TabsList
ref={tabsListRef}
variant="line"
className="h-7 w-full justify-start gap-4 border-b border-border/40 px-0"
onFocusCapture={(event) => {
// Why: Radix Tabs uses roving focus and re-applies tabindex=0 to
// the active trigger on every render, so we can't keep it out of
// the natural Tab order via props or a MutationObserver (race
// with React commits). Instead, intercept focus *on entry* into
// the tabs list:
// - Forward Tab from outside (e.g., Repo combobox) → bounce to
// the search input so the segmented control is skipped.
// - Shift-Tab from the input → relatedTarget is the input, so
// allow focus to land on the active trigger (segmented
// control remains reachable in reverse).
// - Intra-list focus moves (arrow keys) → relatedTarget is
// inside the list; allow.
const previous = event.relatedTarget as HTMLElement | null
const list = tabsListRef.current
const input = localInputRef.current
if (!list || !input) {
return
}
if (!previous || previous === input || list.contains(previous)) {
return
}
event.stopPropagation()
input.focus({ preventScroll: true })
}}
>
{availableModes.map(({ id, label, Icon }) => (
<TabsTrigger
key={id}
value={id}
tabIndex={-1}
data-smart-name-mode={id}
className="flex-none gap-1.5 px-0 text-xs"
>
<Icon className="size-3.5" />
<span>{label}</span>
</TabsTrigger>
))}
</TabsList>
{textOnly ? null : (
<TabsList
ref={tabsListRef}
variant="line"
className="h-7 w-full justify-start gap-4 border-b border-border/40 px-0"
onFocusCapture={(event) => {
// Why: Radix Tabs uses roving focus and re-applies tabindex=0 to
// the active trigger on every render, so we can't keep it out of
// the natural Tab order via props or a MutationObserver (race
// with React commits). Instead, intercept focus on entry into
// the tabs list so forward Tab goes straight to the input.
const previous = event.relatedTarget as HTMLElement | null
const list = tabsListRef.current
const input = localInputRef.current
if (!list || !input) {
return
}
if (!previous || previous === input || list.contains(previous)) {
return
}
event.stopPropagation()
input.focus({ preventScroll: true })
}}
>
{availableModes.map(({ id, label, Icon }) => (
<TabsTrigger
key={id}
value={id}
tabIndex={-1}
data-smart-name-mode={id}
className="flex-none gap-1.5 px-0 text-xs"
>
<Icon className="size-3.5" />
<span>{label}</span>
</TabsTrigger>
))}
</TabsList>
)}
</Tabs>
<Popover

View File

@ -200,12 +200,12 @@ export default function RepoCombobox({
>
<Command shouldFilter={false} value={commandValue} onValueChange={setCommandValue}>
<CommandInput
placeholder="Search repos/folders..."
placeholder="Search projects/folders..."
value={query}
onValueChange={setQuery}
/>
<CommandList>
<CommandEmpty>No repos/folders match your search.</CommandEmpty>
<CommandEmpty>No projects/folders match your search.</CommandEmpty>
{filteredRepos.map((repo) => (
<CommandItem
key={repo.id}
@ -252,7 +252,7 @@ export default function RepoCombobox({
className="h-9 w-full justify-start rounded-none px-3 text-xs font-normal"
>
<FolderPlus className="size-3.5 text-muted-foreground" />
<span>{isAdding ? 'Adding folder/repo…' : 'Add folder/repo'}</span>
<span>{isAdding ? 'Adding project…' : 'Add project'}</span>
</Button>
</div>
</Command>
@ -260,7 +260,7 @@ export default function RepoCombobox({
</Popover>
{showStandaloneAddButton ? (
/* Why: keep the add-repo action visible even when the repo selector is
/* Why: keep the add-project action visible even when the project selector is
collapsed so adding a new source stays one click away in the compact composer header. */
<Button
type="button"
@ -269,7 +269,7 @@ export default function RepoCombobox({
disabled={isAdding}
onClick={() => void handleAddFolder()}
className="size-9 shrink-0 p-0"
aria-label={isAdding ? 'Adding folder or repository' : 'Add folder or repository'}
aria-label={isAdding ? 'Adding project' : 'Add project'}
>
<FolderPlus className="size-3.5" />
</Button>

View File

@ -18,7 +18,7 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { SearchableSetting } from './SearchableSetting'
import { MANAGE_SESSIONS_SEARCH_ENTRIES } from './terminal-search'
import { splitWorktreeId } from '../../../../shared/worktree-id'
import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id'
import { useAppStore } from '../../store'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
@ -55,7 +55,7 @@ function formatWorkspace(session: { cwd: string | null; sessionId: string }): st
const worktreeId = session.sessionId.slice(0, sep)
// Why: take everything after the first `::` to recover the worktree path
// from the canonical `${repoId}::${path}` worktreeId encoding.
return shortCwd(splitWorktreeId(worktreeId)?.worktreePath ?? worktreeId)
return shortCwd(splitWorktreeIdForFilesystem(worktreeId)?.worktreePath ?? worktreeId)
}
return 'unknown'
}

View File

@ -32,12 +32,12 @@ export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[
return [
{
title: 'Display Name',
description: 'Repo-specific display details for the sidebar and tabs.',
keywords: [repo.displayName, repo.path, 'repository name']
description: 'Project-specific display details for the sidebar and tabs.',
keywords: [repo.displayName, repo.path, 'project name', 'repository name']
},
{
title: 'Badge Color',
description: 'Repo color used in the sidebar and tabs.',
description: 'Project color used in the sidebar and tabs.',
keywords: [repo.displayName, 'color', 'badge']
},
...(isFolder
@ -64,9 +64,9 @@ export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[
}
]),
{
title: 'Remove Repo',
description: 'Remove this repository from Orca.',
keywords: [repo.displayName, 'delete', 'repository']
title: 'Remove Project',
description: 'Remove this project from Orca.',
keywords: [repo.displayName, 'delete', 'project', 'repository']
},
...(isFolder
? []
@ -87,7 +87,7 @@ export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[
},
{
title: 'MCP Configs',
description: 'Inspect repo-level MCP server config files.',
description: 'Inspect project-level MCP server config files.',
keywords: [
repo.displayName,
'mcp',
@ -215,7 +215,7 @@ export function RepositoryPane({
const allEntries = getRepositoryPaneSearchEntries(repo)
const identityEntries = allEntries.filter((entry) =>
['Display Name', 'Badge Color', 'Default Worktree Base', 'Remove Repo'].includes(entry.title)
['Display Name', 'Badge Color', 'Default Worktree Base', 'Remove Project'].includes(entry.title)
)
const sparsePresetEntries = allEntries.filter((entry) =>
['Sparse Checkout Presets'].includes(entry.title)
@ -257,7 +257,7 @@ export function RepositoryPane({
<div className="space-y-1">
<h3 className="text-sm font-semibold">Identity</h3>
<p className="text-xs text-muted-foreground">
Repo-specific display details for the sidebar and tabs.
Project-specific display details for the sidebar and tabs.
</p>
<p className="text-xs text-muted-foreground">
Type: <span className="text-foreground">{getRepoKindLabel(repo)}</span>
@ -269,9 +269,9 @@ export function RepositoryPane({
) : null}
</div>
<SearchableSetting
title="Remove Repo"
description="Remove this repository from Orca."
keywords={[repo.displayName, 'delete', 'repository']}
title="Remove Project"
description="Remove this project from Orca."
keywords={[repo.displayName, 'delete', 'project', 'repository']}
>
<Button
variant={confirmingRemove === repo.id ? 'destructive' : 'outline'}
@ -281,15 +281,22 @@ export function RepositoryPane({
className="gap-2"
>
<Trash2 className="size-3.5" />
{confirmingRemove === repo.id ? 'Confirm Remove' : 'Remove Repo'}
{confirmingRemove === repo.id ? 'Confirm Remove' : 'Remove Project'}
</Button>
</SearchableSetting>
</div>
<SearchableSetting
title="Display Name"
description="Repo-specific display details for the sidebar and tabs."
keywords={[repo.displayName, repo.path, 'repository name', 'color', 'badge']}
description="Project-specific display details for the sidebar and tabs."
keywords={[
repo.displayName,
repo.path,
'project name',
'repository name',
'color',
'badge'
]}
className="space-y-2"
id={getRepositoryBadgeColorSectionId(repo.id)}
>

View File

@ -1351,7 +1351,7 @@ function Settings(): React.JSX.Element {
<SettingsSection
key={repo.id}
id={repoSectionId}
title={`Repo Settings > ${repo.displayName}`}
title={`Project Settings > ${repo.displayName}`}
description={repo.path}
searchEntries={getRepositoryPaneSearchEntries(repo)}
>

View File

@ -138,7 +138,7 @@ export function SettingsSidebar({
<div className="space-y-2">
<p className="px-3 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
Repositories
Projects
</p>
{repoSections.length > 0 ? (
@ -183,7 +183,7 @@ export function SettingsSidebar({
</div>
) : (
<p className="px-3 text-xs text-muted-foreground">
{hasRepos ? 'No matching repository settings.' : 'No repositories added yet.'}
{hasRepos ? 'No matching project settings.' : 'No projects added yet.'}
</p>
)}
</div>

View File

@ -1,7 +1,7 @@
import { renderToStaticMarkup } from 'react-dom/server'
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Worktree, WorktreeLineage } from '../../../../shared/types'
import type { Repo, Worktree, WorktreeLineage } from '../../../../shared/types'
const mocks = vi.hoisted(() => {
const state = {
@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => {
removeWorktree: vi.fn(),
clearWorktreeDeleteState: vi.fn(),
allWorktrees: vi.fn<() => Worktree[]>(() => []),
repos: [] as Repo[],
worktreeLineageById: {} as Record<string, WorktreeLineage>,
updateSettings: vi.fn(),
openSettingsTarget: vi.fn(),
@ -104,6 +105,7 @@ describe('DeleteWorktreeDialog lineage copy', () => {
mocks.state.activeModal = 'delete-worktree'
mocks.state.modalData = {}
mocks.state.allWorktrees.mockReturnValue([])
mocks.state.repos = []
mocks.state.worktreeLineageById = {}
mocks.state.deleteStateByWorktreeId = {}
})
@ -142,4 +144,29 @@ describe('DeleteWorktreeDialog lineage copy', () => {
expect(markup).toContain('min-w-0 overflow-hidden')
expect(markup).toContain('truncate text-muted-foreground')
})
it('uses non-destructive disk copy for folder workspace deletes', async () => {
const workspace = {
...makeWorktree('Folder workspace', '/projects/folder'),
repoId: 'folder-repo'
}
mocks.state.modalData = { worktreeId: workspace.id }
mocks.state.allWorktrees.mockReturnValue([workspace])
mocks.state.repos = [
{
id: 'folder-repo',
path: '/projects/folder',
displayName: 'Folder',
badgeColor: 'blue',
addedAt: 1,
kind: 'folder'
}
]
const { default: DeleteWorktreeDialog } = await import('./DeleteWorktreeDialog')
const markup = renderToStaticMarkup(<DeleteWorktreeDialog />)
expect(markup).toContain('from Orca. The project folder on disk will not be deleted.')
expect(markup).not.toContain('from git and delete its workspace folder.')
})
})

View File

@ -15,6 +15,11 @@ import { toast } from 'sonner'
import { runWorktreeDeletesInParallel } from './delete-worktree-flow'
import { getWorkspaceDeleteLineage } from './workspace-delete-lineage'
import { DeleteWorktreeLineageNotice } from './DeleteWorktreeLineageNotice'
import {
countFolderWorkspaceDeletes,
getDeleteWorktreeDialogCopy,
isFolderWorkspaceDelete as getIsFolderWorkspaceDelete
} from './delete-worktree-dialog-copy'
const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
const activeModal = useAppStore((s) => s.activeModal)
@ -23,6 +28,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
const removeWorktree = useAppStore((s) => s.removeWorktree)
const clearWorktreeDeleteState = useAppStore((s) => s.clearWorktreeDeleteState)
const allWorktrees = useAppStore((s) => s.allWorktrees)
const repos = useAppStore((s) => s.repos)
const worktreeLineageById = useAppStore((s) => s.worktreeLineageById)
const updateSettings = useAppStore((s) => s.updateSettings)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
@ -54,7 +60,20 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
const selected = new Set(worktreeIds)
return allWorktrees().filter((item) => selected.has(item.id))
}, [allWorktrees, worktreeIds])
const repoMap = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos])
const isBatchDelete = worktreeIds.length > 1
const isFolderWorkspaceDelete = !isBatchDelete && getIsFolderWorkspaceDelete(repoMap, worktree)
const folderWorkspaceDeleteCount = useMemo(
() => countFolderWorkspaceDeletes(repoMap, worktrees),
[repoMap, worktrees]
)
const deleteCopy = getDeleteWorktreeDialogCopy({
isBatchDelete,
worktree,
worktreeCount: worktrees.length,
folderWorkspaceDeleteCount,
isFolderWorkspaceDelete
})
const deleteStateByWorktreeId = useAppStore((s) => s.deleteStateByWorktreeId)
const lineageDelete = useMemo(
() =>
@ -262,21 +281,8 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
{isBatchDelete ? 'Delete Workspaces' : 'Delete Workspace'}
</DialogTitle>
<DialogDescription className="text-xs">
{isBatchDelete ? (
<>
Remove{' '}
<span className="font-medium text-foreground">{worktrees.length} workspaces</span>{' '}
from git and delete their workspace folders.
</>
) : (
<>
Remove{' '}
<span className="break-all font-medium text-foreground">
{worktree?.displayName}
</span>{' '}
from git and delete its workspace folder.
</>
)}
Remove <span className={deleteCopy.targetClassName}>{deleteCopy.targetLabel}</span>{' '}
{deleteCopy.descriptionSuffix}
</DialogDescription>
</DialogHeader>
@ -328,7 +334,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<div className="min-w-0 flex-1">
This is the <span className="font-semibold">main worktree</span> (the original clone
directory). Git does not allow removing the main worktree.
directory). {deleteCopy.mainWorktreeBlocker}
</div>
</div>
</div>

View File

@ -179,7 +179,7 @@ const SidebarFilter = React.memo(function SidebarFilter({
<DropdownMenuSeparator />
<div className="flex items-center justify-between px-2 py-1">
<span className="text-[11px] font-semibold tracking-wide uppercase text-muted-foreground">
Repositories
Projects
{hasRepoFilter && (
<span className="ml-1.5 normal-case tracking-normal font-medium text-foreground">
· {selectedCount}
@ -214,7 +214,7 @@ const SidebarFilter = React.memo(function SidebarFilter({
>
<CommandInput
autoFocus
placeholder="Search repos..."
placeholder="Search projects..."
value={query}
onValueChange={setQuery}
onKeyDown={(event) => event.stopPropagation()}
@ -223,7 +223,7 @@ const SidebarFilter = React.memo(function SidebarFilter({
iconClassName="h-3.5 w-3.5"
/>
<CommandList className="max-h-64 py-1">
<CommandEmpty className="py-4 text-[11px]">No repos match</CommandEmpty>
<CommandEmpty className="py-4 text-[11px]">No projects match</CommandEmpty>
{filteredRepos.map((r) => {
const checked = selectedRepoIdSet.has(r.id)
return (
@ -258,8 +258,8 @@ const SidebarFilter = React.memo(function SidebarFilter({
)}
<DropdownMenuSeparator />
{/* Why: "Add repo" stays visible regardless of repo count so users
can recover from the 0/1-repo state where the repo section is
{/* Why: "Add project" stays visible regardless of project count so users
can recover from the 0/1-project state where the project section is
hidden. Reset sits beside it only when a filter is active. */}
<div className="flex items-center justify-between gap-1 px-1 py-1">
{hasAnyFilter ? (
@ -279,7 +279,7 @@ const SidebarFilter = React.memo(function SidebarFilter({
className="inline-flex items-center gap-1.5 rounded-[5px] px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<FolderPlus className="size-3.5" />
Add repo
Add project
</button>
</div>
</DropdownMenuContent>

View File

@ -3,7 +3,6 @@ import { Kanban, Plus } from 'lucide-react'
import { useAppStore } from '@/store'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import SidebarWorkspaceOptionsMenu from './SidebarWorkspaceOptionsMenu'
import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer'
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
@ -14,7 +13,9 @@ const SidebarHeader = React.memo(function SidebarHeader() {
const [workspaceBoardMenuOpen, setWorkspaceBoardMenuOpen] = useState(false)
const openModal = useAppStore((s) => s.openModal)
const repos = useAppStore((s) => s.repos)
const canCreateWorktree = repos.some((repo) => isGitRepoKind(repo))
const groupBy = useAppStore((s) => s.groupBy)
const canCreateWorkspace = repos.length > 0
const sidebarTitle = groupBy === 'repo' ? 'Projects' : 'Workspaces'
const handleWorkspaceBoardOpenChange = useCallback((open: boolean) => {
setWorkspaceBoardOpen(open)
@ -66,8 +67,8 @@ const SidebarHeader = React.memo(function SidebarHeader() {
<>
<div className="mt-2 flex h-8 items-center justify-between px-2 gap-2">
<div className="flex min-w-0 items-center gap-1">
<span className="pl-2 pr-0.5 text-[10.5px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/80 select-none">
Workspaces
<span className="pl-2 pr-0.5 text-xs font-semibold text-muted-foreground/80 select-none">
{sidebarTitle}
</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
@ -101,21 +102,21 @@ const SidebarHeader = React.memo(function SidebarHeader() {
variant="ghost"
size="icon-xs"
onClick={() => {
if (!canCreateWorktree) {
if (!canCreateWorkspace) {
return
}
openModal('new-workspace-composer', { telemetrySource: 'sidebar' })
}}
aria-label="New workspace"
disabled={!canCreateWorktree}
disabled={!canCreateWorkspace}
>
<Plus className="size-3.5" strokeWidth={2.25} />
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{canCreateWorktree
{canCreateWorkspace
? `New workspace (${newWorktreeShortcutLabel})`
: 'Add a Git project to create worktrees'}
: 'Add a project to create workspaces'}
</TooltipContent>
</Tooltip>
</div>

View File

@ -31,7 +31,7 @@ const GROUP_BY_OPTIONS = [
{ id: 'none', label: 'None' },
{ id: 'workspace-status', label: 'Status' },
{ id: 'pr-status', label: 'PR' },
{ id: 'repo', label: 'Repo' }
{ id: 'repo', label: 'Project' }
] as const
const PROPERTY_OPTIONS: { id: WorktreeCardProperty; label: string }[] = [
@ -49,7 +49,7 @@ const SORT_OPTIONS = [
description: 'Agents that need attention, then most recent activity.'
},
{ id: 'recent', label: 'Recent', description: null },
{ id: 'repo', label: 'Repo', description: null },
{ id: 'repo', label: 'Project', description: null },
{
id: 'manual',
label: 'Manual',

View File

@ -64,7 +64,7 @@ export default function WorkspaceKanbanStatusLane({
const meta = getWorkspaceStatusVisualMeta(status)
const createTooltip = canCreateWorktree
? `New workspace in ${status.label}`
: 'Add a Git project to create worktrees'
: 'Add a project to create workspaces'
const createButton = (
<Button
type="button"

View File

@ -1,7 +1,8 @@
import { renderToStaticMarkup } from 'react-dom/server'
import type { ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo, Worktree, WorktreeCardProperty } from '../../../../shared/types'
import type WorktreeCardComponent from './WorktreeCard'
const fetchHostedReviewForBranch = vi.fn()
const fetchIssue = vi.fn()
@ -12,6 +13,7 @@ let worktreeCardProperties: WorktreeCardProperty[] = ['status', 'unread']
let tabsByWorktree: Record<string, { id: string }[]> = {}
let ptyIdsByTabId: Record<string, string[]> = {}
let browserTabsByWorktree: Record<string, { id: string }[]> = {}
let WorktreeCard: typeof WorktreeCardComponent
vi.mock('@/store', () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
@ -102,6 +104,10 @@ function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
}
describe('WorktreeCard quick actions', () => {
beforeAll(async () => {
WorktreeCard = (await import('./WorktreeCard')).default
}, 20_000)
beforeEach(() => {
vi.clearAllMocks()
worktreeCardProperties = ['status', 'unread']
@ -110,9 +116,7 @@ describe('WorktreeCard quick actions', () => {
browserTabsByWorktree = {}
})
it('marks the unread toggle as a workspace-board-preserving action', async () => {
const { default: WorktreeCard } = await import('./WorktreeCard')
it('marks the unread toggle as a workspace-board-preserving action', () => {
const markup = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} />
)
@ -121,9 +125,7 @@ describe('WorktreeCard quick actions', () => {
expect(markup).toContain('data-workspace-board-preserve-open=""')
})
it('shows delete as the top-right quick action for an inactive workspace', async () => {
const { default: WorktreeCard } = await import('./WorktreeCard')
it('shows delete as the top-right quick action for an inactive workspace', () => {
const markup = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} />
)
@ -131,8 +133,23 @@ describe('WorktreeCard quick actions', () => {
expect(markup).toContain('aria-label="Delete workspace"')
})
it('shows sleep as the top-right quick action for a workspace with live activity', async () => {
const { default: WorktreeCard } = await import('./WorktreeCard')
it('shows delete as the quick action for inactive folder workspace instances', () => {
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({
id: 'repo-1::/repo::workspace:123e4567-e89b-12d3-a456-426614174000',
path: '/repo',
isMainWorktree: false
})}
repo={{ ...makeRepo(), kind: 'folder' }}
isActive={false}
/>
)
expect(markup).toContain('aria-label="Delete workspace"')
})
it('shows sleep as the top-right quick action for a workspace with live activity', () => {
const worktree = makeWorktree()
tabsByWorktree = { [worktree.id]: [{ id: 'tab-1' }] }
ptyIdsByTabId = { 'tab-1': ['pty-1'] }

View File

@ -416,7 +416,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
)
const quickActionKind = getWorkspaceQuickActionKind({
hasActiveActivity,
isDeletable: !worktree.isMainWorktree && !isFolder,
isDeletable: !worktree.isMainWorktree,
isInactive: !hasActiveActivity,
isMacOptionPressed
})
@ -629,7 +629,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{isSshDisconnected ? 'SSH disconnected' : 'Remote repository via SSH'}
{isSshDisconnected ? 'SSH disconnected' : 'Remote project via SSH'}
</TooltipContent>
</Tooltip>
)}

View File

@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest'
import {
hasSleepableWorkspaceActivity,
isContextWorktreeDeletable,
shouldUseNativeContextMenu,
shouldIgnoreNestedWorktreeContextMenuScope,
shouldRemoveFolderProjectFromContextMenu,
shouldSuppressContextMenuFollowUpClick
} from './WorktreeContextMenu'
@ -113,3 +115,19 @@ describe('hasSleepableWorkspaceActivity', () => {
)
})
})
describe('folder workspace context deletes', () => {
it('routes only the folder root row to project removal', () => {
expect(shouldRemoveFolderProjectFromContextMenu(true, { isMainWorktree: true })).toBe(true)
expect(shouldRemoveFolderProjectFromContextMenu(true, { isMainWorktree: false })).toBe(false)
expect(shouldRemoveFolderProjectFromContextMenu(false, { isMainWorktree: true })).toBe(false)
})
it('treats additional folder workspace rows as deletable workspace rows', () => {
const folderRepo = { kind: 'folder' as const }
expect(isContextWorktreeDeletable({ isMainWorktree: false }, folderRepo)).toBe(true)
expect(isContextWorktreeDeletable({ isMainWorktree: true }, folderRepo)).toBe(false)
expect(isContextWorktreeDeletable({ isMainWorktree: false }, null)).toBe(false)
})
})

View File

@ -29,7 +29,7 @@ import {
import { useAppStore } from '@/store'
import { useRepoById, useRepoMap, useWorktreeMap } from '@/store/selectors'
import { cn } from '@/lib/utils'
import type { Worktree } from '../../../../shared/types'
import type { Repo, Worktree } from '../../../../shared/types'
import { isFolderRepo } from '../../../../shared/repo-kind'
import { runWorktreeBatchDelete, runWorktreeDelete } from './delete-worktree-flow'
import { runSleepWorktrees } from './sleep-worktree-flow'
@ -101,6 +101,20 @@ function hasSleepableWorkspaceActivity(
return hasLiveTerminal || hasBrowser
}
function shouldRemoveFolderProjectFromContextMenu(
isFolder: boolean,
worktree: Pick<Worktree, 'isMainWorktree'>
): boolean {
return isFolder && worktree.isMainWorktree
}
function isContextWorktreeDeletable(
worktree: Pick<Worktree, 'isMainWorktree'>,
repo: Pick<Repo, 'kind'> | null | undefined
): boolean {
return repo != null && !worktree.isMainWorktree
}
function findSidebarVirtualRowByKey(sidebar: Element, rowKey: string): HTMLElement | null {
return (
Array.from(sidebar.querySelectorAll<HTMLElement>('[data-worktree-virtual-row]')).find(
@ -218,10 +232,11 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
() =>
activeContextWorktrees.filter((item) => {
const itemRepo = repoMap.get(item.repoId)
return !item.isMainWorktree && itemRepo != null && !isFolderRepo(itemRepo)
return isContextWorktreeDeletable(item, itemRepo)
}),
[activeContextWorktrees, repoMap]
)
const removesFolderProject = shouldRemoveFolderProjectFromContextMenu(isFolder, worktree)
const sleepLabel =
isMultiContext && sleepableWorktrees.length > 0
? `Sleep ${sleepableWorktrees.length} Workspace${sleepableWorktrees.length === 1 ? '' : 's'}`
@ -325,10 +340,10 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
restoreSidebarPosition()
return
}
if (isFolder) {
// Why: folder mode reuses the worktree row UI for a synthetic root entry,
if (removesFolderProject) {
// Why: folder mode reuses the worktree row UI for the root entry,
// but users still expect "remove" to disconnect the folder from Orca,
// not to run git-style delete semantics against the real folder on disk.
// not to delete the selected logical workspace metadata.
openModal('confirm-remove-folder', {
repoId: worktree.repoId,
displayName: worktree.displayName
@ -345,9 +360,9 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
}, 50)
}, [
batchDeleteWorktrees,
isFolder,
isMultiContext,
openModal,
removesFolderProject,
setMenuOpenState,
worktree.displayName,
worktree.id,
@ -565,7 +580,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
? 'Deleting…'
: isMultiContext
? deleteLabel
: isFolder
: removesFolderProject
? 'Remove Folder from Orca'
: 'Delete'}
</DropdownMenuItem>
@ -581,6 +596,8 @@ export {
WORKTREE_CONTEXT_MENU_SCOPE_ATTR,
WORKTREE_NATIVE_CONTEXT_MENU_ATTR,
hasSleepableWorkspaceActivity,
isContextWorktreeDeletable,
shouldRemoveFolderProjectFromContextMenu,
shouldUseNativeContextMenu,
shouldSuppressContextMenuFollowUpClick,
shouldIgnoreNestedWorktreeContextMenuScope

View File

@ -1966,7 +1966,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
}}
>
<SlidersHorizontal className="size-3.5" />
Repo Settings
Project Settings
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
@ -1979,7 +1979,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
}}
>
<Palette className="size-3.5" />
Change Repo Color
Change Project Color
</DropdownMenuItem>
{row.repo && isGitRepoKind(row.repo) ? (
<DropdownMenuItem

View File

@ -0,0 +1,64 @@
import { isFolderRepo } from '../../../../shared/repo-kind'
import type { Repo, Worktree } from '../../../../shared/types'
type WorktreeRepoRef = Pick<Worktree, 'repoId'>
export function isFolderWorkspaceDelete(
repoMap: ReadonlyMap<string, Repo>,
worktree: WorktreeRepoRef | null | undefined
): boolean {
if (!worktree) {
return false
}
const repo = repoMap.get(worktree.repoId)
return repo ? isFolderRepo(repo) : false
}
export function countFolderWorkspaceDeletes(
repoMap: ReadonlyMap<string, Repo>,
worktrees: readonly WorktreeRepoRef[]
): number {
return worktrees.filter((item) => isFolderWorkspaceDelete(repoMap, item)).length
}
export function getDeleteWorktreeDialogCopy(args: {
isBatchDelete: boolean
worktree: Pick<Worktree, 'displayName'> | null
worktreeCount: number
folderWorkspaceDeleteCount: number
isFolderWorkspaceDelete: boolean
}): {
targetLabel: string | undefined
targetClassName: string
descriptionSuffix: string
mainWorktreeBlocker: string
} {
const allFolderWorkspaceDeletes =
args.isBatchDelete &&
args.worktreeCount > 0 &&
args.folderWorkspaceDeleteCount === args.worktreeCount
const mixedFolderWorkspaceDeletes =
args.isBatchDelete &&
args.folderWorkspaceDeleteCount > 0 &&
args.folderWorkspaceDeleteCount < args.worktreeCount
return {
targetLabel: args.isBatchDelete
? `${args.worktreeCount} workspaces`
: args.worktree?.displayName,
targetClassName: args.isBatchDelete
? 'font-medium text-foreground'
: 'break-all font-medium text-foreground',
descriptionSuffix: args.isBatchDelete
? allFolderWorkspaceDeletes
? 'from Orca. Project folders on disk will not be deleted.'
: mixedFolderWorkspaceDeletes
? 'from Orca. Git worktrees will also be removed from git and disk; folder workspaces will only remove the Orca workspace entry.'
: 'from git and delete their workspace folders.'
: args.isFolderWorkspaceDelete
? 'from Orca. The project folder on disk will not be deleted.'
: 'from git and delete its workspace folder.',
mainWorktreeBlocker: args.isFolderWorkspaceDelete
? 'Remove the folder project instead of deleting this workspace.'
: 'Git does not allow removing the main worktree.'
}
}

View File

@ -166,13 +166,10 @@ export function runWorktreeDeleteWithToast(
* running the delete immediately with toast feedback, or opening the
* confirmation modal.
*
* Why folder mode is handled at the call site: folder-repo removal branches
* to a different modal (`confirm-remove-folder`) and the folder-vs-git
* determination requires the full Worktree record's repoId. Keeping that
* decision adjacent to the caller (rather than branching inside this helper)
* avoids bleeding folder-mode concerns into what is otherwise a simple
* skip-confirm-vs-modal decision, and lets the context menu short-circuit
* before ever entering this funnel.
* Why folder-root removal is handled at the call site: disconnecting the
* folder project branches to a different modal (`confirm-remove-folder`).
* Keeping that decision adjacent to the caller avoids mixing project removal
* into what is otherwise a workspace delete confirmation flow.
*
* The main-worktree / missing-record guard here is defense-in-depth the
* caller is responsible for disabling UI when this is known ahead of time,

View File

@ -24,13 +24,13 @@ describe('repo header create state', () => {
})
).toEqual({
disabled: false,
tooltip: 'Create workspace for orca',
ariaLabel: 'Create workspace for orca',
tooltip: 'Create new worktree for orca',
ariaLabel: 'Create new worktree for orca',
requiresSshReconnect: false
})
})
it('disables folder repos', () => {
it('allows folder repos as workspace creates', () => {
expect(
getRepoHeaderCreateState({
repo: makeRepo({ kind: 'folder' }),
@ -38,8 +38,8 @@ describe('repo header create state', () => {
sshStatus: null
})
).toMatchObject({
disabled: true,
tooltip: 'docs is opened as a folder',
disabled: false,
tooltip: 'Create workspace for docs',
requiresSshReconnect: false
})
})
@ -53,7 +53,7 @@ describe('repo header create state', () => {
})
).toMatchObject({
disabled: false,
tooltip: 'Create workspace for remote',
tooltip: 'Create new worktree for remote',
requiresSshReconnect: false
})
})

View File

@ -17,9 +17,9 @@ export function getRepoHeaderCreateState(input: {
}): RepoHeaderCreateState {
if (!isGitRepoKind(input.repo)) {
return {
disabled: true,
tooltip: `${input.label} is opened as a folder`,
ariaLabel: `${input.label} is opened as a folder`,
disabled: false,
tooltip: `Create workspace for ${input.label}`,
ariaLabel: `Create workspace for ${input.label}`,
requiresSshReconnect: false
}
}
@ -39,8 +39,8 @@ export function getRepoHeaderCreateState(input: {
return {
disabled: false,
tooltip: `Create workspace for ${input.label}`,
ariaLabel: `Create workspace for ${input.label}`,
tooltip: `Create new worktree for ${input.label}`,
ariaLabel: `Create new worktree for ${input.label}`,
requiresSshReconnect: false
}
}

View File

@ -1,6 +1,5 @@
import { useCallback } from 'react'
import { useAppStore } from '@/store'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import type { WorkspaceStatus } from '../../../../shared/types'
export function useWorkspaceKanbanCreateWorktree(): {
@ -8,7 +7,7 @@ export function useWorkspaceKanbanCreateWorktree(): {
createWorktreeForStatus: (workspaceStatus: WorkspaceStatus) => void
} {
const openModal = useAppStore((s) => s.openModal)
const canCreateWorktree = useAppStore((s) => s.repos.some((repo) => isGitRepoKind(repo)))
const canCreateWorktree = useAppStore((s) => s.repos.length > 0)
const createWorktreeForStatus = useCallback(
(workspaceStatus: WorkspaceStatus) => {

View File

@ -30,7 +30,10 @@ import type {
} from '../../../../shared/types'
import { parsePtySessionId } from '../../../../shared/pty-session-id-format'
import { parsePaneKey as parseStablePaneKey } from '../../../../shared/stable-pane-id'
import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../../../shared/worktree-id'
import {
getRepoIdFromWorktreeId,
getWorktreePathBasenameFromId
} from '../../../../shared/worktree-id'
// ─── View-model types (renderer-local) ──────────────────────────────
@ -111,16 +114,7 @@ function deriveRepoIdFromWorktreeId(worktreeId: string): string {
}
function deriveWorktreeNameFromWorktreeId(worktreeId: string): string {
const parsed = splitWorktreeId(worktreeId)
if (!parsed) {
return worktreeId
}
const path = parsed.worktreePath
if (!path) {
return worktreeId
}
const parts = path.split(/[\\/]+/).filter(Boolean)
return parts.at(-1) ?? worktreeId
return getWorktreePathBasenameFromId(worktreeId) ?? worktreeId
}
function shortCwd(cwd: string): string {

View File

@ -23,7 +23,7 @@ type RepoMultiComboboxProps = {
* `null` is never emitted here persistence of "sticky-all" (selection
* equals every eligible repo) is the caller's responsibility. */
onChange: (next: ReadonlySet<string>) => void
/** Clicking the sticky "All repos" row emits a full-set selection AND this
/** Clicking the sticky "All projects" row emits a full-set selection AND this
* signal, so the caller can persist `null` (sticky-all) rather than a
* frozen snapshot that would exclude repos added later. */
onSelectAll: () => void
@ -32,10 +32,10 @@ type RepoMultiComboboxProps = {
function renderTriggerLabel(repos: Repo[], selected: ReadonlySet<string>): React.JSX.Element {
if (repos.length === 0) {
return <span className="text-muted-foreground">No repos</span>
return <span className="text-muted-foreground">No projects</span>
}
if (selected.size === repos.length) {
return <span className="inline-flex min-w-0 items-center gap-1.5">All repos</span>
return <span className="inline-flex min-w-0 items-center gap-1.5">All projects</span>
}
const selectedRepos = repos.filter((r) => selected.has(r.id))
const [first, second, ...rest] = selectedRepos
@ -92,7 +92,7 @@ export default function RepoMultiCombobox({
const handleSelectAll = useCallback(() => {
if (allSelected) {
// Why: toggle — clicking "All repos" while everything is selected
// Why: toggle — clicking "All projects" while everything is selected
// collapses to a single repo. The fetch effect requires at least one
// selection, so we keep the first eligible repo instead of emitting
// an empty set.
@ -120,7 +120,7 @@ export default function RepoMultiCombobox({
<ChevronsUpDown className="size-3.5 opacity-50" />
</Button>
</PopoverTrigger>
{/* Why: trigger width can be as narrow as the "All repos" label, but the
{/* Why: trigger width can be as narrow as the "All projects" label, but the
popover hosts a search input and repo rows with paths. Use the
trigger as a minimum width and let the content expand to a readable
size so the search field and repo names aren't truncated. */}
@ -131,12 +131,12 @@ export default function RepoMultiCombobox({
<Command shouldFilter={false} value={commandValue} onValueChange={setCommandValue}>
<CommandInput
autoFocus
placeholder="Search repos..."
placeholder="Search projects..."
value={query}
onValueChange={setQuery}
className="text-xs"
/>
{/* Why: sticky "All repos" row sits above the CommandList so it
{/* Why: sticky "All projects" row sits above the CommandList so it
stays visible while the user scrolls a long repo list. Selecting
it emits `onSelectAll` (not a snapshot via onChange) so the
caller can persist sticky-all semantics. */}
@ -157,11 +157,11 @@ export default function RepoMultiCombobox({
allSelected ? 'opacity-70' : 'opacity-0'
)}
/>
<span>All repos</span>
<span>All projects</span>
</button>
</div>
<CommandList>
<CommandEmpty>No repos match your search.</CommandEmpty>
<CommandEmpty>No projects match your search.</CommandEmpty>
{filteredRepos.map((repo) => {
const isSelected = selected.has(repo.id)
const isLastSelected = isSelected && selected.size <= 1

View File

@ -116,6 +116,7 @@ export type UseComposerStateOptions = {
export type ComposerCardProps = {
eligibleRepos: ReturnType<typeof useAppStore.getState>['repos']
repoId: string
selectedRepoIsGit: boolean
onRepoChange: (value: string) => void
name: string
onNameValueChange: (value: string) => void
@ -288,7 +289,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const workspaceStatuses = useAppStore((s) => s.workspaceStatuses)
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration)
const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos])
const eligibleRepos = useMemo(() => repos.filter((repo) => Boolean(repo.path)), [repos])
const draftRepoId = persistDraft ? (newWorkspaceDraft?.repoId ?? null) : null
const resolvedInitialWorkspaceStatus = useMemo(
() =>
@ -310,6 +311,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const [internalRepoId, setInternalRepoId] = useState<string>(resolvedInitialRepoId)
const repoId = repoIdOverride ?? internalRepoId
const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId)
const selectedRepoIsGit = selectedRepo ? isGitRepoKind(selectedRepo) : false
const selectedRepoConnectionId = selectedRepo?.connectionId ?? null
const selectedRepoSshState = selectedRepoConnectionId
? (sshConnectionStates.get(selectedRepoConnectionId) ?? null)
@ -512,7 +514,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
[]
)
useEffect(() => {
if (!selectedRepo || !selectedRepoPath) {
if (!selectedRepo || !selectedRepoPath || !selectedRepoIsGit) {
setSelectedRepoSlug(null)
return
}
@ -537,7 +539,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
return () => {
cancelled = true
}
}, [repoId, selectedRepo, selectedRepoPath])
}, [repoId, selectedRepo, selectedRepoIsGit, selectedRepoPath])
const sparsePresetsForRepo = sparsePresetsByRepo[repoId]
const sparsePresets = sparsePresetsForRepo ?? EMPTY_SPARSE_PRESETS
const normalizedSparseDirectories = useMemo(
@ -565,6 +567,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
if (!sparseEnabled) {
return null
}
if (!selectedRepoIsGit) {
return null
}
if (selectedRepo?.connectionId) {
return 'Sparse checkout is only supported for local repos right now.'
}
@ -577,7 +582,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
return 'Use repo-relative directories, not root or parent paths.'
}
return null
}, [normalizedSparseDirectories, selectedRepo?.connectionId, sparseEnabled])
}, [normalizedSparseDirectories, selectedRepo?.connectionId, selectedRepoIsGit, sparseEnabled])
const parsedLinkedIssueNumber = useMemo(
() => (linkedIssue.trim() ? parseGitHubIssueOrPRNumber(linkedIssue) : null),
[linkedIssue]
@ -608,8 +613,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
return null
}, [linkedPR, name, selectedRepoSlug])
const setupConfig = useMemo(
() => getSetupConfig(selectedRepo, yamlHooks),
[selectedRepo, yamlHooks]
() => (selectedRepoIsGit ? getSetupConfig(selectedRepo, yamlHooks) : null),
[selectedRepo, selectedRepoIsGit, yamlHooks]
)
const setupPolicy: SetupRunPolicy = selectedRepo?.hookSettings?.setupRunPolicy ?? 'run-by-default'
const hasIssueAutomationConfig = enableIssueAutomation && issueCommandTemplate.length > 0
@ -633,7 +638,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
? 'run'
: 'skip')
const isSetupCheckPending = Boolean(repoId) && checkedHooksRepoId !== repoId
const shouldWaitForSetupCheck = Boolean(selectedRepo) && isSetupCheckPending
const shouldWaitForSetupCheck = Boolean(selectedRepo) && selectedRepoIsGit && isSetupCheckPending
// Why: when the user leaves the workspace name blank and provides no other
// seed source (prompt, linked issue/PR), pick a repo-scoped unique marine
@ -765,14 +770,20 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
// Why: the compact sparse dropdown is always visible under Advanced, so
// presets must load before sparse mode is enabled.
useEffect(() => {
if (!repoId || selectedRepo?.connectionId) {
if (!repoId || !selectedRepoIsGit || selectedRepo?.connectionId) {
return
}
if (sparsePresetsByRepo[repoId] !== undefined) {
return
}
void fetchSparsePresets(repoId)
}, [fetchSparsePresets, repoId, selectedRepo?.connectionId, sparsePresetsByRepo])
}, [
fetchSparsePresets,
repoId,
selectedRepo?.connectionId,
selectedRepoIsGit,
sparsePresetsByRepo
])
// Why: detect agents for the selected repo. For local repos this runs once
// on mount (deduped by the store). For remote repos it re-runs when the
@ -815,6 +826,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setYamlHooks(null)
setCheckedHooksRepoId(null)
if (!selectedRepoIsGit) {
setHasLoadedIssueCommand(true)
setCheckedHooksRepoId(repoId)
return () => {
cancelled = true
}
}
void loadHookCheckForRepo(repoId)
.then((result) => {
if (!cancelled) {
@ -851,7 +870,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
return () => {
cancelled = true
}
}, [commitHookCheckIfCurrent, enableIssueAutomation, loadHookCheckForRepo, repoId, settings])
}, [
commitHookCheckIfCurrent,
enableIssueAutomation,
loadHookCheckForRepo,
repoId,
selectedRepoIsGit,
settings
])
const onConnectSelectedRepo = useCallback(async (): Promise<void> => {
const targetId = selectedRepoConnectionIdRef.current
@ -871,7 +897,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
try {
await window.api.ssh.connect({ targetId })
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to connect to repository.')
toast.error(error instanceof Error ? error.message : 'Failed to connect to project.')
}
}, [])
@ -885,7 +911,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const prefetchSshConnectedGeneration =
selectedRepoConnectionId && selectedRepoSshStatus === 'connected' ? sshConnectedGeneration : 0
useEffect(() => {
if (!selectedRepo?.path || !canPrefetchSelectedRepoWorkItems) {
if (!selectedRepoIsGit || !selectedRepo?.path || !canPrefetchSelectedRepoWorkItems) {
return
}
prefetchWorkItems(selectedRepo.id, selectedRepo.path, PER_REPO_FETCH_LIMIT, 'is:pr is:open')
@ -894,7 +920,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
prefetchSshConnectedGeneration,
prefetchWorkItems,
selectedRepo?.id,
selectedRepo?.path
selectedRepo?.path,
selectedRepoIsGit
])
// Reset setup decision when config / policy changes.
@ -921,7 +948,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}, [linkQuery])
useEffect(() => {
if (!linkPopoverOpen || !selectedRepo) {
if (!linkPopoverOpen || !selectedRepo || !selectedRepoIsGit) {
return
}
@ -977,10 +1004,15 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
return () => {
cancelled = true
}
}, [linkPopoverOpen, selectedRepo])
}, [linkPopoverOpen, selectedRepo, selectedRepoIsGit])
useEffect(() => {
if (!linkPopoverOpen || !selectedRepo || normalizedLinkQuery.directNumber === null) {
if (
!linkPopoverOpen ||
!selectedRepo ||
!selectedRepoIsGit ||
normalizedLinkQuery.directNumber === null
) {
setLinkDirectItem(null)
setLinkDirectLoading(false)
return
@ -1020,7 +1052,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
return () => {
cancelled = true
}
}, [linkPopoverOpen, normalizedLinkQuery.directNumber, selectedRepo])
}, [linkPopoverOpen, normalizedLinkQuery.directNumber, selectedRepo, selectedRepoIsGit])
const applyLinkedWorkItem = useCallback(
(item: GitHubWorkItem): void => {
@ -1207,7 +1239,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
return null
}
if (!targetRepoPath) {
toast.error('No remote repository path is available for attachments.')
toast.error('No remote project path is available for attachments.')
return { filePaths: [], folderPaths: [] }
}
const destinationDir = joinPath(targetRepoPath, '.orca/drops')
@ -1694,14 +1726,16 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setCreateError(null)
setCreating(true)
try {
const setupTrustDecision = await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup')
const setupTrustDecision = selectedRepoIsGit
? await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup')
: 'skip'
const effectiveSetupDecision: SetupDecision =
setupTrustDecision === 'skip'
? 'skip'
: ((resolvedSetupDecision ?? 'inherit') as SetupDecision)
let issueCommandTrustDecision: 'run' | 'skip' = 'run'
if (shouldRunIssueAutomation) {
if (selectedRepoIsGit && shouldRunIssueAutomation) {
issueCommandTrustDecision =
setupTrustDecision === 'skip'
? 'skip'
@ -1716,9 +1750,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const result = await createWorktree(
repoId,
workspaceName,
baseBranch,
selectedRepoIsGit ? baseBranch : undefined,
effectiveSetupDecision,
sparseEnabled
selectedRepoIsGit && sparseEnabled
? {
directories: normalizedSparseDirectories,
...(effectivePresetId ? { presetId: effectivePresetId } : {})
@ -1832,6 +1866,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
resolvedSetupDecision,
resolvedInitialWorkspaceStatus,
selectedRepo,
selectedRepoIsGit,
selectedRepoRequiresConnection,
settings?.agentCmdOverrides,
settings?.rightSidebarOpenByDefault,
@ -1876,7 +1911,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
try {
let submitSetupConfig = setupConfig
let submitResolvedSetupDecision = resolvedSetupDecision
if (checkedHooksRepoId !== repoId) {
if (selectedRepoIsGit && checkedHooksRepoId !== repoId) {
let hookCheck: HookCheckResult
try {
hookCheck = await loadHookCheckForRepo(repoId)
@ -1895,12 +1930,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
? 'run'
: 'skip')
}
if (submitSetupConfig && setupPolicy === 'ask' && !setupDecision) {
if (selectedRepoIsGit && submitSetupConfig && setupPolicy === 'ask' && !setupDecision) {
setAdvancedOpen(true)
return
}
const trustDecision = await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup')
const trustDecision = selectedRepoIsGit
? await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup')
: 'skip'
const effectiveSetupDecision: SetupDecision =
trustDecision === 'skip'
? 'skip'
@ -1914,9 +1951,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const result = await createWorktree(
repoId,
workspaceName,
baseBranch,
selectedRepoIsGit ? baseBranch : undefined,
effectiveSetupDecision,
sparseEnabled
selectedRepoIsGit && sparseEnabled
? {
directories: normalizedSparseDirectories,
...(effectivePresetId ? { presetId: effectivePresetId } : {})
@ -2073,6 +2110,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
resolvedSetupDecision,
resolvedInitialWorkspaceStatus,
selectedRepo,
selectedRepoIsGit,
selectedRepoRequiresConnection,
settings?.agentCmdOverrides,
settings?.rightSidebarOpenByDefault,
@ -2112,6 +2150,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const cardProps: ComposerCardProps = {
eligibleRepos,
repoId,
selectedRepoIsGit,
onRepoChange: handleRepoChange,
name,
onNameValueChange: handleNameValueChange,
@ -2174,7 +2213,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
shouldWaitForSetupCheck,
resolvedSetupDecision,
createError,
canUseSparseCheckout: !selectedRepo?.connectionId,
canUseSparseCheckout: selectedRepoIsGit && !selectedRepo?.connectionId,
sparsePresets,
sparseSelectedPresetId,
onSparseSelectPreset: handleSparseSelectPreset

View File

@ -705,11 +705,10 @@ export function useIpcEvents(): void {
unsubs.push(
window.api.ui.onOpenNewWorkspace(() => {
// Why: mirror the renderer's App.tsx Cmd+N guard — only open the
// composer when there is at least one real git repo configured, so
// users on a fresh install don't get a modal with nothing to target.
// Why: keep the global shortcut quiet on a fresh install, but allow
// both Git projects and plain folder projects to create workspaces.
const store = useAppStore.getState()
if (!store.repos.some((repo) => isGitRepoKind(repo))) {
if (store.repos.length === 0) {
return
}
if (store.activeModal === 'new-workspace-composer') {

View File

@ -1056,7 +1056,7 @@ export type GitHubSlice = {
* Why: fan out a single work-item query across multiple repos. Partial
* failures don't reject a repo that both fails to fetch *and* has no
* cached fallback contributes nothing and increments `failedCount`, which
* the caller surfaces as a "N of M repos failed to load" banner. A repo
* the caller surfaces as a "N of M projects failed to load" banner. A repo
* served from stale cache on rejection is NOT counted as failed matching
* the single-repo behavior of quietly serving stale data.
*/

View File

@ -3,7 +3,8 @@ import {
WORKTREE_ID_SEPARATOR,
getRepoIdFromWorktreeId,
getWorktreePathBasenameFromId,
splitWorktreeId
splitWorktreeId,
splitWorktreeIdForFilesystem
} from './worktree-id'
describe('WORKTREE_ID_SEPARATOR', () => {
@ -73,6 +74,23 @@ describe('splitWorktreeId', () => {
it('splits on the first separator when the path itself contains "::"', () => {
expect(splitWorktreeId('repo::a::b')).toEqual({ repoId: 'repo', worktreePath: 'a::b' })
})
it('preserves folder workspace instance suffixes in the literal parsed path', () => {
expect(
splitWorktreeId('repo::/folder::workspace:123e4567-e89b-12d3-a456-426614174000')
).toEqual({
repoId: 'repo',
worktreePath: '/folder::workspace:123e4567-e89b-12d3-a456-426614174000'
})
})
})
describe('splitWorktreeIdForFilesystem', () => {
it('strips folder workspace instance suffixes from the parsed path', () => {
expect(
splitWorktreeIdForFilesystem('repo::/folder::workspace:123e4567-e89b-12d3-a456-426614174000')
).toEqual({ repoId: 'repo', worktreePath: '/folder' })
})
})
describe('getWorktreePathBasenameFromId', () => {
@ -88,6 +106,14 @@ describe('getWorktreePathBasenameFromId', () => {
)
})
it('returns the real folder basename for folder workspace instance ids', () => {
expect(
getWorktreePathBasenameFromId(
'repo-123::/abs/project::workspace:123e4567-e89b-12d3-a456-426614174000'
)
).toBe('project')
})
it('returns null when no worktree path is available', () => {
expect(getWorktreePathBasenameFromId('repo-123')).toBeNull()
expect(getWorktreePathBasenameFromId('repo-123::')).toBeNull()

View File

@ -7,6 +7,11 @@ export type ParsedWorktreeId = {
worktreePath: string
}
export const FOLDER_WORKSPACE_INSTANCE_SEPARATOR = '::workspace:'
const FOLDER_WORKSPACE_INSTANCE_SUFFIX = new RegExp(
`${FOLDER_WORKSPACE_INSTANCE_SEPARATOR.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[0-9a-f-]{36}$`
)
export function getRepoIdFromWorktreeId(worktreeId: string): string {
const separatorIdx = worktreeId.indexOf(WORKTREE_ID_SEPARATOR)
return separatorIdx === -1 ? worktreeId : worktreeId.slice(0, separatorIdx)
@ -23,8 +28,22 @@ export function splitWorktreeId(worktreeId: string): ParsedWorktreeId | null {
}
}
export function getWorktreePathBasenameFromId(worktreeId: string): string | null {
export function splitWorktreeIdForFilesystem(worktreeId: string): ParsedWorktreeId | null {
const parsed = splitWorktreeId(worktreeId)
if (!parsed) {
return null
}
return {
repoId: parsed.repoId,
// Why: folder projects can have multiple workspace sessions backed by the
// same directory. Their IDs carry a UUID suffix, but filesystem callers
// still need the real folder path as cwd/root.
worktreePath: parsed.worktreePath.replace(FOLDER_WORKSPACE_INSTANCE_SUFFIX, '')
}
}
export function getWorktreePathBasenameFromId(worktreeId: string): string | null {
const parsed = splitWorktreeIdForFilesystem(worktreeId)
const normalizedPath = parsed?.worktreePath.trim().replace(/[\\/]+$/g, '') ?? ''
if (!normalizedPath) {
return null